~nromdotcom/gemif

acbff041b42d9775f953097bc874de05295b1c85 — Norm MacLennan 11 months ago 9b7a8a5
Add configuration
17 files changed, 216 insertions(+), 76 deletions(-)

M .gitignore
M README.md
M cmd/gemif/main.go
M go.mod
M go.sum
A pkg/gamemanager/engineconfig.go
M pkg/gamemanager/gamemanager.go
M pkg/gamemanager/statetoken.pb.go
M pkg/gamemanager/statetoken.proto
R pkg/gamemanager/{config.go => storybook.go}
R pkg/{web/banners.go => gemserver/banners.go}
R pkg/{web/router.go => gemserver/router.go}
R pkg/{web/routes.go => gemserver/routes.go}
A pkg/gemserver/serverconfig.go
R pkg/{web/template.go => gemserver/template.go}
A sample.config.toml
M static/docs/api.gmi
M .gitignore => .gitignore +2 -1
@@ 3,4 3,5 @@ my.key
pkged.go
tmp/
bin/
coverage.out
\ No newline at end of file
coverage.out
config.toml

M README.md => README.md +40 -18
@@ 14,19 14,41 @@ There's no simple installation process at the moment.
For now, you'll need to clone this repo and build or run `cmd/gemif`
with something like `make build-all`, `go build gemif/cmd/gemif`, or whatever.

Then run the compiled binary with the cloned repo as your working
directory.
### Configure

You'll also need to generate or copy in your TLS cert to `my.crt` and `my.key`.
Once you have the binary compiled, you need a few files to make it run.

The templates and static assets are compiled into the binary and cannot be
changed at runtime, but the `file.yml` holding your story and cert/private key
are read directly from your current working directory.
#### Step One: Generate your TLS Cert

## Managing Your Story
The first thing to do is generate your TLS cert in whatever way makes sense
for you.

Stories must live in the `./stories` directory in your working directory.
At least for right now, maybe it'll be configurable later.
In production, you might use [certbot](https://certbot.eff.org/docs/using.html#standalone)
in standalone mode to generate a LetsEncrypt cert.

Otherwise, since most (all?) Gemini clients use TOFU for TLS certificates,
you can also feel free to [generate a self-signed cer](https://www.digitalocean.com/community/tutorials/openssl-essentials-working-with-ssl-certificates-private-keys-and-csrs)
in whatever way is appropriate for you.

#### Step Two: Gather up your stories

This repo comes with a couple of sample stories in `./stories`. If you want to add, remove,
or change them, go ahead and do that now.

#### Step Three: Make a config file

GemIF configures itself based on the contents of `./config.toml`.
Take `sample.config.toml` and rename it. Change the configuration to match
your desired values.

### Run it

That's it. Go ahead and run the binary.

## Managing Your Stories

By default, stories live in the `./stories` directory in your working directory.
You can change this with `engine.stories_dir` in your `config.toml`.

Each `*.yml` file in that directory represents a separate story to be loaded
up by the engine. The top-level index page will allow the user to choose which


@@ 44,17 66,17 @@ The file must contain two top-level properties: `metadata` and `rooms`.
The `rooms` object holds a list of your rooms/scenes.

Each room has:
* room_id: a unique id for the room, I recommend a GUID
* room_name: a human-readable name
* room_description: a description of the room (what is printed to the screen when the user is there)
* exits: an array of exit objects
* `room_id`: a unique id for the room, I recommend a GUID
* `room_name`: a human-readable name
* `room_description`: a description of the room (what is printed to the screen when the user is there)
* `exits`: an array of exit objects

Each exit contains:
* exit_description: a description of the exit (the text of the exit link)
* destiantion_id: the id of the room this exit takes you to
* exit_id: a unique id for the exit, I recommend a GUID
* set_condition: conditional tag to attach to the game state if the user takes this exit
* if_condition: conditional tag the user must posses to use this exit
* `exit_description`: a description of the exit (the text of the exit link)
* `desination_id`: the id of the room this exit takes you to
* `exit_id`: a unique id for the exit, I recommend a GUID
* `set_condition`: conditional tag to attach to the game state if the user takes this exit
* `if_condition`: conditional tag the user must posses to use this exit

### Conditions


M cmd/gemif/main.go => cmd/gemif/main.go +33 -9
@@ 4,8 4,11 @@ package main
import (
	"fmt"
	"gemif/pkg/gamemanager"
	"gemif/pkg/web"
	"os"
	"gemif/pkg/gemserver"
	"io/ioutil"
	"log"

	"github.com/pelletier/go-toml"
)

//nolint:gochecknoglobals


@@ 17,18 20,39 @@ var (
)

func main() {
	config, loaderErr := gamemanager.NewConfig("./stories")
	if loaderErr != nil {
		fmt.Printf("Error loading stories! %s", loaderErr)
	file, err := ioutil.ReadFile("./config.toml")
	if err != nil {
		log.Fatalf("Couldn't load config file: %s", err)
	}

	config, err := toml.Load(string(file))
	if err != nil {
		log.Fatalf("Could not parse config file: %s", err)
	}

	gameMgr, err := gamemanager.NewGameManager(config)
	book, err := gamemanager.NewStoryBook(config.Get("engine.stories_dir").(string))
	if err != nil {
		fmt.Printf("Error loading game! %s", err)
		os.Exit(1)
		log.Fatalf("Error loading stories! %s", err)
	}

	renderDesc := config.Get("engine.render_descriptions").(bool)
	tokenFormat := config.Get("engine.statetoken_format").(string)
	engineConfig := gamemanager.NewEngineConfig(renderDesc, tokenFormat)

	gameMgr, err := gamemanager.NewGameManager(book, engineConfig)
	if err != nil {
		log.Fatalf("Error loading game! %s", err)
	}

	gsConfig := config.Get("gemserver").(*toml.Tree)
	serverConfig := gemserver.ServerConfig{
		Domain:   gsConfig.Get("domain").(string),
		Port:     gsConfig.Get("port").(int64),
		CertFile: gsConfig.Get("cert_file").(string),
		KeyFile:  gsConfig.Get("key_file").(string),
	}

	fmt.Printf("Starting %s %s (%s) - built %s\n\n", appName, appVersion, appCommit, buildTime)

	web.StartRouter(gameMgr)
	gemserver.StartRouter(gameMgr, serverConfig)
}

M go.mod => go.mod +7 -2
@@ 3,9 3,14 @@ module gemif
go 1.14

require (
	github.com/google/go-cmp v0.5.2 // indirect
	github.com/kr/pretty v0.2.1 // indirect
	github.com/markbates/pkger v0.17.1
	github.com/pelletier/go-toml v1.8.1
	github.com/pitr/gig v0.9.7
	github.com/stretchr/testify v1.4.0
	github.com/stretchr/testify v1.6.1
	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
	google.golang.org/protobuf v1.25.0
	gopkg.in/yaml.v2 v2.4.0
	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
	gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776
)

M go.sum => go.sum +16 -2
@@ 26,8 26,12 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=


@@ 36,6 40,8 @@ github.com/markbates/pkger v0.17.1 h1:/MKEtWqtc0mZvu9OinB9UzVN9iYCwLWuyUv4Bw+PCn
github.com/markbates/pkger v0.17.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
github.com/matryer/is v1.3.0 h1:9qiso3jaJrOe6qBRJRBt2Ldht05qDiFP9le0JOIhRSI=
github.com/matryer/is v1.3.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM=
github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc=
github.com/pitr/gig v0.9.7 h1:VMwGTynX2LHppwOJTlEeUtIZnPOG/iwOMWvDZ61/RXQ=
github.com/pitr/gig v0.9.7/go.mod h1:YHUShtPtgG/zAsdlVG2HyzfGA1EKB+QBVFKxJ2qzxhU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=


@@ 45,6 51,8 @@ github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.1.0 h1:RZqt0yGBsps8NGvLSGW804QQqCUYYLsaOjTVHy1Ocw4=


@@ 71,6 79,8 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=


@@ 92,9 102,13 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

A pkg/gamemanager/engineconfig.go => pkg/gamemanager/engineconfig.go +15 -0
@@ 0,0 1,15 @@
package gamemanager

// EngineConfig contains settings information for driving the engine.
type EngineConfig struct {
	RenderDescriptions bool
	StateTokenFormat   string
}

// NewEngineConfig news up an EngineConfig based on passed in values.
func NewEngineConfig(renderDesc bool, tokenFormat string) EngineConfig {
	return EngineConfig{
		RenderDescriptions: renderDesc,
		StateTokenFormat:   tokenFormat,
	}
}

M pkg/gamemanager/gamemanager.go => pkg/gamemanager/gamemanager.go +38 -13
@@ 5,12 5,14 @@ import (
	"errors"
	"fmt"

	"google.golang.org/protobuf/encoding/protojson"
	"google.golang.org/protobuf/proto"
)

// GameManager contains configuration info about stories and managed GameState objects.
type GameManager struct {
	Config Config
	Book         StoryBook
	EngineConfig EngineConfig
}

// SpeculativeState is used to provide "what if" GameStates for next available actions.


@@ 20,6 22,8 @@ type SpeculativeState struct {
}

var (
	// ErrInvalidSerializationFormat occurs when engine config specifies a unsupported format.
	ErrInvalidSerializationFormat = errors.New("invalid serialization format")
	// ErrRoomNotFound occurs when the room doesn't exist in the given story.
	ErrRoomNotFound = errors.New("room not found")
	// ErrExitNotFound occurs when the exit doesn't exit in the given room.


@@ 27,26 31,29 @@ var (
)

// NewGameManager creates a new GameManager, using Stories in the Config.
func NewGameManager(config Config) (*GameManager, error) {
	return &GameManager{config}, nil
func NewGameManager(book StoryBook, config EngineConfig) (*GameManager, error) {
	return &GameManager{
		Book:         book,
		EngineConfig: config,
	}, nil
}

// StartGame creates a new GameState representing the start of a given storyID.
func (gm *GameManager) StartGame(storyID string) (GameState, error) {
	return GameState{
		StoryID:     storyID,
		CurrentRoom: gm.Config.Stories[storyID].Rooms[0].ID,
		CurrentRoom: gm.Book.Stories[storyID].Rooms[0].ID,
		Conditions:  []string{},
	}, nil
}

// GetStories lists all StoryMetadata for stories tracked by GameManager.
func (gm *GameManager) GetStories() []StoryMetadata {
	metadata := make([]StoryMetadata, len(gm.Config.Stories))
	metadata := make([]StoryMetadata, len(gm.Book.Stories))

	i := 0

	for _, e := range gm.Config.Stories {
	for _, e := range gm.Book.Stories {
		metadata[i] = e.Metadata
		i++
	}


@@ 56,7 63,7 @@ func (gm *GameManager) GetStories() []StoryMetadata {

// GetRoomByID finds Room information based on room and story IDs.
func (gm *GameManager) GetRoomByID(storyID string, roomID string) (Room, error) {
	for _, r := range gm.Config.Stories[storyID].Rooms {
	for _, r := range gm.Book.Stories[storyID].Rooms {
		if r.ID == roomID {
			return r, nil
		}


@@ 101,8 108,15 @@ func (gm *GameManager) DeserializeState(stateToken string) (GameState, error) {
	}

	var st StateToken
	if unmarshallErr := proto.Unmarshal(decodedString, &st); unmarshallErr != nil {
		return GameState{}, fmt.Errorf("failed to load state: %w", unmarshallErr)

	if gm.EngineConfig.StateTokenFormat == "proto" {
		if unmarshallErr := proto.Unmarshal(decodedString, &st); unmarshallErr != nil {
			return GameState{}, fmt.Errorf("failed to load state: %w", unmarshallErr)
		}
	} else if gm.EngineConfig.StateTokenFormat == "json" {
		if unmarshallErr := protojson.Unmarshal(decodedString, &st); unmarshallErr != nil {
			return GameState{}, fmt.Errorf("failed to load state: %w", unmarshallErr)
		}
	}

	return GameState{


@@ 120,12 134,23 @@ func (gm *GameManager) SerializeState(gameState GameState) (string, error) {
		Conditions:  gameState.Conditions,
	}

	protoBytes, marshallErr := proto.Marshal(&st)
	if marshallErr != nil {
		return "", fmt.Errorf("failed to save state: %w", marshallErr)
	if gm.EngineConfig.StateTokenFormat == "proto" {
		stateBytes, err := proto.Marshal(&st)
		if err != nil {
			return "", fmt.Errorf("failed to save state: %w", err)
		}

		return base64.StdEncoding.EncodeToString(stateBytes), nil
	} else if gm.EngineConfig.StateTokenFormat == "json" {
		stateBytes, err := protojson.Marshal(&st)
		if err != nil {
			return "", fmt.Errorf("failed to save state: %w", err)
		}

		return base64.StdEncoding.EncodeToString(stateBytes), nil
	}

	return base64.StdEncoding.EncodeToString(protoBytes), nil
	return "", fmt.Errorf("%w: %s", ErrInvalidSerializationFormat, gm.EngineConfig.StateTokenFormat)
}

// ConstructSpeculativeStates makes a SpeculativeState from the active GameState and actions available in currentRoom.

M pkg/gamemanager/statetoken.pb.go => pkg/gamemanager/statetoken.pb.go +11 -12
@@ 25,9 25,9 @@ type StateToken struct {
	sizeCache     protoimpl.SizeCache
	unknownFields protoimpl.UnknownFields

	StoryID     string   `protobuf:"bytes,1,opt,name=StoryID,proto3" json:"StoryID,omitempty"`
	CurrentRoom string   `protobuf:"bytes,2,opt,name=CurrentRoom,proto3" json:"CurrentRoom,omitempty"`
	Conditions  []string `protobuf:"bytes,3,rep,name=Conditions,proto3" json:"Conditions,omitempty"`
	StoryID     string   `protobuf:"bytes,1,opt,name=StoryID,json=sid,proto3" json:"StoryID,omitempty"`
	CurrentRoom string   `protobuf:"bytes,2,opt,name=CurrentRoom,json=cr,proto3" json:"CurrentRoom,omitempty"`
	Conditions  []string `protobuf:"bytes,3,rep,name=Conditions,json=cn,proto3" json:"Conditions,omitempty"`
}

func (x *StateToken) Reset() {


@@ 88,15 88,14 @@ var File_gamemanager_statetoken_proto protoreflect.FileDescriptor
var file_gamemanager_statetoken_proto_rawDesc = []byte{
	0x0a, 0x1c, 0x67, 0x61, 0x6d, 0x65, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x2f, 0x73, 0x74,
	0x61, 0x74, 0x65, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0b,
	0x67, 0x61, 0x6d, 0x65, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x22, 0x68, 0x0a, 0x0a, 0x53,
	0x74, 0x61, 0x74, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x53, 0x74, 0x6f,
	0x72, 0x79, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x53, 0x74, 0x6f, 0x72,
	0x79, 0x49, 0x44, 0x12, 0x20, 0x0a, 0x0b, 0x43, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x52, 0x6f,
	0x6f, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x43, 0x75, 0x72, 0x72, 0x65, 0x6e,
	0x74, 0x52, 0x6f, 0x6f, 0x6d, 0x12, 0x1e, 0x0a, 0x0a, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69,
	0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x43, 0x6f, 0x6e, 0x64, 0x69,
	0x74, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x0d, 0x5a, 0x0b, 0x67, 0x61, 0x6d, 0x65, 0x6d, 0x61, 0x6e,
	0x61, 0x67, 0x65, 0x72, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
	0x67, 0x61, 0x6d, 0x65, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x22, 0x53, 0x0a, 0x0a, 0x53,
	0x74, 0x61, 0x74, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x07, 0x53, 0x74, 0x6f,
	0x72, 0x79, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x73, 0x69, 0x64, 0x12,
	0x17, 0x0a, 0x0b, 0x43, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x52, 0x6f, 0x6f, 0x6d, 0x18, 0x02,
	0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x63, 0x72, 0x12, 0x16, 0x0a, 0x0a, 0x43, 0x6f, 0x6e, 0x64,
	0x69, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x02, 0x63, 0x6e,
	0x42, 0x0d, 0x5a, 0x0b, 0x67, 0x61, 0x6d, 0x65, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x62,
	0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}

var (

M pkg/gamemanager/statetoken.proto => pkg/gamemanager/statetoken.proto +4 -3
@@ 1,9 1,10 @@
syntax = "proto3";
package gamemanager;

option go_package = "gamemanager";

message StateToken {
  string StoryID = 1;
  string CurrentRoom = 2;
  repeated string Conditions = 3;
  string StoryID = 1 [json_name="sid"];
  string CurrentRoom = 2 [json_name="cr"];
  repeated string Conditions = 3 [json_name="cn"];
}
\ No newline at end of file

R pkg/gamemanager/config.go => pkg/gamemanager/storybook.go +15 -5
@@ 1,15 1,21 @@
package gamemanager

import (
	"errors"
	"fmt"
	"io/ioutil"
	"log"
	"os"
	"path/filepath"

	"gopkg.in/yaml.v2"
	"gopkg.in/yaml.v3"
)

type Config struct {
// ErrInvalidStoryDirectory occurs when the config specifies a nonexistent story directory.
var ErrInvalidStoryDirectory = errors.New("story directory does not exist")

// StoryBook contains information on all of the stories loaded from disk.
type StoryBook struct {
	Stories map[string]Story `yaml:"stories" json:"stories"`
}



@@ 27,10 33,14 @@ type StoryMetadata struct {
	Author      string `yaml:"author"`
}

func NewConfig(root string) (Config, error) {
// NewStoryBook makes a new Config object and loads in all of the stories from the given path.
func NewStoryBook(root string) (StoryBook, error) {
	var files []string

	err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
		if info == nil {
			return fmt.Errorf("%w: %s", ErrInvalidStoryDirectory, root)
		}
		if info.IsDir() {
			return nil
		}


@@ 39,10 49,10 @@ func NewConfig(root string) (Config, error) {
		return nil
	})
	if err != nil {
		panic(err)
		log.Fatalf("Couldn't load stories from disk: %s", err)
	}

	c := Config{
	c := StoryBook{
		Stories: map[string]Story{},
	}


R pkg/web/banners.go => pkg/gemserver/banners.go +1 -1
@@ 1,4 1,4 @@
package web
package gemserver

import "math/rand"


R pkg/web/router.go => pkg/gemserver/router.go +3 -3
@@ 1,4 1,4 @@
package web
package gemserver

import (
	"fmt"


@@ 11,7 11,7 @@ import (
)

// StartRouter starts the gig server for handling Gemini requests.
func StartRouter(gm *gamemanager.GameManager) {
func StartRouter(gm *gamemanager.GameManager, gc ServerConfig) {
	//https://github.com/golangci/golangci-lint/issues/741
	//nolint:staticcheck
	pkger.Include("/static")


@@ 34,5 34,5 @@ func StartRouter(gm *gamemanager.GameManager) {
	g.Handle("/game/:statetoken", handleGame(gm, sceneRenderer))
	g.Handle("/docs*", handleStatic)

	panic(g.Run("my.crt", "my.key"))
	panic(g.Run(fmt.Sprintf(":%d", gc.Port), gc.CertFile, gc.KeyFile))
}

R pkg/web/routes.go => pkg/gemserver/routes.go +11 -5
@@ 1,4 1,4 @@
package web
package gemserver

import (
	"fmt"


@@ 66,9 66,15 @@ func handleGame(gm *gamemanager.GameManager, renderer scenerenderer.Template) fu
			return fmt.Errorf("could not find room: %w", roomErr)
		}

		renderedRoom, renderErr := renderer.ExecuteTemplate(gameState, currentRoom.Description)
		if renderErr != nil {
			return fmt.Errorf("could not render room %w", renderErr)
		roomDesc := currentRoom.Description

		if gm.EngineConfig.RenderDescriptions {
			renderedRoom, renderErr := renderer.ExecuteTemplate(gameState, currentRoom.Description)
			if renderErr != nil {
				return fmt.Errorf("could not render room %w", renderErr)
			}

			roomDesc = renderedRoom
		}

		speculativeStates, speculationErr := gm.ConstructSpeculativeStates(gameState, currentRoom)


@@ 78,7 84,7 @@ func handleGame(gm *gamemanager.GameManager, renderer scenerenderer.Template) fu

		return c.Render("gemif:/static/templates/room.gmi.tmpl", map[string]interface{}{
			"RoomName":    currentRoom.Name,
			"Description": renderedRoom,
			"Description": roomDesc,
			"Actions":     speculativeStates,
		})
	}

A pkg/gemserver/serverconfig.go => pkg/gemserver/serverconfig.go +8 -0
@@ 0,0 1,8 @@
package gemserver

type ServerConfig struct {
	Domain   string
	Port     int64
	CertFile string
	KeyFile  string
}

R pkg/web/template.go => pkg/gemserver/template.go +1 -1
@@ 1,4 1,4 @@
package web
package gemserver

import (
	"fmt"

A sample.config.toml => sample.config.toml +10 -0
@@ 0,0 1,10 @@
[gemserver]
domain    = "gemif.fedi.farm"
port      = 1967
cert_file = "./itsa.crt"
key_file  = "./anda.key"

[engine]
stories_dir         = "./stories"
render_descriptions = true
statetoken_format   = "proto"
\ No newline at end of file

M static/docs/api.gmi => static/docs/api.gmi +1 -1
@@ 3,6 3,6 @@

So how does this work in the confines of the Gemini protocol?

Making a call to `/start` creates you a new instance of the game, serializes it, and encodes it to base64. This is your `state token`. You are redirected to `/game/[state token]`.
Making a call to `/start` creates you a new instance of the game, serializes via protobuf it, and encodes it to base64. This is your `state token`. You are redirected to `/game/[state token]`.

`/game/[state token]` renders your current scene. A "speculative state" is calculated for each exit in the current scene and each exit link is rendered as a link to `/game/[speculative state token]`.