Skip to content

Instantly share code, notes, and snippets.

@CyberShadow
Created June 17, 2020 06:32
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save CyberShadow/bc8e856b2954cb0122322a9cac3256ef to your computer and use it in GitHub Desktop.
Save CyberShadow/bc8e856b2954cb0122322a9cac3256ef to your computer and use it in GitHub Desktop.
Tombs & Tentacles
/twine_graph
/graph.dot
/graph.dot.svg
#!/bin/bash
set -eu
drun twine_graph > graph.dot
dot -Tsvg -O graph.dot
import std.algorithm.comparison;
import std.algorithm.iteration;
import std.algorithm.searching;
import std.array;
import std.conv;
import std.exception;
import std.file;
import std.math;
import std.stdio;
import std.string;
import std.typecons;
import ae.utils.array;
import ae.utils.regex;
import ae.utils.text;
import ae.utils.xml.entities;
import ae.utils.xml.lite;
void main()
{
auto d = "/home/vladimir/tmp/2020-06-16/data/twine.xml".readText.xmlParse();
string[string] passages;
foreach (n; d.findChildren("tw-storydata")[$-1].findChildren("tw-passagedata"))
passages[n.attributes["name"]] = n.text;
writeln("digraph {");
writeln("\tgraph [size=\"20,1000\"] [ratio=compress concentrate=true]");
auto fontName = "helvetica";
writefln("\tgraph [fontname=%(%s%)]", [fontName]);
writefln("\tnode [fontname=%(%s%)]", [fontName]);
writefln("\tedge [fontname=%(%s%)]", [fontName]);
writefln(" graph [bgcolor=black];");
writefln(" node [color=white fontcolor=white];");
writefln(" edge [color=white fontcolor=white];");
foreach (name, passage; passages)
{
scope(failure) stderr.writeln("Error with " ~ name ~ ":");
struct State
{
string[][string] parents;
string[][string][] queuedParents;
int blockLevel;
string fontFlush;
bool terminal;
}
State[] stack = [State(null)];
void popStack()
{
enforce(stack.length > 1, "Stack underflow");
auto tail = stack.stackPop();
if (!tail.terminal)
stack[$-1].queuedParents ~= tail.parents;
}
void mergeParents()
{
string[][string][] nextParents;
if (!stack[$-1].terminal)
nextParents ~= stack[$-1].parents;
nextParents ~= stack[$-1].queuedParents;
string[][string] mergedParents;
foreach (qp; nextParents)
foreach (qpName, labels; qp)
mergedParents[qpName] ~= labels;
stack[$-1] = State(mergedParents);
}
int counter;
void putEdges(string[][string] parents, string target)
{
foreach (parent, props; parents)
{
writef("\t%(%s%) -> %(%s%)", [parent], [target]);
if (target == "Start")
props ~= "constraint=false";
if (props.length)
writef(" [%-(%s %)]", props);
writeln(";");
}
}
string newNode()
{
auto result = counter++ == 0 ? name : name ~ "__" ~ text(counter);
putEdges(stack[$-1].parents, result);
stack[$-1].parents = [result:null];
return result;
}
string html;
bool allowStyles;
void flush()
{
html ~= stack[$-1].fontFlush;
stack[$-1].fontFlush = null;
html = html.replace(`<B></B>`, ``);
html = html.strip;
// html = html.replace("\n", `<BR/>`);
if (html.length)
writefln("\t%s [shape=box label=<%s>]", newNode(), html.wrapNodeText);
html = null;
}
string parseExpression(string var, string value, string node)
{
string result;
if (var == "$return")
{
enforce(value.startsWith('"') && value.endsWith('"'));
writefln("\t%(%s%) -> %(%s%) [style=dashed];", [node], [value[1 .. $-1]]);
return "here";
}
else
if (value.matchCaptures(re!`^Math\.floor\(Math\.random\(\)\*(.*)\)\+1$`,
(string max)
{
result = "a random number\nbetween 1 and " ~ max;
}))
return result;
else
if (value.matchCaptures(re!`^Math\.floor\(Math\.random\(\)\*([0-9.]+)\)(?:/([0-9.]+))?([-+][0-9.]+)$`,
(double max, double divisor, double base)
{
max = floor(max - 0.001);
if (!divisor.isNaN)
max /= divisor;
result = "a random number\nbetween %s and %s".format(base, base + max);
}))
return result;
else
if (value.matchCaptures(re!`^Math\.random\(\)\*([0-9.]+)$`,
(double max)
{
result = "a real random number\nbetween 0 and " ~ max.text;
}))
return result;
else
return value;
//Math.floor(Math.random()*75)/10.0+1
}
while (passage.length)
if (passage.skipOver("\\\n"))
continue;
else
if (!passage.startsWith("[[[") && passage.skipOver("[["))
{
flush();
auto dir = passage.skipUntil("]]").enforce("Unterminated [[");
auto parts = dir.findSplit("->");
string target, text;
if (parts[1].length)
list(text, null, target) = parts;
else
text = target = dir;
enforce(stack[$-1].parents.length, "Choice at start");
// stack[$-1].terminal = true;
stack ~= State(stack[$-1].parents);
stack[$-1].terminal = true;
writefln("\t%s [shape=box style=filled fillcolor=\"#333333\" label=%(%s%)]", newNode(), ["▶ " ~ text]);
putEdges(stack[$-1].parents, target);
popStack();
}
else
if (passage.skipOver("["))
{
flush();
stack[$-1].blockLevel++;
}
else
if (passage.skipOver("]"))
{
flush();
stack[$-1].blockLevel--;
}
else
if (stack.length > 1 && stack[$-1].blockLevel == 0 && passage.startsWith("\n"))
{
flush();
popStack();
mergeParents();
}
else
if (passage.skipOver("(set:"))
{
flush();
auto dir = passage.skipUntilBalancedClosed('(', ')').enforce("Unterminated (").strip();
auto parts = dir.findSplit(" to ");
enforce(parts[1].length, "No ' to ' in (set: ...): " ~ dir);
// parts[0].skipOver("$");
auto node = newNode();
auto value = parseExpression(parts[0], parts[2], node);
writefln("\t%s [label=%(%s%) shape=box color=\"#BBDDFF\" style=filled fillcolor=\"#002244\"]", node, ["Set %s\nto %s".format(parts[0], value)]);
}
else
if (passage.skipOver("(display:"))
{
flush();
auto dir = passage.skipUntilBalancedClosed('(', ')').enforce("Unterminated (").strip();
enforce(stack[$-1].parents.length, "(display:) at start");
if (dir.startsWith('"') && dir.endsWith('"'))
{
dir = dir[1..$-1];
putEdges(stack[$-1].parents, dir);
}
else
writefln("\t%s [label=%(%s%) shape=box color=\"#BBDDFF\" style=filled fillcolor=\"#002244\"]", newNode(), ["Go to\n" ~ dir]);
stack[$-1].parents = null;
}
else
if (passage.skipOver("(if:"))
{
flush();
auto dir = passage.skipUntilBalancedClosed('(', ')').enforce("Unterminated (").strip();
writefln("\t%s [label=%(%s%) shape=box color=\"#FFAAFF\" style=filled fillcolor=\"#330033\"]", newNode(), [dir ~ " ?"]);
stack ~= [State(stack[$-1].parents.byKeyValue.map!(kv => tuple(kv.key, kv.value ~ ["color=green"])).assocArray)];
stack[$-2].parents = stack[$-2].parents.byKeyValue.map!(kv => tuple(kv.key, kv.value ~ ["color=red"])).assocArray;
}
else
if (passage.skipOver("(else:"))
{
flush();
/* auto dir = */ passage.skipUntilBalancedClosed('(', ')').enforce("Unterminated (") /*.strip()*/;
popStack();
stack[$-1].terminal = true; // no implicit pass-through after else
stack ~= [State(stack[$-1].parents.byKeyValue.map!(kv => tuple(kv.key, kv.value ~ ["color=red"])).assocArray)];
}
else
if (passage.skipOver("(text-input:"))
{
flush();
/* auto dir = */ passage.skipUntilBalancedClosed('(', ')').enforce("Unterminated (") /*.strip()*/;
writefln("\t%s [shape=box style=filled fillcolor=\"#333333\" label=%(%s%)]", newNode(), ["▶ ________\n(text input)"]);
}
else
if (passage.skipOver("(either:"))
{
flush();
auto dir = passage.skipUntilBalancedClosed('(', ')').enforce("Unterminated (").strip();
// stack[$-1].terminal = true;
// foreach (word; dir.split(","))
// {
// stack ~= State(stack[$-1].parents);
// writefln("\t%s [shape=box label=%(%s%)]", newNode(), [word[1..$-1]]);
// popStack();
// }
// mergeParents();
writefln("\t%s [shape=record label=%(%s%)]", newNode(), [dir.split(",").map!(w => w[1..$-1]).join("|")]);
}
else
if (passage.skipOver("```"))
allowStyles = !allowStyles;
else
if (allowStyles && passage.skipOver("#{font:bold}"))
{
html ~= "<B>";
stack[$-1].fontFlush = "</B>" ~ stack[$-1].fontFlush;
}
else
if (allowStyles && passage.skipOver("#{font:default}"))
{
html ~= stack[$-1].fontFlush;
stack[$-1].fontFlush = null;
}
else
{
html ~= passage[0..1].encodeEntities();
passage = passage[1..$];
}
flush();
}
writeln("}");
}
string wrapNodeText(string html)
{
assert(html.length, "Empty node!");
// if (draft)
// return html;
static struct Word
{
string html;
size_t width;
}
Word[][] paragraphs;
foreach (line; html.split("\n"))
{
Word[] words;
Word word;
bool inTag, inEntity;
foreach (i, dchar c; line)
{
if (inTag)
{
if (c == '>')
inTag = false;
}
else
if (inEntity)
{
if (c == ';')
inEntity = false;
}
else
if (c == '<')
inTag = true;
else
if (c == '&')
{
inEntity = true;
word.width++;
}
else
if (c == ' ')
{
words ~= word;
word = Word.init;
continue;
}
else
word.width++;
word.html ~= c;
}
words ~= word;
paragraphs ~= words;
}
string[] tryWrap(size_t width)
{
string[] allLines;
foreach (words; paragraphs)
{
string[] lines;
size_t currentWidth;
foreach (ref word; words)
{
if (!lines || (currentWidth > 0 && currentWidth + word.width > width))
{
lines ~= null;
currentWidth = 0;
}
if (lines[$-1].length)
lines[$-1] ~= ' ';
lines[$-1] ~= word.html;
currentWidth += word.width;
}
if (!lines.length)
lines ~= null;
allLines ~= lines;
}
return allLines;
}
auto textWidth = paragraphs.map!(words => words.map!(word => word.width).sum + words.length - 1).reduce!max;
auto initWidth = cast(int)(sqrt(float(textWidth)) * 2).clamp(15, 100);
auto width = initWidth;
while (width && tryWrap(width - 1).length == tryWrap(width).length)
width--;
return tryWrap(width).join("<br/>").strip;
}
string skipUntilBalancedClosed(ref string s, char open, char close)
{
int level = 1;
size_t p = 0;
while (true)
{
if (s[p] == open)
level++;
else
if (s[p] == close)
{
level--;
if (!level)
{
auto r = s[0 .. p];
s = s[p+1 .. $];
return r;
}
}
p++;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment