Compare commits

...

14 Commits
v1.0.4 ... main

27 changed files with 541 additions and 228 deletions

View File

@ -3,7 +3,6 @@
using System;
using System.Linq;
using System.IO;
using Microsoft.SqlServer.Server;
using Parser;
using Parser.Render;
using Parser.Model.Parser;
@ -16,8 +15,7 @@
{
if(e.ExceptionObject is FicdownException)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine(e.ExceptionObject.ToString());
Console.WriteLine(e.ExceptionObject.ToString());
Environment.Exit(3);
}
};
@ -85,98 +83,115 @@
ShowHelp();
return 0;
}
if (string.IsNullOrWhiteSpace(format) || string.IsNullOrWhiteSpace(infile))
{
ShowHelp();
return 1;
}
if (!File.Exists(infile))
{
Console.WriteLine(@"Source file {0} not found.", infile);
return 2;
}
if (string.IsNullOrWhiteSpace(output))
if (format == "html")
output = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"html");
else if (format == "epub")
output = "output.epub";
if (!string.IsNullOrWhiteSpace(output) && (Directory.Exists(output) || File.Exists(output)))
Logger.Initialize(debug);
var logger = Logger.GetLogger<Program>();
var lintMode = format == "lint";
if(!lintMode)
{
Console.WriteLine(@"Specified output {0} already exists.", output);
return 2;
}
if (!string.IsNullOrWhiteSpace(tempdir))
{
if (!Directory.Exists(tempdir))
if (string.IsNullOrWhiteSpace(format) || string.IsNullOrWhiteSpace(infile))
{
Console.WriteLine(@"Template directory {0} does not exist.", tempdir);
ShowHelp();
return 1;
}
if (!File.Exists(infile))
{
logger.Error($"Source file {infile} not found.");
return 2;
}
if (!File.Exists(Path.Combine(tempdir, "index.html")) ||
!File.Exists(Path.Combine(tempdir, "scene.html")) ||
!File.Exists(Path.Combine(tempdir, "styles.css")))
{
Console.WriteLine(
@"Template directory must contain ""index.html"", ""scene.html"", and ""style.css"" files.");
}
}
if (string.IsNullOrWhiteSpace(output))
if (format == "html")
output = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"html");
else if (format == "epub")
output = "output.epub";
else if(format == "lint")
lintMode = true;
if (!string.IsNullOrWhiteSpace(images) && !Directory.Exists(images))
{
Console.WriteLine(@"Images directory {0} does not exist.", images);
return 2;
if (!string.IsNullOrWhiteSpace(output) && (Directory.Exists(output) || File.Exists(output)))
{
logger.Error($"Specified output {output} already exists.");
return 2;
}
if (!string.IsNullOrWhiteSpace(tempdir))
{
if (!Directory.Exists(tempdir))
{
logger.Error($"Template directory {tempdir} does not exist.");
return 2;
}
if (!File.Exists(Path.Combine(tempdir, "index.html")) ||
!File.Exists(Path.Combine(tempdir, "scene.html")) ||
!File.Exists(Path.Combine(tempdir, "styles.css")))
{
logger.Error(
@"Template directory must contain ""index.html"", ""scene.html"", and ""style.css"" files.");
}
}
if (!string.IsNullOrWhiteSpace(images) && !Directory.Exists(images))
{
logger.Error($"Images directory {images} does not exist.");
return 2;
}
}
var parser = new FicdownParser();
var storyText = File.ReadAllText(infile);
Console.WriteLine(@"Parsing story...");
string storyText;
if(!lintMode)
{
storyText = File.ReadAllText(infile);
logger.Log(@"Parsing story...");
}
else
{
storyText = Console.In.ReadToEnd();
}
var story = parser.ParseStory(storyText);
story.Orphans.ToList().ForEach(o =>
{
var currentColor = Console.ForegroundColor;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.Error.WriteLine("Warning (line {0}): {1} {2} is unreachable", o.LineNumber, o.Type, o.Name);
Console.ForegroundColor = currentColor;
});
parser.Warnings.Select(w => w.ToString()).Distinct().ToList().ForEach(s => logger.Raw(s));
story.Orphans.ToList().ForEach(o => logger.Raw($"Warning L{o.LineNumber},1: \"{o.Name}\": Unreachable {o.Type}"));
IRenderer rend;
switch (format)
if(!lintMode && parser.Warnings.Count() == 0)
{
case "html":
Directory.CreateDirectory(output);
rend = new HtmlRenderer(language);
break;
case "epub":
if (string.IsNullOrWhiteSpace(author))
{
Console.WriteLine(@"Epub format requires the --author argument.");
IRenderer rend;
switch (format)
{
case "html":
Directory.CreateDirectory(output);
rend = new HtmlRenderer(language);
break;
case "epub":
if (string.IsNullOrWhiteSpace(author))
{
logger.Error(@"Epub format requires the --author argument.");
return 1;
}
rend = new EpubRenderer(author, bookid, language);
break;
default:
ShowHelp();
return 1;
}
rend = new EpubRenderer(author, bookid, language);
break;
default:
ShowHelp();
return 1;
}
if (!string.IsNullOrWhiteSpace(tempdir))
{
rend.IndexTemplate = File.ReadAllText(Path.Combine(tempdir, "index.html"));
rend.SceneTemplate = File.ReadAllText(Path.Combine(tempdir, "scene.html"));
rend.StylesTemplate = File.ReadAllText(Path.Combine(tempdir, "styles.css"));
};
if (!string.IsNullOrWhiteSpace(images)) rend.ImageDir = images;
logger.Log(@"Rendering story...");
rend.Render(story, output, debug);
logger.Log(@"Done.");
}
if (!string.IsNullOrWhiteSpace(tempdir))
{
rend.IndexTemplate = File.ReadAllText(Path.Combine(tempdir, "index.html"));
rend.SceneTemplate = File.ReadAllText(Path.Combine(tempdir, "scene.html"));
rend.StylesTemplate = File.ReadAllText(Path.Combine(tempdir, "styles.css"));
};
if (!string.IsNullOrWhiteSpace(images)) rend.ImageDir = images;
Console.WriteLine(@"Rendering story...");
rend.Render(story, output, debug);
Console.WriteLine(@"Done.");
return 0;
}
@ -184,7 +199,7 @@
private static void ShowHelp()
{
Console.WriteLine(
@"Usage: ficdown.exe
@"Usage: ficdown
--format (html|epub)
--in ""/path/to/source.md""
[--out ""/path/to/output""]
@ -193,7 +208,9 @@
[--author ""Author Name""]
[--bookid ""ePub Book ID""]
[--language ""language""]
[--debug]");
[--debug]
or: ficdown --format lint
(reads input from stdin)");
}
}
}

View File

@ -4,13 +4,19 @@
using Parser;
using Model.Parser;
using Extensions;
using System.Collections.Generic;
public class BlockHandlerTests
{
private BlockHandler NewBlockHandler
{
get { return new BlockHandler { Warnings = new List<FicdownException>() }; }
}
[Fact]
public void NoStoryBlockThrowsException()
{
var bh = new BlockHandler();
var bh = NewBlockHandler;
Assert.Throws<FicdownException>(() => bh.ParseBlocks(bh.ExtractBlocks(@"
## this file has no story
just a lonely scene".ToLines())));
@ -19,7 +25,7 @@ just a lonely scene".ToLines())));
[Fact]
public void StoryWithNoAnchorThrowsException()
{
var bh = new BlockHandler();
var bh = NewBlockHandler;
Assert.Throws<FicdownException>(() => bh.ParseBlocks(bh.ExtractBlocks(@"
# my story
doesn't link to a scene
@ -30,17 +36,23 @@ nothing links here".ToLines())));
[Fact]
public void StoriesWithFancyAnchorsThrowExceptions()
{
var bh = new BlockHandler();
Assert.Throws<FicdownException>(() => bh.ParseBlocks(bh.ExtractBlocks(@"
var bh = NewBlockHandler;
bh.ParseBlocks(bh.ExtractBlocks(@"
# [my story](/a-scene?conditional)
story with a conditional
## a scene
this is a scene".ToLines())));
Assert.Throws<FicdownException>(() => bh.ParseBlocks(bh.ExtractBlocks(@"
this is a scene".ToLines()));
Assert.NotEmpty(bh.Warnings);
bh = NewBlockHandler;
bh.ParseBlocks(bh.ExtractBlocks(@"
# [my story](/a-scene#toggle)
story with a toggle
## a scene
this is a scene".ToLines())));
this is a scene".ToLines()));
Assert.NotEmpty(bh.Warnings);
bh = NewBlockHandler;
Assert.Throws<FicdownException>(() => bh.ParseBlocks(bh.ExtractBlocks(@"
# [my story](/a-scene#?conditional#toggle)
story with a conditional and a toggle
@ -51,7 +63,7 @@ this is a scene".ToLines())));
[Fact]
public void StoryLinkingToNonExistentSceneThrowsException()
{
var bh = new BlockHandler();
var bh = NewBlockHandler;
Assert.Throws<FicdownException>(() => bh.ParseBlocks(bh.ExtractBlocks(@"
# [a story](/non-existent)
this story links to a first scene that doesn't exist
@ -62,7 +74,7 @@ this scene is so cold and lonely".ToLines())));
[Fact]
public void StoryWithALegitAnchorParses()
{
var bh = new BlockHandler();
var bh = NewBlockHandler;
bh.ParseBlocks(bh.ExtractBlocks(@"
# [my story](/a-scene)
story with a simple link
@ -73,8 +85,8 @@ this is a scene".ToLines()));
[Fact]
public void StoryWithDuplicateActionsThrowsException()
{
var bh = new BlockHandler();
Assert.Throws<FicdownException>(() => bh.ParseBlocks(bh.ExtractBlocks(@"
var bh = NewBlockHandler;
bh.ParseBlocks(bh.ExtractBlocks(@"
# [a story](/a-scene)
this story is action-happy
## a scene
@ -84,13 +96,14 @@ this is an action
## another scene
this is another scene
### an action
oops, this is the same action!".ToLines())));
oops, this is the same action!".ToLines()));
Assert.NotEmpty(bh.Warnings);
}
[Fact]
public void StoryWithScenesAndActionsParses()
{
var bh = new BlockHandler();
var bh = NewBlockHandler;
var story = bh.ParseBlocks(bh.ExtractBlocks(@"
# [my story](/a-scene)
story with a simple link

View File

@ -9,6 +9,7 @@
[Fact]
public void CanParseValidStoryFile()
{
Logger.Initialize(true);
var parser = new FicdownParser();
var storyText = File.ReadAllText(Path.Combine(Template.BaseDir, "TestStories", "CloakOfDarkness.md"));
var story = parser.ParseStory(storyText);

View File

@ -1,36 +1,40 @@
namespace Ficdown.Parser.Tests
{
using System.Collections.Generic;
using System.Linq;
using Model.Parser;
using Parser;
using Xunit;
public class UtilityTests
{
private List<FicdownException> Warnings = new List<FicdownException>();
private Utilities Utilities
{
get { return Utilities.GetInstance("none", 0); }
get { return Utilities.GetInstance(Warnings, "none", 0); }
}
[Fact]
public void FullAnchorMatches()
{
var anchorStr = @"[Link text](/target-scene)";
var anchor = Utilities.ParseAnchor(anchorStr);
var anchor = Utilities.ParseAnchor(anchorStr, 0, 0);
Assert.Equal(anchorStr, anchor.Original);
anchorStr = @"[Link text](?condition-state#toggle-state ""Title text"")";
anchor = Utilities.ParseAnchor(anchorStr);
anchor = Utilities.ParseAnchor(anchorStr, 0, 0);
Assert.Equal(anchorStr, anchor.Original);
anchorStr = @"[Link text](""Title text"")";
anchor = Utilities.ParseAnchor(anchorStr);
anchor = Utilities.ParseAnchor(anchorStr, 0, 0);
Assert.Equal(anchorStr, anchor.Original);
}
[Fact]
public void AnchorWithTargetMatches()
{
var anchor = Utilities.ParseAnchor(@"[Link text](/target-scene)");
var anchor = Utilities.ParseAnchor(@"[Link text](/target-scene)", 0, 0);
Assert.Equal("Link text", anchor.Text);
Assert.Equal("target-scene", anchor.Href.Target);
}
@ -38,15 +42,15 @@
[Fact]
public void AnchorsWithConditionsMatch()
{
var anchor = Utilities.ParseAnchor(@"[Link text](?condition-state)");
var anchor = Utilities.ParseAnchor(@"[Link text](?condition-state)", 0, 0);
Assert.Equal("Link text", anchor.Text);
Assert.True(anchor.Href.Conditions["condition-state"]);
anchor = Utilities.ParseAnchor(@"[Link text](?!condition-state)");
anchor = Utilities.ParseAnchor(@"[Link text](?!condition-state)", 0, 0);
Assert.Equal("Link text", anchor.Text);
Assert.False(anchor.Href.Conditions["condition-state"]);
anchor = Utilities.ParseAnchor(@"[Link text](?condition-1&!condition-2)");
anchor = Utilities.ParseAnchor(@"[Link text](?condition-1&!condition-2)", 0, 0);
Assert.Equal("Link text", anchor.Text);
Assert.True(anchor.Href.Conditions["condition-1"]);
Assert.False(anchor.Href.Conditions["condition-2"]);
@ -55,15 +59,15 @@
[Fact]
public void AnchorsWithTogglesMatch()
{
var anchor = Utilities.ParseAnchor(@"[Link text](#toggle-state)");
var anchor = Utilities.ParseAnchor(@"[Link text](#toggle-state)", 0, 0);
Assert.Equal("Link text", anchor.Text);
Assert.Equal("#toggle-state", anchor.Href.Original);
anchor = Utilities.ParseAnchor(@"[Link text](#toggle-1+toggle-2)");
anchor = Utilities.ParseAnchor(@"[Link text](#toggle-1+toggle-2)", 0, 0);
Assert.Equal("Link text", anchor.Text);
Assert.Equal("#toggle-1+toggle-2", anchor.Href.Original);
anchor = Utilities.ParseAnchor(@"[Link text](#toggle-1+toggle-2)");
anchor = Utilities.ParseAnchor(@"[Link text](#toggle-1+toggle-2)", 0, 0);
Assert.Equal("Link text", anchor.Text);
Assert.Equal("#toggle-1+toggle-2", anchor.Href.Original);
}
@ -71,11 +75,11 @@
[Fact]
public void AnchorsWithTitlesMatch()
{
var anchor = Utilities.ParseAnchor(@"[Link text](""Title text"")");
var anchor = Utilities.ParseAnchor(@"[Link text](""Title text"")", 0, 0);
Assert.Equal("Link text", anchor.Text);
Assert.Equal("Title text", anchor.Title);
anchor = Utilities.ParseAnchor(@"[Talking to Kid](""Lobby"")");
anchor = Utilities.ParseAnchor(@"[Talking to Kid](""Lobby"")", 0, 0);
Assert.Equal("Talking to Kid", anchor.Text);
Assert.Equal("Lobby", anchor.Title);
}
@ -83,20 +87,20 @@
[Fact]
public void ComplexAnchorsMatch()
{
var anchor = Utilities.ParseAnchor(@"[Link text](/target-scene?condition-state#toggle-state ""Title text"")");
var anchor = Utilities.ParseAnchor(@"[Link text](/target-scene?condition-state#toggle-state ""Title text"")", 0, 0);
Assert.Equal("Link text", anchor.Text);
Assert.Equal("/target-scene?condition-state#toggle-state", anchor.Href.Original);
Assert.Equal("Title text", anchor.Title);
anchor = Utilities.ParseAnchor(@"[Link text](/target-scene#toggle-state)");
anchor = Utilities.ParseAnchor(@"[Link text](/target-scene#toggle-state)", 0, 0);
Assert.Equal("Link text", anchor.Text);
Assert.Equal("/target-scene#toggle-state", anchor.Href.Original);
anchor = Utilities.ParseAnchor(@"[Link text](/target-scene?condition-state)");
anchor = Utilities.ParseAnchor(@"[Link text](/target-scene?condition-state)", 0, 0);
Assert.Equal("Link text", anchor.Text);
Assert.Equal("/target-scene?condition-state", anchor.Href.Original);
anchor = Utilities.ParseAnchor(@"[Link text](?condition-state#toggle-state)");
anchor = Utilities.ParseAnchor(@"[Link text](?condition-state#toggle-state)", 0, 0);
Assert.Equal("Link text", anchor.Text);
Assert.Equal("?condition-state#toggle-state", anchor.Href.Original);
}

View File

@ -4,6 +4,7 @@
namespace Ficdown.Parser
{
using System;
using System.Collections.Generic;
using System.Linq;
using Model.Parser;
using Parser;
@ -11,32 +12,81 @@ namespace Ficdown.Parser
public class FicdownParser
{
private static Logger _logger = Logger.GetLogger<FicdownParser>();
public List<FicdownException> Warnings { get; private set; }
private IBlockHandler _blockHandler;
internal IBlockHandler BlockHandler
{
get { return _blockHandler ?? (_blockHandler = new BlockHandler()); }
set { _blockHandler = value; }
get
{
return _blockHandler ??
(_blockHandler = new BlockHandler { Warnings = Warnings });
}
set
{
_blockHandler = value;
_blockHandler.Warnings = Warnings;
}
}
private IGameTraverser _gameTraverser;
internal IGameTraverser GameTraverser
{
get { return _gameTraverser ?? (_gameTraverser = new GameTraverser()); }
set { _gameTraverser = value; }
get { return _gameTraverser ??
(_gameTraverser = new GameTraverser { Warnings = Warnings }); }
set
{
_gameTraverser = value;
_gameTraverser.Warnings = Warnings;
}
}
private IStateResolver _stateResolver;
internal IStateResolver StateResolver
{
get { return _stateResolver ?? (_stateResolver = new StateResolver()); }
set { _stateResolver = value; }
get
{
return _stateResolver ??
(_stateResolver = new StateResolver { Warnings = Warnings });
}
set
{
_stateResolver = value;
_stateResolver.Warnings = Warnings;
}
}
public FicdownParser()
{
Warnings = new List<FicdownException>();
}
public ResolvedStory ParseStory(string storyText)
{
var lines = storyText.Split(new[] {"\n", "\r\n"}, StringSplitOptions.None);
_logger.Debug($"Parsed {lines.Length} lines.");
var blocks = BlockHandler.ExtractBlocks(lines);
_logger.Debug($"Extracted {blocks.Count()} blocks.");
var story = BlockHandler.ParseBlocks(blocks);
_logger.Debug("Finished initial story breakdown.");
// dupe scene sanity check
foreach(var key in story.Scenes.Keys)
{
foreach(var scene in story.Scenes[key])
{
foreach(var otherScene in story.Scenes[key].Where(s => s != scene))
{
if((scene.Conditions == null && otherScene.Conditions == null)
|| (scene.Conditions != null && otherScene.Conditions != null
&& scene.Conditions.Count == otherScene.Conditions.Count
&& !scene.Conditions.Except(otherScene.Conditions).Any()))
Warnings.Add(new FicdownException(scene.Name, string.Format("Scene defined again on line {0}", otherScene.LineNumber), scene.LineNumber));
}
}
}
GameTraverser.Story = story;
var resolved = StateResolver.Resolve(GameTraverser.Enumerate(), story);
resolved.Orphans = GameTraverser.OrphanedScenes.Select(o => new Orphan

View File

@ -12,6 +12,8 @@
<ItemGroup>
<PackageReference Include="Epub4Net" Version="1.2.0" />
<PackageReference Include="Ionic.Zip" Version="1.9.1.8" />
<PackageReference Include="MarkdownSharp" Version="2.0.5" />
<PackageReference Include="Markdig" Version="0.17.1" />
<PackageReference Include="System.Security.Permissions" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="4.5.1" />
</ItemGroup>
</Project>

63
Ficdown.Parser/Logger.cs Normal file
View File

@ -0,0 +1,63 @@
namespace Ficdown.Parser
{
using System;
using System.Collections.Generic;
public class Logger
{
private static bool _initialized = false;
private static bool _debug = false;
private static Dictionary<Type, Logger> _cache;
public Type Type { get; private set; }
private Logger(Type type)
{
Type = type;
}
public static void Initialize(bool debug)
{
_debug = debug;
_cache = new Dictionary<Type, Logger>();
_initialized = true;
}
public static Logger GetLogger<T>()
{
var type = typeof(T);
lock(_cache)
{
if(!_cache.ContainsKey(type))
_cache.Add(type, new Logger(type));
}
return _cache[type];
}
private string Decorate(string message)
{
return $"{DateTime.Now.ToString("")} <{Type.Name}> {message}";
}
public void Raw(string message)
{
Console.WriteLine(message);
}
public void Log(string message)
{
Raw(Decorate(message));
}
public void Debug(string message)
{
if(!_debug) return;
Log($"DEBUG: {message}");
}
public void Error(string message, Exception ex = null)
{
Console.Error.WriteLine(Decorate($"ERROR: {message}"));
}
}
}

View File

@ -6,5 +6,7 @@
public string Text { get; set; }
public Href Href { get; set; }
public string Title { get; set; }
public int LineNumber { get; set; }
public int ColNumber { get; set; }
}
}

View File

@ -6,24 +6,25 @@ namespace Ficdown.Parser.Model.Parser
{
public string BlockName { get; private set; }
public int? LineNumber { get; private set; }
public int? ColNumber { get; private set; }
public FicdownException(string blockName, int? lineNumber, string message) : base(message)
public FicdownException(string blockName, string message, int? lineNumber = null, int? colNumber = null) : base(message)
{
BlockName = blockName;
LineNumber = lineNumber;
ColNumber = colNumber;
}
public FicdownException(string message) : base(message) { }
public override string ToString()
{
return !string.IsNullOrEmpty(BlockName)
? string.Format("Error in block \"{0}\" (Line {1}): {2}",
BlockName,
LineNumber.HasValue
? LineNumber.ToString()
: "unknown", Message)
: string.Format("Error: {0}", Message);
return string.Format("Error L{0},{1}: {2}",
LineNumber ?? 1,
ColNumber ?? 1,
!string.IsNullOrEmpty(BlockName)
? string.Format("\"{0}\": {1}", BlockName, Message)
: Message);
}
}
}

View File

@ -7,6 +7,8 @@
internal class PageState
{
public StateManager Manager { get; set; }
public Guid Id { get; set; }
public Scene Scene { get; set; }
public State State { get; set; }
@ -21,12 +23,12 @@
public string UniqueHash
{
get { return _uniqueHash ?? (_uniqueHash = StateManager.GetUniqueHash(State, Scene.Key)); }
get { return _uniqueHash ?? (_uniqueHash = Manager.GetUniqueHash(State, Scene.Key)); }
}
public string CompressedHash
{
get { return _compressedHash ?? (_compressedHash = StateManager.GetCompressedHash(this)); }
get { return _compressedHash ?? (_compressedHash = Manager.GetCompressedHash(this)); }
}
}
}
}

View File

@ -4,6 +4,7 @@
{
public int Id { get; set; }
public string Toggle { get; set; }
public string RawDescription { get; set; }
public string Description { get; set; }
public int LineNumber { get; set; }
public bool Visited { get; set; }

View File

@ -8,6 +8,7 @@
public string Name { get; set; }
public string Key { get; set; }
public string Description { get; set; }
public string RawDescription { get; set; }
public IDictionary<string, bool> Conditions { get; set; }
public int LineNumber { get; set; }
public bool Visited { get; set; }

View File

@ -10,6 +10,8 @@
internal class BlockHandler : IBlockHandler
{
public List<FicdownException> Warnings { get; set; }
public IEnumerable<Block> ExtractBlocks(IEnumerable<string> lines)
{
var blocks = new List<Block>();
@ -45,22 +47,22 @@
public Story ParseBlocks(IEnumerable<Block> blocks)
{
// get the story
var storyBlock = blocks.SingleOrDefault(b => b.Type == BlockType.Story);
if(storyBlock == null) throw new FicdownException("No story block found");
var storyBlocks = blocks.Where(b => b.Type == BlockType.Story);
if(storyBlocks.Count() == 0) throw new FicdownException("No story block found");
if(storyBlocks.Count() > 1) throw new FicdownException("More than one story block found");
Anchor storyAnchor;
try
var storyBlock = storyBlocks.Single();
var storyAnchor = Utilities.GetInstance(Warnings, storyBlock.Name, storyBlock.LineNumber).ParseAnchor(storyBlock.Name, storyBlock.LineNumber, 1);
if(storyAnchor == null || storyAnchor.Href == null)
{
storyAnchor = Utilities.GetInstance(storyBlock.Name, storyBlock.LineNumber).ParseAnchor(storyBlock.Name);
}
catch(FicdownException ex)
{
throw new FicdownException(ex.BlockName, ex.LineNumber, "Story block must be an anchor pointing to the first scene");
throw new FicdownException(storyBlock.Name, "Story name must be an anchor pointing to the first scene", storyBlock.LineNumber);
}
if (storyAnchor.Href.Target == null || storyAnchor.Href.Conditions != null ||
storyAnchor.Href.Toggles != null)
throw new FicdownException(storyBlock.Name, storyBlock.LineNumber, "Story href should only have target");
Warnings.Add(new FicdownException(storyBlock.Name, "Story href should only have a target", storyBlock.LineNumber));
var story = new Story
{
@ -88,11 +90,11 @@
var a = blocks.First(b => b.Type == BlockType.Action && blocks.Any(d => b != d && BlockToAction(b, 0).Toggle == BlockToAction(d, 0).Toggle));
var actionA = BlockToAction(a, a.LineNumber);
var dupe = blocks.First(b => b.Type == BlockType.Action && b != a && BlockToAction(b, 0).Toggle == actionA.Toggle);
throw new FicdownException(actionA.Toggle, actionA.LineNumber, string.Format("Action is defined again on line {0}", dupe.LineNumber));
Warnings.Add(new FicdownException(actionA.Toggle, string.Format("Action is defined again on line {0}", dupe.LineNumber), actionA.LineNumber));
}
if (!story.Scenes.ContainsKey(storyAnchor.Href.Target))
throw new FicdownException(storyBlock.Name, storyBlock.LineNumber, string.Format("Story targets non-existent scene: {0}", storyAnchor.Href.Target));
throw new FicdownException(storyBlock.Name, string.Format("Story links to undefined scene: {0}", storyAnchor.Href.Target), storyBlock.LineNumber);
story.FirstScene = storyAnchor.Href.Target;
return story;
@ -105,22 +107,23 @@
{
Id = id,
LineNumber = block.LineNumber,
RawDescription = string.Join("\n", block.Lines.Select(l => l.Text)),
Description = string.Join("\n", block.Lines.Select(l => l.Text)).Trim()
};
try
Anchor sceneName;
if(RegexLib.Anchors.IsMatch(block.Name) && (sceneName = Utilities.GetInstance(Warnings, block.Name, block.LineNumber).ParseAnchor(block.Name, block.LineNumber, 1)).Href != null)
{
var sceneName = Utilities.GetInstance(block.Name, block.LineNumber).ParseAnchor(block.Name);
scene.Name = sceneName.Title != null ? sceneName.Title.Trim() : sceneName.Text.Trim();
scene.Key = Utilities.GetInstance(block.Name, block.LineNumber).NormalizeString(sceneName.Text);
scene.Key = Utilities.GetInstance(Warnings, block.Name, block.LineNumber).NormalizeString(sceneName.Text);
if(sceneName.Href.Target != null || sceneName.Href.Toggles != null)
throw new FicdownException(block.Name, block.LineNumber, string.Format("Scene href should only have conditions: {0}", block.Name));
Warnings.Add(new FicdownException(block.Name, "Scene href should only have conditions", block.LineNumber));
scene.Conditions = sceneName.Href.Conditions;
}
catch(FicdownException)
else
{
scene.Name = block.Name.Trim();
scene.Key = Utilities.GetInstance(block.Name, block.LineNumber).NormalizeString(block.Name);
scene.Key = Utilities.GetInstance(Warnings, block.Name, block.LineNumber).NormalizeString(block.Name);
}
return scene;
@ -131,7 +134,8 @@
return new Action
{
Id = id,
Toggle = Utilities.GetInstance(block.Name, block.LineNumber).NormalizeString(block.Name),
Toggle = Utilities.GetInstance(Warnings, block.Name, block.LineNumber).NormalizeString(block.Name),
RawDescription = string.Join("\n", block.Lines.Select(l => l.Text)),
Description = string.Join("\n", block.Lines.Select(l => l.Text)).Trim(),
LineNumber = block.LineNumber
};

View File

@ -6,6 +6,7 @@
internal interface IBlockHandler
{
List<FicdownException> Warnings { set; }
IEnumerable<Block> ExtractBlocks(IEnumerable<string> lines);
Story ParseBlocks(IEnumerable<Block> blocks);
}

View File

@ -7,6 +7,7 @@
internal interface IStateResolver
{
List<FicdownException> Warnings { set; }
ResolvedStory Resolve(IEnumerable<PageState> pages, Story story);
}
}

View File

@ -1,6 +1,5 @@
namespace Ficdown.Parser.Parser
{
using System;
using System.Linq;
using System.Collections.Generic;

View File

@ -10,11 +10,14 @@
internal class StateResolver : IStateResolver
{
private static Logger _logger = Logger.GetLogger<StateResolver>();
private static readonly Random _random = new Random((int) DateTime.Now.Ticks);
private readonly IDictionary<string, string> _pageNames;
private readonly HashSet<string> _usedNames;
private Story _story;
public List<FicdownException> Warnings { private get; set; }
public StateResolver()
{
_pageNames = new Dictionary<string, string>();
@ -23,6 +26,7 @@
public ResolvedStory Resolve(IEnumerable<PageState> pages, Story story)
{
_logger.Debug("Resolving story paths...");
_story = story;
return new ResolvedStory
{
@ -43,12 +47,15 @@
private string ResolveAnchor(string blockName, int lineNumber, Anchor anchor, IDictionary<string, bool> playerState, string targetHash)
{
var text = anchor.Text;
if (anchor.Href.Conditions != null)
if (anchor.Href != null && anchor.Href.Conditions != null)
{
var satisfied = Utilities.GetInstance(blockName, lineNumber).ConditionsMet(playerState, anchor.Href.Conditions);
var alts = Utilities.GetInstance(blockName, lineNumber).ParseConditionalText(text);
var replace = alts[satisfied];
text = RegexLib.EscapeChar.Replace(replace, string.Empty);
var satisfied = Utilities.GetInstance(Warnings, blockName, lineNumber).ConditionsMet(playerState, anchor.Href.Conditions);
var alts = Utilities.GetInstance(Warnings, blockName, lineNumber).ParseConditionalText(anchor);
if(alts != null)
{
var replace = alts[satisfied];
text = RegexLib.EscapeChar.Replace(replace, string.Empty);
}
}
return !string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(targetHash)
? string.Format("[{0}](/{1})", text, GetPageNameForHash(targetHash))
@ -67,7 +74,7 @@
if (page.State.ActionsToShow[i])
{
var actionTuple = _story.Actions.Single(a => a.Value.Id == i + 1);
var actionAnchors = Utilities.GetInstance(page.Scene.Name, page.Scene.LineNumber).ParseAnchors(actionTuple.Value.Description);
var actionAnchors = Utilities.GetInstance(Warnings, page.Scene.Name, page.Scene.LineNumber).ParseAnchors(actionTuple.Value.RawDescription);
var anchorDict = GetStateDictionary(page);
if (
actionAnchors.Any(
@ -86,7 +93,7 @@
}
}
var anchors = Utilities.GetInstance(page.Scene.Name, page.Scene.LineNumber).ParseAnchors(page.Scene.Description);
var anchors = Utilities.GetInstance(Warnings, page.Scene.Name, page.Scene.LineNumber).ParseAnchors(page.Scene.RawDescription);
var stateDict = GetStateDictionary(page);
var text =
RegexLib.EmptyListItem.Replace(

View File

@ -1,7 +1,5 @@

namespace Ficdown.Parser.Parser
namespace Ficdown.Parser.Parser
{
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
@ -9,19 +7,32 @@ namespace Ficdown.Parser.Parser
internal class Utilities
{
public static Utilities GetInstance(string blockName, int lineNumber)
private List<FicdownException> _warnings { get; set; }
public static Utilities GetInstance()
{
return new Utilities
{
_warnings = new List<FicdownException>(),
_blockName = string.Empty
};
}
public static Utilities GetInstance(List<FicdownException> warnings, string blockName, int lineNumber)
{
return new Utilities
{
_warnings = warnings,
_blockName = blockName,
_lineNumber = lineNumber
};
}
public static Utilities GetInstance(string blockName)
public static Utilities GetInstance(List<FicdownException> warnings, string blockName)
{
return new Utilities
{
_warnings = warnings,
_blockName = blockName
};
}
@ -34,7 +45,7 @@ namespace Ficdown.Parser.Parser
return Regex.Replace(Regex.Replace(raw.ToLower(), @"^\W+|\W+$", string.Empty), @"\W+", "-");
}
private Href ParseHref(string href)
private Href ParseHref(string href, int lineNumber, int colNumber)
{
var match = RegexLib.Href.Match(href);
if (match.Success)
@ -57,27 +68,55 @@ namespace Ficdown.Parser.Parser
: null
};
}
throw new FicdownException(_blockName, _lineNumber, string.Format("Invalid href: {0}", href));
_warnings.Add(new FicdownException(_blockName, string.Format("Invalid href: {0}", href), lineNumber, colNumber));
return null;
}
public Anchor ParseAnchor(string anchorText)
public Anchor ParseAnchor(string anchorText, int lineNumber, int colNumber)
{
var match = RegexLib.Anchors.Match(anchorText);
if (!match.Success) throw new FicdownException(_blockName, _lineNumber, string.Format("Invalid anchor: {0}", anchorText));
return MatchToAnchor(match);
if (!match.Success)
{
_warnings.Add(new FicdownException(_blockName, string.Format("Invalid anchor: {0}", anchorText), lineNumber, colNumber));
return null;
}
return MatchToAnchor(match, lineNumber, colNumber);
}
private void PosFromIndex(string text, int index, out int line, out int col)
{
line = 1;
col = 1;
for (int i = 0; i <= index - 1; i++)
{
col++;
if (text[i] == '\n')
{
line++;
col = 1;
}
}
}
public IList<Anchor> ParseAnchors(string text)
{
var matches = RegexLib.Anchors.Matches(text);
return matches.Cast<Match>().Select(MatchToAnchor).ToList();
return matches.Cast<Match>().Select(m =>
{
int line, col;
PosFromIndex(text, m.Index, out line, out col);
if(_lineNumber.HasValue) line += _lineNumber.Value;
return MatchToAnchor(m, line, col);
}).ToList();
}
private Anchor MatchToAnchor(Match match)
private Anchor MatchToAnchor(Match match, int lineNumber, int colNumber)
{
var astr = match.Groups["anchor"].Value;
var txstr = match.Groups["text"].Value;
var ttstr = match.Groups["title"].Value;
var ttstr = match.Groups["title"].Success
? match.Groups["title"].Value
: null;
var hrefstr = match.Groups["href"].Value;
if (hrefstr.StartsWith(@""""))
{
@ -89,14 +128,21 @@ namespace Ficdown.Parser.Parser
Original = !string.IsNullOrEmpty(astr) ? astr : null,
Text = !string.IsNullOrEmpty(txstr) ? txstr : null,
Title = ttstr,
Href = ParseHref(hrefstr)
Href = ParseHref(hrefstr, lineNumber, colNumber),
LineNumber = lineNumber,
ColNumber = colNumber
};
}
public IDictionary<bool, string> ParseConditionalText(string text)
public IDictionary<bool, string> ParseConditionalText(Anchor anchor)
{
var match = RegexLib.ConditionalText.Match(text);
if (!match.Success) throw new FicdownException(_blockName, _lineNumber, string.Format(@"Invalid conditional text: {0}", text));
var match = RegexLib.ConditionalText.Match(anchor.Text);
if (!match.Success)
{
_warnings.Add(new FicdownException(_blockName, string.Format(@"Invalid conditional text: {0}", anchor.Text), anchor.LineNumber, anchor.ColNumber));
return null;
}
return new Dictionary<bool, string>
{
{true, match.Groups["true"].Value},

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Model.Parser;
using Model.Player;
using Model.Story;
using Parser;
@ -10,6 +11,7 @@
internal class GameTraverser : IGameTraverser
{
private static Logger _logger = Logger.GetLogger<GameTraverser>();
private StateManager _manager;
private Queue<StateQueueItem> _processingQueue;
private IDictionary<string, PageState> _processed;
@ -17,6 +19,8 @@
private IDictionary<int, Action> _actionMatrix;
private bool _wasRun = false;
public List<FicdownException> Warnings { private get; set; }
private Story _story;
public Story Story
{
@ -25,7 +29,7 @@
{
_story = value;
_actionMatrix = _story.Actions.ToDictionary(a => a.Value.Id, a => a.Value);
_manager = new StateManager(_story);
_manager = new StateManager(_story, Warnings);
_processingQueue = new Queue<StateQueueItem>();
_processed = new Dictionary<string, PageState>();
_compressed = new Dictionary<string, PageState>();
@ -56,6 +60,7 @@
_wasRun = true;
// generate comprehensive enumeration
_logger.Debug("Enumerating story scenes...");
var initial = _manager.InitialState;
_processingQueue.Enqueue(new StateQueueItem
@ -63,6 +68,7 @@
Page = initial,
AffectedStates = new List<State> {initial.AffectedState}
});
var interval = 0;
while (_processingQueue.Count > 0)
{
var state = _processingQueue.Dequeue();
@ -71,9 +77,13 @@
_processed.Add(state.Page.UniqueHash, state.Page);
ProcessState(state);
}
if(++interval % 100 == 0 || _processingQueue.Count == 0)
_logger.Debug($"Processed {interval} scenes, {_processingQueue.Count} queued...");
}
// make sure every page gets affected data on every page that it links to
_logger.Debug("Processing scene links...");
foreach (var pageTuple in _processed)
{
foreach (var linkTuple in pageTuple.Value.Links)
@ -92,6 +102,7 @@
}
// compress redundancies
_logger.Debug("Compressing redundant scenes...");
foreach (var row in _processed)
{
if (!_compressed.ContainsKey(row.Value.CompressedHash))
@ -125,15 +136,15 @@
var states = new HashSet<string>();
var anchors = Utilities.GetInstance(currentState.Page.Scene.Name, currentState.Page.Scene.LineNumber).ParseAnchors(currentState.Page.Scene.Description).ToList();
var anchors = Utilities.GetInstance(Warnings, currentState.Page.Scene.Name, currentState.Page.Scene.LineNumber).ParseAnchors(currentState.Page.Scene.RawDescription).ToList();
foreach (var action in GetActionsForPage(currentState.Page))
{
action.Visited = true;
anchors.AddRange(Utilities.GetInstance(action.Toggle, action.LineNumber).ParseAnchors(action.Description));
anchors.AddRange(Utilities.GetInstance(Warnings, action.Toggle, action.LineNumber).ParseAnchors(action.RawDescription));
}
var conditionals =
anchors.SelectMany(
a => a.Href.Conditions != null ? a.Href.Conditions.Select(c => c.Key) : new string[] {})
a => a.Href != null && a.Href.Conditions != null ? a.Href.Conditions.Select(c => c.Key) : new string[] {})
.Distinct()
.ToArray();
var hasFirstSeen = RegexLib.BlockQuotes.IsMatch(currentState.Page.Scene.Description);
@ -143,32 +154,46 @@
// signal to previous scenes that this scene's used conditionals are important
if(currentState.Page.Scene.Conditions != null)
foreach (var conditional in currentState.Page.Scene.Conditions)
_manager.ToggleStateOn(affected, conditional.Key);
foreach (var conditional in conditionals) _manager.ToggleStateOn(affected, conditional);
{
var anchor = anchors.FirstOrDefault(a =>
a.Href.Conditions != null
&& a.Href.Conditions.Keys.Contains(conditional.Key));
_manager.ToggleStateOn(affected, conditional.Key, currentState.Page.Scene.Name, anchor != null ? anchor.LineNumber : currentState.Page.Scene.LineNumber, anchor != null ? anchor.ColNumber : 1);
}
foreach (var conditional in conditionals)
{
var anchor = anchors.FirstOrDefault(a =>
a.Href.Conditions != null
&& a.Href.Conditions.Keys.Contains(conditional));
_manager.ToggleStateOn(affected, conditional, currentState.Page.Scene.Name, anchor != null ? anchor.LineNumber : currentState.Page.Scene.LineNumber, anchor != null ? anchor.ColNumber : 1);
}
// signal to previous scenes if this scene has first-seen text
if (hasFirstSeen) _manager.ToggleSeenSceneOn(affected, currentState.Page.Scene.Id);
}
foreach (var anchor in anchors.Where(a => a.Href.Target != null || a.Href.Toggles != null))
foreach (var anchor in anchors.Where(a => a.Href != null && (a.Href.Target != null || a.Href.Toggles != null)))
{
// don't follow links that would be hidden
if (anchor.Href.Conditions != null &&
string.IsNullOrEmpty(
Utilities.GetInstance(currentState.Page.Scene.Name, currentState.Page.Scene.LineNumber).ParseConditionalText(anchor.Text)[
Utilities.GetInstance(currentState.Page.Scene.Name, currentState.Page.Scene.LineNumber).ConditionsMet(StateResolver.GetStateDictionary(currentState.Page),
Utilities.GetInstance(Warnings, currentState.Page.Scene.Name, currentState.Page.Scene.LineNumber).ParseConditionalText(anchor)[
Utilities.GetInstance(Warnings, currentState.Page.Scene.Name, currentState.Page.Scene.LineNumber).ConditionsMet(StateResolver.GetStateDictionary(currentState.Page),
anchor.Href.Conditions)])) continue;
var newState = _manager.ResolveNewState(anchor, currentState.Page);
if (!currentState.Page.Links.ContainsKey(anchor.Original))
currentState.Page.Links.Add(anchor.Original, newState.UniqueHash);
if (!states.Contains(newState.UniqueHash) && !_processed.ContainsKey(newState.UniqueHash))
if(newState.Scene != null)
{
states.Add(newState.UniqueHash);
var newAffected = new List<State>(currentState.AffectedStates);
newAffected.Add(newState.AffectedState);
_processingQueue.Enqueue(new StateQueueItem {Page = newState, AffectedStates = newAffected});
if (!currentState.Page.Links.ContainsKey(anchor.Original))
currentState.Page.Links.Add(anchor.Original, newState.UniqueHash);
if (!states.Contains(newState.UniqueHash) && !_processed.ContainsKey(newState.UniqueHash))
{
states.Add(newState.UniqueHash);
var newAffected = new List<State>(currentState.AffectedStates);
newAffected.Add(newState.AffectedState);
_processingQueue.Enqueue(new StateQueueItem {Page = newState, AffectedStates = newAffected});
}
}
}
}

View File

@ -1,11 +1,13 @@
namespace Ficdown.Parser.Player
{
using System.Collections.Generic;
using Model.Parser;
using Model.Player;
using Model.Story;
internal interface IGameTraverser
{
List<FicdownException> Warnings { set; }
Story Story { get; set; }
IEnumerable<PageState> Enumerate();
IEnumerable<Scene> OrphanedScenes { get; }

View File

@ -11,16 +11,33 @@
internal class StateManager
{
private static Logger _logger = Logger.GetLogger<StateManager>();
private readonly Story _story;
private readonly Dictionary<string, int> _stateMatrix;
private readonly int _sceneCount;
private readonly int _actionCount;
private BitArray _scenesSeenMask;
public StateManager(Story story)
private List<FicdownException> _warnings { get; set; }
public StateManager(Story story, List<FicdownException> warnings)
{
_warnings = warnings;
_story = story;
var allScenes = _story.Scenes.SelectMany(s => s.Value);
_sceneCount = allScenes.Max(s => s.Id);
_scenesSeenMask = new BitArray(_sceneCount);
// figure out which scenes can affect state
var masked = 0;
foreach(var scene in allScenes)
{
if(Utilities.GetInstance().ParseAnchors(scene.RawDescription).Any(a => a.Href.Toggles != null) || RegexLib.BlockQuotes.IsMatch(scene.RawDescription))
{
_scenesSeenMask[scene.Id - 1] = true;
masked++;
}
}
_actionCount = _story.Actions.Count > 0 ? _story.Actions.Max(a => a.Value.Id) : 0;
_stateMatrix = new Dictionary<string, int>();
var state = 0;
@ -28,23 +45,34 @@
var toggle in
allScenes.SelectMany(
sc =>
Utilities.GetInstance(sc.Name, sc.LineNumber).ParseAnchors(sc.Description)
Utilities.GetInstance(_warnings, sc.Name, sc.LineNumber).ParseAnchors(sc.RawDescription)
.SelectMany(
a =>
a.Href.Toggles != null
a.Href != null && a.Href.Toggles != null
? a.Href.Toggles.Where(t => !_stateMatrix.ContainsKey(t))
: new string[] {})))
{
_stateMatrix.Add(toggle, state++);
}
_logger.Debug($"{_sceneCount} scenes ({masked} can change state).");
_logger.Debug($"{_actionCount} actions.");
_logger.Debug($"{_stateMatrix.Count()} states.");
}
public PageState InitialState
{
get
{
var scene = _story.Scenes[_story.FirstScene].Where(s => s.Conditions == null);
if(scene.Count() == 0)
throw new FicdownException(_story.Name, string.Format("Story links to undefined scene: {0}", _story.FirstScene));
if(scene.Count() > 1)
_warnings.Add(new FicdownException(_story.Name, string.Format("Story links to scene that is defined more than once: {0}", _story.FirstScene)));
return new PageState
{
Manager = this,
Id = Guid.Empty,
Links = new Dictionary<string, string>(),
State = new State
@ -61,7 +89,7 @@
ActionsToShow = new BitArray(_actionCount),
ActionFirstToggles = null
},
Scene = _story.Scenes[_story.FirstScene].Single(s => s.Conditions == null),
Scene = scene.First(),
StateMatrix = _stateMatrix
};
}
@ -83,7 +111,7 @@
if(actionFirstToggles == null) actionFirstToggles = new List<bool>();
newState.State.ActionsToShow[_story.Actions[toggle].Id - 1] = true;
if (
Utilities.GetInstance(_story.Actions[toggle].Toggle, _story.Actions[toggle].LineNumber).ParseAnchors(_story.Actions[toggle].Description)
Utilities.GetInstance(_warnings, _story.Actions[toggle].Toggle, _story.Actions[toggle].LineNumber).ParseAnchors(_story.Actions[toggle].RawDescription)
.Any(a => a.Href.Conditions != null && a.Href.Conditions.ContainsKey(toggle)))
actionFirstToggles.Add(!current.State.PlayerState[_stateMatrix[toggle]]);
}
@ -93,13 +121,16 @@
newState.State.ActionFirstToggles = actionFirstToggles != null
? new BitArray(actionFirstToggles.ToArray())
: null;
newState.Scene = GetScene(current.Scene.Name, current.Scene.LineNumber, target, newState.State.PlayerState);
newState.Scene = GetScene(current.Scene.Name, anchor, target, newState.State.PlayerState);
return newState;
}
public void ToggleStateOn(State state, string toggle)
public void ToggleStateOn(State state, string toggle, string blockName, int lineNumber, int colNumber)
{
state.PlayerState[_stateMatrix[toggle]] = true;
if(_stateMatrix.ContainsKey(toggle))
state.PlayerState[_stateMatrix[toggle]] = true;
else
_warnings.Add(new FicdownException(blockName, string.Format("Conditional for undefined state: {0}", toggle), lineNumber, colNumber));
}
public void ToggleSeenSceneOn(State state, int sceneId)
@ -107,14 +138,14 @@
state.ScenesSeen[sceneId - 1] = true;
}
public static string GetUniqueHash(State state, string sceneKey)
public string GetUniqueHash(State state, string sceneKey)
{
var combined =
new bool[
state.PlayerState.Count + state.ScenesSeen.Count + state.ActionsToShow.Count +
(state.ActionFirstToggles != null ? state.ActionFirstToggles.Count : 0)];
state.PlayerState.CopyTo(combined, 0);
state.ScenesSeen.CopyTo(combined, state.PlayerState.Count);
state.ScenesSeen.And(_scenesSeenMask).CopyTo(combined, state.PlayerState.Count);
state.ActionsToShow.CopyTo(combined, state.PlayerState.Count + state.ScenesSeen.Count);
if (state.ActionFirstToggles != null)
state.ActionFirstToggles.CopyTo(combined,
@ -122,11 +153,15 @@
var ba = new BitArray(combined);
var byteSize = (int)Math.Ceiling(combined.Length / 8.0);
var encoded = new byte[byteSize];
for(var i = 0; i < byteSize; i++)
{
encoded[i] = 0;
}
ba.CopyTo(encoded, 0);
return string.Format("{0}=={1}", sceneKey, Convert.ToBase64String(encoded));
}
public static string GetCompressedHash(PageState page)
public string GetCompressedHash(PageState page)
{
var compressed = new State
{
@ -138,24 +173,26 @@
return GetUniqueHash(compressed, page.Scene.Key);
}
private Scene GetScene(string blockName, int lineNumber, string target, BitArray playerState)
private Scene GetScene(string blockName, Anchor anchor, string target, BitArray playerState)
{
if (!_story.Scenes.ContainsKey(target))
throw new FicdownException(blockName, lineNumber, string.Format("Encountered link to non-existent scene: {0}", target));
Scene newScene = null;
foreach (var scene in _story.Scenes[target])
if (_story.Scenes.ContainsKey(target))
{
if (ConditionsMatch(scene, playerState) &&
(newScene == null || newScene.Conditions == null ||
scene.Conditions.Count > newScene.Conditions.Count))
Scene newScene = null;
foreach (var scene in _story.Scenes[target])
{
newScene = scene;
if (ConditionsMatch(scene, playerState) &&
(newScene == null || newScene.Conditions == null ||
(scene.Conditions != null && scene.Conditions.Count > newScene.Conditions.Count)))
{
newScene = scene;
}
}
if (newScene == null)
_warnings.Add(new FicdownException(blockName, string.Format("Link to scene that is undefined for conditionals: {0}", target), anchor.LineNumber, anchor.ColNumber));
return newScene;
}
if (newScene == null)
throw new FicdownException(blockName, lineNumber, string.Format("Scene {0} reached with unmatched player state", target));
return newScene;
_warnings.Add(new FicdownException(blockName, string.Format("Link to undefined scene: {0}", target), anchor.LineNumber, anchor.ColNumber));
return null;
}
private bool ConditionsMatch(Scene scene, BitArray playerState)
@ -163,15 +200,18 @@
if (scene.Conditions == null) return true;
scene.Conditions.ToList().ForEach(c =>
{
if(!_stateMatrix.ContainsKey(c.Key)) throw new FicdownException(scene.Name, scene.LineNumber, string.Format("Reference to non-existent state: {0}", c.Key));
if(!_stateMatrix.ContainsKey(c.Key))
_warnings.Add(new FicdownException(scene.Name, string.Format("Conditional for undefined state: {0}", c.Key), scene.LineNumber));
});
return scene.Conditions.All(c => playerState[_stateMatrix[c.Key]] == c.Value);
return scene.Conditions.Where(c => _stateMatrix.ContainsKey(c.Key)).All(c => playerState[_stateMatrix[c.Key]] == c.Value);
}
private PageState ClonePage(PageState page)
{
return new PageState
{
Manager = this,
Id = Guid.NewGuid(),
Links = new Dictionary<string, string>(),
State = new State

View File

@ -89,6 +89,7 @@
public class EpubRenderer : HtmlRenderer
{
private static readonly Logger _logger = Logger.GetLogger<EpubRenderer>();
private readonly string _author;
private readonly string _bookId;
private readonly string _language;
@ -98,10 +99,12 @@
_author = author;
_bookId = bookId ?? Guid.NewGuid().ToString("D");
_language = language ?? "en";
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
public override void Render(Model.Parser.ResolvedStory story, string outPath, bool debug = false)
{
_logger.Debug("Generating epub...");
var temppath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(temppath);
base.Render(story, temppath, debug);

View File

@ -3,15 +3,16 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MarkdownSharp;
using Markdig;
using Model.Parser;
using Parser;
public class HtmlRenderer : IRenderer
{
private static Logger _logger = Logger.GetLogger<HtmlRenderer>();
private readonly string _language;
protected readonly Markdown Markdown;
public List<FicdownException> Warnings { private get; set; }
public string IndexTemplate { get; set; }
public string SceneTemplate { get; set; }
@ -23,7 +24,6 @@
public HtmlRenderer(string language)
{
_language = language;
Markdown = new Markdown();
}
public virtual void Render(ResolvedStory story, string outPath, bool debug = false)
@ -40,11 +40,12 @@
protected void GenerateHtml(ResolvedStory story, string outPath, bool debug)
{
_logger.Debug("Generating HTML...");
var index = FillTemplate(IndexTemplate ?? Template.Index, new Dictionary<string, string>
{
{"Language", _language},
{"Title", story.Name},
{"Description", Markdown.Transform(story.Description)},
{"Description", Markdown.ToHtml(story.Description)},
{"FirstScene", string.Format("{0}.html", story.FirstPage)}
});
@ -55,7 +56,7 @@
File.WriteAllText(Path.Combine(outPath, "styles.css"), StylesTemplate ?? Template.Styles);
var content = page.Content;
foreach (var anchor in Utilities.GetInstance(page.Name).ParseAnchors(page.Content))
foreach (var anchor in Utilities.GetInstance(Warnings, page.Name).ParseAnchors(page.Content))
{
var newAnchor = string.Format("[{0}]({1}.html)", anchor.Text, anchor.Href.Target);
content = content.Replace(anchor.Original, newAnchor);
@ -70,7 +71,7 @@
{
{"Language", _language},
{"Title", story.Name},
{"Content", Markdown.Transform(content)}
{"Content", Markdown.ToHtml(content)}
});
File.WriteAllText(Path.Combine(outPath, string.Format("{0}.html", page.Name)), scene);

View File

@ -1,10 +1,11 @@
namespace Ficdown.Parser.Render
{
using System.Security.Cryptography.X509Certificates;
using System.Collections.Generic;
using Model.Parser;
public interface IRenderer
{
List<FicdownException> Warnings { set; }
string IndexTemplate { get; set; }
string SceneTemplate { get; set; }
string StylesTemplate { get; set; }

21
LICENSE.txt Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Rudis Muiznieks
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -10,6 +10,7 @@ test:
dotnet test Ficdown.Parser.Tests
publish: clean
rm -rf /tmp/ficdown*
dotnet publish --self-contained -c Release -r linux-x64 Ficdown.Console
tar -C Ficdown.Console/bin/Release/netcoreapp2.1/linux-x64/publish -cvzf /tmp/ficdown-linux64.tar.gz .
dotnet publish --self-contained -c Release -r win-x64 Ficdown.Console

View File

@ -1,6 +1,6 @@
# Ficdown
Ficdown is a system for building interactive fiction using MarkDown syntax. See [Ficdown.com](http://www.ficdown.com) for more information.
Ficdown is a system for building interactive fiction using MarkDown syntax.
This project contains the core Ficdown library for parsing Ficdown stories, as well as a console application that can be used to generate HTML or epub ebook formats.
@ -10,7 +10,7 @@ Ficdown is written using .NET Core and should run on Windows, Linux, and OSX wit
## Obtaining
If you want to use Ficdown to convert your stories into ebooks, download the latest version from the [releases](https://github.com/rudism/Ficdown/releases) page and decompress it somewhere on your hard drive. Ficdown does not include an installer, the application and all of its dependencies are included directly in the zip archive.
If you want to use Ficdown to convert your stories into ebooks, download the latest version from the [releases](https://code.sitosis.com/rudism/ficdown/releases) page and decompress it somewhere on your hard drive. Ficdown does not include an installer, the application and all of its dependencies are included directly in the zip archive.
## Usage
@ -29,8 +29,8 @@ The pre-built releases are self-contained .NET Core deployments, so you should b
Running ficdown.exe without any arguments will produce the following help text:
Usage: ficdown.exe
--format (html|epub)
--in "/path/to/source.md"
--format (html|epub|lint)
--in "/path/to/source.md" (lint reads stdin)
[--out "/path/to/output"]
[--template "/path/to/template/dir"]
[--images "/path/to/images/dir"]
@ -85,6 +85,10 @@ If you pass this option, all of the pages in your story will include output at t
To generate other formats than HTML or epub, you will have to use third party tools. [Calibre](http://www.calibre-ebook.com) is a popular ebook management suite that includes the ability to convert books from almost any format to any other format. Also, Amazon has an official tool called [KindleGen](http://www.amazon.com/gp/feature.html?docId=1000765211) that you can use to convert your epub to a format that can be read on Kindles.
### Interactive Website
## Additional Tools
Ficdown stories can be played interactively in a web browser without even requiring the command line utility here. See [Ficdown.js](https://github.com/rudism/Ficdown.js) for a Javascript Ficdown parser and interpreter that you can include on your own website to present your Ficdown stories.
- Ficdown stories can be played interactively in a web browser without even requiring the command line utility here. See [Ficdown.js](https://code.sitosis.com/rudism/ficdown.js) for a Javascript Ficdown parser and interpreter that you can include on your own website to present your Ficdown stories.
- [Ficdown-editor](https://byfernanz.github.io/ficdown-editor/) is a web-based GUI for writing Ficdown.
- [Prop](https://github.com/ByFernanz/prop) is a YAML-header style preprocessor for Ficdown