/* * Copyright 2010 by Dan Fabulich. * * Dan Fabulich licenses this file to you under the * ChoiceScript License, Version 1.0 (the "License"); you may * not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.choiceofgames.com/LICENSE-1.0.txt * * See the License for the specific language governing * permissions and limitations under the License. * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, * either express or implied. */ function Scene(name, stats, nav, options) { if (!name) name = ""; if (!stats) stats = {}; // the name of the scene this.name = name; // the permanent statistics and the temporary values this.stats = stats; this.temps = {choice_reuse:"allow", choice_user_restored:false}; // the navigator determines which scene comes next this.nav = nav; options = options || {}; // should we print debugging information? this.debugMode = options.debugMode || false; // used for stats screen, and maybe other secondary views someday this.secondaryMode = options.secondaryMode; this.saveSlot = options.saveSlot || ""; // the array of lines in the scene file this.lines = []; // the current line number (WARNING 0-based!) this.lineNum = 0; this.rollbackLineCoverage(); // when this is true, the main printLoop will halt this.finished = false; // map of label names to line numbers this.labels = {}; // the current amount of indentation this.indent = 0; // Did the previous line contain text? this.prevLine = "empty"; // Have we ever printed any text? this.screenEmpty = true; // Have we run any commands (except for create and scene_list) yet? this.initialCommands = true; this.stats.sceneName = name; // for easy reachability from the window this.stats.scene = this; // where should we print text? this.target = null; } Scene.prototype.reexecute = function reexecute() { this.lineNum = this.stats.testEntryPoint || 0; this.finished = 0; this.indent = this.getIndent(this.lines[this.lineNum]); this.prevLine = "empty"; this.screenEmpty = true; this.execute(); }; // the main loop of the scene Scene.prototype.printLoop = function printLoop() { var line; for (;!this.finished && this.lineNum < this.lines.length; this.lineNum++) { line = this.lines[this.lineNum]; if (!trim(line)) { this.paragraph(); continue; } var indent = this.getIndent(line); if (indent > this.indent) { // ignore indentation level of *comments if (/\s*\*comment\b/.test(line)) continue; throw new Error(this.lineMsg() + "increasing indent not allowed, expected " + this.indent + " was " + indent); } else if (indent < this.indent) { this.dedent(indent); } if (this.temps.fakeChoiceLines && this.temps.fakeChoiceLines[this.lineNum]) { this.rollbackLineCoverage(); this.lineNum = this.temps.fakeChoiceEnd; this.rollbackLineCoverage(); delete this.temps.fakeChoiceEnd; delete this.temps.fakeChoiceLines; continue; } this.indent = indent; if (!this.runCommand(line)) { if (/^\s*#/.test(line)) { if (this.temps.fakeChoiceEnd) { this.rollbackLineCoverage(); this.lineNum = this.temps.fakeChoiceEnd; this.rollbackLineCoverage(); delete this.temps.fakeChoiceEnd; delete this.temps.fakeChoiceLines; continue; } else { throw new Error(this.lineMsg() + "It is illegal to fall out of a *choice statement; you must *goto or *finish before the end of the indented block."); } } this.prevLine = "text"; this.screenEmpty = false; this.printLine(trim(line)); printx(' ', this.target); } } this.rollbackLineCoverage(); if (!this.finished) { this.autofinish(); } this.save(null, "temp"); if (this.skipFooter) { this.skipFooter = false; } else { printFooter(); } }; Scene.prototype.dedent = function dedent(newDent) {}; Scene.prototype.printLine = function printLine(line, parent) { if (!line) return null; line = this.replaceVariables(line); if (!parent) parent = this.target; return printx(line, parent); }; Scene.prototype.replaceVariables = function (line) { if (!line.replace) line = String(line); var self = this; // replace ${variables} with values line = line.replace(/\$(\!?\!?)\{([a-zA-Z][_\w]*)\}/g, function (matched, capitalize, variable) { var value = self.getVar(variable); if (capitalize == "!") { value = ""+value; value = value.charAt(0).toUpperCase() + value.slice(1); } else if (capitalize == "!!") { value = (""+value).toUpperCase(); } return value; }); // double-check for unreplaced/invalid ${} expressions var unreplaced = line.search(/\$(\!?)\{/) + 1; if (unreplaced) { throw new Error(this.lineMsg() + "invalid ${} variable substitution at letter " + unreplaced); } return line; }; Scene.prototype.paragraph = function paragraph() { if (this.prevLine == "text") { println("", this.target); println("", this.target); } else if (this.prevLine == "block") { println("", this.target); } this.prevLine = "empty"; }; Scene.prototype.loadSceneFast = function loadSceneFast(url) { if (this.loading) return; this.loading = true; var result; if (window.cachedResult) { result = window.cachedResult; window.cachedResult = null; return this.loadLinesFast(result.crc, result.lines, result.labels); } else if (typeof allScenes != "undefined") { result = allScenes[this.name]; return this.loadLinesFast(result.crc, result.lines, result.labels); } startLoading(); if (!url) { url = Scene.baseUrl + "/" + this.name.replace(/ /g, "_") + ".txt.json"; } var xhr = findXhr(); xhr.open("GET", url, true); var self = this; var done = false; xhr.onreadystatechange = function() { if (done) return; if (xhr.readyState != 4) return; if (xhr.status == 403) { try { var err = JSON.parse(xhr.responseText); if (err.error == "not registered") { return isRegistered(function(registered) { if (registered) { logout(); loginDiv(); } return clearScreen(function() { loginForm(main, 0/*optional*/, "Please sign in to access this part of the game.", function() { clearScreen(loadAndRestoreGame); }); }); }); } } catch (e) {} // JSON parse failure? must not be a login prompt } if (window.isWeb && xhr.status != 200) { var status = xhr.status || "network"; main.innerHTML = "
Our apologies; there was a " + status + " error while loading game data."+ " Please refresh your browser now; if that doesn't work, please click the Restart button and email "+getSupportEmail()+" with details.
"+ " "; return; } else if (!xhr.status && !xhr.responseText) { throw new Error("Couldn't load " + url); } done = true; var result = xhr.responseText; result = jsonParse(result); self.loadLinesFast(result.crc, result.lines, result.labels); }; if (isIE) { // IE8 swallows errors in onreadystatechange if xhr.send is in a try block xhr.send(null); } else { try { xhr.send(null); } catch (e) { if (window.location.protocol == "file:" && !window.isMobile) { if (/Chrome/.test(navigator.userAgent)) { window.onerror("We're sorry, Google Chrome has blocked ChoiceScript from functioning. (\"file:\" URLs cannot "+ "load files in Chrome.) ChoiceScript works just fine in Chrome, but only on a published website like "+ "choiceofgames.com. For the time being, please try another browser like Mozilla Firefox."); return; } } window.onerror("Couldn't load URL: " + url + "\n" + e); } } }; Scene.prototype.loadLinesFast = function loadLinesFast(crc, lines, labels) { this.checkSum(crc); this.lines = lines; this.labels = labels; this.loading = false; this.loaded = true; var self = this; if (this.executing) { safeCall(this, function() { doneLoading(); self.execute(); }); } }; // load the scene file from the specified URL (or from default URL by name) Scene.prototype.loadScene = function loadScene(url) { if (this.loading) return; this.loading = true; startLoading(); if (!url) { url = Scene.baseUrl + "/" + this.name + ".txt"; } var xhr = findXhr(); xhr.open("GET", url, true); var self = this; var done = false; xhr.onreadystatechange = function() { if (done) return; if (xhr.readyState != 4) return; done = true; if (xhr.status == 403) { try { var err = JSON.parse(xhr.responseText); if (err.error == "not registered") { return isRegistered(function(registered) { if (registered) { logout(); loginDiv(); } return clearScreen(function() { loginForm(main, 0/*optional*/, "Please sign in to access this part of the game.", function() { clearScreen(loadAndRestoreGame); }); }); }); } } catch (e) {} // JSON parse failure? must not be a login prompt } if (window.isWeb && xhr.status != 200) { var status = xhr.status || "network"; main.innerHTML = "Our apologies; there was a " + status + " error while loading game data."+ " Please refresh your browser now; if that doesn't work, please email "+getSupportEmail()+" with details.
"+ " "; return; } else if (!xhr.status && !xhr.responseText) { throw new Error("Couldn't load " + url); } var result = xhr.responseText; scene = result; scene = scene.replace(/\r/g, ""); this.loading = false; self.loadLines(scene); if (self.executing) { safeCall(self, function () { doneLoading(); self.execute(); }); } }; if (isIE) { // IE8 swallows errors in onreadystatechange if xhr.send is in a try block xhr.send(null); } else { try { xhr.send(null); } catch (e) { if (window.location.protocol == "file:" && !window.isMobile) { if (/Chrome/.test(navigator.userAgent)) { window.onerror("We're sorry, Google Chrome has blocked ChoiceScript from functioning. (\"file:\" URLs cannot "+ "load files in Chrome.) ChoiceScript works just fine in Chrome, but only on a published website like "+ "choiceofgames.com. For the time being, please try another browser like Mozilla Firefox."); return; } } window.onerror("Couldn't load URL: " + url + "\n" + e); } } }; Scene.prototype.checkSum = function checkSum(crc) { if (this.temps.choice_crc) { if (this.temps.choice_crc != crc) { // The scene has changed; restart the scene var userRestored = this.temps.choice_user_restored || false; this.temps = {choice_reuse:"allow", choice_user_restored:userRestored, choice_crc: crc}; this.lineNum = 0; this.indent = 0; } } else { this.temps.choice_crc = crc; } }; Scene.prototype.loadLines = function loadLines(str) { var crc = crc32(str); this.checkSum(crc); this.lines = str.split('\n'); this.parseLabels(); this.loaded = true; }; // launch the vignette as soon as it's available Scene.prototype.execute = function execute() { if (!this.loaded) { this.executing = true; if (Scene.generatedFast || (typeof generatedFast != "undefined" && generatedFast) || typeof allScenes != 'undefined') { this.loadSceneFast(); } else { this.loadScene(); } return; } if (this.nav) this.nav.repairStats(stats); doneLoading(); if (typeof this.targetLabel != "undefined") { var label = this.targetLabel.label.toLowerCase(); if (typeof(this.labels[label]) != "undefined") { this.lineNum = this.labels[label]; this.indent = this.getIndent(this.lines[this.lineNum]); delete this.targetLabel; } else { throw new Error(this.targetLabel.origin + " line " + (this.targetLabel.originLine+1) + ": "+this.name+" doesn't contain label " + label); } } this.printLoop(); }; // loop through the file looking for *label commands Scene.prototype.parseLabels = function parseLabels() { var lineLength = this.lines.length; var oldLineNum = this.lineNum; for (this.lineNum = 0; this.lineNum < lineLength; this.lineNum++) { this.rollbackLineCoverage(); var line = this.lines[this.lineNum]; var result = /^(\s*)\*(\w+)(.*)/.exec(line); if (!result) continue; var indentation = result[1]; var indent = indentation.length; var command = result[2].toLowerCase(); var data = trim(result[3]); if ("label" == command) { data = data.toLowerCase(); if (/\s/.test(data)) throw new Error(this.lineMsg() + "label '"+data+"' is not allowed to contain spaces"); if (this.labels.hasOwnProperty(data)) { throw new Error(this.lineMsg() + "label '"+data+"' already defined on line " + (this.labels[data]*1+1)); } this.labels[data] = this.lineNum; } } this.rollbackLineCoverage(); this.lineNum = oldLineNum; }; // if this is a command line, run it Scene.prototype.runCommand = function runCommand(line) { var result = /^\s*\*(\w+)(.*)/.exec(line); if (!result) return false; var command = result[1].toLowerCase(); var data = trim(result[2]); if (Scene.validCommands[command]) { if ("comment" == command) return true; if (Scene.initialCommands[command]) { if ("startup" != this.name || !this.screenEmpty || !this.initialCommands) { throw new Error(this.lineMsg() + "Invalid "+command+" instruction, only allowed at the top of startup.txt"); } } else { this.initialCommands = false; } this[command](data); } else { throw new Error(this.lineMsg() + "Non-existent command '"+command+"'"); } return true; }; // *choice [group1] [group2] ... // prompt the user with a multiple choice question. // nested lines are options to be presented to the user // // Examples: // *choice // good // Good choice // *finish // bad // Bad choice // *finish // // *choice toy // spaceship // Nice spaceship // *finish // train // Nice train // *finish // doll // Nice doll // *finish // // *choice color toy // red // spaceship // Nice red spaceship // *finish // train // Nice red train // *finish // blue // spaceship // Nice blue spaceship // *finish // train // Nice red train // *finish // If a group is specified, generate a prompt message, e.g. "*choice toy" -> "Select a toy:" // If no group is specified, don't generate a prompt message // if multiple groups are specified, allow the user to make multiple choices simultaneously // all multi-dimensional choices must be valid (otherwise throw a parse error) Scene.prototype.choice = function choice(data) { var startLineNum = this.lineNum; var groups = data.split(/ /); for (var i = 0; i < groups.length; i++) { if (!/^\w*$/.test(groups[i])) { throw new Error(this.lineMsg() + "invalid choice group name: " + groups[i]); } } var options = this.parseOptions(this.indent, groups); var self = this; this.renderOptions(groups, options, function(option) { self.standardResolution(option); }); this.finished = true; if (this.fakeChoice) { this.temps.fakeChoiceEnd = this.lineNum; var fakeChoiceLines = {}; for (i = 0; i < options.length; i++) { fakeChoiceLines[options[i].line-1] = 1; } this.temps.fakeChoiceLines = fakeChoiceLines; } this.lineNum = startLineNum; }; Scene.prototype.fake_choice = function fake_choice(data) { this.fakeChoice = true; this.choice(data, true); delete this.fakeChoice; }; Scene.prototype.standardResolution = function(option) { var self = this; self.lineNum = option.line; self.indent = self.getIndent(self.nextNonBlankLine(true/*includingThisOne*/)); if (option.reuse && option.reuse != "allow") self.temps.choice_used[option.line-1] = 1; if (this.nav) this.nav.bugLog.push("#"+(option.line+1) + " " + option.name); self.finished = false; self.resetPage(); }; Scene.prototype.nextNonBlankLine = function nextNonBlankLine(includingThisOne) { var line; var i = this.lineNum; if (!includingThisOne) i++; while(isDefined(line = this.lines[i]) && !trim(line)) { i++; } return line; }; // reset the page and invoke code after clearing the screen Scene.prototype.resetPage = function resetPage() { var self = this; this.save(function() { self.prevLine = "empty"; self.screenEmpty = true; clearScreen(function() {self.execute();}); }, ""); }; /* The function needs some explaining. We want the game to be "refreshable," e.g. on the web. So we only make a "real" autosave as you click "Next" But if we do it that way, when we visit the stat screen, it's out of date So we make a "temp" autosave slot, right as the page finishes redrawing, and the stat screen uses the "temp" autosave to display your current data. When you refresh the page, the "temp" autosave is rewritten. If you save stats on the stat screen, they're written into the "temp" autosave In addition, setVar will mark a special choice_dirty_stats variable, to indicate that there's something important in the "temp" autosave slot. Back in the main game, we load the temp save slot when saving the either the main autosave slot or the temp save slot. If the main game is about to rewrite the temp slot, but the temp slot has dirty stats (written by the stat screen), then skip saving temp. Otherwise, save the temp slot normally. If the main game is about to write the main slot, clear the old temp slot, and save the temp stats as the "real" autosave slot. Thus, stat changes on the stat screen will only be permanently saved when the player clicks "Next" in the main game, ensuring that the game is still refreshable. */ Scene.prototype.save = function save(callback, slot) { if (!slot) slot = this.saveSlot; var self = this; if (this.saveSlot) { saveCookie(callback, slot, self.stats, self.temps, self.lineNum, self.indent, self.debugMode, self.nav); } else { loadTempStats(this.stats, function(stats) { if (slot == "temp") { if (stats.choice_dirty_stats) { safeTimeout(callback, 0); } else { saveCookie(callback, "temp", self.stats, self.temps, self.lineNum, self.indent, self.debugMode, self.nav); } } else { clearTemp(); delete stats.choice_dirty_stats; stats.sceneName = self.name; stats.scene = self; self.stats = stats; if (typeof window != "undefined") window.stats = stats; saveCookie(callback, slot, stats, self.temps, self.lineNum, self.indent, self.debugMode, self.nav); } }); } }; // *goto labelName // Go to the line labeled with the label command *label labelName Scene.prototype["goto"] = function scene_goto(label) { label = label.toLowerCase(); if (typeof(this.labels[label]) != "undefined") { this.lineNum = this.labels[label]; this.indent = this.getIndent(this.lines[this.lineNum]); } else { throw new Error(this.lineMsg() + "bad label " + label); } }; Scene.prototype.gosub = function scene_gosub(label) { if (!this.temps.choice_substack) { this.temps.choice_substack = []; } this.temps.choice_substack.push({lineNum: this.lineNum, indent: this.indent}); this["goto"](label); }; Scene.prototype.gosub_scene = function scene_gosub_scene(data) { if (!this.stats.choice_subscene_stack) { this.stats.choice_subscene_stack = []; } this.stats.choice_subscene_stack.push({name:this.name, lineNum: this.lineNum + 1, indent: this.indent, temps: this.temps}); this.goto_scene(data); }; Scene.prototype["return"] = function scene_return() { var stackFrame; if (this.temps.choice_substack && this.temps.choice_substack.length) { stackFrame = this.temps.choice_substack.pop(); this.lineNum = stackFrame.lineNum; this.indent = stackFrame.indent; } else if (this.stats.choice_subscene_stack && this.stats.choice_subscene_stack.length) { stackFrame = this.stats.choice_subscene_stack.pop(); this.finished = true; this.skipFooter = true; var scene = new Scene(stackFrame.name, this.stats, this.nav, {debugMode:this.debugMode, secondaryMode:this.secondaryMode}); scene.temps = stackFrame.temps; scene.screenEmpty = this.screenEmpty; scene.prevLine = this.prevLine; scene.lineNum = stackFrame.lineNum; scene.indent = stackFrame.indent; scene.execute(); } else if (!this.temps.choice_substack && !this.stats.choice_subscene_stack) { throw new Error(this.lineMsg() + "invalid return; gosub has not yet been called"); } else { throw new Error(this.lineMsg() + "invalid return; we've already returned from the last gosub"); } }; // *gotoref expression // Go to the label identified by the expression // // *temp foo // *set foo "bar" // *gotoref foo // Skipped! // *label bar Scene.prototype["gotoref"] = function scene_gotoref(expression) { var stack = this.tokenizeExpr(expression); var value = this.evaluateExpr(stack); this["goto"](value); }; // *finish // halt the scene Scene.prototype.finish = function finish(buttonName) { this.paragraph(); this.finished = true; var self = this; if (this.secondaryMode == "stats") { if (typeof window == "undefined") return; if (window.forcedScene == "choicescript_stats") return; if (window.isAndroidApp && window.statsMode.get()) return; printButton(buttonName || "Next", main, false, function() { clearScreen(loadAndRestoreGame); } ); return; } var nextSceneName = this.nav && nav.nextSceneName(this.name); // if there are no more scenes, then just halt if (!nextSceneName) { if (!this.secondaryMode) this.ending(); return; } if (this.screenEmpty) { this.goto_scene(nextSceneName); return; } if (!buttonName) buttonName = "Next Chapter"; buttonName = this.replaceVariables(buttonName); printButton(buttonName, main, false, function() { safeCall(self, function() { var scene = new Scene(nextSceneName, self.stats, self.nav, {debugMode:self.debugMode, secondaryMode:self.secondaryMode}); scene.resetPage(); }); } ); if (this.debugMode) println(toJson(this.stats)); }; Scene.prototype.autofinish = function autofinish(buttonName) { this.finish(buttonName); }; // *reset // clear all stats Scene.prototype.reset = function reset() { this.nav.resetStats(this.stats); this.stats.scene = this; }; // *goto_scene foo // Scene.prototype.goto_scene = function gotoScene(data) { var args = trim(data).split(/ /); var sceneName, label; if (args.length == 1) { sceneName = data; } else { sceneName = args[0]; label = args[1]; } this.finished = true; this.skipFooter = true; var scene = new Scene(sceneName, this.stats, this.nav, {debugMode:this.debugMode, secondaryMode:this.secondaryMode}); scene.screenEmpty = this.screenEmpty; scene.prevLine = this.prevLine; if (typeof label != "undefined") scene.targetLabel = {label:label, origin:this.name, originLine:this.lineNum}; scene.execute(); }; // *redirect_scene foo Scene.prototype.redirect_scene = function redirectScene(data) { if (this.secondaryMode != "stats") throw new Error(this.lineMsg() + "The *redirect_scene command can only be used from the stats screen."); var args = trim(data).split(/ /); var sceneName, label; if (args.length == 1) { sceneName = data; } else { sceneName = args[0]; label = args[1]; } this.finished = true; this.skipFooter = true; var self = this; redirectFromStats(sceneName, label, this.lineNum, function() { delete self.secondaryMode; self.goto_scene(data); }); }; Scene.prototype.restore_purchases = function scene_restorePurchases(data) { var self = this; var target = this.target; if (!target) target = document.getElementById('text'); var button = printButton("Restore Purchases", target, false, function() { safeCall(self, function() { restorePurchases(function() { self["goto"](data); self.finished = false; self.resetPage(); }); }); } ); setClass(button, ""); this.prevLine = "block"; }; Scene.prototype.check_purchase = function scene_checkPurchase(data) { this.finished = true; this.skipFooter = true; var self = this; checkPurchase(data, function(ok, result) { self.finished = false; self.skipFooter = false; if (!ok) { result = {billingSupported:true}; self.temps.choice_purchase_error = true; } result = result || {}; var products = data.split(/ /); var everything = true; for (var i = 0; i < products.length; i++) { var purchasedProduct = result[products[i]] || false; self.temps["choice_purchased_"+products[i]] = purchasedProduct; if (!purchasedProduct) everything = false; } self.temps.choice_purchased_everything = everything; self.temps.choice_purchase_supported = !!result.billingSupported; self.execute(); }); }; Scene.prototype.purchase = function purchase_button(data) { var result = /^(\w+)\s+(\S+)\s+(.*)/.exec(data); if (!result) throw new Error(this.lineMsg() + "invalid line; can't parse purchaseable product: " + data); var product = result[1]; var priceGuess = trim(result[2]); var label = trim(result[3]); this.finished = true; this.skipFooter = true; var self = this; getPrice(product, function (price) { if (!price || "free" == price) { self["goto"](label); self.finished = false; self.resetPage(); } else { if (price == "guess") price = priceGuess; var target = self.target; if (!target) target = document.getElementById('text'); self.paragraph(); var button = printButton("Buy It Now for " + price, target, false, function() { safeCall(self, function() { purchase(product, function() { safeCall(self, function() { self["goto"](label); self.finished = false; self.resetPage(); }); }); }); } ); self.prevLine = "block"; if (isRestorePurchasesSupported()) { self.prevLine = "text"; printLink(target, "#", "Restore Purchases", function() { safeCall(self, function() { restorePurchases(function() { checkPurchase([product], function(ok, purchases) { if (purchases[product]) { self["goto"](label); self.finished = false; self.resetPage(); } else { asyncAlert("Restore completed. This product is not yet purchased."); // refresh, in case we're on web showing a full-screen login. Not necessary on mobile? But, meh. clearScreen(loadAndRestoreGame); } }); }); }); } ); } self.skipFooter = false; self.finished = false; self.execute(); } }); }; // *abort // halt the scene in error(?) Scene.prototype.abort = Scene.prototype.finish; // *create // create a new permanent stat Scene.prototype.create = function create(line) { var result = /^(\w*)(.*)/.exec(line); if (!result) throw new Error(this.lineMsg()+"Invalid create instruction, no variable specified: " + line); var variable = result[1]; this.validateVariable(variable); variable = variable.toLowerCase(); var expr = result[2]; var stack = this.tokenizeExpr(expr); if (stack.length === 0) throw new Error(this.lineMsg()+"Invalid create instruction, no value specified: " + line); var self = this; function complexError() { throw new Error(self.lineMsg()+"Invalid create instruction, value must be a a number, true/false, or a quoted string: " + line); } if (stack.length > 1) complexError(); var token = stack[0]; if (!/STRING|NUMBER|VAR/.test(token.name)) complexError(); if ("VAR" == token.name && !/^true|false$/i.test(token.value)) complexError(); if ("STRING" == token.name && /\$!?!?{/.test(token.value)) throw new Error(this.lineMsg() + "Invalid create instruction, value must be a simple string without ${}: " + line); var value = this.evaluateExpr(stack); this.stats[variable] = value; if (this.nav) this.nav.startingStats[variable] = value; }; // *temp // create a temporary stat for the current scene Scene.prototype.temp = function temp(line) { var result = /^(\w*)(.*)/.exec(line); if (!result) throw new Error(this.lineMsg()+"Invalid temp instruction, no variable specified: " + line); var variable = result[1]; this.validateVariable(variable); var expr = result[2]; var stack = this.tokenizeExpr(expr); if (stack.length === 0) { this.temps[variable.toLowerCase()] = null; return; } var value = this.evaluateExpr(stack); this.temps[variable.toLowerCase()] = value; }; // retrieve the value of the variable, preferring temp scope Scene.prototype.getVar = function getVar(variable) { var value; variable = String(variable).toLowerCase(); if (variable == "true") return true; if (variable == "false") return false; if (variable == "choice_subscribe_allowed") return true; if (variable == "choice_register_allowed") return isRegisterAllowed(); if (variable == "choice_registered") return typeof window != "undefined" && !!window.registered; if (variable == "choice_is_web") return typeof window != "undefined" && window.isWeb; if (variable == "choice_is_advertising_supported") return typeof isAdvertisingSupported != "undefined" && !!isAdvertisingSupported(); if (variable == "choice_is_trial") return !!(typeof isTrial != "undefined" && isTrial); if (variable == "choice_kindle") return false; if (variable == "choice_randomtest") return !!this.randomtest; if (variable == "choice_restore_purchases_allowed") return isRestorePurchasesSupported(); if (variable == "choice_save_allowed") return areSaveSlotsSupported(); if (variable == "choice_time_stamp") return Math.floor(new Date()/1000); if ("undefined" === typeof this.temps[variable]) { if ("undefined" === typeof this.stats[variable]) { throw new Error(this.lineMsg() + "Non-existent variable '"+variable+"'"); } value = this.stats[variable]; if (value === null || value === undefined) { throw new Error(this.lineMsg() + "Variable '"+variable+"' exists but has no value"); } if (this.debugMode) println("stats["+ variable + "]==" + value); return value; } value = this.temps[variable]; if (value === null || value === undefined) { throw new Error(this.lineMsg() + "Variable '"+variable+"' exists but has no value"); } if (this.debugMode) println("temps["+ variable + "]==" + value); return value; }; // set the value of the variable, preferring temp scope Scene.prototype.setVar = function setVar(variable, value) { variable = variable.toLowerCase(); if (this.debugMode) println(variable +"="+ value); if ("undefined" === typeof this.temps[variable]) { if ("undefined" === typeof this.stats[variable]) { throw new Error(this.lineMsg() + "Non-existent variable '"+variable+"'"); } this.stats[variable] = value; if (this.saveSlot == "temp") this.stats.choice_dirty_stats = true; } else { this.temps[variable] = value; } }; // *delete variable // deletes the named variable Scene.prototype["delete"] = function scene_delete(variable) { variable = variable.toLowerCase(); if ("undefined" === typeof this.temps[variable]) { if ("undefined" === typeof this.stats[variable]) { throw new Error(this.lineMsg() + "Non-existent variable '"+variable+"'"); } delete this.stats[variable]; } else { delete this.temps[variable]; } }; // during a choice, recursively parse the options Scene.prototype.parseOptions = function parseOptions(startIndent, choicesRemaining, expectedSubOptions) { // nextIndent: the level of indentation after the current line // For example, in the color/toy sample above, we start at 0 // then the nextIndent is 2 for "red" // then the nextIndent is 4 for "spaceship" var nextIndent = null; var options = []; var line; var currentChoice = choicesRemaining[0]; if (!currentChoice) currentChoice = "choice"; var suboptionsEncountered = false; var bodyExpected = false; var previousSubOptions; var namesEncountered = {}; var atLeastOneSelectableOption = false; var prevOption, ifResult; var startingLine = this.lineNum; function removeModifierCommand() { line = trim(line.replace(/^\s*\*(\w+)(.*)/, "$2")); parsed = /^\s*\*(\w+)(.*)/.exec(line); if (parsed) { command = parsed[1].toLowerCase(); data = trim(parsed[2]); } else { command = ""; } } while(isDefined(line = this.lines[++this.lineNum])) { if (!trim(line)) { this.rollbackLineCoverage(); continue; } var indent = this.getIndent(line); if (nextIndent === null || nextIndent === undefined) { // initialize nextIndent with whatever indentation the line turns out to be // ...unless it's not indented at all if (indent <= startIndent) { throw new Error(this.lineMsg() + "invalid indent, expected at least one '" + currentChoice + "'"); } this.indent = nextIndent = indent; } if (indent <= startIndent) { // it's over! // TODO is this error test valid? if (choicesRemaining.length>1 && !suboptionsEncountered) { throw new Error(this.lineMsg() + "invalid indent, there were subchoices remaining: [" + choicesRemaining.join(",") + "]"); } if (bodyExpected && !this.fakeChoice) { throw new Error(this.lineMsg() + "Expected choice body"); } if (!atLeastOneSelectableOption) this.conflictingOptions("line " + (startingLine+1) + ": No selectable options"); if (expectedSubOptions) { this.verifyOptionsMatch(expectedSubOptions, options); } this.rollbackLineCoverage(); prevOption = options[options.length-1]; if (!prevOption.endLine) prevOption.endLine = this.lineNum; this.lineNum--; this.rollbackLineCoverage(); return options; } if (indent < this.indent) { // TODO drift detection if (false) /*(indent != nextIndent)*/ { // error: indentation has decreased, but not all the way back // Example: // *choice // red // blue throw new Error(this.lineMsg() + "invalid indent, expected "+this.indent+", was " + indent); } // we must be falling out of a sub-block this.dedent(indent); this.indent = indent; } if (indent > this.indent) { // body of the choice // ...unless we haven't identified our choices yet // TODO is this error test valid? if (choicesRemaining.length>1) throw new Error(this.lineMsg() + "invalid indent, there were subchoices remaining: [" + choicesRemaining.join(",") + "]"); this.rollbackLineCoverage(); bodyExpected = false; continue; } // here's the end of the previous option if (options.length) { prevOption = options[options.length-1]; if (!prevOption.endLine) prevOption.endLine = this.lineNum; } // Execute *if commands (etc.) during option loop // sub-commands may modify this.indent var parsed = /^\s*\*(\w+)(.*)/.exec(line); var unselectable = false; var inlineIf = null; var selectableIf = null; var self = this; var overrideDefaultReuseSetting = false; var reuse = this.temps.choice_reuse; if (parsed) { var command = parsed[1].toLowerCase(); var data = trim(parsed[2]); // TODO whitelist commands if ("hide_reuse" == command) { reuse = "hide"; overrideDefaultReuseSetting = true; removeModifierCommand(); } if ("disable_reuse" == command) { reuse = "disable"; overrideDefaultReuseSetting = true; removeModifierCommand(); } if ("allow_reuse" == command) { reuse = "allow"; overrideDefaultReuseSetting = true; removeModifierCommand(); } if ("print" == command) { line = this.evaluateExpr(this.tokenizeExpr(data)); } else if ("if" == command) { ifResult = this.parseOptionIf(data, command); if (ifResult) { inlineIf = ifResult.condition; if (ifResult.result) { line = ifResult.line; } else { continue; } } else { this["if"](data, true /*inChoice*/); continue; } } else if (/^(else|elseif|elsif)$/.test(command)) { this[command](data, true /*inChoice*/); continue; } else if ("selectable_if" == command) { ifResult = this.parseOptionIf(data, command); if (!ifResult) throw new Error(this.lineMsg() + "Couldn't parse the line after *selectable_if: " + data); line = ifResult.line; selectableIf = ifResult.condition; unselectable = unselectable || !ifResult.result; } else if ("comment" == command) { continue; } else if (!command) { // command was rewritten by earlier modifier } else { if (Scene.validCommands[command]) { throw new Error(this.lineMsg() + "Invalid indent? Expected an #option here, not *"+command); } else { throw new Error(this.lineMsg() + "Non-existent command '"+command+"'"); } } } if ("allow" != reuse) { if (!this.temps.choice_used) this.temps.choice_used = {}; if (this.temps.choice_used[this.lineNum]) { if ("hide" == reuse) continue; unselectable = true; } } // this line should be a valid option if (!/^\s*\#\s*\S/.test(line)) { throw new Error(this.lineMsg() + "Expected option starting with #"); } // replace variables here and discard the result, so error messages display the correct line this.replaceVariables(line); line = trim(trim(line).substring(1)); var option = {name:line, group:currentChoice}; if (reuse != "allow") option.reuse = reuse; if (this.displayOptionConditions) { option.displayIf = []; for (var i = 0; i < this.displayOptionConditions.length; i++) { option.displayIf[i] = this.displayOptionConditions[i]; } if (inlineIf) option.displayIf.push(inlineIf); } else if (inlineIf) { option.displayIf = [inlineIf]; } if (selectableIf) { option.selectableIf = selectableIf; } option.line = this.lineNum + 1; if (unselectable) { option.unselectable = true; } if (namesEncountered[line]) { this.conflictingOptions(this.lineMsg() + "Invalid option; conflicts with option '"+option.name+"' on line " + namesEncountered[line]); } else { namesEncountered[line] = option.line; } options.push(option); if (choicesRemaining.length>1) { // recursive call will modify this.indent option.suboptions = this.parseOptions(this.indent, choicesRemaining.slice(1), previousSubOptions); // now restore it this.indent = nextIndent; if (!previousSubOptions) previousSubOptions = option.suboptions; suboptionsEncountered = true; } else { bodyExpected = true; } if (!unselectable) atLeastOneSelectableOption = true; } if (bodyExpected && !this.fakeChoice) { throw new Error(this.lineMsg() + "Expected choice body"); } prevOption = options[options.length-1]; if (!prevOption.endLine) prevOption.endLine = this.lineNum; if (!atLeastOneSelectableOption) this.conflictingOptions("line " + (startingLine+1) + ": No selectable options"); return options; }; // compute *if statement during options Scene.prototype.parseOptionIf = function parseOptionIf(data) { var parsed = /^\s*\((.*)\)\s+(#.*)/.exec(data); if (!parsed) { return; } var condition = parsed[1]; var stack = this.tokenizeExpr(condition); var result = this.evaluateExpr(stack); if (this.debugMode) println(condition + " :: " + result); result = bool(result, this.lineNum+1); // In the autotester, all conditionals are enabled result = result || this.testPath; return {result:result, line:parsed[2], condition:null}; }; // Add this as a separate method so we can override it elsewhere // We want this error during randomtest but not during autotest // Because autotest makes impossible situations happen Scene.prototype.conflictingOptions = function conflictingOptions(str) { throw new Error(str); }; // verify that the current option set corresponds to the previous option set // (for multichoices) Scene.prototype.verifyOptionsMatch = function verifyOptionsMatch(prev, current) { // find matching option by name function findMatch(name, options) { for (var i = 0; i < options.length; i++) { var option = options[i]; if (option && name == option.name) { return option; } } return null; } var prevOpt, curOpt; for (var i = 0; i < prev.length; i++) { prevOpt = prev[i]; curOpt = findMatch(prevOpt.name, current); if (!curOpt) throw new Error(this.lineMsg()+"Missing expected suboption '"+prevOpt.name+"'; all suboptions must have same option list"); } if (prev.length == current.length) return; for (i = 0; i < current.length; i++) { curOpt = current[i]; prevOpt = findMatch(curOpt.name, prev); if (!prevOpt) throw new Error(this.lineMsg()+"Added unexpected suboption '"+curOpt.name+"'; all suboptions must have same option list"); } throw new Error(this.lineMsg()+"Bug? previous options and current options mismatch, but no particular missing element"); }; // render the prompt and the radio buttons Scene.prototype.renderOptions = function renderOptions(groups, options, callback) { for (var i = 0; i < options.length; i++) { var option = options[i]; option.name = this.replaceVariables(option.name); } this.paragraph(); printOptions(groups, options, callback); if (this.debugMode) println(toJson(this.stats)); if (this.finished) printFooter(); }; // *page_break // pause and prompt the user to press "Next" Scene.prototype.page_break = function page_break(buttonName) { if (this.screenEmpty) return; if (!buttonName) buttonName = "Next"; buttonName = this.replaceVariables(buttonName); this.paragraph(); this.finished = true; var self = this; printButton(buttonName, main, false, function() { self.finished = false; self.resetPage(); } ); if (this.debugMode) println(toJson(this.stats)); }; // *line_break // single line break in the middle of a paragraph Scene.prototype.line_break = function line_break() { println("", this.target); }; // *image // display named image Scene.prototype.image = function image(data) { data = data || ""; var args = data.split(" "); if (args > 2) throw new Error(this.lineMsg()+"Too many words; expected filename and alignment: " + data); var source = args[0]; var alignment = args[1]; alignment = alignment || "center"; if (!/(right|left|center|none)/.test(alignment)) throw new Error(this.lineMsg()+"Invalid alignment, expected right, left, center, or none: " + data); printImage(source, alignment); if (this.verifyImage) this.verifyImage(source); }; // *sound // play named sound file Scene.prototype.sound = function sound(source) { if (typeof playSound == "function") playSound(source); if (this.verifyImage) this.verifyImage(source); }; // *link // Display URL with anchor text Scene.prototype.link = function link(data) { var result = /^(\S+)\s*(.*)/.exec(data); if (!result) throw new Error(this.lineMsg() + "invalid line; this line should have an URL: " + data); var href = result[1]; var anchorText = trim(result[2]) || href; printLink(this.target, href, anchorText); this.prevLine = "text"; this.screenEmpty = false; }; // *link_button // Display button that takes you to an URL Scene.prototype.link_button = function linkButton(data) { if (typeof window == "undefined") return; var result = /^(\S+)\s*(.*)/.exec(data); if (!result) throw new Error(this.lineMsg() + "invalid line; this line should have an URL: " + data); var href = result[1]; var anchorText = trim(result[2]) || href; var target = this.target; if (!target) target = document.getElementById('text'); printButton(anchorText, target, false, function() { window.location.href = href; }); this.prevLine = "empty"; this.screenEmpty = false; }; // how many spaces is this line indented? Scene.prototype.getIndent = function getIndent(line) { if (line === null || line === undefined) return 0; var spaces = line.match(/^([ \t]*)/); if (spaces === null || spaces === undefined) return 0; var whitespace = spaces[0]; var len = whitespace.length; if (0 === len) return 0; var tab = /\t/.test(whitespace); var space = / /.test(whitespace); if (tab && space) { throw new Error(this.lineMsg()+"Tabs and spaces appear on the same line"); } if (tab) { this.firstTab = this.lineNum+1; if (this.firstSpace) { throw new Error(this.lineMsg()+"Illegal mixing of spaces and tabs; this line has a tab, but there were spaces on line " + this.firstSpace); } } else { this.firstSpace = this.lineNum + 1; if (this.firstTab) { throw new Error(this.lineMsg()+"Illegal mixing of spaces and tabs; this line has a space, but there were tabs on line " + this.firstTab); } } return len; }; // *comment ignorable text Scene.prototype.comment = function comment(line) { if (this.debugMode) println("*comment " + line); }; Scene.prototype.advertisement = function advertisement() { if (typeof isFullScreenAdvertisingSupported != "undefined" && isFullScreenAdvertisingSupported()) { this.finished = true; this.skipFooter = true; var self = this; showFullScreenAdvertisement(function() { self.finished = false; self.skipFooter = false; self.resetPage(); }); } }; // *looplimit 5 // The number of times a given line is allowed to be accessed Scene.prototype.looplimit = function looplimit() {}; // TODO looplimit Scene.prototype.hide_reuse = function hide_reuse() { this.temps.choice_reuse = "hide"; }; Scene.prototype.disable_reuse = function disable_reuse() { this.temps.choice_reuse = "disable"; }; Scene.prototype.allow_reuse = function allow_reuse() { this.temps.choice_reuse = "allow"; }; // *label labelName // Labels a line for use later in *goto // Do nothing here; these labels are parsed in this.parseLabel Scene.prototype.label = function label() {}; // *print expr // print the value of the specified expression Scene.prototype.print = function scene_print(expr) { var value = this.evaluateExpr(this.tokenizeExpr(expr)); this.prevLine = "text"; this.screenEmpty = false; this.printLine(value); printx(' ', this.target); }; // *input_text var // record text typed by the user and store it in the specified variable Scene.prototype.input_text = function input_text(variable) { var inputType = "text"; var longMatch = /^\S+(\s+long)/.exec(variable); if (longMatch) { variable = variable.substring(0, variable.length-longMatch[1].length); inputType = "textarea"; } this.validateVariable(variable); this.finished = true; this.paragraph(); var self = this; printInput(this.target, inputType, function(value) { safeCall(self, function() { value = ""+value || ""; value = value.replace(/\n/g, "[n/]"); if (self.nav) self.nav.bugLog.push("*input_text " + variable + " " + value); self.setVar(variable, value); self.finished = false; self.save(function() {self.resetPage();}, "temp"); }); }); if (this.debugMode) println(toJson(this.stats)); }; // *input_number var min max // record number typed by the user and store it in the specified variable Scene.prototype.input_number = function input_number(data) { var args = data.split(/ /); if (args.length != 3) { throw new Error(this.lineMsg() + "Invalid input_number statement, expected three args: varname min max"); } var variable, minimum, maximum; variable = args[0]; this.validateVariable(variable); minimum = this.evaluateValueExpr(args[1]); if (isNaN(minimum*1)) throw new Error(this.lineMsg() + "Invalid minimum, not numeric: " + minimum); maximum = this.evaluateValueExpr(args[2]); if (isNaN(maximum*1)) throw new Error(this.lineMsg() + "Invalid maximum, not numeric: " + maximum); if (parseFloat(minimum) > parseFloat(maximum)) throw new Error(this.lineMsg() + "Minimum " + minimum+ " should not be greater than maximum " + maximum); function isInt(x) { var y=parseInt(x,10); if (isNaN(y)) return false; return x==y && x.toString()==y.toString(); } var intRequired; if (isInt(minimum) && isInt(maximum)) { intRequired = 1; } this.finished = true; this.paragraph(); var self = this; printInput(this.target, "number", function(value) { safeCall(self, function() { var numValue = parseFloat(""+value); if (isNaN(numValue)) { asyncAlert("Please type in a number."); return; } if (intRequired && !isInt(value)) { asyncAlert("Please type in an integer number."); return; } if (numValue < minimum * 1) { asyncAlert("Please use a number greater than or equal to " + minimum); return; } if (numValue > maximum * 1) { asyncAlert("Please use a number less than or equal to " + maximum); return; } if (self.nav) self.nav.bugLog.push("*input_number " + variable + " " + value); self.setVar(variable, numValue); self.finished = false; self.resetPage(); }); }, minimum, maximum, intRequired); if (this.debugMode) println(toJson(this.stats)); }; // *script code // evaluate the specified ECMAScript Scene.prototype.script = function script(code) { var stats = this.stats; var temps = this.temps; if (typeof window == "undefined") { (function() { var window = _global; eval(code); }).call(this); } else { eval(code); } }; // is this a valid variable name? Scene.prototype.validateVariable = function validateVariable(variable) { if (!variable || !/^[a-zA-Z]/.test(variable)) { throw new Error(this.lineMsg()+"Invalid variable name, must start with a letter: " + variable); } if (!/^\w+$/.test(variable)) { throw new Error(this.lineMsg()+"Invalid variable name: '" + variable + "'"); } if (/^(and|or|true|false)$/.test(variable)) throw new Error(this.lineMsg()+"Invalid variable name, '" + variable + "' is a reserved word"); if (/^choice_/.test(variable)) throw new Error(this.lineMsg()+"Invalid variable name, variables may not start with 'choice_'; this is a reserved prefix"); }; // *rand varname min max // Set varname to a random number from min to max // // Example: // *rand foo 1 6 // roll a cube die // *rand foo 1.0 6.0 // compute a decimal from [1.0,6.0) Scene.prototype.rand = function rand(data) { // TODO make this parser more general var args = data.split(/ /); if (args.length != 3) { throw new Error(this.lineMsg() + "Invalid rand statement, expected three args: varname min max"); } var variable, minimum, maximum, diff; variable = args[0]; this.validateVariable(variable); minimum = this.evaluateValueExpr(args[1]); maximum = this.evaluateValueExpr(args[2]); diff = maximum - minimum; if (isNaN(diff)) { throw new Error(this.lineMsg() + "Invalid rand statement, min and max must be numbers"); } if (diff < 0) { throw new Error(this.lineMsg() + "Invalid rand statement, min must be less than max: " + minimum + " > " + maximum); } if (diff === 0) { this.setVar(variable, minimum); return; } function isInt(x) { var y=parseInt(x,10); if (isNaN(y)) return false; return x==y && x.toString()==y.toString(); } var result; var random = Math.random(); if (isInt(minimum) && isInt(maximum)) { // int random result = 1*minimum + Math.floor(random*(diff+1)); } else { result = 1*minimum + random*diff; } this.setVar(variable, result); if (this.randomLog) { this.randomLog("*rand " + variable + " " + result); } if (this.nav) this.nav.bugLog.push("*rand " + variable + " " + result); }; // *set varname expr // sets the specified varname to the value of the expr // // Examples: // // literal // literal int: 2 // literal decimal: 2.3 // boolean value: true // quoted string: "fie" // with backslash escaping "she said it was \"ironic\"!" "c:\\foo" // variable name: foo // // math // +: 2+2 // -: foo-3 // *: 2*3 // /: 8/2 // if one operand is a string, we'll try to parse it as a number, fail if that doesn't work // // fairmath // %+: foo%+30 // %-: foo%-20 // // concatenate // &: "foo"&bar // // may omit leading operand // *set foo +2 // *set foo %+30 // *set foo &"blah blah" // // spaces optional // *set foo+2 // *set foo bar + 2 // *set bar% +30 // // multiple operators in one line, parentheses mandatory // *set foo (foo+2)/4 // *set foo 2+(foo/2) // *set foo (foo/2)+(bar/3) // *set foo +(bar/3) // Scene.prototype.set = function set(line) { var result = /^(\w*)(.*)/.exec(line); if (!result) throw new Error(this.lineMsg()+"Invalid set instruction, no variable specified: " + line); var variable = result[1]; this.validateVariable(variable); var expr = result[2]; var stack = this.tokenizeExpr(expr); if (stack.length === 0) throw new Error(this.lineMsg()+"Invalid set instruction, no expression specified: " + line); // if the first token is an operator, then it's implicitly based on the variable if (/OPERATOR|FAIRMATH/.test(stack[0].name)) stack.unshift({name:"VAR", value:variable, pos:"(implicit)"}); var value = this.evaluateExpr(stack); this.setVar(variable, value); }; // *setref variableExpr expr // just like *set, but variableExpr is a string expression naming a variable reference // // Example: // *set foo "bar" // *setref foo 3 // *comment now bar=3 Scene.prototype.setref = function setref(line) { var stack = this.tokenizeExpr(line); var reference = this.evaluateValueToken(stack.shift(), stack); var referenceExpressionString; try { referenceExpressionString = trim(line.substring(0, stack[0].pos - stack[0].value.length)); } catch (e) {} try { this.validateVariable(reference); } catch (e) { if (typeof referenceExpressionString !== undefined) { throw new Error(this.lineMsg()+ "The expression ("+referenceExpressionString+") was \""+reference+"\", which is invalid:\n" + e.message); } } // if the first token is an operator, then it's implicitly based on the variable if (/OPERATOR|FAIRMATH/.test(stack[0].name)) stack.unshift({name:"VAR", value:reference, pos:"(implicit)"}); var value = this.evaluateExpr(stack); this.setVar(reference, value); }; Scene.prototype.share_this_game = function share_links(now) { now = !!trim(now); this.paragraph(); printShareLinks(this.target, now); this.prevLine = "empty"; // printShareLinks provides its own paragraph break }; Scene.prototype.more_games = function more_games(now) { if (typeof window == "undefined" || typeof moreGames == "undefined") return; if (!!trim(now)) { moreGames(); return; } var self = this; var target = this.target; if (!target) target = document.getElementById('text'); var button = printButton("Play More Games Like This", target, false, function() { safeCall(self, moreGames); } ); setClass(button, ""); this.prevLine = "block"; }; Scene.prototype.ending = function ending() { if (typeof window == "undefined") return; this.paragraph(); var groups = [""]; options = []; options.push({name:"Play again.", group:"choice", restart:true}); options.push({name:"Play more games like this.", group:"choice", moreGames:true}); options.push({name:"Share this game with friends.", group:"choice", share:true}); options.push({name:"Email me when new games are available.", group:"choice", subscribe:true}); var self = this; function endingMenu() { printFollowButtons(); self.renderOptions([""], options, function(option) { if (option.restart) { self.restart(); return; } else if (option.moreGames) { self.more_games("now"); setTimeout(function() {callIos("curl");}, 0); } else if (option.share) { clearScreen(function() { self.share_this_game("now"); endingMenu(); }); } else if (option.subscribe) { subscribeLink(); } }); } endingMenu(); this.finished = true; }; Scene.prototype.restart = function restart() { if (this.secondaryMode) throw new Error(this.lineMsg() + "Cannot *restart in " + this.secondaryMode + " mode"); this.finished = true; this.reset(); delayBreakEnd(); var startupScene = this.nav.getStartupScene(); var scene = new Scene(startupScene, this.stats, this.nav, {debugMode:this.debugMode, secondaryMode:false}); scene.resetPage(); }; Scene.prototype.subscribe = function scene_subscribe(now) { // "now" means we should immediately display the signup form // otherwise, we should display a Subscribe button which displays the form // On some platforms, "now" is impossible, so we ignore it. now = ("now" == now); this.prevLine = "block"; this.finished = true; this.skipFooter = true; var self = this; subscribe(this.target, now, function(now) { self.finished = false; // if "now" actually worked, then continue the scene // otherwise, reset the page before continuing if (now) { self.skipFooter = false; self.execute(); } else { self.resetPage(); } }); }; Scene.prototype.restore_game = function restore_game() { this.finished = true; this.skipFooter = true; var self = this; var unrestorableScenes = this.parseRestoreGame(false/*alreadyFinished*/); function renderRestoreMenu(saveList, dirtySaveList) { self.paragraph(); var options = []; for (var i = 0; i < saveList.length; i++) { var save = saveList[i]; var date = new Date(save.timestamp*1); if (!save) continue; var name = ""; if (save.temps && save.temps.choice_restore_name) name = save.temps.choice_restore_name; options.push({name:name + " ("+simpleDateTimeFormat(date)+")", group:"choice", state:save}); } if (false) options.push({name:"Restore using a password.", group:"choice", password:true}); options.push({name:"Retrieve saved games online from choiceofgames.com.", group:"choice", fetch:true}); if (dirtySaveList.length) options.push({name:"Upload saved games to choiceofgames.com.", group:"choice", upload:true}); options.push({name:"Cancel.", group:"choice", cancel:true}); var groups = [""]; self.renderOptions(groups, options, function(option) { if (option.upload) { clearScreen(function() { fetchEmail(function(defaultEmail){ self.printLine("Please type your email address to identify yourself."); promptEmailAddress(this.target, defaultEmail, function(cancel, email) { if (cancel) { self.finished = false; self.resetPage(); return; } clearScreen(function() { startLoading(); submitDirtySaves(dirtySaveList, email, function(ok) { doneLoading(); self.prevLine = "text"; // Put some space between the message and the option list if (!ok) { self.printLine("Error uploading saves. Please try again later."); renderRestoreMenu(saveList, dirtySaveList); } else { var count = dirtySaveList.length + (dirtySaveList.length == 1 ? " save" : " saves"); self.printLine("Uploaded " + count + "."); renderRestoreMenu(saveList, []); } }); }); }); }); }); } else if (option.fetch) { clearScreen(function() { fetchEmail(function(defaultEmail){ self.printLine("Please type your email address to identify yourself."); promptEmailAddress(this.target, defaultEmail, function(cancel, email) { if (cancel) { self.finished = false; self.resetPage(); return; } clearScreen(function() { startLoading(); getRemoteSaves(email, function (remoteSaveList) { doneLoading(); self.prevLine = "text"; if (!remoteSaveList) { self.printLine("Error downloading saves. Please try again later."); renderRestoreMenu(saveList, dirtySaveList); } else { mergeRemoteSaves(remoteSaveList, "recordDirty", function(saveList, newRemoteSaves, dirtySaveList) { if (!remoteSaveList.length) { self.printLine("No saves downloaded for email address \""+email+"\". (Is that the correct email address?) If you're having trouble, please contact support at "+getSupportEmail()+"."); renderRestoreMenu(saveList, dirtySaveList); } else { var downloadCount = remoteSaveList.length + " saved " + (remoteSaveList.length == 1 ? "game" : "games"); var newCount = newRemoteSaves + " new saved " + (newRemoteSaves == 1 ? "game" : "games"); self.printLine("Synchronized " + downloadCount + ". Downloaded " + newCount + "."); renderRestoreMenu(saveList, dirtySaveList); } }); } }); }); }); }); }); } else if (option.password) { clearScreen(function() { self.restore_password(); }); } else { if (option.cancel) { self.finished = false; self.resetPage(); } else { var state = option.state; var sceneName = null; if (state.stats && state.stats.sceneName) sceneName = (""+state.stats.sceneName).toLowerCase(); var unrestorable = unrestorableScenes[sceneName]; if (unrestorable) { asyncAlert(unrestorable); self.finished = false; self.resetPage(); return; } saveCookie(function() { clearScreen(function() { restoreGame(state, null, /*userRestored*/true); }); }, "", state.stats, state.temps, state.lineNum, state.indent, this.debugMode, this.nav); } } }); } getDirtySaveList(function(dirtySaveList) { getSaves(function(saveList) { renderRestoreMenu(saveList, dirtySaveList); }); }); }; Scene.prototype.restore_password = function restore_password() { var alreadyFinished = this.finished; this.finished = true; this.paragraph(); this.printLine('Please paste your password here, then press "Next" below to continue.'); this.prevLine = "text"; this.paragraph(); var self = this; var unrestorableScenes = this.parseRestoreGame(alreadyFinished); getPassword(this.target, function (cancel, password) { if (cancel) { self.finished = false; self.resetPage(); return; } password = password.replace(/\s/g, ""); password = password.replace(/^.*BEGINPASSWORD-----/, ""); var token = self.deobfuscatePassword(password); token = token.replace(/^[^\{]*/, ""); token = token.replace(/[^\}]*$/, ""); var state; try { state = jsonParse(token); } catch (e) { asyncAlert("Sorry, that password was invalid. Please contact " + getSupportEmail() + " for assistance. Be sure to include your password in the email."); return; } var sceneName = null; if (state.stats && state.stats.sceneName) sceneName = (""+state.stats.sceneName).toLowerCase(); var unrestorable = unrestorableScenes[sceneName]; if (unrestorable) { asyncAlert(unrestorable); self.finished = false; self.resetPage(); return; } saveCookie(function() { clearScreen(function() { // we're going to pretend not to be user restored, so we get reprompted to save restoreGame(state, null, /*userRestored*/false); }); }, "", state.stats, state.temps, state.lineNum, state.indent, this.debugMode, this.nav); }); if (alreadyFinished) printFooter(); }; Scene.prototype.parseRestoreGame = function parseRestoreGame(alreadyFinished) { if (alreadyFinished) { // if we're already finished, the printLoop bumped us an extra line ahead this.lineNum--; this.rollbackLineCoverage(); } // nextIndent: the level of indentation after the current line var nextIndent = null; var unrestorableScenes = {}; var line; var startIndent = this.indent; while(isDefined(line = this.lines[++this.lineNum])) { if (!trim(line)) { this.rollbackLineCoverage(); continue; } var indent = this.getIndent(line); if (nextIndent === null || nextIndent === undefined) { // initialize nextIndent with whatever indentation the line turns out to be // ...unless it's not indented at all if (indent > startIndent) { this.indent = nextIndent = indent; } } if (indent <= startIndent) { // it's over! if (!alreadyFinished) { this.lineNum--; this.rollbackLineCoverage(); } return unrestorableScenes; } if (indent != this.indent) { // all chart rows are supposed to be at the same indentation level // anything at the wrong indentation level might be a mis-indented title/definition // or just a typo throw new Error(this.lineMsg() + "invalid indent, expected "+this.indent+", was " + indent); } // // *restore_game // ending You can't restore to the ending. line = trim(line); var result = /^(\w+)\s+(.*)/.exec(line); if (!result) throw new Error(this.lineMsg() + "invalid line; this line should have a scene name followed by an error message: " + line); var sceneName = result[1].toLowerCase(); var error = trim(result[2]); unrestorableScenes[sceneName] = error; } if (!alreadyFinished) { this.lineNum--; this.rollbackLineCoverage(); } return unrestorableScenes; }; Scene.prototype.check_registration = function scene_checkRegistration() { if (typeof window == "undefined" || typeof isRegistered == "undefined") return; this.finished = true; this.skipFooter = true; var self = this; isRegistered(function() { self.finished = false; self.skipFooter = false; self.execute(); }); }; Scene.prototype.login = function scene_login(optional) { if (typeof window == "undefined" || typeof loginForm == "undefined") return; optional = trim(optional); if (optional) { if (optional != "optional") throw new Error(this.lineMsg() + "invalid *login option: " + optional); optional = 1; } var self = this; this.finished = true; this.skipFooter = true; this.paragraph(); var target = this.target; if (!target) target = document.getElementById('text'); loginForm(target, optional, null, function() { clearScreen(function() { self.finished = false; self.prevLine = "empty"; self.screenEmpty = true; self.execute(); }); }); }; Scene.prototype.save_game = function save_game(destinationSceneName) { if (!destinationSceneName) throw new Error(this.lineMsg()+"*save_game requires a destination file name, e.g. *save_game Episode2"); if (this.temps.choice_user_restored) return; var self = this; this.finished = true; this.skipFooter = true; fetchEmail(function(defaultEmail){ self.paragraph(); var form = document.createElement("form"); setClass(form, "saveGame"); form.action="#"; var message = document.createElement("div"); message.style.color = "red"; message.style.fontWeight = "bold"; form.appendChild(message); var saveName = document.createElement("input"); saveName.type="text"; saveName.name="saveName"; saveName.setAttribute("placeholder", "Type a name for your saved game"); saveName.setAttribute("style", "font-size: 25px; width: 90%;"); form.appendChild(saveName); if (_global.automaticCloudStorage) { println("", form); } else { println("", form); println("", form); println("Please login to the choiceofgames.com save system with your email address below.", form); var emailInput = document.createElement("input"); // This can fail on IE try { emailInput.type="email"; } catch (e) {} emailInput.name="email"; emailInput.value=defaultEmail; emailInput.setAttribute("placeholder", "you@example.com"); emailInput.setAttribute("style", "font-size: 25px; width: 90%;"); form.appendChild(emailInput); println("", form); println("", form); var subscribeLabel = document.createElement("label"); subscribeLabel.setAttribute("for", "subscribeBox"); var subscribeBox = document.createElement("input"); subscribeBox.type = "checkbox"; subscribeBox.name = "subscribe"; subscribeBox.setAttribute("id", "subscribeBox"); subscribeBox.setAttribute("checked", true); subscribeLabel.appendChild(subscribeBox); subscribeLabel.appendChild(document.createTextNode("Email me when new games are available.")); form.appendChild(subscribeLabel); } println("", form); var target = this.target; if (!target) target = document.getElementById('text'); target.appendChild(form); printButton("Next", form, true); printButton("Cancel", target, false, function() { clearScreen(function() { self.finished = false; self.prevLine = "empty"; self.screenEmpty = true; self.execute(); }); }); form.onsubmit = function(e) { preventDefault(e); safeCall(this, function() { var messageText; if (!trim(saveName.value)) { messageText = document.createTextNode("Please type a name for your saved game."); message.innerHTML = ""; message.appendChild(messageText); return; } var slot = "save" + new Date().getTime(); // create a clone stats object whose scene name is the destination scene var saveStats = {}; for (var stat in self.stats) { if ("scene" == stat) continue; saveStats[stat] = self.stats[stat]; } saveStats.scene = {name:destinationSceneName}; if (_global.automaticCloudStorage) { clearScreen(function() { saveCookie(function() { recordSave(slot, function() { self.finished = false; self.prevLine = "empty"; self.screenEmpty = true; self.execute(); }); }, slot, saveStats, {choice_reuse:"allow", choice_user_restored:true, choice_restore_name:saveName.value}, 0, 0, false, self.nav); }); return; } var shouldSubscribe = subscribeBox.checked; var email = trim(emailInput.value); if (!/^\S+@\S+\.\S+$/.test(email)) { messageText = document.createTextNode("Sorry, \""+email+"\" is not an email address. Please type your email address again."); message.innerHTML = ""; message.appendChild(messageText); return; } recordEmail(email, function() { clearScreen(function() { saveCookie(function() { recordSave(slot, function() { startLoading(); submitRemoteSave(slot, email, shouldSubscribe, function(ok) { doneLoading(); if (!ok) { asyncAlert("Couldn't upload your saved game to choiceofgames.com. You can try again later from the Restore menu.", function() { self.finished = false; self.prevLine = "empty"; self.screenEmpty = true; self.execute(); }); } else { self.finished = false; self.prevLine = "empty"; self.screenEmpty = true; self.execute(); } }); }); }, slot, saveStats, {choice_reuse:"allow", choice_user_restored:true, choice_restore_name:saveName.value}, 0, 0, false, self.nav); }); }); }); }; printFooter(); }); }; Scene.prototype.show_password = function show_password() { if (this.temps.choice_user_restored) return; this.paragraph(); if (typeof(window) != "undefined" && !window.isMobile) { this.printLine('Please copy and paste the password in a safe place, then press "Next" below to continue.'); println("", this.target); println("", this.target); } var password = computeCookie(this.stats, this.temps, this.lineNum, this.indent); password = this.obfuscate(password); showPassword(this.target, password); this.prevLine = "block"; }; Scene.prototype.obfuscate = function obfuscate(password) { var self = this; return password.replace(/./g, function(x) { var y = self.obfuscator[x]; return y; } ); }; // The obfuscator must take US-ASCII and obfuscate it // for use in a password. This password will be sent via // HTML email and its whitespace handling will be unpredictable, // So we can't output any of these characters: [ <>&] // Since we're losing four characters of output, we JSON-escape // four characters of input [^`|~]. Scene.prototype.obfuscator = { " ": "k", "!": "E", "\"": "`", "#": "\\", "$": "r", "%": "J", "&": "o", "'": "0", "(": "Z", ")": "M", "*": "G", "+": "t", ",": "Y", "-": "f", ".": "2", "/": "!", "0": "i", "1": "*", "2": "1", "3": "3", "4": "[", "5": "6", "6": "v", "7": "\"", "8": "F", "9": "9", ":": "{", ";": "Q", "<": "?", "=": "5", ">": "#", "?": "K", "@": "/", "A": "=", "B": "N", "C": "z", "D": "$", "E": "W", "F": "(", "G": ")", "H": "q", "I": "C", "J": "+", "K": "U", "L": ".", "M": "H", "N": "B", "O": "S", "P": "X", "Q": "I", "R": "-", "S": "m", "T": "D", "U": "^", "V": "A", "W": "a", "X": "y", "Y": ",", "Z": "d", "[": "O", "\\": "s", "]": "8", "^": "sVii6h", "_": "]", "`": "sViivi", "a": "4", "b": "g", "c": "%", "d": "w", "e": "h", "f": "n", "g": "b", "h": "7", "i": "x", "j": "~", "k": "_", "l": "l", "m": ":", "n": "c", "o": "L", "p": "j", "q": "u", "r": "R", "s": "}", "t": "p", "u": "V", "v": "P", "w": "'", "x": "T", "y": "|", "z": "@", "{": "e", "|": "sVii\"%", "}": ";", "~": "sVii\"h" }; Scene.prototype.deobfuscator = { "k": " ", "E": "!", "`": "\"", "\\": "#", "r": "$", "J": "%", "o": "&", "0": "'", "Z": "(", "M": ")", "G": "*", "t": "+", "Y": ",", "f": "-", "2": ".", "!": "/", "i": "0", "*": "1", "1": "2", "3": "3", "[": "4", "6": "5", "v": "6", "\"": "7", "F": "8", "9": "9", "{": ":", "Q": ";", "?": "<", "5": "=", "#": ">", "K": "?", "/": "@", "=": "A", "N": "B", "z": "C", "$": "D", "W": "E", "(": "F", ")": "G", "q": "H", "C": "I", "+": "J", "U": "K", ".": "L", "H": "M", "B": "N", "S": "O", "X": "P", "I": "Q", "-": "R", "m": "S", "D": "T", "^": "U", "A": "V", "a": "W", "y": "X", ",": "Y", "d": "Z", "O": "[", "s": "\\", "8": "]", "]": "_", "4": "a", "g": "b", "%": "c", "w": "d", "h": "e", "n": "f", "b": "g", "7": "h", "x": "i", "~": "j", "_": "k", "l": "l", ":": "m", "c": "n", "L": "o", "j": "p", "u": "q", "R": "r", "}": "s", "p": "t", "V": "u", "P": "v", "'": "w", "T": "x", "|": "y", "@": "z", "e": "{", ";": "}" }; Scene.prototype.deobfuscatePassword = function deobfuscatePassword(password) { var self = this; password = password.replace(/./g, function(x) { var y = self.deobfuscator[x]; return y; } ); return password; }; Scene.prototype.stat_chart = function stat_chart() { var rows = this.parseStatChart(); var target = this.target; if (!target) target = document.getElementById('text'); if (this.prevLine == "text") println("", target); var barWidth = 0; var standardFontSize = 0; function fixFontSize(span1, span2) { if (!standardFontSize) { if (window.getComputedStyle) { standardFontSize = parseInt(getComputedStyle(document.body).fontSize, 10); } else if (document.body.currentStyle) { standardFontSize = parseInt(document.body.currentStyle.fontSize, 10); } else { standardFontSize = 16; } } if (!barWidth) barWidth = span1.parentNode.offsetWidth; var spanMaxWidth, biggestSpanWidth; if (span2) { spanMaxWidth = barWidth / 2 - 1; /* minus one as a fudge factor; why is this needed? */ biggestSpanWidth = Math.max(span1.offsetWidth, span2.offsetWidth); } else { spanMaxWidth = barWidth; biggestSpanWidth = span1.offsetWidth; } if (biggestSpanWidth > spanMaxWidth) { span1.parentNode.style.fontSize = Math.floor(standardFontSize * spanMaxWidth / biggestSpanWidth) + "px"; } } for (i = 0; i < rows.length; i++) { var row = rows[i]; var type = row.type; var variable = row.variable; var value = this.getVar(variable); var label = this.replaceVariables(row.label); var definition = this.replaceVariables(row.definition || ""); var statWidth, div, span, statValue; if (type == "text") { div = document.createElement("div"); setClass(div, "statLine"); span = document.createElement("span"); if (trim(label) || trim(value)) { printx("\u00a0\u00a0"+label + ": " + value, span); } else { // unofficial line_break printx(" ", span); } div.appendChild(span); target.appendChild(div); } else if (type == "percent") { div = document.createElement("div"); setClass(div, "statBar statLine"); span = document.createElement("span"); printx("\u00a0\u00a0"+label+": "+value+"%", span); div.appendChild(span); statValue = document.createElement("div"); setClass(statValue, "statValue"); statValue.style.width = value+"%"; statValue.innerHTML = " "; div.appendChild(statValue); target.appendChild(div); fixFontSize(span); } else if (type == "opposed_pair") { div = document.createElement("div"); setClass(div, "statBar statLine opposed"); span0 = document.createElement("span"); printx("\u00a0\u00a0"+label+": "+value+"% ", span0); div.appendChild(span0); span = document.createElement("span"); span.setAttribute("style", "float: right"); printx(row.opposed_label+": "+(100-value)+"%\u00a0\u00a0", span); div.appendChild(span); statValue = document.createElement("div"); setClass(statValue, "statValue"); statValue.style.width = value+"%"; statValue.innerHTML = " "; div.appendChild(statValue); target.appendChild(div); fixFontSize(span0, span); } else { throw new Error("Bug! Parser accepted an unknown row type: " + type); } } this.prevLine = "block"; this.screenEmpty = false; }; Scene.prototype.parseStatChart = function parseStatChart() { // nextIndent: the level of indentation after the current line var nextIndent = null; var rows = []; var line, line1, line2, line2indent; var startIndent = this.indent; while(isDefined(line = this.lines[++this.lineNum])) { if (!trim(line)) { this.rollbackLineCoverage(); continue; } var indent = this.getIndent(line); if (nextIndent === null || nextIndent === undefined) { // initialize nextIndent with whatever indentation the line turns out to be // ...unless it's not indented at all if (indent <= startIndent) { throw new Error(this.lineMsg() + "invalid indent, expected at least one row"); } this.indent = nextIndent = indent; } if (indent <= startIndent) { // it's over! this.rollbackLineCoverage(); this.lineNum--; this.rollbackLineCoverage(); return rows; } if (indent != this.indent) { // all chart rows are supposed to be at the same indentation level // anything at the wrong indentation level might be a mis-indented title/definition // or just a typo throw new Error(this.lineMsg() + "invalid indent, expected "+this.indent+", was " + indent); } // // *stat_chart // text wounds Wounds // Definition // percent Infamy Infamy // Definition // opposed_pair brutality // Brutality // Strength and cruelty // Finesse // Precision and aerial maneuverability // // TODO opposed_pair // TODO definitions // TODO variable substitutions // TODO *if/*else // TODO *line_break line = trim(line); var result = /^(text|percent|opposed_pair)\s+(.*)/.exec(line); if (!result) throw new Error(this.lineMsg() + "invalid line; this line should start with 'percent', 'text', or 'opposed_pair'"); var type = result[1].toLowerCase(); var data = trim(result[2]); if ("opposed_pair" == type) { this.getVar(data); line1 = this.lines[++this.lineNum]; this.replaceVariables(line1); line1indent = this.getIndent(line1); if (line1indent <= this.indent) throw new Error(this.lineMsg() + "invalid indent; expected at least one indented line to indicate opposed pair name. indent: " + line1indent + ", expected greater than " + this.indent); line2 = this.lines[this.lineNum + 1]; line2indent = this.getIndent(line2); if (line2indent <= this.indent) { // line1 was the only line rows.push({type: type, variable: data, label: data, opposed_label: trim(line1)}); } else { this.lineNum++; this.replaceVariables(line2); if (line2indent == line1indent) { // two lines: first label, second label rows.push({type: type, variable: data, label: trim(line1), opposed_label: trim(line2)}); } else if (line2indent > line1indent) { // line 2 is a definition; therefore the opposed_label and its definition must be on lines 3 and 4 var line1definition = line2; var line3 = this.lines[++this.lineNum]; this.replaceVariables(line3); var line3indent = this.getIndent(line3); if (line3indent != line1indent) throw new Error(this.lineMsg() + "invalid indent; this line should be the opposing label name. expected " + line1indent + " was " + line3indent); var line4 = this.lines[++this.lineNum]; this.replaceVariables(line4); var line4indent = this.getIndent(line4); if (line4indent != line2indent) throw new Error(this.lineMsg() + "invalid indent; this line should be the opposing label definition. expected " + line2indent + " was " + line4indent); rows.push({type: type, variable: data, label: trim(line1), definition:trim(line2), opposed_label: trim(line3), opposed_definition: trim(line4)}); } else { throw new Error(this.lineMsg() + "invalid indent; expected a second line with indent " + line1indent + " to match line " + this.lineNum + ", or else no more opposed_pair lines"); } } } else { var variable, label; if (!/ /.test(data)) { variable = data; label = data; } else { result = /^(\S+) (.*)/.exec(data); if (!result) throw new Error(this.lineMsg() + "Bug! can't find a space when a space was found"); variable = result[1]; label = result[2]; } this.getVar(variable); this.replaceVariables(label); line2 = this.lines[this.lineNum + 1]; line2indent = this.getIndent(line2); if (line2indent <= this.indent) { // No definition line rows.push({type: type, variable: variable, label: label}); } else { this.lineNum++; this.replaceVariables(line2); rows.push({type: type, variable: variable, label: label, definition: trim(line2)}); } } } return rows; }; Scene.prototype.delay_break = function(durationInSeconds) { if (isNaN(durationInSeconds * 1)) throw new Error(this.lineMsg() + "invalid duration"); this.finished = true; this.skipFooter = true; var target = this.target; if (!target) { target = document.createElement("p"); document.getElementById('text').appendChild(target); } var self = this; delayBreakStart(function(delayStart) { var endTimeInSeconds = durationInSeconds * 1 + delayStart * 1; showTicker(target, endTimeInSeconds, function() { printButton("Next", target, false, function() { delayBreakEnd(); self.finished = false; self.resetPage(); }); }); printFooter(); }); }; Scene.prototype.delay_ending = function(data) { var args = data.split(/ /); var durationInSeconds = args[0]; var fullPriceGuess = args[1]; var singleUsePriceGuess = args[2]; if (isNaN(durationInSeconds * 1)) throw new Error(this.lineMsg() + "invalid duration"); if (!/^\$/.test(fullPriceGuess)) throw new Error(this.lineMsg() + "invalid fullPriceGuess: \""+fullPriceGuess+"\""); if (singleUsePriceGuess && !/^\$/.test(singleUsePriceGuess)) throw new Error(this.lineMsg() + "invalid singleUsePriceGuess: \""+singleUsePriceGuess+"\""); this.finished = true; this.skipFooter = true; var self = this; checkPurchase("adfree", function(ok, result) { if (result.adfree || !result.billingSupported || (typeof window != "undefined" && window.isWeb)) { self.ending(); return; } getPrice("adfree", function (fullPrice) { if (fullPrice == "guess") fullPrice = fullPriceGuess; getPrice("skiponce", function (singleUsePrice) { if (singleUsePrice == "guess") singleUsePrice = singleUsePriceGuess; options = []; var finishedWaiting = {name: "Play again after a short wait. ", unselectable: true}; options.push(finishedWaiting); var upgradeSkip = {name: "Upgrade to the unlimited version for " + fullPrice + " to skip the wait forever."}; options.push(upgradeSkip); var skipOnce = {name: "Skip the wait one time for " + singleUsePrice + "."}; if (singleUsePriceGuess) options.push(skipOnce); var restorePurchasesOption = {name: "Restore purchases from another device."}; if (isRestorePurchasesSupported()) options.push(restorePurchasesOption); var playMoreGames = {name: "Play more games like this."}; options.push(playMoreGames); var emailMe = {name: "Email me when new games are available."}; options.push(emailMe); self.paragraph(); printOptions([""], options, function(option) { if (option == playMoreGames) { self.more_games("now"); setTimeout(function() {callIos("curl");}, 0); } else if (option == emailMe) { subscribeLink(); } else if (option == upgradeSkip) { purchase("adfree", function() { safeCall(self, function() { self.restart(); }); }); } else if (option == skipOnce) { purchase("skiponce", function() { safeCall(self, function() { self.restart(); }); }); } else if (option == restorePurchasesOption) { restorePurchases(function() { clearScreen(loadAndRestoreGame); }); } else { self.restart(); } }); var target = document.getElementById("0").parentElement; delayBreakStart(function(delayStart) { var endTimeInSeconds = durationInSeconds * 1 + delayStart * 1; showTicker(target, endTimeInSeconds, function() { clearScreen(function() { self.ending(); }); }); printFooter(); }); }); }); }); }; // *if booleanExpr // execute different code depending on whether the booleanExpr is true or false // // Examples: // *if bool-expression // blah // blah // *elseif bool-expression // blah // blah // *else // blah // blah // bool-expression // by reference // *set foo true // *if foo ... // equality // foo=2 // foo="blah" // 2="2" // true // inequality // foo>2 // foo<2 // foo<=2 // foo>=2 // and/or logic, parentheses mandatory // (foo=2) or (foo=3) // ((foo>4) and (foo<8)) or (bar=0) // // NOTE: *if commands may be used inside *choices, to make some choices conditionally available Scene.prototype["if"] = function scene_if(line) { var stack = this.tokenizeExpr(line); var result = this.evaluateExpr(stack); if (this.debugMode) println(line + " :: " + result); result = bool(result, this.lineNum+1); if (result) { // "true" branch, just go on to the next line this.indent = this.getIndent(this.nextNonBlankLine()); } else { // "false" branch; skip over the true branch this.skipTrueBranch(false); } }; // TODO Rename this function to just skipBranch Scene.prototype.skipTrueBranch = function skipTrueBranch(inElse) { var startIndent = this.indent; var nextIndent = null; while (isDefined(line = this.lines[++this.lineNum])) { this.rollbackLineCoverage(); if (!trim(line)) continue; var indent = this.getIndent(line); if (nextIndent === null || nextIndent === undefined) { if (indent <= startIndent) throw new Error(this.lineMsg() + "invalid indent, expected at least one line in 'if' true block"); nextIndent = indent; } if (indent <= startIndent) { // true block is over var parsed; // check to see if this is an *else or *elseif if (indent == startIndent) parsed = /^\s*\*(\w+)(.*)/.exec(line); if (!parsed || inElse) { this.lineNum--; this.rollbackLineCoverage(); this.indent = indent; return; } var command = parsed[1].toLowerCase(); var data = trim(parsed[2]); if ("else" == command) { if (data) { if (/^if\b/.test(data)) { throw new Error(this.lineMsg() + "'else if' is invalid, use 'elseif'"); } throw new Error(this.lineMsg() + "nothing should appear on a line after 'else': " + data); } this.lineNum = this.lineNum; // code coverage // go on to the next line this.indent = this.getIndent(this.nextNonBlankLine()); } else if (/^else?if$/.test(command)) { this.lineNum = this.lineNum; // code coverage this["if"](data); } else { this.lineNum--; this.rollbackLineCoverage(); this.indent = this.getIndent(this.nextNonBlankLine()); } return; } if (indent < nextIndent) { // *if foo // foo // bar throw new Error(this.lineMsg() + "invalid indent, expected "+nextIndent+", was " + indent); } } }; Scene.prototype["else"] = Scene.prototype.elsif = Scene.prototype.elseif = function scene_else(data, inChoice) { if (inChoice) { this.skipTrueBranch(true); return; } throw new Error(this.lineMsg() + "It is illegal to fall in to an *else statement; you must *goto or *finish before the end of the indented block."); }; // break the string up into a stack of tokens, defined in Scene.tokens below Scene.prototype.tokenizeExpr = function tokenizeExpr(str) { var stack = []; var tokenTypes = Scene.tokens; var tokenTypesLength = tokenTypes.length; var pos = 0; while (str) { var matched = false; for (var i = 0; i < tokenTypesLength; i++) { var tokenType = tokenTypes[i]; var token = tokenType.test(str, this.lineNum+1); if (token) { matched = true; str = str.substr(token.length); pos += token.length; if ("WHITESPACE" == tokenType.name) { break; } stack.push({name:tokenType.name, value:token, pos:pos}); break; } } if (!matched) throw new Error(this.lineMsg()+"Invalid expression, couldn't extract another token: " + str); } return stack; }; // evaluate the stack of tokens // parenthetical == true if we're evaluating a parenthetical expression // all expressions consist of either a "singleton" value (2) or two values and one operator (2+2) Scene.prototype.evaluateExpr = function evaluateExpr(stack, parenthetical) { if (!stack.length) { throw new Error(this.lineMsg() + "no expression specified"); } var self = this; function getToken() { var token = stack.shift(); if (!token) throw new Error(self.lineMsg() + "null token"); return token; } var token, value1, value2, operator, result; value1 = this.evaluateValueToken(getToken(), stack); if (!stack.length) { if (parenthetical) { throw new Error(this.lineMsg() + "Invalid expression, expected final closing parenthesis"); } return value1; } token = getToken(); if (parenthetical && parenthetical == token.name) { return value1; } // Since this isn't a singleton, it must be an operator operator = Scene.operators[token.value]; if (!operator) throw new Error(this.lineMsg() + "Invalid expression at char "+token.pos+", expected OPERATOR, was: " + token.name + " [" + token.value + "]"); // fetch the final value value2 = this.evaluateValueToken(getToken(), stack); // and do the operator result = operator(value1, value2, this.lineNum+1); if (parenthetical) { // expect close parenthesis if (stack.length) { token = getToken(); if (parenthetical == token.name) { return result; } else { throw new Error(this.lineMsg() + "Invalid expression at char "+token.pos+", expected closing parenthesis, was: " + token.name + " [" + token.value + "]"); } } else { throw new Error(this.lineMsg() + "Invalid expression, expected final closing parenthesis"); } } else { // if not parenthetical, expect no more tokens if (stack.length) { token = getToken(); throw new Error(this.lineMsg() + "Invalid expression at char "+token.pos+", expected no more tokens, found: " + token.name + " [" + token.value + "]"); } else { return result; } } throw new Error(this.lineMsg() + "bug, how did I get here?"); }; // turn a number, string, or var token into its value // or, if this is an open parenthesis, evaluate the parenthetical expression Scene.prototype.evaluateValueToken = function evaluateValueToken(token, stack) { var name = token.name; var value; if ("OPEN_PARENTHESIS" == name) { return this.evaluateExpr(stack, "CLOSE_PARENTHESIS"); } else if ("OPEN_CURLY" == name) { value = this.evaluateExpr(stack, "CLOSE_CURLY"); return this.getVar(value); } else if ("FUNCTION" == name) { var functionName = /^\w+/.exec(token.value)[0]; if (!this.functions[functionName]) throw new Error(this.lineMsg + "Unknown function " + functionName); value = this.evaluateExpr(stack, "CLOSE_PARENTHESIS"); return this.functions[functionName].call(this, value); } else if ("NUMBER" == name) { return token.value; } else if ("STRING" == name) { // strip off the quotes and unescape backslashes return this.replaceVariables(token.value.slice(1,-1).replace(/\\(.)/g, "$1")); } else if ("VAR" == name) { return this.getVar(token.value); } else { throw new Error(this.lineMsg() + "Invalid expression at char "+token.pos+", expected NUMBER, STRING, VAR or PARENTHETICAL, was: " + name + " [" + token.value + "]"); } }; Scene.prototype.functions = { not: function(value) { return !bool(value, this.lineNum+1); }, round: function(value) { if (isNaN(value*1)) throw new Error(this.lineMsg()+"round() value is not a number: " + value); return Math.round(value); }, timestamp: function(value) { return Date.parse(value)/1000; }, log: function(value) { if (isNaN(value*1)) throw new Error(this.lineMsg()+"log() value is not a number: " + value); return Math.log(value)/Math.log(10); } }; Scene.prototype.evaluateValueExpr = function evaluateValueExpr(expr) { var stack = this.tokenizeExpr(expr); var token = stack.shift(); if (!token) throw new Error(this.lineMsg() + "null token"); var value = this.evaluateValueToken(token, stack); if (stack.length) { token = stack.shift(); if (!token) throw new Error(this.lineMsg() + "null token"); throw new Error(this.lineMsg() + "Invalid expression at char "+token.pos+", expected no more tokens, found: " + token.name + " [" + token.value + "]"); } return value; }; Scene.prototype.goto_random_scene = function gotoRandomScene(data) { var parsed = this.parseGotoRandomScene(data); var allowReuseGlobally = /\ballow_reuse\b/.test(data); var allowNoSelection = /\ballow_no_selection\b/.test(data); var option = this.computeRandomSelection(Math.random(), parsed, allowReuseGlobally); if (option) { this.goto_scene(option.name); } else { if (allowNoSelection) { return; } else { throw new Error(this.lineMsg() + "No selectable scenes"); } } }; Scene.prototype.parseGotoRandomScene = function parseGotoRandomScene(data) { data = data || ""; var directives = data.split(" "); var allowReuseGlobally = false; var allowNoSelection = false; for (var i = 0; i < directives; i++) { var directive = trim(directives[i]); if (!directive) continue; if (directive == "allow_reuse") { allowReuseGlobally = true; } else if (directive == "allow_no_selection") { allowNoSelection = true; } else { throw new Error(this.lineMsg() + "invalid command: '" + directive + "'"); } } // nextIndent: the level of indentation after the current line var nextIndent = null; var options = []; var line; var startIndent = this.indent; while(isDefined(line = this.lines[++this.lineNum])) { if (!trim(line)) { this.rollbackLineCoverage(); continue; } var indent = this.getIndent(line); if (nextIndent === null || nextIndent === undefined) { // initialize nextIndent with whatever indentation the line turns out to be // ...unless it's not indented at all if (indent <= startIndent) { throw new Error(this.lineMsg() + "invalid indent, expected at least one line in *goto_random_scene"); } this.indent = nextIndent = indent; } if (indent <= startIndent) { // it's over! this.rollbackLineCoverage(); this.lineNum--; this.rollbackLineCoverage(); break; } if (indent != this.indent) { // all chart rows are supposed to be at the same indentation level // anything at the wrong indentation level might be a mis-indented title/definition // or just a typo throw new Error(this.lineMsg() + "invalid indent, expected "+this.indent+", was " + indent); } line = trim(line); var option = {allowReuse:allowReuseGlobally}; var command; while(!!(command = /^\*(\S+)/.exec(line))) { command = command[1]; if ("allow_reuse" == command) { option.allowReuse = true; line = trim(line.substring("*allow_reuse".length)); command = /^\*(\S+)/.exec(line); continue; } else if ("if" == command) { var conditional = /^\*if\s+\((.+)\)\s+([^\)]+)/.exec(line); if (!conditional) throw new Error(this.lineMsg() + " invalid *if, expected () followed by scene name: " + line); line = conditional[2]; var stack = this.tokenizeExpr(conditional[1]); this.evaluateExpr(stack); option.conditional = conditional[1]; } else { throw new Error(this.lineMsg() + " invalid command: " + line); } } // TODO weights option.name = trim(line); options.push(option); } return options; }; Scene.prototype.computeRandomSelection = function computeRandomSelection(randomFloat, options, allowReuseGlobally) { var filtered = []; var finished = {}; if (!allowReuseGlobally) { if (!this.stats.choice_grs) this.stats.choice_grs = []; } var grs = this.stats.choice_grs; for (var i = 0; i < grs.length; i++) { finished[grs[i]] = 1; } var option; for (i = 0; i < options.length; i++) { option = options[i]; if (!option.allowReuse) { if (finished[option.name]) continue; } if (option.conditional) { var stack = this.tokenizeExpr(option.conditional); var pass = this.evaluateExpr(stack); if (!pass) continue; } filtered.push(option); } if (!filtered.length) return null; // TODO weights var randomSelection = Math.floor(randomFloat*filtered.length); option = filtered[randomSelection]; if (!option.allowReuse) { this.stats.choice_grs.push(option.name); } return option; }; Scene.prototype.end_trial = function endTrial() { this.paragraph(); printLink(this.target, "#", "Start Over from the Beginning", function(e) { preventDefault(e); return restartGame("prompt"); }); this.prevLine = "block"; this.screenEmpty = false; this.finished = true; }; Scene.prototype.achieve = function scene_achieve(name) { name = name.toLowerCase(); if (!this.nav.achievements.hasOwnProperty(name)) { throw new Error(this.lineMsg() + "the achievement name "+name+" was not declared as an *achievement in startup"); } var achievement = this.nav.achievements[name]; this.nav.achieved[name] = true; if (typeof window != "undefined" && typeof achieve != "undefined") { achieve(name, achievement.title, achievement.earnedDescription); } }; Scene.prototype.check_achievements = function scene_checkAchievements() { var self = this; function callback(immediately) { for (var achievement in nav.achievements) { self.temps["choice_achieved_"+achievement] = nav.achieved.hasOwnProperty(achievement); } if (!immediately) { self.finished = false; self.skipFooter = false; self.execute(); } } if (typeof checkAchievements == "undefined") { callback("immediately"); } else { this.finished = true; this.skipFooter = true; checkAchievements(callback); } }; Scene.prototype.scene_list = function scene_list() { var scenes = this.parseSceneList(); this.nav.setSceneList(scenes); }; Scene.prototype.parseSceneList = function parseSceneList() { var nextIndent = null; var scenes = []; var line; var startIndent = this.indent; while(isDefined(line = this.lines[++this.lineNum])) { if (!trim(line)) { this.rollbackLineCoverage(); continue; } var indent = this.getIndent(line); if (nextIndent === null || nextIndent === undefined) { // initialize nextIndent with whatever indentation the line turns out to be // ...unless it's not indented at all if (indent <= startIndent) { throw new Error(this.lineMsg() + "invalid indent, expected at least one row"); } this.indent = nextIndent = indent; } if (indent <= startIndent) { // it's over! this.rollbackLineCoverage(); this.lineNum--; this.rollbackLineCoverage(); return scenes; } if (indent != this.indent) { // all scenes are supposed to be at the same indentation level throw new Error(this.lineMsg() + "invalid indent, expected "+this.indent+", was " + indent); } line = trim(line); var purchaseMatch = /^\$(\w*)\s+(.*)/.exec(line); if (purchaseMatch) { line = purchaseMatch[2]; } if (!scenes.length && "startup" != line) scenes.push("startup"); scenes.push(line); } return scenes; }; Scene.prototype.title = function scene_title(title) { if (typeof changeTitle != "undefined") { changeTitle(title); } }; Scene.prototype.author = function scene_author(author) { if (typeof changeAuthor != "undefined") { changeAuthor(author); } }; // *achievement name hidden|visible 100 Achievement Title // Earned description // Pre-earned description Scene.prototype.achievement = function scene_achievement(data) { var parsed = /(\S+)\s+(\S+)\s+(\S+)\s+(.*)/.exec(data); if (!parsed) throw new Error(this.lineMsg() + "Invalid *achievement, requires short name, visibility, points, and display title: " + data); var achievementName = parsed[1].toLowerCase(); if (!/[a-z]+/.test(achievementName)) throw new Error(this.lineMsg()+"Invalid achievement name: " +achievementName); if (this.nav.achievements.hasOwnProperty(achievementName)) { // this achievement already exists... if (!this.nav.achievements[achievementName].lineNumber) { // blow away pre-existing mygame.js achievements this.nav.achievements = {}; this.nav.achievementList = []; } else if (this.nav.achievements[achievementName].lineNumber != (this.lineNum+1)) { // don't allow redefining achievements // restarting/randomtest will naturally re-run *achievements; ignore those throw new Error(this.lineMsg()+"Achievement "+achievementName+" already defined on line " + this.nav.achievements[achievementName].lineNumber); } } var lineNumber = this.lineNum+1; var visibility = parsed[2]; if (visibility != "hidden" && visibility != "visible") { throw new Error(this.lineMsg()+"Invalid *achievement, the second word should be either 'hidden' or 'visible': " +visibility); } var visible = (visibility != "hidden"); var pointString = parsed[3]; if (!/[1-9][0-9]*/.test(pointString)) { throw new Error(this.lineMsg()+"Invalid *achievement, the third word should be an integer number of points: " + pointString); } var points = parseInt(pointString, 10); if (points > 100) throw new Error(this.lineMsg()+"Invalid *achievement, no achievement may be worth more than 100 points: " + points); if (!this.achievementTotal) this.achievementTotal = 0; this.achievementTotal += points; if (this.achievementTotal > 1000) { throw new Error(this.lineMsg()+"Invalid achievements. Adding " + points + " would add up to more than 1,000 points: " + this.achievementTotal); } var title = parsed[4]; if (/(\$\{)/.test(title)) throw new Error(this.lineMsg()+"Invalid *achievement. ${} not permitted in achievement title: " + title); if (/(\[)/.test(title)) throw new Error(this.lineMsg()+"Invalid *achievement. [] not permitted in achievement title: " + title); // Get the description from the next indented line var line = this.lines[++this.lineNum]; var indent = this.getIndent(line); if (!indent) { throw new Error(this.lineMsg()+"Invalid *achievement. An indented description is required."); } var preEarnedDescription = trim(line); if (/(\$\{)/.test(preEarnedDescription)) throw new Error(this.lineMsg()+"Invalid *achievement. ${} not permitted in achievement description: " + preEarnedDescription); if (/(\[)/.test(preEarnedDescription)) throw new Error(this.lineMsg()+"Invalid *achievement. [] not permitted in achievement description: " + preEarnedDescription); if (!visible) { if (preEarnedDescription.toLowerCase() != "hidden") throw new Error(this.lineMsg()+"Invalid *achievement. Hidden achievements must set their pre-earned description to 'hidden'."); } // Optionally get a post-earned description from the next line var postEarnedDescription = null; while(isDefined(line = this.lines[++this.lineNum])) { if (trim(line)) break; this.rollbackLineCoverage(); } indent = this.getIndent(line); if (indent) { postEarnedDescription = trim(line); if (/(\$\{)/.test(postEarnedDescription)) throw new Error(this.lineMsg()+"Invalid *achievement. ${} not permitted in achievement description: " + postEarnedDescription); if (/(\[)/.test(postEarnedDescription)) throw new Error(this.lineMsg()+"Invalid *achievement. [] not permitted in achievement description: " + postEarnedDescription); } else { // No indent means the next line is not a post-earned description this.rollbackLineCoverage(); this.lineNum--; this.rollbackLineCoverage(); } if (!postEarnedDescription) { if (!visible) throw new Error(this.lineMsg()+"Invalid *achievement. Hidden achievements must set a post-earned description."); postEarnedDescription = preEarnedDescription; } if (!this.nav.achievements.hasOwnProperty(achievementName)) { this.nav.achievementList.push(achievementName); if (this.nav.achievementList.length > 100) { throw new Error(this.lineMsg()+"Too many *achievements. Each game can have up to 100 achievements."); } } this.nav.achievements[achievementName] = { visible: visible, points: points, title: title, earnedDescription: postEarnedDescription, preEarnedDescription: preEarnedDescription, lineNumber: lineNumber }; if (typeof setButtonTitles != "undefined") setButtonTitles(); }; Scene.prototype.bug = function scene_bug(message) { if (message) { message = "Bug: " + this.replaceVariables(message); } else { message = "Bug"; } throw new Error(this.lineMsg() + message); }; Scene.prototype.lineMsg = function lineMsg() { return this.name + " line " + (this.lineNum+1) + ": "; }; Scene.prototype.rollbackLineCoverage = function() {}; Scene.baseUrl = "scenes"; Scene.regexpMatch = function regexpMatch(str, re) { var result = re.exec(str); if (!result) return null; return result[0]; }; // Each token has a name and a test, which returns the matching string Scene.tokens = [ {name:"OPEN_PARENTHESIS", test:function(str){ return Scene.regexpMatch(str,/^\(/); } }, {name:"CLOSE_PARENTHESIS", test:function(str){ return Scene.regexpMatch(str,/^\)/); } }, {name:"OPEN_CURLY", test:function(str){ return Scene.regexpMatch(str,/^\{/); } }, {name:"CLOSE_CURLY", test:function(str){ return Scene.regexpMatch(str,/^\}/); } }, {name:"FUNCTION", test:function(str){ return Scene.regexpMatch(str,/^(not|round|timestamp|log)\s*\(/); } }, {name:"NUMBER", test:function(str){ return Scene.regexpMatch(str,/^\d+(\.\d+)?/); } }, {name:"STRING", test:function(str, line) { var i; if (!/^\"/.test(str)) return null; for (i = 1; i < str.length; i++) { var x = str.charAt(i); if ("\\" == x) { i++; } else if ('"' == x) { return str.substring(0,i+1); } } throw new Error("line "+line+": Invalid string, open quote with no close quote: " + str); } }, {name:"WHITESPACE", test:function(str){ return Scene.regexpMatch(str,/^\s+/); } }, {name:"BOOLEAN_OPERATOR", test:function(str){ return Scene.regexpMatch(str,/^(and|or)\b/); } }, {name:"VAR", test:function(str){ return Scene.regexpMatch(str,/^[a-zA-Z]\w*/); } }, {name:"FAIRMATH", test:function(str){ return Scene.regexpMatch(str,/^%[\+\-]/); } }, {name:"OPERATOR", test:function(str){ return Scene.regexpMatch(str,/^[\+\-\*\/\&\%\^]/); } }, {name:"INEQUALITY", test:function(str){ return Scene.regexpMatch(str,/^[\!<>]\=?/); } }, {name:"EQUALITY", test:function(str){ return Scene.regexpMatch(str,/^=/); } } // ]; Scene.operators = { "+": function add(v1,v2,line) { return num(v1,line) + num(v2,line); }, "-": function subtract(v1,v2,line) { return num(v1,line) - num(v2,line); }, "*": function multiply(v1,v2,line) { return num(v1,line) * num(v2,line); }, "/": function divide(v1,v2,line) { return num(v1,line) / num(v2,line); }, "%": function modulo(v1,v2,line) { return num(v1,line) % num(v2,line); }, "^": function exponent(v1,v2,line) { return Math.pow(num(v1,line), num(v2,line)); }, "&": function concatenate(v1,v2) { return [v1,v2].join(""); }, "%+": function fairAdd(v1, v2, line) { v1 = num(v1,line); v2 = num(v2,line); var validValue = (v1 > 0 && v1 < 100); if (!validValue) { throw new Error("line "+line+": Can't fairAdd to non-percentile value: " + v1); } var multiplier = (100 - v1) / 100; var actualModifier = v2 * multiplier; var value = 1 * v1 + actualModifier; value = Math.floor(value); if (value > 99) value = 99; return value; }, "%-": function fairSubtract(v1, v2, line) { v1 = num(v1,line); v2 = num(v2,line); var validValue = (v1 > 0 && v1 < 100); if (!validValue) { throw new Error("line "+line+": Can't fairAdd to non-percentile value: " + v1); } var multiplier = v1 / 100; var actualModifier = v2 * multiplier; var value = v1 - actualModifier; value = Math.ceil(value); if (value < 1) value = 1; return value; }, "=": function equals(v1,v2) { return v1 == v2; }, "<": function lessThan(v1,v2,line) { return num(v1,line) < num(v2,line); }, ">": function greaterThan(v1,v2,line) { return num(v1,line) > num(v2,line); }, "<=": function lessThanOrEquals(v1,v2,line) { return num(v1,line) <= num(v2,line); }, ">=": function greaterThanOrEquals(v1,v2,line) { return num(v1,line) >= num(v2,line); }, "!=": function notEquals(v1,v2) { return v1 != v2; }, "and": function and(v1, v2, line) { return bool(v1,line) && bool(v2,line); }, "or": function or(v1, v2, line) { return bool(v1,line) || bool(v2,line); } }; Scene.initialCommands = {"create":1,"scene_list":1,"title":1,"author":1,"comment":1,"achievement":1}; Scene.validCommands = {"comment":1, "goto":1, "gotoref":1, "label":1, "looplimit":1, "finish":1, "abort":1, "choice":1, "create":1, "temp":1, "delete":1, "set":1, "setref":1, "print":1, "if":1, "rand":1, "page_break":1, "line_break":1, "script":1, "else":1, "elseif":1, "elsif":1, "reset":1, "goto_scene":1, "fake_choice":1, "input_text":1, "ending":1, "share_this_game":1, "stat_chart":1, "subscribe":1, "show_password":1, "gosub":1, "return":1, "hide_reuse":1, "disable_reuse":1, "allow_reuse":1, "check_purchase":1,"restore_purchases":1,"purchase":1,"restore_game":1,"advertisement":1, "save_game":1,"delay_break":1,"image":1,"link":1,"input_number":1,"goto_random_scene":1, "restart":1,"more_games":1,"delay_ending":1,"end_trial":1,"login":1,"achieve":1,"scene_list":1,"title":1, "bug":1,"link_button":1,"check_registration":1,"sound":1,"author":1,"gosub_scene":1,"achievement":1, "check_achievements":1,"redirect_scene":1 };