Skip to content

Commit

Permalink
DEV-1096 - add a new "pipeline" concept (#67)
Browse files Browse the repository at this point in the history
* add a new "pipeline" concept

- added new Pipeline type which is a series of stages
- added a global registry to facilitate plugin architecture
- 100% test coverage

* Refactor rulesets to provide and use Pipeline

* fix copypasta comments

* fix lint for unused method

* include game over stages in ruleset pipelines

* clean up unused private standard methods

* remove unused private methods in squad ruleset

* remove unused private methods in royale ruleset

* refactor: pipeline clone + return next board state

* YAGNI: remove unused Append

* refactor: improve stage names

* add no-op behavior to stages for initial state

* refactor: no-op decision within stage functions

* remove misleading comment that isn't true

* dont bother checking for init in gameover stages

* remove redundant test

* refactor: provide a combined ruleset/pipeline type

* fix: movement no-op for GameOver check

IsGameOver needs to run pipeline, move snakes needs to no-op for that

* add test coverage

* refactor: improve stage names and use constants

* add Error method

Support error checking before calling Execute()

* update naming to be American style

* panic when overwriting stages in global registry

* rename "Error" method and improve docs

* use testify lib for panic assertion

* remove redundant food stage

* use ruleset-specific logic for game over checks

* re-work Pipeline errors

* rework errors again

* add defensive check for zero length snake

* use old logic which checks current state, not next

* add warning about how PipelineRuleset checks for game over
  • Loading branch information
torbensky authored Apr 19, 2022
1 parent 86ef6ad commit d378759
Show file tree
Hide file tree
Showing 18 changed files with 723 additions and 235 deletions.
2 changes: 1 addition & 1 deletion board.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func NewBoardState(width, height int32) *BoardState {
}
}

// Clone returns a deep copy of prevState that can be safely modified inside Ruleset.CreateNextBoardState
// Clone returns a deep copy of prevState that can be safely modified without affecting the original
func (prevState *BoardState) Clone() *BoardState {
nextState := &BoardState{
Turn: prevState.Turn,
Expand Down
8 changes: 8 additions & 0 deletions cases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ func (gc *gameTestCase) clone() *gameTestCase {

// requireValidNextState requires that the ruleset produces a valid next state
func (gc *gameTestCase) requireValidNextState(t *testing.T, r Ruleset) {
t.Helper()
t.Run(gc.name, func(t *testing.T) {
t.Helper()
prev := gc.prevState.Clone() // clone to protect against mutation (so we can ru-use test cases)
nextState, err := r.CreateNextBoardState(prev, gc.moves)
require.Equal(t, gc.expectedError, err)
Expand All @@ -39,3 +41,9 @@ func (gc *gameTestCase) requireValidNextState(t *testing.T, r Ruleset) {
}
})
}

func mockSnakeMoves() []SnakeMove {
return []SnakeMove{
{ID: "test-mock-move", Move: "mocked"},
}
}
56 changes: 24 additions & 32 deletions constrictor.go
Original file line number Diff line number Diff line change
@@ -1,45 +1,39 @@
package rules

var constrictorRulesetStages = []string{
StageMovementStandard,
StageStarvationStandard,
StageHazardDamageStandard,
StageFeedSnakesStandard,
StageEliminationStandard,
StageSpawnFoodNoFood,
StageModifySnakesAlwaysGrow,
StageGameOverStandard,
}

type ConstrictorRuleset struct {
StandardRuleset
}

func (r *ConstrictorRuleset) Name() string { return GameTypeConstrictor }

func (r *ConstrictorRuleset) ModifyInitialBoardState(initialBoardState *BoardState) (*BoardState, error) {
initialBoardState, err := r.StandardRuleset.ModifyInitialBoardState(initialBoardState)
if err != nil {
return nil, err
}

r.removeFood(initialBoardState)

err = r.applyConstrictorRules(initialBoardState)
if err != nil {
return nil, err
}
func (r ConstrictorRuleset) Execute(bs *BoardState, s Settings, sm []SnakeMove) (bool, *BoardState, error) {
return NewPipeline(constrictorRulesetStages...).Execute(bs, s, sm)
}

return initialBoardState, nil
func (r *ConstrictorRuleset) ModifyInitialBoardState(initialBoardState *BoardState) (*BoardState, error) {
_, nextState, err := r.Execute(initialBoardState, r.Settings(), nil)
return nextState, err
}

func (r *ConstrictorRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) {
nextState, err := r.StandardRuleset.CreateNextBoardState(prevState, moves)
if err != nil {
return nil, err
}

r.removeFood(nextState)

err = r.applyConstrictorRules(nextState)
if err != nil {
return nil, err
}
_, nextState, err := r.Execute(prevState, r.Settings(), moves)

return nextState, nil
return nextState, err
}

func (r *ConstrictorRuleset) removeFood(b *BoardState) {
_, _ = r.callStageFunc(RemoveFoodConstrictor, b, []SnakeMove{})
func (r *ConstrictorRuleset) IsGameOver(b *BoardState) (bool, error) {
return GameOverStandard(b, r.Settings(), nil)
}

func RemoveFoodConstrictor(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
Expand All @@ -49,14 +43,12 @@ func RemoveFoodConstrictor(b *BoardState, settings Settings, moves []SnakeMove)
return false, nil
}

func (r *ConstrictorRuleset) applyConstrictorRules(b *BoardState) error {
_, err := r.callStageFunc(GrowSnakesConstrictor, b, []SnakeMove{})
return err
}

func GrowSnakesConstrictor(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
// Set all snakes to max health and ensure they grow next turn
for i := 0; i < len(b.Snakes); i++ {
if len(b.Snakes[i].Body) <= 0 {
return false, ErrorZeroLengthSnake
}
b.Snakes[i].Health = SnakeMaxHealth

tail := b.Snakes[i].Body[len(b.Snakes[i].Body)-1]
Expand Down
8 changes: 7 additions & 1 deletion constrictor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ func TestConstrictorModifyInitialBoardState(t *testing.T) {
{11, 11, []string{}},
{11, 11, []string{"one", "two", "three", "four", "five"}},
}

r := ConstrictorRuleset{}
for testNum, test := range tests {
state, err := CreateDefaultBoardState(test.Width, test.Height, test.IDs)
Expand Down Expand Up @@ -104,8 +103,15 @@ func TestConstrictorCreateNextBoardState(t *testing.T) {
standardCaseErrZeroLengthSnake,
constrictorMoveAndCollideMAD,
}
rb := NewRulesetBuilder().WithParams(map[string]string{
ParamGameType: GameTypeConstrictor,
})
r := ConstrictorRuleset{}
for _, gc := range cases {
gc.requireValidNextState(t, &r)
// also test a RulesBuilder constructed instance
gc.requireValidNextState(t, rb.Ruleset())
// also test a pipeline with the same settings
gc.requireValidNextState(t, rb.PipelineRuleset(GameTypeConstrictor, NewPipeline(constrictorRulesetStages...)))
}
}
190 changes: 190 additions & 0 deletions pipeline.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package rules

import "fmt"

// StageRegistry is a mapping of stage names to stage functions
type StageRegistry map[string]StageFunc

const (
StageSpawnFoodStandard = "spawn_food.standard"
StageGameOverStandard = "game_over.standard"
StageStarvationStandard = "starvation.standard"
StageFeedSnakesStandard = "feed_snakes.standard"
StageMovementStandard = "movement.standard"
StageHazardDamageStandard = "hazard_damage.standard"
StageEliminationStandard = "elimination.standard"

StageGameOverSoloSnake = "game_over.solo_snake"
StageGameOverBySquad = "game_over.by_squad"
StageSpawnFoodNoFood = "spawn_food.no_food"
StageSpawnHazardsShrinkMap = "spawn_hazards.shrink_map"
StageEliminationResurrectSquadCollisions = "elimination.resurrect_squad_collisions"
StageModifySnakesAlwaysGrow = "modify_snakes.always_grow"
StageMovementWrapBoundaries = "movement.wrap_boundaries"
StageModifySnakesShareAttributes = "modify_snakes.share_attributes"
)

// globalRegistry is a global, default mapping of stage names to stage functions.
// It can be extended by plugins through the use of registration functions.
// Plugins that wish to extend the available game stages should call RegisterPipelineStageError
// to add additional stages.
var globalRegistry = StageRegistry{
StageSpawnFoodNoFood: RemoveFoodConstrictor,
StageSpawnFoodStandard: SpawnFoodStandard,
StageGameOverSoloSnake: GameOverSolo,
StageGameOverBySquad: GameOverSquad,
StageGameOverStandard: GameOverStandard,
StageHazardDamageStandard: DamageHazardsStandard,
StageSpawnHazardsShrinkMap: PopulateHazardsRoyale,
StageStarvationStandard: ReduceSnakeHealthStandard,
StageEliminationResurrectSquadCollisions: ResurrectSnakesSquad,
StageFeedSnakesStandard: FeedSnakesStandard,
StageEliminationStandard: EliminateSnakesStandard,
StageModifySnakesAlwaysGrow: GrowSnakesConstrictor,
StageMovementStandard: MoveSnakesStandard,
StageMovementWrapBoundaries: MoveSnakesWrapped,
StageModifySnakesShareAttributes: ShareAttributesSquad,
}

// RegisterPipelineStage adds a stage to the registry.
// If a stage has already been mapped it will be overwritten by the newly
// registered function.
func (sr StageRegistry) RegisterPipelineStage(s string, fn StageFunc) {
sr[s] = fn
}

// RegisterPipelineStageError adds a stage to the registry.
// If a stage has already been mapped an error will be returned.
func (sr StageRegistry) RegisterPipelineStageError(s string, fn StageFunc) error {
if _, ok := sr[s]; ok {
return RulesetError(fmt.Sprintf("stage '%s' has already been registered", s))
}

sr.RegisterPipelineStage(s, fn)
return nil
}

// RegisterPipelineStage adds a stage to the global stage registry.
// It will panic if the a stage has already been registered with the same name.
func RegisterPipelineStage(s string, fn StageFunc) {
err := globalRegistry.RegisterPipelineStageError(s, fn)
if err != nil {
panic(err)
}
}

// Pipeline is an ordered sequences of game stages which are executed to produce the
// next game state.
//
// If a stage produces an error or an ended game state, the pipeline is halted at that stage.
type Pipeline interface {
// Execute runs the pipeline stages and produces a next game state.
//
// If any stage produces an error or an ended game state, the pipeline
// immediately stops at that stage.
//
// Errors should be checked and the other results ignored if error is non-nil.
//
// If the pipeline is already in an error state (this can be checked by calling Err()),
// this error will be immediately returned and the pipeline will not run.
//
// After the pipeline runs, the results will be the result of the last stage that was executed.
Execute(*BoardState, Settings, []SnakeMove) (bool, *BoardState, error)
// Err provides a way to check for errors before/without calling Execute.
// Err returns an error if the Pipeline is in an error state.
// If this error is not nil, this error will also be returned from Execute, so it is
// optional to call Err.
// The idea is to reduce error-checking verbosity for the majority of cases where a
// Pipeline is immediately executed after construction (i.e. NewPipeline(...).Execute(...)).
Err() error
}

// pipeline is an implementation of Pipeline
type pipeline struct {
// stages is a list of stages that should be executed from slice start to end
stages []StageFunc
// if the pipeline has an error
err error
}

// NewPipeline constructs an instance of Pipeline using the global registry.
// It is a convenience wrapper for NewPipelineFromRegistry when you want
// to use the default, global registry.
func NewPipeline(stageNames ...string) Pipeline {
return NewPipelineFromRegistry(globalRegistry, stageNames...)
}

// NewPipelineFromRegistry constructs an instance of Pipeline, using the specified registry
// of pipeline stage functions.
//
// The order of execution for the pipeline stages will correspond to the order that
// the stage names are provided.
//
// Example:
// NewPipelineFromRegistry(r, s, "stage1", "stage2")
// ... will result in stage "stage1" running first, then stage "stage2" running after.
//
// An error will be returned if an unregistered stage name is used (a name that is not
// mapped in the registry).
func NewPipelineFromRegistry(registry map[string]StageFunc, stageNames ...string) Pipeline {
// this can't be useful and probably indicates a problem
if len(registry) == 0 {
return &pipeline{err: ErrorEmptyRegistry}
}

// this also can't be useful and probably indicates a problem
if len(stageNames) == 0 {
return &pipeline{err: ErrorNoStages}
}

p := pipeline{}
for _, s := range stageNames {
fn, ok := registry[s]
if !ok {
return pipeline{err: ErrorStageNotFound}
}

p.stages = append(p.stages, fn)
}

return &p
}

// impl
func (p pipeline) Err() error {
return p.err
}

// impl
func (p pipeline) Execute(state *BoardState, settings Settings, moves []SnakeMove) (bool, *BoardState, error) {
// Design Detail
//
// If the pipeline is in an error state, Execute must return that error
// because the pipeline is invalid and cannot execute.
//
// This is done for API use convenience to satisfy the common pattern
// of wanting to write NewPipeline().Execute(...).
//
// This way you can do that without having to do 2 error checks.
// It defers errors from construction to being checked on execution.
if p.err != nil {
return false, nil, p.err
}

// Actually execute
var ended bool
var err error
state = state.Clone()
for _, fn := range p.stages {
// execute current stage
ended, err = fn(state, settings, moves)

// stop if we hit any errors or if the game is ended
if err != nil || ended {
return ended, state, err
}
}

// return the result of the last stage as the final pipeline result
return ended, state, err
}
Loading

0 comments on commit d378759

Please sign in to comment.