");
- 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('");
+ 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);
+ }
+}
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);
+ });
+
+ });
+
+});