From c59499472e342ef08ad5801735afd0ae18b939eb Mon Sep 17 00:00:00 2001 From: Ned Redmond Date: Thu, 25 Jun 2026 22:27:04 -0400 Subject: [PATCH 1/4] separate data and ui layer for playMode --- .../inklewriter-source/inklewriter-main.js | 1 + .../inklewriter-readmode.js | 1 + .../inklewriter-source/playMode.js | 613 +++--------------- .../inklewriter-source/storyEngine.js | 301 +++++++++ 4 files changed, 406 insertions(+), 510 deletions(-) create mode 100644 app/assets/javascripts/inklewriter-source/storyEngine.js diff --git a/app/assets/javascripts/inklewriter-source/inklewriter-main.js b/app/assets/javascripts/inklewriter-source/inklewriter-main.js index 65986d3..fb595e3 100644 --- a/app/assets/javascripts/inklewriter-source/inklewriter-main.js +++ b/app/assets/javascripts/inklewriter-source/inklewriter-main.js @@ -19,6 +19,7 @@ //= require editor //= require editorAccount //= require editorMenu +//= require storyEngine //= require playMode //= require tutorialStory_fromRead //= require splash diff --git a/app/assets/javascripts/inklewriter-source/inklewriter-readmode.js b/app/assets/javascripts/inklewriter-source/inklewriter-readmode.js index 8d0b924..6b62639 100644 --- a/app/assets/javascripts/inklewriter-source/inklewriter-readmode.js +++ b/app/assets/javascripts/inklewriter-source/inklewriter-readmode.js @@ -12,6 +12,7 @@ //= require storyModel //= require dialogue //= require editorAccount +//= require storyEngine //= require playMode //= require tooltip //= require_tree ./rangy-1.2.2 diff --git a/app/assets/javascripts/inklewriter-source/playMode.js b/app/assets/javascripts/inklewriter-source/playMode.js index f1e28d3..3621baf 100644 --- a/app/assets/javascripts/inklewriter-source/playMode.js +++ b/app/assets/javascripts/inklewriter-source/playMode.js @@ -1,9 +1,9 @@ var Player = function() { var chunks = []; - var readOnly = false; - + var engine = new StoryEngine(); + //---------------------- // Chunk of text //---------------------- @@ -24,96 +24,49 @@ var Player = function() { this.jqFlags = this.jqPlayChunk.find('.flags'); this.jqFlags.hide(); - - this.prevChunk = chunks.last(); - if (this.prevChunk) { - for (var i = 0; i < this.prevChunk.flagsCollected.length; i++) { - self.flagsCollected.push(this.prevChunk.flagsCollected[i]); - } - } - + + var prevFlags = chunks.last() ? chunks.last().flagsCollected : []; + if (!fromStitch) { // Normal read mode, when editing - if( !readOnly ) { + if (!readOnly) { removeDeadOptions(); this.jqPlayChunk.html(tr("This page intentionally left blank.") + "
(" + tr("Continue the story from here") + ".)"); } - // Read only mode (e.g. from sharing) else { this.jqPlayChunk.html("
" + tr("End") + "
"); } - $("#read_area").append(this.jqPlayChunk); return; } - - var appendStitch = fromStitch; - - this.jqTextBlock = this.jqPlayChunk.find('.stitch_block'); - - var renderedText = ""; - var carriedText = ""; - - while(appendStitch) - { - // we store the stitch regardless of conditions - this.stitches.push(appendStitch); - if (appendStitch.pageNumberLabel() >= 1) { - this.hadSectionHeading = true; - } + var chunkData = engine.buildChunk(fromStitch, prevFlags); - // but only render text / collect flags if it's valid - if (StoryModel.doesArrayMeetConditions(appendStitch._ifConditions, appendStitch._notIfConditions, this.flagsCollected)) { + this.stitches = chunkData.stitches; + this.flagsCollected = chunkData.flags; + this.wordCount = chunkData.wordCount; + this.hadSectionHeading = chunkData.hadSectionHeading; - - if (appendStitch.image()) { - - renderedText += "\n%|%|%" + appendStitch.image() + "$|$|$\n"; - - } - - carriedText += appendStitch.text().replace(/\n/g," ") + " "; - - if (( !appendStitch.text().match(/\[\.\.\.\]/) && !appendStitch.runOn() ) || !appendStitch.divert()) - { - renderedText += renderText(carriedText, this.flagsCollected) + "\n"; - carriedText = ""; - - } - - if (appendStitch.numberOfFlags() > 0) { - StoryModel.processFlagSetting(appendStitch, this.flagsCollected); - - if( !readOnly ) { - var jqList = this.jqFlags.find('ul'); - for (var i = 0 ; i < appendStitch.numberOfFlags() ; i++) { - var flagText = appendStitch.flagByIndex(i); - var counterAlterRegex = /^(.*?)\s*(\+|\-)\s*(\b.*\b)\s*$/; - if ( matchSet = flagText.match(counterAlterRegex) ) { - - flagText += " (" + tr("now") + " " + StoryModel.getValueOfFlag(matchSet[1], this.flagsCollected) + ")"; - - } + this.jqTextBlock = this.jqPlayChunk.find('.stitch_block'); + this.jqTextBlock.html(chunkData.html); + this.jqPlayChunk.append(this.jqTextBlock); - jqList.append('
  • ' + flagText + '
  • '); - } - this.jqFlags.show(); - } + // Show flag side-panel in editor mode + if (!readOnly && chunkData.newFlags.length > 0) { + var jqList = this.jqFlags.find('ul'); + for (var i = 0; i < chunkData.newFlags.length; i++) { + var flagInfo = chunkData.newFlags[i]; + var displayText = flagInfo.text; + if (flagInfo.valueAfterSet !== null) { + displayText += " (" + tr("now") + " " + flagInfo.valueAfterSet + ")"; } - + jqList.append('
  • ' + displayText + '
  • '); } - appendStitch = appendStitch.divert(); + this.jqFlags.show(); } - - this.wordCount += wordCountOf(renderedText); - this.jqTextBlock.html(renderChunk(renderedText)); - - this.jqPlayChunk.append(this.jqTextBlock); - $("#read_area").append(this.jqPlayChunk); this.createOptionBlock(); @@ -138,7 +91,6 @@ var Player = function() { this.jqRewindButton.text(tr("Start again")); } } - } PlayChunk.prototype.remove = function() { @@ -148,124 +100,102 @@ var Player = function() { PlayChunk.prototype.createOptionBlock = function() { var dividerLineHtml = "
    "; - - this.jqOptBlock = $("
    "+dividerLineHtml+"
    "); - - if (this.stitches.last().options.length == 0) - { + + this.jqOptBlock = $("
    " + dividerLineHtml + "
    "); + + var builtOptions = engine.buildOptions(this.stitches, this.flagsCollected); + var isEnd = this.stitches.last().options.length === 0; + + if (isEnd) { // end! this.jqTextBlock.append('
    ' + tr("End") + '
    '); - if( !readOnly ) { + if (!readOnly) { this.jqTextBlock.append('
    (' + tr("Go back to Write mode to continue") + '.)'); - } else { - - this.jqTextBlock.find('.the_end').append("
    "); - this.jqTextBlock.find('.back_to_top').bind('click tap', function() { - scrollTo(chunks.first().jqPlayChunk); - }); - - + this.jqTextBlock.find('.the_end').append("
    "); + this.jqTextBlock.find('.back_to_top').bind('click tap', function() { + scrollTo(chunks.first().jqPlayChunk); + }); $("#read_area").append("
    " + tr("Text © the author.") + " inklewriter © inkle
    "); - } - } - else - { - var options = this.stitches.last().options; - - for (var i = 0 ; i < options.length ; i++) - { - var thisOpt = options[i]; - - var valid = StoryModel.doesArrayMeetConditions(thisOpt._ifConditions, thisOpt._notIfConditions, this.flagsCollected); - - if (valid || !readOnly) - { - var newOpt = new PlayOption(thisOpt, valid); - + } else { + for (var i = 0; i < builtOptions.length; i++) { + var optInfo = builtOptions[i]; + if (optInfo.valid || !readOnly) { + var newOpt = new PlayOption(optInfo.option, optInfo.valid); this.optionBoxes.push(newOpt); this.jqOptBlock.append(newOpt.jqPlayOption); this.jqOptBlock.append(dividerLineHtml); - } } - this.jqPlayChunk.append(this.jqOptBlock); - } - + removeDeadOptions(); - }; - + PlayChunk.prototype.rewindToHere = function() { // Remove all chunks after this one var chunkIdx = chunks.indexOf(this); - for(var deleteChunkIdx = chunkIdx+1; deleteChunkIdx' + smartQuote(optionLink.text()) + ''); + this.jqPlayOption = $('
    ' + engine.smartQuote(optionLink.text()) + '
    '); this.linkTo = optionLink.linkStitch(); - - var linkTo = this.linkTo; + + var linkTo = this.linkTo; if (!optionLink.writeModeOnly && valid) { this.jqPlayOption.bind("click tap", function() { - + // Show the rewind button on the chunk we just visited - if( !readOnly || (chunks.last().hadSectionHeading && StoryModel.allowCheckpoints)) { + if (!readOnly || (chunks.last().hadSectionHeading && StoryModel.allowCheckpoints)) { chunks.last().jqRewindButton.show(); - } - + } // Only first chunk's rewind button should be visible else { chunks.first().jqRewindButton.show(); } - - + var nextStitch = linkTo; $(".option_block").addClass('expired'); - - chunks.push( new PlayChunk(nextStitch) ); - + + chunks.push(new PlayChunk(nextStitch)); + saveGame(); - + updateWordCount(); - addOptionText(optionLink); - - - + }); } else { // Todo --> indicate the WRITE mode button with a popup this.jqPlayOption.addClass("disabled"); - if (optionLink.writeModeOnly ) + if (optionLink.writeModeOnly) this.jqPlayOption.attr("tooltip", tr("Switch to writing mode to continue.")); else this.jqPlayOption.attr("tooltip", tr("This option has been disallowed by conditions.")); @@ -273,191 +203,16 @@ var Player = function() { }; var addOptionText = function(optionLink) { - if (optionLink.text() != "..." && StoryModel.optionMirroring) - chunks.last().jqPlayChunk.prepend('
    ' + smartQuote(optionLink.text()) + '
    '); - - }; - - //---------------------- - // Text rendering functions - //---------------------- - - // entry point for doing chunk level processing eg. [...] - var renderChunk = function(textHtml) { - textHtml = smartQuote(textHtml); - - textHtml = textHtml.replace(/\n+/g, "
    "); - textHtml = processMultipleSpaces(textHtml); - textHtml = locateLinks(textHtml); - textHtml = locateImages(textHtml); - - return "
    " + textHtml + "
    "; - } - - var locateLinks = function(textHtml) { - textHtml = textHtml.replace(/\[(.*?)\|(.*?)\]/g, '$2'); - return textHtml; - } - - var locateImages = function(textHtml) { - textHtml = textHtml.replace(/\%\|\%\|\%(.*?)\$\|\$\|\$/g, '
    '); - return textHtml; - } - - var processMultipleSpaces = function(textHtml) { - // replace multiple spaces with a single space - textHtml = textHtml.replace(/(\ \;|\s)+/g, ' '); - // remove space before punctuation - textHtml = textHtml.replace(/(\ \;|\s)+(\.|\,|\;|\:|\?|\!|\”|\’)/g, '$2'); - // remove spaces after open quotes - textHtml = textHtml.replace(/(\“|\‘)(\ \;|\s)+/g, '$1'); - return textHtml; - } - - - // entry point for doing conditional strings, etc. - var renderText = function(textString, flagsCollected) { - - - textString = stripEllipsisConjunctive(textString); - textString = printCounters(textString, flagsCollected); - - var startText = ""; - while (startText !== textString) { - startText = textString; - textString = processConditionalBraces(textString, flagsCollected); - textString = processRandomBraces(textString); - } - textString = processFormatting(textString); - - return textString; - } - - var printCounters = function(textString, flagsCollected) { - var counterRegex = /\[\s*(number|value)\s*\:\s*(.*?)\s*\]/; - while(matchSet = textString.match(counterRegex)) { - var replaceVal = StoryModel.getValueOfFlag(matchSet[2], flagsCollected); - if (!replaceVal) { - replaceVal = 0; - } - if (matchSet[1] == "value") { - var numToWords = new NumToWords(); - replaceVal = numToWords.convert(replaceVal); - } - textString = textString.replace(counterRegex, replaceVal); - } - return textString; - }; - - - - var smartQuote = function(textString) { - // "" quotes - textString = textString.replace(/\"([^\n]*?)\"/g, "“$1”" ); - - // open quotes after spaces/line starts - // open italic or bold - textString = textString.replace(/(\s|^|\n|||\(|\“)\'/g, "$1‘"); - - // close quotes - everywhere else!! - textString = textString.replace(/\'/g, "’"); - - // single open double quote at start of a paragraph - good for multi-paragraph quotes. - - textString = textString.replace(/(^|\n)\"/g, "$1“"); - - return textString; - } - - var stripEllipsisConjunctive = function(textHtml) { - return textHtml.replace(/\[\.\.\.\]/g, ' '); - } - - var processFormatting = function(textString) { - - textString = textString.replace(/\*\-(.*?)\-\*/g, "$1"); - textString = textString.replace(/\/\=(.*?)\=\//g, "$1"); - - // remove any extraneous formatting elements - textString = textString.replace(/(\/\=|\=\/|\*\-|\-\*)/g, ""); - - return textString; - } - - - - - - - var processRandomBraces = function(textString) { - var randomTextRegex = /\{\~([^\{\}]*?)\}/; - var randomMatches; - while (randomMatches = textString.match(randomTextRegex)) { - var parts = randomMatches[1].split("|"); - var n = parseInt(Math.random() * parts.length); - textString = textString.replace(randomTextRegex, parts[n]); - } - return textString; + chunks.last().jqPlayChunk.prepend('
    ' + engine.smartQuote(optionLink.text()) + '
    '); }; - var processConditionalBraces = function(textString, flagsCollected) { - - var conditionalTextRegex = /\{([^\~\{]*?)\:([^\{]*?)(\|([^\{]*?))?\}/; - - var stripEdgeWhitespace = /(^\s*|\s*$)/g; - var andMatch = /\s*(&&|\band\b)\s*/; - var notMatch = /\s*(\!|\bnot\b)\s*(.+?)\s*$/; - var conditionalMatches; - - var matchCount = 0; - - while (conditionalMatches = textString.match(conditionalTextRegex)) - { - matchCount++; - if (matchCount > 1000) - { - alert(tr("Error in conditional!")); - break; - } - if (conditionalMatches.length > 0) - { - var useablePart = ""; - - var ifs = []; - var notIfs = []; - var terms = conditionalMatches[1].split(andMatch); - for (var idx = 0 ; idx < terms.length ; idx++) { - if (terms[idx] != "&&" && terms[idx] != "and") - { - var notTerms = terms[idx].match(notMatch); - - if ( notTerms ) { - notIfs.push(notTerms[2].replace(stripEdgeWhitespace, '')); - } else { - ifs.push(terms[idx].replace(stripEdgeWhitespace, '')); - } - } - } - - if (StoryModel.doesArrayMeetConditions(ifs, notIfs, flagsCollected)) - useablePart = conditionalMatches[2]; - else if (conditionalMatches[4] !== undefined) - useablePart = conditionalMatches[4]; - - textString = textString.replace(conditionalTextRegex, ' ' + useablePart + ' '); - } - } - - return textString; - } - //---------------------- var updateWordCount = function() { if (readOnly) return false; var totWordCount = 0; - for (i = 0 ; i < chunks.length ; i++ ) { + for (var i = 0; i < chunks.length; i++) { totWordCount += chunks[i].wordCount; } @@ -471,7 +226,7 @@ var Player = function() { var scrollTo = function(target) { var jqTarget = $(target); - + if (readOnly) { var jqContainer = $("body"); var destination = jqTarget.offset().top - 20; @@ -479,25 +234,24 @@ var Player = function() { var jqContainer = $("#read_area"); var destination = jqContainer.scrollTop() + jqTarget.position().top - 20; } - jqContainer.stop().animate({"scrollTop": destination}, 1000); } var launchGraph = function() { - var graph = new GraphModel(chunks.last().stitches.last()); + var graph = new GraphModel(chunks.last().stitches.last()); } - + var createFromModel = function(thisStitchList) { var prevStitch = null; - for (var i = 0; i < thisStitchList.length ; i++) { + for (var i = 0; i < thisStitchList.length; i++) { $(".option_block").remove(); - var newChunk = new PlayChunk(thisStitchList[i]) ; - chunks.push (newChunk); - + var newChunk = new PlayChunk(thisStitchList[i]); + chunks.push(newChunk); + if (prevStitch) { - for ( var j = 0 ; j < prevStitch.options.length ; j++) { + for (var j = 0; j < prevStitch.options.length; j++) { if (prevStitch.options[j].linkStitch() === thisStitchList[i]) { addOptionText(prevStitch.options[j]); break; @@ -505,123 +259,78 @@ var Player = function() { } } - if( !readOnly || (chunks.last().hadSectionHeading && StoryModel.allowCheckpoints)) { + if (!readOnly || (chunks.last().hadSectionHeading && StoryModel.allowCheckpoints)) { chunks.last().jqRewindButton.show(); - } - - prevStitch = newChunk.stitches.last(); + } + prevStitch = newChunk.stitches.last(); } - + chunks.last().jqRewindButton.hide(); chunks.first().jqRewindButton.show(); - saveGame(); + saveGame(); updateWordCount(); - } - + var clear = function() { - + // we record the stitches read so the editor can mimic it var playList = []; - // Adapted from editor.js - // Don't know why I need to do it like this, but here goes. - - for(var i=0; i\
    \
    \
    \
    '); - + $("#main_viewport").append(jqPlayer); createFromModel(fromStitch); - + var jqFirstChunk = $("#read_area .chunk").first(); jqFirstChunk.prepend('

    ' + StoryModel.storyName() + '

    ' - +'

    by ' + StoryModel.authorName() + '

    ' + + '

    by ' + StoryModel.authorName() + '

    ' ); - + var jqFirstStitch = jqFirstChunk.find(".stitch").first(); jqFirstStitch.addClass("openingStitch"); - - sizeEditorCorrectly(); - + sizeEditorCorrectly(); } - + var setReadOnlyMode = function(_readOnly) { readOnly = _readOnly; } - - var saveKey = function() { - return "inklewriter_saved_game" + StoryModel.storyName().camelCase().substring(0, 16); + + var saveGame = function() { + if (!hasStorage || !readOnly) return; + var savedStitchList = []; + for (var i = 0; i < chunks.length; i++) { + savedStitchList.push(chunks[i].stitches.first().name()); + } + engine.saveGame(savedStitchList); } - + var getSavedGame = function() { - var defaultSavedGame = [ StoryModel.initialStitch ]; - var savedGame = null; - var constructedSaveGame = []; - if ( hasStorage ) { - // ensure stitches are named (only need to do this once) - StoryModel.nameStitches(); - if ( localStorage[saveKey()] ) { - savedGame = JSON.parse(localStorage[saveKey()]); - - /* - validate the save game - */ - - var found = (savedGame.length > 0); - for (var i = 0 ; i < savedGame.length && found ; i++ ) { - found = false; - for (var j = 0; !found && j < StoryModel.stitches.length ; j++) { - if (StoryModel.stitches[j].name() == savedGame[i]) { - // replace array entry with the appropriate stitch - constructedSaveGame.push( StoryModel.stitches[j] ); - found = true; - } - } - } - if (!found) - constructedSaveGame = []; - } - } - - if ( constructedSaveGame.length > 0 ) - return constructedSaveGame; - return defaultSavedGame; + return engine.getSavedGame(); } - - var saveGame = function() { - if (!hasStorage || !readOnly) return; - var savedStitchList = []; - for (var i = 0 ; i < chunks.length ; i++ ) { - savedStitchList.push( chunks[i].stitches.first().name() ); - } - localStorage[saveKey()] = JSON.stringify(savedStitchList); - } - // Module design pattern: Return public object return { setup: setup, @@ -632,122 +341,6 @@ var Player = function() { launchGraph: launchGraph, readOnly: function() { return readOnly; } }; - - -}(); // NOTE: These brackets cause the PlayMode object to be immediately constructed -function NumToWords() -{ - - this.convert = function (n) - { - var prefix = ""; - if (n < 0 ) { - prefix = tr("minus") + " "; - n = -n; - } - if (n == 0) - { - return tr('zero'); - } - - var str = _getIntAsWordsRecursively(n, 0); - var c = str.lastIndexOf(',' ); - var h = str.lastIndexOf('hundred'); - - // If no 'hundred' substring was found after the last comma... - if (h < c) - { - // Replace the last ', ' with ' and '. - str = str.substring(0, c) + ' and ' + str.substring(c + 2); - } - - return prefix + str; - }; - - // Private functions. //////////////////////////////////////////////////////////////////////// - - /* - * - */ - function _getIntAsWordsRecursively(n, nThousandsExponent) - { - var str = _getNonZeroNumberLessThanOneThousandAsWords(n % 1000); - - if (str != '' && nThousandsExponent > 0) - { - str += ' ' + _words.illions[nThousandsExponent - 1]; - } - - if (n < 1000) - { - return str; - } - - return _getIntAsWordsRecursively(Math.floor(n / 1000), nThousandsExponent + 1) + - ( - (str == '')? '': ', ' + str - ); - } - - /* - * - */ - function _getNonZeroNumberLessThanOneThousandAsWords(n) - { - if (n == 0) - { - return ''; - } - - if (n < 10) - { - // n < 10. - return _words.digits[n - 1]; - } - - if (n < 20) - { - // 10 <= n < 20. - return _words.teens[n - 10]; - } - - if (n < 100) - { - // 20 <= n < 100. - return _words.tens[Math.floor(n / 10) - 1] + - ( - (n % 10 == 0)? '': '-' + _words.digits[n % 10 - 1] - ); - } - - if (n < 1000) - { - // 100 <= n < 1000. - return _words.digits[Math.floor(n / 100) - 1] + ' ' + tr("hundred") + - ( - (n % 100 == 0)? '': ' ' + tr("and") + ' ' + _getNonZeroNumberLessThanOneThousandAsWords(n % 100) - ); - } - - return n; - } - - // Private variables. //////////////////////////////////////////////////////////////////////// - - var _words = - { - digits: [tr('one'), tr('two'), tr('three'), tr('four'), tr('five'), tr('six'), tr('seven'), tr('eight'), tr('nine')], - tens : [tr('ten'), tr('twenty'), tr('thirty'), tr('forty'), tr('fifty'), tr('sixty'), tr('seventy'), tr('eighty'), tr('ninety')], - teens : - [ - tr('ten'), tr('eleven'), tr('twelve'), tr('thirteen'), tr('fourteen'), - tr('fifteen'), tr('sixteen'), tr('seventeen'), tr('eighteen'), tr('nineteen') - ], - illions: - [ - // See http://en.wikipedia.org/wiki/Names_of_large_numbers. - tr('thousand'), tr('million'), tr('billion'), tr('trillion') - ] - }; -} + +}(); // NOTE: These brackets cause the PlayMode object to be immediately constructed diff --git a/app/assets/javascripts/inklewriter-source/storyEngine.js b/app/assets/javascripts/inklewriter-source/storyEngine.js new file mode 100644 index 0000000..f27dfbf --- /dev/null +++ b/app/assets/javascripts/inklewriter-source/storyEngine.js @@ -0,0 +1,301 @@ +// NumToWords: standalone constructor used by StoryEngine.printCounters + +class NumToWords { + constructor() { + this.convert = function (n) { + var prefix = ""; + if (n < 0) { + prefix = tr("minus") + " "; + n = -n; + } + if (n == 0) return tr('zero'); + var str = _getIntAsWordsRecursively(n, 0); + var c = str.lastIndexOf(','); + var h = str.lastIndexOf('hundred'); + if (h < c) str = str.substring(0, c) + ' and ' + str.substring(c + 2); + return prefix + str; + }; + } + + function _getIntAsWordsRecursively(n, nThousandsExponent) { + var str = _getNonZeroNumberLessThanOneThousandAsWords(n % 1000); + if (str != '' && nThousandsExponent > 0) + str += ' ' + _words.illions[nThousandsExponent - 1]; + if (n < 1000) return str; + return _getIntAsWordsRecursively(Math.floor(n / 1000), nThousandsExponent + 1) + + ((str == '') ? '' : ', ' + str); + } + + function _getNonZeroNumberLessThanOneThousandAsWords(n) { + if (n == 0) return ''; + if (n < 10) return _words.digits[n - 1]; + if (n < 20) return _words.teens[n - 10]; + if (n < 100) return _words.tens[Math.floor(n / 10) - 1] + ((n % 10 == 0) ? '' : '-' + _words.digits[n % 10 - 1]); + if (n < 1000) return _words.digits[Math.floor(n / 100) - 1] + ' ' + tr("hundred") + + ((n % 100 == 0) ? '' : ' ' + tr("and") + ' ' + _getNonZeroNumberLessThanOneThousandAsWords(n % 100)); + return n; + } + + var _words = { + digits: [tr('one'), tr('two'), tr('three'), tr('four'), tr('five'), tr('six'), tr('seven'), tr('eight'), tr('nine')], + tens: [tr('ten'), tr('twenty'), tr('thirty'), tr('forty'), tr('fifty'), tr('sixty'), tr('seventy'), tr('eighty'), tr('ninety')], + teens: [tr('ten'), tr('eleven'), tr('twelve'), tr('thirteen'), tr('fourteen'), + tr('fifteen'), tr('sixteen'), tr('seventeen'), tr('eighteen'), tr('nineteen')], + illions: [tr('thousand'), tr('million'), tr('billion'), tr('trillion')] + }; +} + + +// StoryEngine: pure story traversal and text rendering — zero DOM, zero jQuery. +// Instantiated by Player in playMode.js: var engine = new StoryEngine(); + +class StoryEngine { + constructor() { } + // ---- Chunk traversal ---- + // Walk the divert chain from fromStitch, evaluate conditions, collect text/flags/images. + // Returns a plain state object; no DOM is touched. + buildChunk(fromStitch, prevFlags) { + var flagsCollected = prevFlags ? prevFlags.slice() : []; + + if (!fromStitch) { + return { + stitches: [], flags: flagsCollected, newFlags: [], + html: '', options: [], isEnd: true, + hadSectionHeading: false, wordCount: 0 + }; + } + + var stitches = []; + var renderedText = ""; + var carriedText = ""; + var hadSectionHeading = false; + var newFlags = []; // [{text, valueAfterSet}] for editor flag display + var appendStitch = fromStitch; + + while (appendStitch) { + stitches.push(appendStitch); + + if (appendStitch.pageNumberLabel() >= 1) hadSectionHeading = true; + + if (StoryModel.doesArrayMeetConditions(appendStitch._ifConditions, appendStitch._notIfConditions, flagsCollected)) { + + if (appendStitch.image()) + renderedText += "\n%|%|%" + appendStitch.image() + "$|$|$\n"; + + carriedText += appendStitch.text().replace(/\n/g, " ") + " "; + + if ((!appendStitch.text().match(/\[\.\.\.\]/) && !appendStitch.runOn()) || !appendStitch.divert()) { + renderedText += this.renderText(carriedText, flagsCollected) + "\n"; + carriedText = ""; + } + + if (appendStitch.numberOfFlags() > 0) { + StoryModel.processFlagSetting(appendStitch, flagsCollected); + // Capture display text + current value for editor flag list + var counterAlterRegex = /^(.*?)\s*(\+|\-)\s*(\b.*\b)\s*$/; + for (var i = 0; i < appendStitch.numberOfFlags(); i++) { + var flagText = appendStitch.flagByIndex(i); + var matchSet = flagText.match(counterAlterRegex); + newFlags.push({ + text: flagText, + valueAfterSet: matchSet ? StoryModel.getValueOfFlag(matchSet[1], flagsCollected) : null + }); + } + } + } + + appendStitch = appendStitch.divert(); + } + + return { + stitches: stitches, + flags: flagsCollected, + newFlags: newFlags, + html: this.renderChunk(renderedText), + options: this.buildOptions(stitches, flagsCollected), + isEnd: stitches[stitches.length - 1].options.length === 0, + hadSectionHeading: hadSectionHeading, + wordCount: wordCountOf(renderedText) + }; + } + // Evaluate option conditions against flagsCollected; return array of option descriptors. + buildOptions(stitches, flagsCollected) { + var lastStitch = stitches[stitches.length - 1]; + if (!lastStitch || lastStitch.options.length === 0) return []; + + var result = []; + for (var i = 0; i < lastStitch.options.length; i++) { + var opt = lastStitch.options[i]; + result.push({ + option: opt, + valid: StoryModel.doesArrayMeetConditions(opt._ifConditions, opt._notIfConditions, flagsCollected), + writeModeOnly: opt.writeModeOnly + }); + } + return result; + } + // ---- Text rendering (all pure) ---- + renderChunk(textHtml) { + textHtml = this.smartQuote(textHtml); + textHtml = textHtml.replace(/\n+/g, "
    "); + textHtml = this.processMultipleSpaces(textHtml); + textHtml = this.locateLinks(textHtml); + textHtml = this.locateImages(textHtml); + return "
    " + textHtml + "
    "; + } + locateLinks(textHtml) { + return textHtml.replace(/\[(.*?)\|(.*?)\]/g, '$2'); + } + locateImages(textHtml) { + return textHtml.replace(/\%\|\%\|\%(.*?)\$\|\$\|\$/g, '
    '); + } + processMultipleSpaces(textHtml) { + textHtml = textHtml.replace(/(\ \;|\s)+/g, ' '); + textHtml = textHtml.replace(/(\ \;|\s)+(\.|\,|\;|\:|\?|\!|\"|\')/g, '$2'); + textHtml = textHtml.replace(/(\"|\')(\ \;|\s)+/g, '$1'); + return textHtml; + } + renderText(textString, flagsCollected) { + textString = this.stripEllipsisConjunctive(textString); + textString = this.printCounters(textString, flagsCollected); + var startText = ""; + while (startText !== textString) { + startText = textString; + textString = this.processConditionalBraces(textString, flagsCollected); + textString = this.processRandomBraces(textString); + } + textString = this.processFormatting(textString); + return textString; + } + printCounters(textString, flagsCollected) { + var counterRegex = /\[\s*(number|value)\s*\:\s*(.*?)\s*\]/; + var matchSet; + while (matchSet = textString.match(counterRegex)) { + var replaceVal = StoryModel.getValueOfFlag(matchSet[2], flagsCollected); + if (!replaceVal) replaceVal = 0; + if (matchSet[1] == "value") { + var numToWords = new NumToWords(); + replaceVal = numToWords.convert(replaceVal); + } + textString = textString.replace(counterRegex, replaceVal); + } + return textString; + } + smartQuote(textString) { + textString = textString.replace(/\"([^\n]*?)\"/g, "“$1”"); + textString = textString.replace(/(\s|^|\n|||\(|\")\'/g, "$1‘"); + textString = textString.replace(/\'/g, "’"); + textString = textString.replace(/(^|\n)\"/g, "$1“"); + return textString; + } + stripEllipsisConjunctive(textHtml) { + return textHtml.replace(/\[\.\.\.\]/g, ' '); + } + processFormatting(textString) { + textString = textString.replace(/\*\-(.*?)\-\*/g, "$1"); + textString = textString.replace(/\/\=(.*?)\=\//g, "$1"); + textString = textString.replace(/(\/\=|\=\/|\*\-|\-\*)/g, ""); + return textString; + } + processRandomBraces(textString) { + var randomTextRegex = /\{\~([^\{\}]*?)\}/; + var randomMatches; + while (randomMatches = textString.match(randomTextRegex)) { + var parts = randomMatches[1].split("|"); + var n = parseInt(Math.random() * parts.length); + textString = textString.replace(randomTextRegex, parts[n]); + } + return textString; + } + processConditionalBraces(textString, flagsCollected) { + var conditionalTextRegex = /\{([^\~\{]*?)\:([^\{]*?)(\|([^\{]*?))?\}/; + var stripEdgeWhitespace = /(^\s*|\s*$)/g; + var andMatch = /\s*(&&|\band\b)\s*/; + var notMatch = /\s*(\!|\bnot\b)\s*(.+?)\s*$/; + var conditionalMatches; + var matchCount = 0; + + while (conditionalMatches = textString.match(conditionalTextRegex)) { + matchCount++; + if (matchCount > 1000) { + alert(tr("Error in conditional!")); + break; + } + if (conditionalMatches.length > 0) { + var useablePart = ""; + var ifs = []; + var notIfs = []; + var terms = conditionalMatches[1].split(andMatch); + for (var idx = 0; idx < terms.length; idx++) { + if (terms[idx] != "&&" && terms[idx] != "and") { + var notTerms = terms[idx].match(notMatch); + if (notTerms) { + notIfs.push(notTerms[2].replace(stripEdgeWhitespace, '')); + } else { + ifs.push(terms[idx].replace(stripEdgeWhitespace, '')); + } + } + } + if (StoryModel.doesArrayMeetConditions(ifs, notIfs, flagsCollected)) + useablePart = conditionalMatches[2]; + else if (conditionalMatches[4] !== undefined) + useablePart = conditionalMatches[4]; + textString = textString.replace(conditionalTextRegex, ' ' + useablePart + ' '); + } + } + return textString; + } + // ---- Save / restore (localStorage, no DOM) ---- + saveKey() { + return "inklewriter_saved_game" + StoryModel.storyName().camelCase().substring(0, 16); + } + getSavedGame() { + var defaultSavedGame = [StoryModel.initialStitch]; + var constructedSaveGame = []; + + if (hasStorage) { + StoryModel.nameStitches(); + if (localStorage[this.saveKey()]) { + var savedGame = JSON.parse(localStorage[this.saveKey()]); + var found = (savedGame.length > 0); + for (var i = 0; i < savedGame.length && found; i++) { + found = false; + for (var j = 0; !found && j < StoryModel.stitches.length; j++) { + if (StoryModel.stitches[j].name() == savedGame[i]) { + constructedSaveGame.push(StoryModel.stitches[j]); + found = true; + } + } + } + if (!found) constructedSaveGame = []; + } + } + + return constructedSaveGame.length > 0 ? constructedSaveGame : defaultSavedGame; + } + // stitchNameList: array of stitch name strings (not stitch objects) + saveGame(stitchNameList) { + if (!hasStorage) return; + localStorage[this.saveKey()] = JSON.stringify(stitchNameList); + } +} + + + + + + + + + + + + + + + + + + + + From bc999b591f11a6f1b3f327fff9df1575cc90799a Mon Sep 17 00:00:00 2001 From: Ned Redmond Date: Thu, 25 Jun 2026 22:31:25 -0400 Subject: [PATCH 2/4] Update storyEngine.js rm whitespace at the end --- .../inklewriter-source/storyEngine.js | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/app/assets/javascripts/inklewriter-source/storyEngine.js b/app/assets/javascripts/inklewriter-source/storyEngine.js index f27dfbf..9b89ef2 100644 --- a/app/assets/javascripts/inklewriter-source/storyEngine.js +++ b/app/assets/javascripts/inklewriter-source/storyEngine.js @@ -279,23 +279,3 @@ class StoryEngine { localStorage[this.saveKey()] = JSON.stringify(stitchNameList); } } - - - - - - - - - - - - - - - - - - - - From 7f60176411f3a4feb510fa41292ee02a022579c9 Mon Sep 17 00:00:00 2001 From: Ned Redmond Date: Thu, 25 Jun 2026 22:35:15 -0400 Subject: [PATCH 3/4] add tests --- test/javascript/storyEngine_test.js | 233 ++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 test/javascript/storyEngine_test.js diff --git a/test/javascript/storyEngine_test.js b/test/javascript/storyEngine_test.js new file mode 100644 index 0000000..4d50082 --- /dev/null +++ b/test/javascript/storyEngine_test.js @@ -0,0 +1,233 @@ +// Tests for StoryEngine - pure story traversal and text rendering +// Zero DOM, zero jQuery — only pure functions are tested here. + +import { loadSourceFile } from './helpers/load_source.js'; +loadSourceFile('aux'); +loadSourceFile('storyModel'); +loadSourceFile('storyEngine'); + +// Minimal StoryModel.Stitch factory for tests +function makeStitch(text, opts) { + opts = opts || {}; + var stitch = new StoryModel.Stitch(text); + stitch._ifConditions = opts.ifConditions || []; + stitch._notIfConditions = opts.notIfConditions || []; + stitch._flags = opts.flags || []; + stitch.divertStitch = opts.divert || null; + stitch.options = opts.options || []; + stitch._pageNumberHeader = opts.pageNumber || 0; + stitch._runOn = opts.runOn || false; + stitch._image = opts.image || false; + return stitch; +} + +QUnit.module('StoryEngine', function(hooks) { + var engine; + + hooks.beforeEach(function() { + global.$ = { trim: function(s) { return s.trim(); } }; + global.hasStorage = false; + StoryModel.clear(); + StoryModel.initialStitch = null; + engine = new StoryEngine(); + }); + + QUnit.module('buildChunk()', function() { + + QUnit.test('returns isEnd:true and empty state when fromStitch is null', function(assert) { + var result = engine.buildChunk(null, []); + assert.equal(result.isEnd, true, 'isEnd should be true'); + assert.equal(result.stitches.length, 0, 'stitches should be empty'); + assert.equal(result.wordCount, 0, 'wordCount should be 0'); + }); + + QUnit.test('collects a single stitch with no divert', function(assert) { + var s = makeStitch('Hello world.'); + var result = engine.buildChunk(s, []); + assert.equal(result.stitches.length, 1, 'should have one stitch'); + assert.equal(result.stitches[0], s, 'should be the stitch passed in'); + assert.equal(result.isEnd, true, 'no options means isEnd'); + assert.ok(result.html.indexOf('Hello world') > -1, 'html should contain stitch text'); + }); + + QUnit.test('follows divert chain and collects all stitches', function(assert) { + var s2 = makeStitch('Second.'); + var s1 = makeStitch('First.', { divert: s2 }); + s1.divertStitch = s2; + var result = engine.buildChunk(s1, []); + assert.equal(result.stitches.length, 2, 'should follow divert and collect both stitches'); + assert.equal(result.stitches[0], s1); + assert.equal(result.stitches[1], s2); + }); + + QUnit.test('skips stitch text when ifCondition is not met', function(assert) { + var s = makeStitch('Conditional text.', { ifConditions: ['secret_flag'] }); + var result = engine.buildChunk(s, []); // no flags collected + assert.ok(result.html.indexOf('Conditional text') === -1, 'text should be absent when condition not met'); + }); + + QUnit.test('includes stitch text when ifCondition is met', function(assert) { + var s = makeStitch('Conditional text.', { ifConditions: ['secret_flag'] }); + var flags = [{ flagName: 'secret_flag', value: true }]; + var result = engine.buildChunk(s, flags); + assert.ok(result.html.indexOf('Conditional text') > -1, 'text should appear when condition is met'); + }); + + QUnit.test('collects flags from stitches and mutates flagsCollected', function(assert) { + var s = makeStitch('You found it.', { flags: ['found_key'] }); + var result = engine.buildChunk(s, []); + var keyFlag = result.flags.filter(function(f) { return f.flagName === 'found_key'; }); + assert.equal(keyFlag.length, 1, 'found_key should be in collected flags'); + assert.equal(keyFlag[0].value, true); + }); + + QUnit.test('flags set in chunk affect condition evaluation for later stitches in same chunk', function(assert) { + var s2 = makeStitch('Unlocked.', { ifConditions: ['door_open'] }); + var s1 = makeStitch('You opened the door.', { flags: ['door_open'], divert: s2 }); + s1.divertStitch = s2; + var result = engine.buildChunk(s1, []); + assert.ok(result.html.indexOf('Unlocked') > -1, 'second stitch should be visible after flag set by first'); + }); + + QUnit.test('inherits flags from prevFlags', function(assert) { + var s = makeStitch('Welcome back.', { ifConditions: ['returning'] }); + var prevFlags = [{ flagName: 'returning', value: true }]; + var result = engine.buildChunk(s, prevFlags); + assert.ok(result.html.indexOf('Welcome back') > -1, 'inherited flag should satisfy condition'); + }); + + QUnit.test('does not mutate the prevFlags array passed in', function(assert) { + var s = makeStitch('Set something.', { flags: ['new_flag'] }); + var prevFlags = [{ flagName: 'old_flag', value: true }]; + engine.buildChunk(s, prevFlags); + assert.equal(prevFlags.length, 1, 'prevFlags should not be mutated'); + }); + + QUnit.test('marks hadSectionHeading when stitch has a page number label', function(assert) { + var s = makeStitch('Chapter start.', { pageNumber: 1 }); + var result = engine.buildChunk(s, []); + assert.equal(result.hadSectionHeading, true); + }); + + QUnit.test('wordCount is greater than zero for non-empty text', function(assert) { + var s = makeStitch('The quick brown fox.'); + var result = engine.buildChunk(s, []); + assert.ok(result.wordCount > 0, 'wordCount should be positive'); + }); + + }); + + QUnit.module('buildOptions()', function() { + + QUnit.test('returns empty array when last stitch has no options', function(assert) { + var s = makeStitch('Dead end.'); + var result = engine.buildOptions([s], []); + assert.equal(result.length, 0); + }); + + QUnit.test('returns one descriptor per option', function(assert) { + var s = makeStitch('Choose.'); + var opt1 = new StoryModel.Option(s); + opt1._text = 'Go left'; + var opt2 = new StoryModel.Option(s); + opt2._text = 'Go right'; + s.options = [opt1, opt2]; + var result = engine.buildOptions([s], []); + assert.equal(result.length, 2); + }); + + QUnit.test('marks option valid:false when ifCondition not met', function(assert) { + var s = makeStitch('Choose.'); + var opt = new StoryModel.Option(s); + opt._text = 'Secret path'; + opt._ifConditions = ['has_key']; + s.options = [opt]; + var result = engine.buildOptions([s], []); + assert.equal(result[0].valid, false); + }); + + QUnit.test('marks option valid:true when ifCondition is met', function(assert) { + var s = makeStitch('Choose.'); + var opt = new StoryModel.Option(s); + opt._text = 'Secret path'; + opt._ifConditions = ['has_key']; + s.options = [opt]; + var flags = [{ flagName: 'has_key', value: true }]; + var result = engine.buildOptions([s], flags); + assert.equal(result[0].valid, true); + }); + + }); + + QUnit.module('renderText()', function() { + + QUnit.test('returns plain text unchanged when no markup', function(assert) { + var result = engine.renderText('Hello world.', []); + assert.ok(result.indexOf('Hello world') > -1); + }); + + QUnit.test('{flag: yes|no} outputs yes-branch when flag is set', function(assert) { + var flags = [{ flagName: 'brave', value: true }]; + var result = engine.renderText('You are {brave: courageous|cowardly}.', flags); + assert.ok(result.indexOf('courageous') > -1, 'yes branch should appear'); + assert.ok(result.indexOf('cowardly') === -1, 'no branch should not appear'); + }); + + QUnit.test('{flag: yes|no} outputs no-branch when flag is not set', function(assert) { + var result = engine.renderText('You are {brave: courageous|cowardly}.', []); + assert.ok(result.indexOf('cowardly') > -1, 'no branch should appear'); + assert.ok(result.indexOf('courageous') === -1, 'yes branch should not appear'); + }); + + QUnit.test('*-bold-* renders as ', function(assert) { + var result = engine.renderText('This is *-important-* news.', []); + assert.ok(result.indexOf('important') > -1); + }); + + QUnit.test('/=italic=/ renders as ', function(assert) { + var result = engine.renderText('A /=gentle=/ breeze.', []); + assert.ok(result.indexOf('gentle') > -1); + }); + + QUnit.test('[...] ellipsis conjunctive is stripped', function(assert) { + var result = engine.renderText('She waited[...] and then spoke.', []); + assert.ok(result.indexOf('[...]') === -1); + }); + + }); + + QUnit.module('renderChunk()', function() { + + QUnit.test('wraps output in a stitch div', function(assert) { + var result = engine.renderChunk('Some text.'); + assert.ok(result.indexOf("
    ") > -1); + }); + + QUnit.test('[url|label] syntax becomes an anchor tag', function(assert) { + var result = engine.renderChunk('[http://example.com|click here]'); + assert.ok(result.indexOf('click here') > -1); + }); + + QUnit.test('double-quoted text gets curly quotes', function(assert) { + var result = engine.renderChunk('"Hello"'); + assert.ok(result.indexOf('“') > -1, 'should have left double quote'); + assert.ok(result.indexOf('”') > -1, 'should have right double quote'); + }); + + }); + + QUnit.module('getSavedGame()', function() { + + QUnit.test('returns [initialStitch] when localStorage is unavailable', function(assert) { + global.hasStorage = false; + var s = makeStitch('Start.'); + StoryModel.initialStitch = s; + StoryModel.stitches = [s]; + var result = engine.getSavedGame(); + assert.equal(result.length, 1); + assert.equal(result[0], s); + }); + + }); + +}); From c3c2ebeb06941543252da96e44758c23d96794cf Mon Sep 17 00:00:00 2001 From: Ned Redmond Date: Thu, 25 Jun 2026 23:10:28 -0400 Subject: [PATCH 4/4] fix whitespace --- .../inklewriter-source/storyEngine.js | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/app/assets/javascripts/inklewriter-source/storyEngine.js b/app/assets/javascripts/inklewriter-source/storyEngine.js index 9b89ef2..3030a32 100644 --- a/app/assets/javascripts/inklewriter-source/storyEngine.js +++ b/app/assets/javascripts/inklewriter-source/storyEngine.js @@ -17,32 +17,32 @@ class NumToWords { }; } - function _getIntAsWordsRecursively(n, nThousandsExponent) { - var str = _getNonZeroNumberLessThanOneThousandAsWords(n % 1000); - if (str != '' && nThousandsExponent > 0) - str += ' ' + _words.illions[nThousandsExponent - 1]; - if (n < 1000) return str; - return _getIntAsWordsRecursively(Math.floor(n / 1000), nThousandsExponent + 1) + - ((str == '') ? '' : ', ' + str); - } + _getIntAsWordsRecursively(n, nThousandsExponent) { + var str = _getNonZeroNumberLessThanOneThousandAsWords(n % 1000); + if (str != '' && nThousandsExponent > 0) + str += ' ' + _words.illions[nThousandsExponent - 1]; + if (n < 1000) return str; + return _getIntAsWordsRecursively(Math.floor(n / 1000), nThousandsExponent + 1) + + ((str == '') ? '' : ', ' + str); + } - function _getNonZeroNumberLessThanOneThousandAsWords(n) { - if (n == 0) return ''; - if (n < 10) return _words.digits[n - 1]; - if (n < 20) return _words.teens[n - 10]; - if (n < 100) return _words.tens[Math.floor(n / 10) - 1] + ((n % 10 == 0) ? '' : '-' + _words.digits[n % 10 - 1]); - if (n < 1000) return _words.digits[Math.floor(n / 100) - 1] + ' ' + tr("hundred") + - ((n % 100 == 0) ? '' : ' ' + tr("and") + ' ' + _getNonZeroNumberLessThanOneThousandAsWords(n % 100)); - return n; - } + _getNonZeroNumberLessThanOneThousandAsWords(n) { + if (n == 0) return ''; + if (n < 10) return _words.digits[n - 1]; + if (n < 20) return _words.teens[n - 10]; + if (n < 100) return _words.tens[Math.floor(n / 10) - 1] + ((n % 10 == 0) ? '' : '-' + _words.digits[n % 10 - 1]); + if (n < 1000) return _words.digits[Math.floor(n / 100) - 1] + ' ' + tr("hundred") + + ((n % 100 == 0) ? '' : ' ' + tr("and") + ' ' + _getNonZeroNumberLessThanOneThousandAsWords(n % 100)); + return n; + } - var _words = { - digits: [tr('one'), tr('two'), tr('three'), tr('four'), tr('five'), tr('six'), tr('seven'), tr('eight'), tr('nine')], - tens: [tr('ten'), tr('twenty'), tr('thirty'), tr('forty'), tr('fifty'), tr('sixty'), tr('seventy'), tr('eighty'), tr('ninety')], - teens: [tr('ten'), tr('eleven'), tr('twelve'), tr('thirteen'), tr('fourteen'), - tr('fifteen'), tr('sixteen'), tr('seventeen'), tr('eighteen'), tr('nineteen')], - illions: [tr('thousand'), tr('million'), tr('billion'), tr('trillion')] - }; + _words = { + digits: [tr('one'), tr('two'), tr('three'), tr('four'), tr('five'), tr('six'), tr('seven'), tr('eight'), tr('nine')], + tens: [tr('ten'), tr('twenty'), tr('thirty'), tr('forty'), tr('fifty'), tr('sixty'), tr('seventy'), tr('eighty'), tr('ninety')], + teens: [tr('ten'), tr('eleven'), tr('twelve'), tr('thirteen'), tr('fourteen'), + tr('fifteen'), tr('sixteen'), tr('seventeen'), tr('eighteen'), tr('nineteen')], + illions: [tr('thousand'), tr('million'), tr('billion'), tr('trillion')] + }; }