~nromdotcom/gemif

d420ac7799121fccf827eefc76ffea36242cb779 — Norm MacLennan 1 year, 1 day ago 1bf4340
Break out GameState to a testable object, simplify description templating, try making a build job
A .build.yml => .build.yml +10 -0
@@ 0,0 1,10 @@
image: golang:1.15
sources:
  - https://git.sr.ht/~nromdotcom/gemif
tasks:
  - setup: |
      make init
  - test: |
      make test
  - build: |
      make build

M Makefile => Makefile +3 -2
@@ 17,6 17,7 @@ help:  ## Display this help

##@ Utilities
init: ## Install utils
	go mod download
	go install github.com/markbates/pkger/cmd/pkger

run: ## go-run locally (optional args="")


@@ 49,7 50,7 @@ generate: ## Generate bindata from static files

##@ Test
test: ## Run tests
	go test -race $(shell go list ./...) -v -coverprofile=coverage.out
	go test $(shell go list ./...) -coverprofile=coverage.out
	go tool cover -func=coverage.out

nonmegalint: ## do a regular golint


@@ 59,4 60,4 @@ megalint: ## Run several linters in parallel (requires docker)
	docker run --rm -v $(PWD):/app -w /app golangci/golangci-lint:latest golangci-lint run \
	--skip-dirs bcrypt \
	--skip-files pkger.go \
	--enable-all 
\ No newline at end of file
	--enable-all
\ No newline at end of file

M go.mod => go.mod +1 -0
@@ 5,5 5,6 @@ go 1.14
require (
	github.com/markbates/pkger v0.17.1
	github.com/pitr/gig v0.9.7
	github.com/stretchr/testify v1.4.0
	gopkg.in/yaml.v2 v2.4.0
)

M pkg/gamemanager/gamemanager.go => pkg/gamemanager/gamemanager.go +11 -24
@@ 7,13 7,6 @@ import (
	"fmt"
)

// GameState contains state information about an active game in progress.
type GameState struct {
	StoryID     string   `json:"si"`
	CurrentRoom string   `json:"cr"`
	Conditions  []string `json:"cn"`
}

// GameManager contains configuration info about stories and managed GameState objects.
type GameManager struct {
	config config


@@ 29,7 22,7 @@ var (
	// 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.
	ErrExitNotFound = errors.New("game not found")
	ErrExitNotFound = errors.New("exit not found")
)

// New creates a new GameManager, loading all stories from a given path.


@@ 83,17 76,20 @@ func (gm *GameManager) UseExit(gameState GameState, exitID string) (GameState, e
		return GameState{}, fmt.Errorf("couldn't find room %s in story %s: %w", gameState.CurrentRoom, gameState.StoryID, err)
	}

	for i := range currentRoom.Exits {
		if currentRoom.Exits[i].ID == exitID {
			if currentRoom.Exits[i].IfCondition != "" && !isInSlice(gameState.Conditions, currentRoom.Exits[i].IfCondition) {
				fmt.Printf("Cant use exit! %s\n", currentRoom.Exits[i].ID)
	for _, e := range currentRoom.Exits {
		if e.ID == exitID {
			if e.IfCondition != "" && !gameState.ConditionMet(e.IfCondition) {
				fmt.Printf("Cant use exit! %s\n", e.ID)

				continue
			}

			gameState.CurrentRoom = currentRoom.Exits[i].Destination
			if currentRoom.Exits[i].SetCondition != "" {
				gameState.Conditions = append(gameState.Conditions, currentRoom.Exits[i].SetCondition)
			gameState.CurrentRoom = e.Destination

			if e.SetCondition != "" {
				gameState.MeetCondition(e.SetCondition)
			}

			return gameState, nil
		}
	}


@@ 174,12 170,3 @@ func (gm *GameManager) ConstructStartingState(stories []StoryMetadata) ([]Specul

	return speculativeStates, nil
}

func isInSlice(slice []string, val string) bool {
	for _, item := range slice {
		if item == val {
			return true
		}
	}
	return false
}

A pkg/gamemanager/gamestate.go => pkg/gamemanager/gamestate.go +30 -0
@@ 0,0 1,30 @@
package gamemanager

// GameState contains state information about an active game in progress.
type GameState struct {
	StoryID     string   `json:"si"`
	CurrentRoom string   `json:"cr"`
	Conditions  []string `json:"cn"`
}

// MeetCondition adds a condtition to the list of met conditions if not already met.
func (gs *GameState) MeetCondition(cond string) {
	if !isInSlice(gs.Conditions, cond) {
		gs.Conditions = append(gs.Conditions, cond)
	}
}

// ConditionMet checks to see if a condition has been met.
func (gs GameState) ConditionMet(cond string) bool {
	return isInSlice(gs.Conditions, cond)
}

func isInSlice(slice []string, val string) bool {
	for _, item := range slice {
		if item == val {
			return true
		}
	}

	return false
}

A pkg/gamemanager/gamestate_test.go => pkg/gamemanager/gamestate_test.go +98 -0
@@ 0,0 1,98 @@
package gamemanager_test

import (
	"gemif/pkg/gamemanager"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestConditionMet(t *testing.T) {
	arrayTests := []struct {
		name      string
		state     gamemanager.GameState
		condition string // input
		expected  bool   // expected result
	}{
		{
			"Not meet when condition missing",
			gamemanager.GameState{
				StoryID:     "a",
				CurrentRoom: "a",
				Conditions:  make([]string, 1),
			},
			"did_thing", false,
		},
		{
			"Meet when condition exists",
			gamemanager.GameState{
				StoryID:     "a",
				CurrentRoom: "a",
				Conditions:  []string{"did_thing"},
			},
			"did_thing", true,
		},
	}

	t.Parallel()

	for _, tc := range arrayTests {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			actual := tc.state.ConditionMet(tc.condition)
			assert.True(t, assert.ObjectsAreEqualValues(tc.expected, actual))
		})
	}
}

func TestMeetCondition(t *testing.T) {
	arrayTests := []struct {
		name      string
		state     gamemanager.GameState
		condition string   // input
		expected  []string // expected result
	}{
		{
			"Meet condition for first time",
			gamemanager.GameState{
				StoryID:     "a",
				CurrentRoom: "a",
				Conditions:  []string{},
			},
			"did_thing",
			[]string{"did_thing"},
		},
		{
			"Meet condition that already exists",
			gamemanager.GameState{
				StoryID:     "a",
				CurrentRoom: "a",
				Conditions:  []string{"did_thing"},
			},
			"did_thing",
			[]string{"did_thing"},
		},
		{
			"Meet when other conditions already met",
			gamemanager.GameState{
				StoryID:     "a",
				CurrentRoom: "a",
				Conditions:  []string{"something_else"},
			},
			"did_thing",
			[]string{"something_else", "did_thing"},
		},
	}

	t.Parallel()

	for _, tc := range arrayTests {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			tc.state.MeetCondition(tc.condition)
			assert.True(t, assert.ObjectsAreEqualValues(tc.expected, tc.state.Conditions))
		})
	}
}

M pkg/scenerenderer/scenerenderer.go => pkg/scenerenderer/scenerenderer.go +8 -12
@@ 3,10 3,8 @@ package scenerenderer
import (
	"bytes"
	"fmt"
	"io"
	"gemif/pkg/gamemanager"
	"text/template"

	"github.com/pitr/gig"
)

// Template contains the room templates.


@@ 14,26 12,24 @@ type Template struct {
	templates *template.Template
}

// New makes a new set of templates for room descriptions.
func New() Template {
	return Template{
		template.New(""),
	}
}

// Render is a simple rendering function for our text/template to Gemini for gig.
func (t *Template) render(w io.Writer, name string, data interface{}, c gig.Context) error {
	return t.templates.ExecuteTemplate(w, name, data)
}

func (t *Template) ExecuteTemplate(storyID string, roomID string, roomDesc string, data interface{}) (string, error) {
	templateName := fmt.Sprintf("%s%s", storyID, roomID)
// ExecuteTemplate executes a rooms description template and returns a string.
func (t *Template) ExecuteTemplate(state gamemanager.GameState, tmpl string) (string, error) {
	templateName := fmt.Sprintf("%s%s", state.StoryID, state.CurrentRoom)
	roomTmpl := t.templates.Lookup(templateName)

	if roomTmpl == nil {
		template.Must(t.templates.New(templateName).Parse(roomDesc))
		template.Must(t.templates.New(templateName).Parse(tmpl))
	}

	var compiledDesc bytes.Buffer
	if compileErr := t.templates.ExecuteTemplate(&compiledDesc, templateName, data); compileErr != nil {
	if compileErr := t.templates.ExecuteTemplate(&compiledDesc, templateName, state); compileErr != nil {
		return "", fmt.Errorf("could not construct room description: %w", compileErr)
	}


M pkg/web/routes.go => pkg/web/routes.go +2 -4
@@ 66,11 66,9 @@ func handleGame(gm *gamemanager.GameManager, renderer scenerenderer.Template) fu
			return fmt.Errorf("could not find room: %w", roomErr)
		}

		renderedRoom, renderErr := renderer.ExecuteTemplate(gameState.StoryID, currentRoom.ID, currentRoom.Description, map[string]interface{}{
			"Conditions": gameState.Conditions,
		})
		renderedRoom, renderErr := renderer.ExecuteTemplate(gameState, currentRoom.Description)
		if renderErr != nil {
			return fmt.Errorf("could not render room %s", renderErr)
			return fmt.Errorf("could not render room %w", renderErr)
		}

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

M stories/sample-story.yml => stories/sample-story.yml +8 -7
@@ 68,17 68,18 @@ rooms:
  room_description: |
    Though because this is a simple demo, you always end up in the same place.

    {{ range $value := .Conditions}}
      {{- if eq $value "choice_a"}}
    {{- if .ConditionMet "choice_a"}}

    oh cool, you made choice a! nice work.
      {{- end}}
      {{- if eq $value "choice_b"}}
    {{- end}}
    {{- if .ConditionMet "choice_b"}}

    oh you made choice b, that's too bad :(
      {{- end}}
      {{- if eq $value "no_choice"}}
    {{- end}}
    {{- if .ConditionMet "no_choice"}}

    wow you're soooo cool and mysterious, making no choice.
    you really thing that makes you interesting? well it doesn't.
      {{- end}}
    {{- end}}
  exits:
  - exit_id: bonus_round