Sharpee's Architecture Solidifies
I have a lot to talk about at this point in the development of my C# interactive fiction authoring platform called Sharpee. The "design" of the system is coming to a working and well-defined point.
One of the first things I had envisioned in developing this platform was to use modern software architecture patterns. I think inherently the world model in an IF story is a graph and I very much wanted to implement an in-memory Node/Edge graph to reflect the world model.
That part of this project has reached a completed and tested state.
Here we have the INode and IEdge interfaces, which are very simple representations of a standard bi-directional graph.
We then consume the DataStore in the "IF" World Model, which is the translation layer between raw graph logic and "IF" logic.
This is where we develop our core world model 'things" to be leveraged by the standard library and the author's story. The base "things" in our world model implement INode so they become nodes in our data store.
public class Thing : INode
public class Animal : Thing
public class Person : Animal
public class Room : Thing
public class Container : Thing, IContainer
And within the lower-level graph, nodes are connected by various types of edges like "in/contains".
I also took the time to add a language capability so all text can be contained in a language class and the author can augment with story-based language class. This allows for any one game to designed for multiple languages.
namespace Language
{
[Language("en-US")]
public static class EnglishUS
{
// Graph Properties
public const string Room_Description = "{description}";
public const string Object_Description = "{description}";
// Verbs
public const string Verb_Go = "go";
public const string Verb_Look = "look";
public const string Verb_Take = "take";
public const string Verb_Get = "get";
public const string Verb_Inventory = "inventory";
public const string Verb_I = "i";
public const string Verb_Drop = "drop";
public const string Verb_Put = "put";
// Go Action
public const string Go_WherePrompt = "Go where?";
public const string Go_CannotGoDirection = "You can't go {0}.";
public const string Go_Success = "You go {0}.";
// Take Action
public const string Take_WhatPrompt = "What do you want to take?";
public const string Take_NotSeen = "You don't see any {0} here.";
public const string Take_CannotTake = "You can't take the {0}.";
public const string Take_Success = "You take the {0}.";
public const string Take_Fail = "You can't seem to take the {0}.";
// Look Action
public const string Look_Nowhere = "You are nowhere.";
public const string Look_TooDark = "It's too dark to see anything.";
public const string Look_YouSee = "\n\nYou see: {0}";
public const string Look_Exits = "\n\nExits: {0}";
public const string Look_DontSee = "You don't see any {0} here.";
public const string Look_ContainerClosed = " It is closed.";
public const string Look_ContainerEmpty = " It is empty.";
public const string Look_ContainerContents = " It contains: {0}.";
// Drop Action
public const string Drop_WhatPrompt = "What do you want to drop?";
public const string Drop_DontHave = "You don't have a {0} to drop.";
public const string Drop_Success = "You drop the {0}.";
public const string Drop_Fail = "You can't seem to drop the {0}.";
// Inventory Action
public const string Inventory_Empty = "You are not carrying anything.";
public const string Inventory_List = "You are carrying: {0}";
// Parser
public const string Parse_ValidCommand = "Valid command";
public const string Parse_UnknownCommand = "I don't understand that command.";
public const string Parse_ItemNotVisible = "I don't see any {0} here.";
public const string Parse_ItemsNotVisible = "I don't see any {0} here.";
public const string Parse_SomethingNotVisible = "Something you mentioned isn't here.";
public const string Parse_Or = " or ";
// Story Runner
public const string StoryRunner_GameOver = "Game Over. Thanks for playing!";
public const string StoryRunner_ItemsInRoom = "You see the following items:";
}
}
And so far, Cloak of Darkness looks like this:
using CloakOfDarkness.Actions;
using CloakOfDarkness.Language;
using Common;
using IFWorldModel;
using Language;
using ParserLibrary;
using StandardLibrary;
using System;
using System.Linq;
using System.Collections.Generic;
namespace CloakOfDarkness
{
public class CloakOfDarknessStory : IStory
{
private WorldModel _worldModel;
private Parser _parser;
private Scope _scope;
private ActionExecutor _actionExecutor;
private Guid _playerId;
private bool _gameOver;
private const int MAX_DARK_MOVES = 3;
public void Initialize()
{
LanguageManager.Initialize("en-US", typeof(StoryLanguageExtensions_EnUS));
_worldModel = new WorldModel();
CreateWorld();
_scope = new Scope(_worldModel, _playerId);
_parser = new Parser(_scope);
_actionExecutor = new ActionExecutor(_worldModel, _scope);
RegisterCustomActions(_actionExecutor);
_gameOver = false;
}
public Scope GetScope() => _scope;
public WorldModel GetWorldModel() => _worldModel;
public Guid GetPlayerId() => _playerId;
public string GetIntroText() => LanguageManager.GetText(CloakOfDarknessKeys.Story_Intro);
public bool IsGameOver() => _gameOver;
public ParsedActionResult ProcessAction(ParsedAction action)
{
var result = _actionExecutor.ExecuteAction(action, _playerId);
result = CheckGameState(result);
_scope.UpdateScope();
return result;
}
public void RegisterCustomActions(ActionExecutor actionExecutor)
{
actionExecutor.RegisterAction(LanguageManager.GetText(CloakOfDarknessKeys.Verb_Hang), new HangAction(_worldModel, _scope));
}
private ParsedActionResult CheckGameState(ParsedActionResult result)
{
var playerLocation = _worldModel.GetPlayerLocation(_playerId);
if (result.Results.Any(r => r.Parameters.ContainsKey("message") &&
r.Parameters["message"].ToString().Contains(LanguageManager.GetText(CloakOfDarknessKeys.Message_StumbledInDark))))
{
_gameOver = true;
return result.AddResult(new ActionResult("game_over", new Dictionary<string, object>
{
{ "message", LanguageManager.GetText(CloakOfDarknessKeys.Message_GameOverDarkness) }
}));
}
if (playerLocation is Room room &&
room.Name == LanguageManager.GetText(CloakOfDarknessKeys.Location_Bar) &&
!_worldModel.GetPlayerItems(_playerId).Any(n => n.Name == LanguageManager.GetText(CloakOfDarknessKeys.Item_Cloak)))
{
_gameOver = true;
return result.AddResult(new ActionResult("game_won", new Dictionary<string, object>
{
{ "message", LanguageManager.GetText(CloakOfDarknessKeys.Message_GameWon) }
}));
}
return result;
}
private void CreateWorld()
{
// Create rooms
Room foyer = (Room)_worldModel.CreateRoom(
LanguageManager.GetText(CloakOfDarknessKeys.Location_Foyer),
LanguageManager.GetText(CloakOfDarknessKeys.Description_Foyer))
.SetPropertyValue("IsDark", false);
Room cloakroom = (Room)_worldModel.CreateRoom(
LanguageManager.GetText(CloakOfDarknessKeys.Location_Cloakroom),
LanguageManager.GetText(CloakOfDarknessKeys.Description_Cloakroom))
.SetPropertyValue("IsDark", false);
Room bar = (Room)_worldModel.CreateRoom(
LanguageManager.GetText(CloakOfDarknessKeys.Location_Bar),
LanguageManager.GetText(CloakOfDarknessKeys.Description_Bar))
.SetPropertyValue("IsDark", true)
.SetPropertyValue("MessageCounter", 0);
// Connect rooms
_worldModel.CreateExit(foyer, bar, LanguageManager.GetText(CloakOfDarknessKeys.Direction_North));
_worldModel.CreateExit(foyer, cloakroom, LanguageManager.GetText(CloakOfDarknessKeys.Direction_West));
// Create player
Person player = (Person)_worldModel.CreatePerson(
LanguageManager.GetText(CloakOfDarknessKeys.Character_Player),
LanguageManager.GetText(CloakOfDarknessKeys.Description_Player),
foyer);
_playerId = player.Id;
// Create cloak
Thing cloak = (Thing)_worldModel.CreateThing(
LanguageManager.GetText(CloakOfDarknessKeys.Item_Cloak),
LanguageManager.GetText(CloakOfDarknessKeys.Description_Cloak),
player);
// Add hook to cloakroom
Scenery hook = (Scenery)_worldModel.CreateScenery(
LanguageManager.GetText(CloakOfDarknessKeys.Item_Hook),
LanguageManager.GetText(CloakOfDarknessKeys.Description_Hook),
cloakroom);
}
}
}
There is much to clean up in the IF World Model classes to improve fluency and consistent behaviors.
I'm currently going through unit testing and improving how the parser and grammar work, which is a fun back and forth exercise.
And the Text Service is coming into focus. I had always wanted to have turn processing send all of the activities of a turn into a text service, but what I ended up doing is creating an ActionResult class that can contain a list of actions that occur within the turn loop.
namespace Common
{
public class ActionResult
{
public string Key { get; }
public Dictionary<string, object> Parameters { get; }
public ActionResult(string key, Dictionary<string, object> parameters = null)
{
Key = key ?? throw new ArgumentNullException(nameof(key));
Parameters = parameters ?? new Dictionary<string, object>();
}
public ActionResult AddParameter(string key, object value)
{
Parameters[key] = value;
return this;
}
}
}
This has led to implementing event sourcing within the Text Service where we send all ActionResults to the text service. Once all activities for the turn have completed, we ask the text service to build the text for those activities and the current world model state relevant to the player's character. A fun aspect of this will enable the ability to have events for NPC's in the event source and being able to ask for a summary from the perspective of an NPC.
This is a critical change on how an IF platform emits text. Instead of a stream of concatenation logic, we have everything in a list of activities and can build a logical template for emitting text for a turn.
I'm actually starting to see potential commercial viability for this project and at this point, I'd love someone else to think through what I'm building and add value.