public static Page TryLoad(
            StreamReader reader,
            Dictionary <string, Unit> unitsByUniqueId,
            Dictionary <string, ReactionArrow> reactionArrowsByUniqueId)
        {
            var result = new Page();

            result.ActionText = null;
            result.Reactions  = new Dictionary <string, ScoredReactionArrow>();

            // End of file right at the beginning (maybe after an 'x' operation) indicates a valid end of the file.
            var line = reader.ReadLine();

            if (line == null)
            {
                return(null);
            }

            while (true)
            {
                var parts = line.Split(SaveFileDelimiter);
                switch (parts[0])
                {
                case "s":
                    Setting setting;
                    switch (parts[2])
                    {
                    case "s":
                        setting = new StringSetting(parts[3]);
                        break;

                    case "c":
                        if (!int.TryParse(parts[3], out var chosen))
                        {
                            throw new InvalidOperationException(string.Format($"Can't parse int chosen '{parts[3]}'."));
                        }
                        if (!int.TryParse(parts[4], out var opportunity))
                        {
                            throw new InvalidOperationException(string.Format($"Can't parse int opportunity '{parts[4]}'."));
                        }
                        setting = new ScoreSetting(chosen, opportunity);
                        break;

                    case "b":
                        switch (parts[3])
                        {
                        case "0":
                            setting = new BooleanSetting(false);
                            break;

                        case "1":
                            setting = new BooleanSetting(true);
                            break;

                        default:
                            throw new InvalidOperationException(string.Format($"Unexpected boolean value '{parts[3]}'."));
                        }
                        break;

                    default:
                        throw new InvalidOperationException(string.Format($"Unexpected setting type '{parts[2]}'."));
                    }
                    result.Settings.Add(parts[1], setting);
                    break;

                case "n":
                    result.NextTargetUnitOnReturn.Push(unitsByUniqueId[parts[1]]);
                    break;

                case "a":
                    result.ActionText = parts[1];
                    break;

                case "r":
                    if (!double.TryParse(parts[2], out var score))
                    {
                        throw new InvalidOperationException(string.Format($"Can't parse double score '{parts[2]}'."));
                    }
                    result.Reactions.Add(parts[3], new ScoredReactionArrow(score, reactionArrowsByUniqueId[parts[1]]));
                    break;

                case "x":
                    // Flip the stack so it's going the right way. When you copy a stack, it flips it.
                    result.NextTargetUnitOnReturn = new Stack <Unit>(result.NextTargetUnitOnReturn);
                    return(result);

                default:
                    throw new InvalidOperationException(string.Format($"Unexpected operation '{parts[0]}'."));
                }
                line = reader.ReadLine();
                if (line == null)
                {
                    throw new InvalidOperationException(string.Format($"Unexpected end of save file."));
                }
            }
        }
        public TokenList(
            string sourceText,
            string sourceNameForErrorMessages,
            Dictionary <string, Setting> settings)
        {
            // Tokens only get created as part of this list of tokens.
            char pushedLetter = '\0';
            char gottenLetter;
            int  letterIndex;
            var  specialIds = new List <string> {
                "John", "Jane", "Smith", "he", "He", "him", "Him", "his", "His", "himself", "Himself", "man", "Man", "boy", "Boy", "Mr", "Mrs", "she", "She", "her", "Her", "hers", "Hers", "herself", "Herself", "woman", "Woman", "girl", "Girl", "Ms"
            };

            TheList = new List <Token>();
            string textAccumulator = "";
            int    lineNumber      = 1;

            letterIndex = 0;

            // This outer loop is for accumulating text strings
            while (true)
            {
                if (!GetLetter())
                {
                    if (textAccumulator.Length != 0)
                    {
                        TheList.Add(new Token(TokenType.Characters, textAccumulator, lineNumber));
                    }
                    TheList.Add(new Token(TokenType.EndOfSourceText, "", lineNumber));
                    return;
                }

                switch (gottenLetter)
                {
                case '\n':
                    // @ lets you explicitly put in a paragraph break. We'll clean up any extra spaces later. This lets you break contiguous text up into multiple lines within 'if' groups without having it affect formatting.
                    textAccumulator += "\n";
                    ++lineNumber;
                    break;

                case '\r':
                    // Ignore the CR in CRLF.
                    break;

                case '\t':
                    textAccumulator += "\t";
                    break;

                // Didn't want to put this way at the bottom--gets lost.
                default:
                    textAccumulator += gottenLetter;
                    break;

                case '[':
                    if (!GetLetter())
                    {
                        TheList.Add(new Token(TokenType.EndOfSourceText, "", lineNumber));
                        return;
                    }

                    // Check for a [[ comment starter.
                    if (gottenLetter != '[')
                    {
                        // No comment--pretend this never happened.
                        UngetLetter();
                    }
                    else
                    {
                        //  [[ This is an example comment. ]]
                        if (!GetComment())
                        {
                            // If it returns false, it means you've reached the end.
                            TheList.Add(new Token(TokenType.EndOfSourceText, "", lineNumber));
                            return;
                        }
                        // We got a text mode comment, so we break back out to the outer loop.
                        break; // switch
                    }

                    // If we have accumulated a text string, record it as the next token before going into code mode.
                    if (textAccumulator.Length != 0)
                    {
                        TheList.Add(new Token(TokenType.Characters, textAccumulator, lineNumber));
                        textAccumulator = "";
                    }

                    // This inner loop is for breaking out control codes
                    for (bool gotClosingBracket = false; !gotClosingBracket;)
                    {
                        if (!GetLetter())
                        {
                            TheList.Add(new Token(TokenType.EndOfSourceText, "", lineNumber));
                            return;
                        }

                        switch (gottenLetter)
                        {
                        case ' ':
                        case '\r':
                            break;

                        case '\n':
                            ++lineNumber;
                            break;

                        case ']':
                            // Leave code mode
                            gotClosingBracket = true;
                            break;

                        case '=':
                            TheList.Add(new Token(TokenType.Equal, "=", lineNumber));
                            break;

                        case '.':
                            TheList.Add(new Token(TokenType.Period, ".", lineNumber));
                            break;

                        case ',':
                            TheList.Add(new Token(TokenType.Comma, ",", lineNumber));
                            break;

                        case '[':
                            // Must be a comment.
                            if (!GetLetter())
                            {
                                TheList.Add(new Token(TokenType.EndOfSourceText, "", lineNumber));
                                return;
                            }
                            if (gottenLetter != '[')
                            {
                                throw new InvalidOperationException(string.Format($"file {sourceNameForErrorMessages} line {lineNumber}: expected '[[' but got '[{gottenLetter}' in\n{sourceText}"));
                            }

                            if (!GetComment())
                            {
                                // If it returns false, it means you've reached the end.
                                TheList.Add(new Token(TokenType.EndOfSourceText, "", lineNumber));
                                return;
                            }
                            // Done skipping comment. Move on to the next token.
                            break;

                        default:
                            if (!char.IsLetterOrDigit(gottenLetter) || gottenLetter == '_')
                            {
                                throw new InvalidOperationException(string.Format($"file {sourceNameForErrorMessages} line {lineNumber}: unexpected character '{gottenLetter}' in\n{sourceText}"));
                            }

                            string id = "";
                            do
                            {
                                id += gottenLetter;

                                if (!GetLetter())
                                {
                                    break;
                                }
                            } while (Char.IsLetterOrDigit(gottenLetter) || gottenLetter == '_' || gottenLetter == '.');

                            UngetLetter();
                            if (id == "if")
                            {
                                TheList.Add(new Token(TokenType.If, id, lineNumber));
                            }
                            else if (id == "else")
                            {
                                TheList.Add(new Token(TokenType.Else, id, lineNumber));
                            }
                            else if (id == "or")
                            {
                                TheList.Add(new Token(TokenType.Or, id, lineNumber));
                            }
                            else if (id == "not")
                            {
                                TheList.Add(new Token(TokenType.Not, id, lineNumber));
                            }
                            else if (id == "end")
                            {
                                TheList.Add(new Token(TokenType.End, id, lineNumber));
                            }
                            else if (id == "when")
                            {
                                TheList.Add(new Token(TokenType.When, id, lineNumber));
                            }
                            else if (id == "set")
                            {
                                TheList.Add(new Token(TokenType.Set, id, lineNumber));
                            }
                            else if (id == "score")
                            {
                                TheList.Add(new Token(TokenType.Score, id, lineNumber));
                            }
                            else if (id == "sort")
                            {
                                TheList.Add(new Token(TokenType.Sort, id, lineNumber));
                            }
                            else if (id == "text")
                            {
                                TheList.Add(new Token(TokenType.Text, id, lineNumber));
                            }
                            else if (id == "merge")
                            {
                                TheList.Add(new Token(TokenType.Merge, id, lineNumber));
                            }
                            else if (id == "return")
                            {
                                TheList.Add(new Token(TokenType.Return, id, lineNumber));
                            }
                            else if (id == "scene")
                            {
                                TheList.Add(new Token(TokenType.Scene, id, lineNumber));
                            }
                            else if (specialIds.Contains(id))
                            {
                                TheList.Add(new Token(TokenType.SpecialId, id, lineNumber));
                            }
                            else
                            {
                                var type = settings.ContainsKey(id) ?
                                           settings[id] switch
                                {
                                    ScoreSetting _ => TokenType.ScoreId,
                                    StringSetting _ => TokenType.StringId,
                                    BooleanSetting _ => TokenType.BooleanId,
                                    _ => throw new NotImplementedException(), // Just making the compiler happy. There is no other case.
                                }
                              :
                                TokenType.Id;
                                TheList.Add(new Token(type, id, lineNumber));
                            }
                            break;
                        }
                    }
                    break;
                }
        public void Build(
            Unit firstUnit,
            string startingTrace)
        {
            // Starting with the current unit box, a) merge the texts of all units connected below it into one text, and b) collect all the reaction arrows.
            var accumulatedReactions = new Dictionary <string, ScoredReactionArrow>();
            // The action text will contain all the merged action texts.
            var accumulatedActionTexts = startingTrace;
            // If there were no reaction arrows, we've reached an end point and need to return to
            var gotAReactionArrow = false;
            // Reactions are sorted by score, which is a floating point number. But some reactions may have the same score. So add a small floating-point sequence number to each one, to disambiguate them.
            double reactionScoreDisambiguator = 0;

            // Scores use this to compute whether you are above average in a score. Set it now, before creating the page, so it can be used in conditions evaluated throughout page creation.
            ScoreSetting.Average = Settings.Values.Where(setting => setting is ScoreSetting).Select(setting => (setting as ScoreSetting).ScoreValue).DefaultIfEmpty().Average();
            if (Game.DebugMode)
            {
                accumulatedActionTexts += String.Format($"@{Game.PositiveDebugTextStart}average = {ScoreSetting.Average:0.00}%{Game.DebugTextStop}");
            }

            // This recursive routine will accumulate all the action and reaction text values in the above variables.
            Accumulate(firstUnit);
            while (!gotAReactionArrow)
            {
                // We got to a dead end without finding any reaction options for the player. So pop back to a pushed location and continue merging from there.
                if (!NextTargetUnitOnReturn.Any())
                {
                    throw new InvalidOperationException(string.Format($"Got to a dead end with no place to return to."));
                }
                var unit = NextTargetUnitOnReturn.Pop();
                if (Game.DebugMode)
                {
                    accumulatedActionTexts += "@" + Game.PositiveDebugTextStart + "pop " + unit.UniqueId + Game.DebugTextStop;
                }
                Accumulate(unit);
            }

            ActionText = FixPlus(accumulatedActionTexts);
            Reactions  = accumulatedReactions;

            void Accumulate(
                Unit unit)
            {
                // First append this action box's own text and execute any settings.
                if (accumulatedActionTexts.Length == 0)
                {
                    accumulatedActionTexts = EvaluateText(unit.ActionCode);
                }
                else
                {
                    accumulatedActionTexts += " " + EvaluateText(unit.ActionCode);
                }

                EvaluateSettingsAndScores(unit.ActionCode, out string trace1);
                accumulatedActionTexts += trace1;

                // Next examine all the arrows for the action.
                var allWhensFailed = true;
                var whenElseArrows = new List <Arrow>();
                var returnArrows   = new List <ReturnArrow>();

                foreach (var arrow in unit.GetArrows())
                {
                    if (arrow is ReturnArrow returnArrow)
                    {
                        // We'll deal with these return arrows at the end of the loop.
                        returnArrows.Add(returnArrow);
                    }
                    else if (EvaluateWhenElse(arrow.Code))
                    {
                        // Save 'when else' arrows for possible later execution.
                        whenElseArrows.Add(arrow);
                    }
                    else
                    {
                        // If conditions in the arrow are false, then just ignore the arrow completely. This includes all types of arrows.
                        (var succeeded, var hadWhen) = EvaluateWhen(arrow.Code, out string trace2);
                        accumulatedActionTexts      += trace2;
                        if (!succeeded)
                        {
                            continue;
                        }
                        if (hadWhen)
                        {
                            allWhensFailed = false;
                        }
                        AccumulateArrow(arrow);
                    }
                }
                if (allWhensFailed)
                {
                    // If none of the 'when EXPRESSIONS' arrows succeeded, execute the 'when else' arrows now.
                    foreach (var arrow in whenElseArrows)
                    {
                        AccumulateArrow(arrow);
                    }
                }
                if (returnArrows.Any())
                {
                    // Create a unit on the fly and push it on the stack for execution on return. This converts the return arrows to merge arrows.
                    var returnUnit = Unit.BuildReturnUnitFor(returnArrows);
                    NextTargetUnitOnReturn.Push(returnUnit);
                }
            }

            void AccumulateArrow(
                Arrow arrow)
            {
                switch (arrow)
                {
                case MergeArrow mergeArrow:
                    // There may be 'set' parameters for a referential merge.
                    EvaluateSettingsAndScores(arrow.Code, out string trace);
                    accumulatedActionTexts += trace;

                    if (Game.DebugMode)
                    {
                        accumulatedActionTexts += "@" + Game.PositiveDebugTextStart + "merge" + (mergeArrow.DebugSceneId != null ? " " + mergeArrow.DebugSceneId : "") + Game.DebugTextStop;
                    }

                    // There are two kinds of merge arrows.
                    Unit targetUnit;
                    if (mergeArrow.TargetSceneUnit != null)
                    {
                        targetUnit = mergeArrow.TargetSceneUnit;
                        // When we finish the jump to the other scene, we will continue merging with the action this arrow points to.
                        NextTargetUnitOnReturn.Push(mergeArrow.TargetUnit);
                    }
                    else
                    {
                        // It's a local merge arrow. Merge the action it points to.
                        // It should be impossible for it to have no target. Let it crash if that's the case.
                        targetUnit = mergeArrow.TargetUnit;
                    }
                    // Call this routine again recursively. It will append the target's text and examine the target's arrows.
                    Accumulate(targetUnit);
                    break;

                case ReactionArrow reactionArrow:
                    gotAReactionArrow = true;
                    double highestScore = 0;
                    var    reactionText = EvaluateText(reactionArrow.Code);
                    // There's a little trickiness with links here...
                    if (reactionText.Length > 0 && reactionText[0] == '{')
                    {
                        // If it's in braces, it refers to a hyperlink already in the text. Don't make a new hyperlink for it. Just take off the braces. When the user clicks on the link, it won't have braces.
                        reactionText = reactionText.Substring(1);
                        var end = reactionText.IndexOf("}");
                        if (end != -1)
                        {
                            reactionText = reactionText.Substring(0, end);
                        }
                        // -1 tells the UI to not put embedded hyperlinks on the list on the screen.
                        highestScore = -1;
                    }
                    else
                    {
                        // Sort by scores.
                        var highestScoreIdPlusSpace = "";
                        reactionArrow.Code.Traverse((code, originalSourceText) =>
                        {
                            if (!(code is ScoreCode scoreCode))
                            {
                                return(true);
                            }
                            foreach (var id in scoreCode.Ids)
                            {
                                ScoreSetting scoreSetting;
                                if (Settings.TryGetValue(id, out Setting setting))
                                {
                                    scoreSetting = setting as ScoreSetting;
                                }
                                else
                                {
                                    scoreSetting = new ScoreSetting();
                                    Settings.Add(id, scoreSetting);
                                }

                                var value = scoreSetting.ScoreValue;
                                if (value > highestScore)
                                {
                                    highestScore            = value;
                                    highestScoreIdPlusSpace = id + " ";
                                }
                            }
                            return(true);
                        });
                        if (Game.DebugMode)
                        {
                            reactionText =
                                Game.PositiveDebugTextStart +
                                highestScoreIdPlusSpace +
                                ((int)(highestScore * 100)).ToString() +
                                "% " +
                                Game.DebugTextStop +
                                reactionText;
                        }
                    }
                    accumulatedReactions[reactionText] = new ScoredReactionArrow(highestScore, reactionArrow);
                    reactionScoreDisambiguator        += 0.00001;
                    break;
                }
            }
        }
        public static Page?TryLoad(
            TextReader reader,
            World world)
        {
            // Loads saved story state data and links it to the static world description. Either loads and returns a valid Page or returns null if it reaches the end of the reader. Run this multiple times to read multiple Pages from the reader.

            var    settings               = new Dictionary <string, Setting>(world.Settings);
            string actionText             = "";
            var    reactions              = new Dictionary <string, ScoredReactionArrow>();
            var    nextTargetNodeOnReturn = new Stack <Node>();

            // End of file right at the beginning (maybe after an 'x' operation) indicates a valid end of the file. That means we're done reading all the pages in the file.
            var line = reader.ReadLine();

            if (line == null)
            {
                return(null);
            }

            while (true)
            {
                var parts = line.Split(SaveFileDelimiter);
                switch (parts[0])
                {
                case "s":
                    Setting setting;
                    switch (parts[2])
                    {
                    case "s":
                        setting = new StringSetting(parts[3]);
                        break;

                    case "c":
                        if (!int.TryParse(parts[3], out var chosen))
                        {
                            throw new InvalidOperationException(string.Format($"Can't parse int chosen '{parts[3]}'."));
                        }
                        if (!int.TryParse(parts[4], out var opportunity))
                        {
                            throw new InvalidOperationException(string.Format($"Can't parse int opportunity '{parts[4]}'."));
                        }
                        setting = new ScoreSetting(chosen, opportunity);
                        break;

                    case "b":
                        switch (parts[3])
                        {
                        case "0":
                            setting = new BooleanSetting(false);
                            break;

                        case "1":
                            setting = new BooleanSetting(true);
                            break;

                        default:
                            throw new InvalidOperationException(string.Format($"Unexpected boolean value '{parts[3]}'."));
                        }
                        break;

                    default:
                        throw new InvalidOperationException(string.Format($"Unexpected setting type '{parts[2]}'."));
                    }
                    settings[parts[1]] = setting;
                    break;

                case "n":
                    nextTargetNodeOnReturn.Push(world.NodesByUniqueId[parts[1]]);
                    break;

                case "a":
                    actionText = parts[1];
                    break;

                case "r":
                    if (!double.TryParse(parts[2], out var score))
                    {
                        throw new InvalidOperationException(string.Format($"Can't parse double score '{parts[2]}'."));
                    }
                    reactions.Add(parts[3], new ScoredReactionArrow(score, world.ReactionArrowsByUniqueId[parts[1]]));
                    break;

                case "x":
                    // Flip the stack so it's going the right way. When you copy a stack, it flips it.
                    nextTargetNodeOnReturn = new Stack <Node>(nextTargetNodeOnReturn);
                    return(new Page(actionText, reactions, settings, nextTargetNodeOnReturn));

                default:
                    throw new InvalidOperationException(string.Format($"Unexpected operation '{parts[0]}'."));
                }
                line = reader.ReadLine();
                if (line == null)
                {
                    throw new InvalidOperationException(string.Format($"Unexpected end of save file."));
                }
            }
        }