diff --git a/tinystory/README.md b/tinystory/README.md new file mode 100644 index 0000000..f4256b2 --- /dev/null +++ b/tinystory/README.md @@ -0,0 +1,37 @@ +# tinystory + +This is a very simple implementation of an interactive story teller. I +currently have two people in mind that will only be able to have minor +control over things with their hands -- so basically probably will be +able to use a phone in one hand, and limited as well (probably only +thumb control). + +Hence, to provide maybe something somewhat fun, I thought of coming up +with a very cheap prototype where interactive stories could be put +together very quickly, and inserted into a service (like a web +server), where said phones can point to and run the interactive +stories. + +The stories should be of this form: + +``` +you see a platypus. you choose to: +- greet it +- ignore it +- scream at it +``` + +So the interface should be very simple (buttons, that lead you to +different parts of the story). + +# build / run + +Run `make` in the root directory. Then you can run the binary in this +directory. You just need to pass the proper flags to the binary to +point to a story repository, and an assets directory (with the html +templates). + +# todo + +Since we're dealing with graphs, might be nice to have some checks to +make sure that certain nodes are not orphaned. diff --git a/tinystory/assets/index.html b/tinystory/assets/index.html new file mode 100644 index 0000000..29344f4 --- /dev/null +++ b/tinystory/assets/index.html @@ -0,0 +1,14 @@ + + + + +

Welcome!

+ +

List of stories:

+ + + + + diff --git a/tinystory/assets/story.html b/tinystory/assets/story.html new file mode 100644 index 0000000..a3ac874 --- /dev/null +++ b/tinystory/assets/story.html @@ -0,0 +1,28 @@ + + + + +

the platypus war: part CXXII (TODO)

+

authors: {{ range .Authors }} • {{ . }} {{else}} no authors specified {{end}}

+ {{ if .Website }} +

{{ .Website }}

+ {{ else }} + {{ end }} + +
+ +

{{ .Fragment.Content }}

+ + {{ range .Fragment.Choices }} +
+ +
+ {{ else }} +

THE END

+ {{ end }} + +
+

back to story directory

+ + + diff --git a/tinystory/lib/errors.go b/tinystory/lib/errors.go new file mode 100644 index 0000000..09182ba --- /dev/null +++ b/tinystory/lib/errors.go @@ -0,0 +1,9 @@ +package tinystory + +import ( + "errors" +) + +var ( + ErrNoTokens = errors.New("no tokens have been provided") +) diff --git a/tinystory/lib/parser.go b/tinystory/lib/parser.go new file mode 100644 index 0000000..59d608e --- /dev/null +++ b/tinystory/lib/parser.go @@ -0,0 +1,96 @@ +/** + * A very simple parser that relies on stories that are written in + * json + */ +package tinystory + +import ( + "encoding/json" + "io/fs" + "path" + "path/filepath" + + "github.com/psyomn/ecophagy/common" +) + +type Choice struct { + Description string + Index int +} + +func (s *Choice) UnmarshalJSON(data []byte) error { + elements := []interface{}{&s.Description, &s.Index} + return json.Unmarshal(data, &elements) +} + +type StoryFragment struct { + Index int + Content string + Choices []Choice +} + +func (s *StoryFragment) UnmarshalJSON(data []byte) error { + elements := []interface{}{&s.Index, &s.Content, &s.Choices} + return json.Unmarshal(data, &elements) +} + +type Document struct { + Title string `json:"title"` + Authors []string `json:"authors"` + Website string `json:"website"` + Fragments []StoryFragment `json:"story"` +} + +func Parse(bjson []byte) (*Document, error) { + doc := &Document{} + + err := json.Unmarshal(bjson, &doc) + if err != nil { + return nil, err + } + + return doc, nil +} + +func ParseAllInDir(dirpath string) ([]Document, error) { + docs := make([]Document, 0, 256) + + err := filepath.Walk(dirpath, func(currpath string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + if path.Ext(currpath) != ".json" { + return nil + } + + if info.IsDir() { + return nil + } + + if !info.Mode().IsRegular() { + return nil + } + + data, err := common.FileToBytes(currpath) + if err != nil { + //nolint + return nil + } + + document, err := Parse(data) + if err != nil { + return err + } + + docs = append(docs, *document) + + return nil + }) + + if err != nil { + return nil, err + } + + return docs, nil +} diff --git a/tinystory/lib/parser_test.go b/tinystory/lib/parser_test.go new file mode 100644 index 0000000..3a2fc14 --- /dev/null +++ b/tinystory/lib/parser_test.go @@ -0,0 +1,26 @@ +package tinystory + +import ( + "testing" + + "github.com/psyomn/ecophagy/common" +) + +const fixture = "../stories/simple.json" + +func TestTinyStoryParserTitle(t *testing.T) { + data, err := common.FileToBytes(fixture) + + if err != nil { + t.Fatalf("no such file: %s", fixture) + } + + document, err := Parse(data) + if err != nil { + t.Fatalf("error: %s", err.Error()) + } + + if document == nil { + t.Fatalf("document must not be nil") + } +} diff --git a/tinystory/lib/server.go b/tinystory/lib/server.go new file mode 100644 index 0000000..67651ae --- /dev/null +++ b/tinystory/lib/server.go @@ -0,0 +1,145 @@ +package tinystory + +import ( + "fmt" + "html/template" + "log" + "net/http" + "path" + "strconv" + "strings" + + "github.com/psyomn/ecophagy/common" +) + +type Server struct { + visitor *Visitor + httpServer *http.Server + + indexTemplate *template.Template + storyTemplate *template.Template +} + +const indexFilename = "index.html" +const storyFilename = "story.html" + +func ServerNew(sess *Session, documents []Document) (*Server, error) { + muxer := http.NewServeMux() + + var indexTemplate *template.Template + { + data, err := common.FileToBytes(path.Join(sess.Assets, indexFilename)) + if err != nil { + return nil, err + } + + maybeIndex, err := template.New("index").Parse(string(data)) + if err != nil { + return nil, err + } + indexTemplate = maybeIndex + } + + var storyTemplate *template.Template + { + data, err := common.FileToBytes(path.Join(sess.Assets, storyFilename)) + if err != nil { + return nil, err + } + + maybeStory, err := template.New("story").Parse(string(data)) + if err != nil { + return nil, err + } + storyTemplate = maybeStory + } + + server := &Server{ + indexTemplate: indexTemplate, + storyTemplate: storyTemplate, + httpServer: &http.Server{ + Addr: sess.Host + ":" + sess.Port, + Handler: muxer, + }, + visitor: VisitorNew(documents), + } + + muxer.HandleFunc("/", server.HandleRoot) + muxer.HandleFunc("/story/", server.HandleStory) + + return server, nil +} + +func (s *Server) Start() error { + log.Println("starting server...") + return s.httpServer.ListenAndServe() +} + +func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) { + listing := struct { + Items []IndexListing + }{ + Items: s.visitor.GetIndexListing(), + } + + if err := s.indexTemplate.Execute(w, listing); err != nil { + fmt.Println("error writing template: ", err) + } +} + +func (s *Server) HandleStory(w http.ResponseWriter, r *http.Request) { + var storyIndex, nodeIndex int + { + thinPath := strings.TrimPrefix(r.URL.Path, "/story/") + parts := strings.Split(thinPath, "/") + + if len(parts) < 2 { + renderError(w, "badly formed path") + return + } + + maybeStoryIndex, err := strconv.Atoi(parts[0]) + if err != nil { + renderError(w, "stories should be numeric") + return + } + + maybeNodeIndex, err := strconv.Atoi(parts[1]) + if err != nil { + renderError(w, "parts should be numeric") + return + } + + storyIndex = maybeStoryIndex + nodeIndex = maybeNodeIndex + } + + // TODO: make a safe index check here with min(len(documents), actual) + + responseData := struct { + Title string + Authors []string + Website string + Fragment StoryFragment + StoryIndex int + NodeIndex int + }{ + Title: s.visitor.Documents[storyIndex].Title, + Authors: s.visitor.Documents[storyIndex].Authors, + Website: s.visitor.Documents[storyIndex].Website, + Fragment: s.visitor.Documents[storyIndex].Fragments[nodeIndex], + StoryIndex: storyIndex, + } + + if err := s.storyTemplate.Execute(w, responseData); err != nil { + renderError(w, "problem getting that fragment") + return + } +} + +func renderError(w http.ResponseWriter, str string) { + w.WriteHeader(http.StatusBadRequest) + if _, err := w.Write([]byte(str)); err != nil { + fmt.Printf("problem writing error: %s\n", err.Error()) + } +} diff --git a/tinystory/lib/session.go b/tinystory/lib/session.go new file mode 100644 index 0000000..64461e2 --- /dev/null +++ b/tinystory/lib/session.go @@ -0,0 +1,19 @@ +package tinystory + +type Session struct { + Host string + Port string + Repository string + Assets string + + ExperimentalParser string +} + +func MakeDefaultSession() *Session { + return &Session{ + Host: "127.0.0.1", + Port: "9090", + Repository: "./stories", + Assets: "./assets", + } +} diff --git a/tinystory/lib/tinystory_parser.go b/tinystory/lib/tinystory_parser.go new file mode 100644 index 0000000..f153a82 --- /dev/null +++ b/tinystory/lib/tinystory_parser.go @@ -0,0 +1,63 @@ +/** + * A more experimental parser for the tinystory project. I'm mostly + * leveraging around generating json for now. Maybe we can scrap that + * in the future for our very own parser... + */ +package tinystory + +import ( + "errors" + "io" + "os" +) + +type TokenTypeEnum uint64 + +const ( + TokenKeyword TokenTypeEnum = iota + TokenWord + TokenWhitespace + TokenNewline + TokenNumber +) + +// TODO: this will eventually be used +// nolint +var terminals = []string{ + "TITLE", + "COMMENTS", + "AUTHORS", + "CHOICE", + "FRAGMENT", + "ENDFRAGMENT", + "GOTO", +} + +type Token struct { + Type TokenTypeEnum + Value string + LineNumber uint64 +} + +func ParseTinyStoryFormat(path string) (*Document, error) { + fs, err := os.Open(path) + if err != nil { + return nil, err + } + doc, err := ParseTinystoryFormat(fs) + return doc, err +} + +func ParseTinystoryFormat(reader io.ReadCloser) (*Document, error) { + var b [1]byte + + for { + _, err := reader.Read(b[:]) + if errors.Is(err, io.EOF) { + break + } + } + + defer reader.Close() + return nil, nil +} diff --git a/tinystory/lib/visitor.go b/tinystory/lib/visitor.go new file mode 100644 index 0000000..ff179bf --- /dev/null +++ b/tinystory/lib/visitor.go @@ -0,0 +1,26 @@ +package tinystory + +// The visistor shall provide read only access to possible stories. +type Visitor struct { + Documents []Document +} + +func VisitorNew(documents []Document) *Visitor { + return &Visitor{ + Documents: documents, + } +} + +type IndexListing struct { + Index int + Title string +} + +func (s *Visitor) GetIndexListing() []IndexListing { + listing := make([]IndexListing, 0, len(s.Documents)) + for index := range s.Documents { + listing = append(listing, IndexListing{index, s.Documents[index].Title}) + } + + return listing +} diff --git a/tinystory/main.go b/tinystory/main.go new file mode 100644 index 0000000..a67de54 --- /dev/null +++ b/tinystory/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/psyomn/ecophagy/tinystory/lib" +) + +func makeFlags(sess *tinystory.Session) { + flag.StringVar(&sess.Host, "host", sess.Host, "specify host to bind server") + flag.StringVar(&sess.Port, "port", sess.Port, "specify port to bind server") + flag.StringVar(&sess.Repository, "repository", sess.Repository, "specify story repository") + flag.StringVar(&sess.Assets, "assets", sess.Assets, "specify the assets root path") + flag.StringVar(&sess.ExperimentalParser, "experimental-parser", sess.ExperimentalParser, "use experimental parser") + flag.Parse() +} + +func main() { + sess := tinystory.MakeDefaultSession() + makeFlags(sess) + + if sess.ExperimentalParser != "" { + // TODO experimental for now + _, _ = tinystory.ParseTinyStoryFormat(sess.ExperimentalParser) + } + + // TODO: there should be a less bleedy initialization here + docs, err := tinystory.ParseAllInDir(sess.Repository) + if err != nil { + fmt.Printf("error parsing stories: %s\n", err.Error()) + os.Exit(1) + } + + server, err := tinystory.ServerNew(sess, docs) + if err != nil { + fmt.Println("could not start server:", err) + } + + if err := server.Start(); err != nil { + fmt.Println(err) + } +} diff --git a/tinystory/stories/do-as-i-say.json b/tinystory/stories/do-as-i-say.json new file mode 100644 index 0000000..716467d --- /dev/null +++ b/tinystory/stories/do-as-i-say.json @@ -0,0 +1,20 @@ +{ + "title": "DO AS I SAY", + "authors": ["tyrant doasisayson"], + "website": "https://github.com/psyomn/cacophagy", + + "dev_comment": "mostly used to demonstrate that cycles work", + + "story": [ + [0, + "DO AS I SAY", + [["yes", 1], + ["no", 0]] + ], + + [1, + "I'm glad we agree", + [] + ] + ] +} diff --git a/tinystory/stories/simple.json b/tinystory/stories/simple.json new file mode 100644 index 0000000..2478cfc --- /dev/null +++ b/tinystory/stories/simple.json @@ -0,0 +1,32 @@ +{ + "title": "the platypus war: part CXXII", + "authors": ["jon doe", "jon smith", "jane doe"], + "website": "https://github.com/psyomn/cacophagy", + + "dev_comments": "maybe we can do something better in the future, like provide an editor to create stories easier", + + "story": [ + [0, + "you wake up in a dark room. you see a platypus. what do you do?", + [["choose to greet the platypus", 1], + ["choose to ignore the platypus", 1], + ["choose to look at the ceiling", 1]] + ], + + [1, + "the platypus heeds your greeting, and tells you about a terrible war between the platypii and gingerbread men\nHe asks for your help.", + [["help platypus fight gingerbread men", 2], + ["decline to help the platypus", 3]] + ], + + [2, + "you help the platypus by travelling far lands, finding where the gingerbread men infestation is, and drown their whole civilizatio in mapple syrup. the battle is won!\nThe platypus thanks you, and you go home.", + [] + ], + + [3, + "you decline to help the platypus because you have better things to do. gingerbread men erupt from the ground, grab the platypus and eat it alive right in front of you. you monster.", + [] + ] + ] +} diff --git a/tinystory/stories/simple.tinystory b/tinystory/stories/simple.tinystory new file mode 100644 index 0000000..00ff1e3 --- /dev/null +++ b/tinystory/stories/simple.tinystory @@ -0,0 +1,40 @@ +TITLE +The platypus war: part CXXII; + +AUTHORS +jon doe; +jon smith; +jane doeson; +jake andbake; + + +COMMENTS +maybe we can do something better in the future, like provide +an editor to create stories easier. For now maybe the + +FRAGMENT 0 +you wake up in a dark room. you see a platypus. what do you do? + GOTO 1 choose to greet the platypus + GOTO 1 choose to ignore the platypus + GOTO 1 choose to look at the ceiling +ENDFRAGMENT + +FRAGMENT 1 +the platypus heeds your greeting, and tells you about a terrible war +between the platypii and gingerbread men. He asks for your help. + GOTO 2 help platypus fight gingerbread men + GOTO 3 decline to help the platypus +ENDFRAGMENT + +FRAGMENT 2 +you help the platypus by travelling far lands, finding where the +gingerbread men infestation is, and drown their whole civilizatio in +mapple syrup. the battle is won! The platypus thanks you, and you go +home. +ENDFRAGMENT + +FRAGMENT 3 +you decline to help the platypus because you have better things to +do. gingerbread men erupt from the ground, grab the platypus and eat +it alive right in front of you. you monster. +ENDFRAGMENT \ No newline at end of file