using System;
using System.Collections.Generic;
using System.Text;
using System.Diagnostics;
using System.IO;
namespace Ink.Runtime
{
///
/// All story state information is included in the StoryState class,
/// including global variables, read counts, the pointer to the current
/// point in the story, the call stack (for tunnels, functions, etc),
/// and a few other smaller bits and pieces. You can save the current
/// state using the json serialisation functions ToJson and LoadJson.
///
public class StoryState
{
///
/// The current version of the state save file JSON-based format.
///
public const int kInkSaveStateVersion = 9; // new: multi-flows, but backward compatible
const int kMinCompatibleLoadVersion = 8;
///
/// Callback for when a state is loaded
///
public event Action onDidLoadState;
///
/// Exports the current state to json format, in order to save the game.
///
/// The save state in json format.
public string ToJson() {
var writer = new SimpleJson.Writer();
WriteJson(writer);
return writer.ToString();
}
///
/// Exports the current state to json format, in order to save the game.
/// For this overload you can pass in a custom stream, such as a FileStream.
///
public void ToJson(Stream stream) {
var writer = new SimpleJson.Writer(stream);
WriteJson(writer);
}
///
/// Loads a previously saved state in JSON format.
///
/// The JSON string to load.
public void LoadJson(string json)
{
var jObject = SimpleJson.TextToDictionary (json);
LoadJsonObj(jObject);
if(onDidLoadState != null) onDidLoadState();
}
///
/// Gets the visit/read count of a particular Container at the given path.
/// For a knot or stitch, that path string will be in the form:
///
/// knot
/// knot.stitch
///
///
/// The number of times the specific knot or stitch has
/// been enountered by the ink engine.
/// The dot-separated path string of
/// the specific knot or stitch.
public int VisitCountAtPathString(string pathString)
{
int visitCountOut;
if ( _patch != null ) {
var container = story.ContentAtPath(new Path(pathString)).container;
if (container == null)
throw new Exception("Content at path not found: " + pathString);
if( _patch.TryGetVisitCount(container, out visitCountOut) )
return visitCountOut;
}
if (_visitCounts.TryGetValue(pathString, out visitCountOut))
return visitCountOut;
return 0;
}
public int VisitCountForContainer(Container container)
{
if (!container.visitsShouldBeCounted)
{
story.Error("Read count for target (" + container.name + " - on " + container.debugMetadata + ") unknown.");
return 0;
}
int count = 0;
if (_patch != null && _patch.TryGetVisitCount(container, out count))
return count;
var containerPathStr = container.path.ToString();
_visitCounts.TryGetValue(containerPathStr, out count);
return count;
}
public void IncrementVisitCountForContainer(Container container)
{
if( _patch != null ) {
var currCount = VisitCountForContainer(container);
currCount++;
_patch.SetVisitCount(container, currCount);
return;
}
int count = 0;
var containerPathStr = container.path.ToString();
_visitCounts.TryGetValue(containerPathStr, out count);
count++;
_visitCounts[containerPathStr] = count;
}
public void RecordTurnIndexVisitToContainer(Container container)
{
if( _patch != null ) {
_patch.SetTurnIndex(container, currentTurnIndex);
return;
}
var containerPathStr = container.path.ToString();
_turnIndices[containerPathStr] = currentTurnIndex;
}
public int TurnsSinceForContainer(Container container)
{
if (!container.turnIndexShouldBeCounted)
{
story.Error("TURNS_SINCE() for target (" + container.name + " - on " + container.debugMetadata + ") unknown.");
}
int index = 0;
if ( _patch != null && _patch.TryGetTurnIndex(container, out index) ) {
return currentTurnIndex - index;
}
var containerPathStr = container.path.ToString();
if (_turnIndices.TryGetValue(containerPathStr, out index))
{
return currentTurnIndex - index;
}
else
{
return -1;
}
}
public int callstackDepth {
get {
return callStack.depth;
}
}
// REMEMBER! REMEMBER! REMEMBER!
// When adding state, update the Copy method, and serialisation.
// REMEMBER! REMEMBER! REMEMBER!
public List outputStream {
get {
return _currentFlow.outputStream;
}
}
public List currentChoices {
get {
// If we can continue generating text content rather than choices,
// then we reflect the choice list as being empty, since choices
// should always come at the end.
if( canContinue ) return new List();
return _currentFlow.currentChoices;
}
}
public List generatedChoices {
get {
return _currentFlow.currentChoices;
}
}
// TODO: Consider removing currentErrors / currentWarnings altogether
// and relying on client error handler code immediately handling StoryExceptions etc
// Or is there a specific reason we need to collect potentially multiple
// errors before throwing/exiting?
public List currentErrors { get; private set; }
public List currentWarnings { get; private set; }
public VariablesState variablesState { get; private set; }
public CallStack callStack {
get {
return _currentFlow.callStack;
}
// set {
// _currentFlow.callStack = value;
// }
}
public List evaluationStack { get; private set; }
public Pointer divertedPointer { get; set; }
public int currentTurnIndex { get; private set; }
public int storySeed { get; set; }
public int previousRandom { get; set; }
public bool didSafeExit { get; set; }
public Story story { get; set; }
///
/// String representation of the location where the story currently is.
///
public string currentPathString {
get {
var pointer = currentPointer;
if (pointer.isNull)
return null;
else
return pointer.path.ToString();
}
}
public Runtime.Pointer currentPointer {
get {
return callStack.currentElement.currentPointer;
}
set {
callStack.currentElement.currentPointer = value;
}
}
public Pointer previousPointer {
get {
return callStack.currentThread.previousPointer;
}
set {
callStack.currentThread.previousPointer = value;
}
}
public bool canContinue {
get {
return !currentPointer.isNull && !hasError;
}
}
public bool hasError
{
get {
return currentErrors != null && currentErrors.Count > 0;
}
}
public bool hasWarning {
get {
return currentWarnings != null && currentWarnings.Count > 0;
}
}
public string currentText
{
get
{
if( _outputStreamTextDirty ) {
var sb = new StringBuilder ();
foreach (var outputObj in outputStream) {
var textContent = outputObj as StringValue;
if (textContent != null) {
sb.Append(textContent.value);
}
}
_currentText = CleanOutputWhitespace (sb.ToString ());
_outputStreamTextDirty = false;
}
return _currentText;
}
}
string _currentText;
// Cleans inline whitespace in the following way:
// - Removes all whitespace from the start and end of line (including just before a \n)
// - Turns all consecutive space and tab runs into single spaces (HTML style)
string CleanOutputWhitespace(string str)
{
var sb = new StringBuilder(str.Length);
int currentWhitespaceStart = -1;
int startOfLine = 0;
for (int i = 0; i < str.Length; i++) {
var c = str[i];
bool isInlineWhitespace = c == ' ' || c == '\t';
if (isInlineWhitespace && currentWhitespaceStart == -1)
currentWhitespaceStart = i;
if (!isInlineWhitespace) {
if (c != '\n' && currentWhitespaceStart > 0 && currentWhitespaceStart != startOfLine) {
sb.Append(' ');
}
currentWhitespaceStart = -1;
}
if (c == '\n')
startOfLine = i + 1;
if (!isInlineWhitespace)
sb.Append(c);
}
return sb.ToString();
}
public List currentTags
{
get
{
if( _outputStreamTagsDirty ) {
_currentTags = new List();
foreach (var outputObj in outputStream) {
var tag = outputObj as Tag;
if (tag != null) {
_currentTags.Add (tag.text);
}
}
_outputStreamTagsDirty = false;
}
return _currentTags;
}
}
List _currentTags;
public string currentFlowName {
get {
return _currentFlow.name;
}
}
public bool inExpressionEvaluation {
get {
return callStack.currentElement.inExpressionEvaluation;
}
set {
callStack.currentElement.inExpressionEvaluation = value;
}
}
public StoryState (Story story)
{
this.story = story;
_currentFlow = new Flow(kDefaultFlowName, story);
OutputStreamDirty();
evaluationStack = new List ();
variablesState = new VariablesState (callStack, story.listDefinitions);
_visitCounts = new Dictionary ();
_turnIndices = new Dictionary ();
currentTurnIndex = -1;
// Seed the shuffle random numbers
int timeSeed = DateTime.Now.Millisecond;
storySeed = (new Random (timeSeed)).Next () % 100;
previousRandom = 0;
GoToStart();
}
public void GoToStart()
{
callStack.currentElement.currentPointer = Pointer.StartOf (story.mainContentContainer);
}
internal void SwitchFlow_Internal(string flowName)
{
if(flowName == null) throw new System.Exception("Must pass a non-null string to Story.SwitchFlow");
if( _namedFlows == null ) {
_namedFlows = new Dictionary();
_namedFlows[kDefaultFlowName] = _currentFlow;
}
if( flowName == _currentFlow.name ) {
return;
}
Flow flow;
if( !_namedFlows.TryGetValue(flowName, out flow) ) {
flow = new Flow(flowName, story);
_namedFlows[flowName] = flow;
}
_currentFlow = flow;
variablesState.callStack = _currentFlow.callStack;
// Cause text to be regenerated from output stream if necessary
OutputStreamDirty();
}
internal void SwitchToDefaultFlow_Internal()
{
if( _namedFlows == null ) return;
SwitchFlow_Internal(kDefaultFlowName);
}
internal void RemoveFlow_Internal(string flowName)
{
if(flowName == null) throw new System.Exception("Must pass a non-null string to Story.DestroyFlow");
if(flowName == kDefaultFlowName) throw new System.Exception("Cannot destroy default flow");
// If we're currently in the flow that's being removed, switch back to default
if( _currentFlow.name == flowName ) {
SwitchToDefaultFlow_Internal();
}
_namedFlows.Remove(flowName);
}
// Warning: Any Runtime.Object content referenced within the StoryState will
// be re-referenced rather than cloned. This is generally okay though since
// Runtime.Objects are treated as immutable after they've been set up.
// (e.g. we don't edit a Runtime.StringValue after it's been created an added.)
// I wonder if there's a sensible way to enforce that..??
public StoryState CopyAndStartPatching()
{
var copy = new StoryState(story);
copy._patch = new StatePatch(_patch);
// Hijack the new default flow to become a copy of our current one
// If the patch is applied, then this new flow will replace the old one in _namedFlows
copy._currentFlow.name = _currentFlow.name;
copy._currentFlow.callStack = new CallStack (_currentFlow.callStack);
copy._currentFlow.currentChoices.AddRange(_currentFlow.currentChoices);
copy._currentFlow.outputStream.AddRange(_currentFlow.outputStream);
copy.OutputStreamDirty();
// The copy of the state has its own copy of the named flows dictionary,
// except with the current flow replaced with the copy above
// (Assuming we're in multi-flow mode at all. If we're not then
// the above copy is simply the default flow copy and we're done)
if( _namedFlows != null ) {
copy._namedFlows = new Dictionary();
foreach(var namedFlow in _namedFlows)
copy._namedFlows[namedFlow.Key] = namedFlow.Value;
copy._namedFlows[_currentFlow.name] = copy._currentFlow;
}
if (hasError) {
copy.currentErrors = new List ();
copy.currentErrors.AddRange (currentErrors);
}
if (hasWarning) {
copy.currentWarnings = new List ();
copy.currentWarnings.AddRange (currentWarnings);
}
// ref copy - exactly the same variables state!
// we're expecting not to read it only while in patch mode
// (though the callstack will be modified)
copy.variablesState = variablesState;
copy.variablesState.callStack = copy.callStack;
copy.variablesState.patch = copy._patch;
copy.evaluationStack.AddRange (evaluationStack);
if (!divertedPointer.isNull)
copy.divertedPointer = divertedPointer;
copy.previousPointer = previousPointer;
// visit counts and turn indicies will be read only, not modified
// while in patch mode
copy._visitCounts = _visitCounts;
copy._turnIndices = _turnIndices;
copy.currentTurnIndex = currentTurnIndex;
copy.storySeed = storySeed;
copy.previousRandom = previousRandom;
copy.didSafeExit = didSafeExit;
return copy;
}
public void RestoreAfterPatch()
{
// VariablesState was being borrowed by the patched
// state, so restore it with our own callstack.
// _patch will be null normally, but if you're in the
// middle of a save, it may contain a _patch for save purpsoes.
variablesState.callStack = callStack;
variablesState.patch = _patch; // usually null
}
public void ApplyAnyPatch()
{
if (_patch == null) return;
variablesState.ApplyPatch();
foreach(var pathToCount in _patch.visitCounts)
ApplyCountChanges(pathToCount.Key, pathToCount.Value, isVisit:true);
foreach (var pathToIndex in _patch.turnIndices)
ApplyCountChanges(pathToIndex.Key, pathToIndex.Value, isVisit:false);
_patch = null;
}
void ApplyCountChanges(Container container, int newCount, bool isVisit)
{
var counts = isVisit ? _visitCounts : _turnIndices;
counts[container.path.ToString()] = newCount;
}
void WriteJson(SimpleJson.Writer writer)
{
writer.WriteObjectStart();
// Flows
writer.WritePropertyStart("flows");
writer.WriteObjectStart();
// Multi-flow
if( _namedFlows != null ) {
foreach(var namedFlow in _namedFlows) {
writer.WriteProperty(namedFlow.Key, namedFlow.Value.WriteJson);
}
}
// Single flow
else {
writer.WriteProperty(_currentFlow.name, _currentFlow.WriteJson);
}
writer.WriteObjectEnd();
writer.WritePropertyEnd(); // end of flows
writer.WriteProperty("currentFlowName", _currentFlow.name);
writer.WriteProperty("variablesState", variablesState.WriteJson);
writer.WriteProperty("evalStack", w => Json.WriteListRuntimeObjs(w, evaluationStack));
if (!divertedPointer.isNull)
writer.WriteProperty("currentDivertTarget", divertedPointer.path.componentsString);
writer.WriteProperty("visitCounts", w => Json.WriteIntDictionary(w, _visitCounts));
writer.WriteProperty("turnIndices", w => Json.WriteIntDictionary(w, _turnIndices));
writer.WriteProperty("turnIdx", currentTurnIndex);
writer.WriteProperty("storySeed", storySeed);
writer.WriteProperty("previousRandom", previousRandom);
writer.WriteProperty("inkSaveVersion", kInkSaveStateVersion);
// Not using this right now, but could do in future.
writer.WriteProperty("inkFormatVersion", Story.inkVersionCurrent);
writer.WriteObjectEnd();
}
void LoadJsonObj(Dictionary jObject)
{
object jSaveVersion = null;
if (!jObject.TryGetValue("inkSaveVersion", out jSaveVersion)) {
throw new Exception ("ink save format incorrect, can't load.");
}
else if ((int)jSaveVersion < kMinCompatibleLoadVersion) {
throw new Exception("Ink save format isn't compatible with the current version (saw '"+jSaveVersion+"', but minimum is "+kMinCompatibleLoadVersion+"), so can't load.");
}
// Flows: Always exists in latest format (even if there's just one default)
// but this dictionary doesn't exist in prev format
object flowsObj = null;
if (jObject.TryGetValue("flows", out flowsObj)) {
var flowsObjDict = (Dictionary)flowsObj;
// Single default flow
if( flowsObjDict.Count == 1 )
_namedFlows = null;
// Multi-flow, need to create flows dict
else if( _namedFlows == null )
_namedFlows = new Dictionary();
// Multi-flow, already have a flows dict
else
_namedFlows.Clear();
// Load up each flow (there may only be one)
foreach(var namedFlowObj in flowsObjDict) {
var name = namedFlowObj.Key;
var flowObj = (Dictionary)namedFlowObj.Value;
// Load up this flow using JSON data
var flow = new Flow(name, story, flowObj);
if( flowsObjDict.Count == 1 ) {
_currentFlow = new Flow(name, story, flowObj);
} else {
_namedFlows[name] = flow;
}
}
if( _namedFlows != null && _namedFlows.Count > 1 ) {
var currFlowName = (string)jObject["currentFlowName"];
_currentFlow = _namedFlows[currFlowName];
}
}
// Old format: individually load up callstack, output stream, choices in current/default flow
else {
_namedFlows = null;
_currentFlow.name = kDefaultFlowName;
_currentFlow.callStack.SetJsonToken ((Dictionary < string, object > )jObject ["callstackThreads"], story);
_currentFlow.outputStream = Json.JArrayToRuntimeObjList ((List