diff --git a/board.go b/board.go index 675ebc6..ec86c0c 100644 --- a/board.go +++ b/board.go @@ -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, diff --git a/cases_test.go b/cases_test.go index 16333dd..efb58a8 100644 --- a/cases_test.go +++ b/cases_test.go @@ -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) @@ -39,3 +41,9 @@ func (gc *gameTestCase) requireValidNextState(t *testing.T, r Ruleset) { } }) } + +func mockSnakeMoves() []SnakeMove { + return []SnakeMove{ + {ID: "test-mock-move", Move: "mocked"}, + } +} diff --git a/constrictor.go b/constrictor.go index 4598858..883140f 100644 --- a/constrictor.go +++ b/constrictor.go @@ -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) Execute(bs *BoardState, s Settings, sm []SnakeMove) (bool, *BoardState, error) { + return NewPipeline(constrictorRulesetStages...).Execute(bs, s, sm) +} + 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 - } - - return initialBoardState, nil + _, 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 - } + _, nextState, err := r.Execute(prevState, r.Settings(), moves) - r.removeFood(nextState) - - err = r.applyConstrictorRules(nextState) - if err != nil { - return nil, err - } - - 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) { @@ -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] diff --git a/constrictor_test.go b/constrictor_test.go index 68c987c..84a964e 100644 --- a/constrictor_test.go +++ b/constrictor_test.go @@ -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) @@ -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...))) } } diff --git a/pipeline.go b/pipeline.go new file mode 100644 index 0000000..2086d21 --- /dev/null +++ b/pipeline.go @@ -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 +} diff --git a/pipeline_internal_test.go b/pipeline_internal_test.go new file mode 100644 index 0000000..861faf4 --- /dev/null +++ b/pipeline_internal_test.go @@ -0,0 +1,98 @@ +package rules + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPipelineRuleset(t *testing.T) { + r := StageRegistry{ + "doesnt_end": mockStageFn(false, nil), + "ends": mockStageFn(true, nil), + } + + // Name/Error methods + p := NewPipelineFromRegistry(r, "404doesntexist") + pr := pipelineRuleset{ + name: "test", + pipeline: p, + } + require.Equal(t, "test", pr.Name()) + require.Equal(t, ErrorStageNotFound, pr.Err()) + + // test game over when it does end + p = NewPipelineFromRegistry(r, "doesnt_end", "ends") + pr = pipelineRuleset{ + name: "test", + pipeline: p, + } + ended, err := pr.IsGameOver(&BoardState{}) + require.NoError(t, err) + require.True(t, ended) + + // Test game over when it doesn't end + p = NewPipelineFromRegistry(r, "doesnt_end") + pr = pipelineRuleset{ + name: "test", + pipeline: p, + } + ended, err = pr.IsGameOver(&BoardState{}) + require.NoError(t, err) + require.False(t, ended) + + // test a stage that adds food, except on initialization + r.RegisterPipelineStage("add_food", func(bs *BoardState, s Settings, sm []SnakeMove) (bool, error) { + if IsInitialization(bs, s, sm) { + return false, nil + } + bs.Food = append(bs.Food, Point{X: 0, Y: 0}) + return false, nil + }) + b := &BoardState{} + p = NewPipelineFromRegistry(r, "add_food") + pr = pipelineRuleset{ + name: "test", + pipeline: p, + } + require.Empty(t, b.Food) + b, err = pr.ModifyInitialBoardState(b) + require.NoError(t, err) + require.Empty(t, b.Food, "food should not be added on initialisation phase") + b, err = pr.CreateNextBoardState(b, mockSnakeMoves()) + require.NoError(t, err) + require.NotEmpty(t, b.Food, "fodo should be added now") +} + +func TestPipelineGlobals(t *testing.T) { + oldReg := globalRegistry + globalRegistry = StageRegistry{} + + // ensure that we can register a function without errors + RegisterPipelineStage("test", mockStageFn(false, nil)) + require.Contains(t, globalRegistry, "test") + + // ensure that the global registry panics if you register an existing stage name + require.Panics(t, func() { + RegisterPipelineStage("test", mockStageFn(false, nil)) + }) + RegisterPipelineStage("other", mockStageFn(true, nil)) // otherwise should not panic + + // ensure that we can build a pipeline using the global registry + p := NewPipeline("test", "other") + require.NotNil(t, p) + + // ensure that it runs okay too + ended, next, err := p.Execute(&BoardState{}, Settings{}, nil) + require.NoError(t, err) + require.NotNil(t, next) + require.True(t, ended) + + globalRegistry = oldReg +} + +func mockStageFn(ended bool, err error) StageFunc { + return func(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { + return ended, err + } +} diff --git a/pipeline_test.go b/pipeline_test.go new file mode 100644 index 0000000..ccd1c29 --- /dev/null +++ b/pipeline_test.go @@ -0,0 +1,91 @@ +package rules_test + +import ( + "errors" + "testing" + + "github.com/BattlesnakeOfficial/rules" + "github.com/stretchr/testify/require" +) + +func TestPipeline(t *testing.T) { + r := rules.StageRegistry{} + + // test empty registry error + p := rules.NewPipelineFromRegistry(r) + require.Equal(t, rules.ErrorEmptyRegistry, p.Err()) + _, _, err := p.Execute(nil, rules.Settings{}, nil) + require.Equal(t, rules.ErrorEmptyRegistry, err) + + // test empty stages names error + r.RegisterPipelineStage("astage", mockStageFn(false, nil)) + p = rules.NewPipelineFromRegistry(r) + require.Equal(t, rules.ErrorNoStages, p.Err()) + _, _, err = p.Execute(&rules.BoardState{}, rules.Settings{}, nil) + require.Equal(t, rules.ErrorNoStages, err) + + // test that an unregistered stage name errors + p = rules.NewPipelineFromRegistry(r, "doesntexist") + _, _, err = p.Execute(&rules.BoardState{}, rules.Settings{}, nil) + require.Equal(t, rules.ErrorStageNotFound, p.Err()) + require.Equal(t, rules.ErrorStageNotFound, err) + + // simplest case - one stage + ended, next, err := rules.NewPipelineFromRegistry(r, "astage").Execute(&rules.BoardState{}, rules.Settings{}, nil) + require.NoError(t, err) + require.NoError(t, err) + require.NotNil(t, next) + require.False(t, ended) + + // test that the pipeline short-circuits for a stage that errors + r.RegisterPipelineStage("errors", mockStageFn(false, errors.New(""))) + ended, next, err = rules.NewPipelineFromRegistry(r, "errors", "astage").Execute(&rules.BoardState{}, rules.Settings{}, nil) + require.Error(t, err) + require.NotNil(t, next) + require.False(t, ended) + + // test that the pipeline short-circuits for a stage that ends + r.RegisterPipelineStage("ends", mockStageFn(true, nil)) + ended, next, err = rules.NewPipelineFromRegistry(r, "ends", "astage").Execute(&rules.BoardState{}, rules.Settings{}, nil) + require.NoError(t, err) + require.NotNil(t, next) + require.True(t, ended) + + // test that the pipeline runs normally for multiple stages + ended, next, err = rules.NewPipelineFromRegistry(r, "astage", "ends").Execute(&rules.BoardState{}, rules.Settings{}, nil) + require.NoError(t, err) + require.NotNil(t, next) + require.True(t, ended) +} + +func TestStageRegistry(t *testing.T) { + sr := rules.StageRegistry{} + + // register a stage without error + require.NoError(t, sr.RegisterPipelineStageError("test", mockStageFn(false, nil))) + require.Contains(t, sr, "test") + + // error on duplicate + var e rules.RulesetError + err := sr.RegisterPipelineStageError("test", mockStageFn(false, nil)) + require.Error(t, err) + require.True(t, errors.As(err, &e), "error should be a RulesetError") + require.Equal(t, "stage 'test' has already been registered", err.Error()) + + // register another stage with no error + require.NoError(t, sr.RegisterPipelineStageError("other", mockStageFn(false, nil))) + require.Contains(t, sr, "other") + + // register stage + sr.RegisterPipelineStage("last", mockStageFn(false, nil)) + require.Contains(t, sr, "last") + + // register existing stage (should just be okay and not panic or anything) + sr.RegisterPipelineStage("test", mockStageFn(false, nil)) +} + +func mockStageFn(ended bool, err error) rules.StageFunc { + return func(b *rules.BoardState, settings rules.Settings, moves []rules.SnakeMove) (bool, error) { + return ended, err + } +} diff --git a/royale.go b/royale.go index e7ac868..c049202 100644 --- a/royale.go +++ b/royale.go @@ -5,6 +5,17 @@ import ( "math/rand" ) +var royaleRulesetStages = []string{ + StageMovementStandard, + StageStarvationStandard, + StageHazardDamageStandard, + StageFeedSnakesStandard, + StageSpawnFoodStandard, + StageEliminationStandard, + StageSpawnHazardsShrinkMap, + StageGameOverStandard, +} + type RoyaleRuleset struct { StandardRuleset @@ -15,31 +26,22 @@ type RoyaleRuleset struct { func (r *RoyaleRuleset) Name() string { return GameTypeRoyale } +func (r RoyaleRuleset) Execute(bs *BoardState, s Settings, sm []SnakeMove) (bool, *BoardState, error) { + return NewPipeline(royaleRulesetStages...).Execute(bs, s, sm) +} + func (r *RoyaleRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) { if r.StandardRuleset.HazardDamagePerTurn < 1 { return nil, errors.New("royale damage per turn must be greater than zero") } - - nextBoardState, err := r.StandardRuleset.CreateNextBoardState(prevState, moves) - if err != nil { - return nil, err - } - - // Royale's only job is now to populate the hazards for next turn - StandardRuleset takes care of applying hazard damage. - err = r.populateHazards(nextBoardState) - if err != nil { - return nil, err - } - - return nextBoardState, nil -} - -func (r *RoyaleRuleset) populateHazards(b *BoardState) error { - _, err := r.callStageFunc(PopulateHazardsRoyale, b, []SnakeMove{}) - return err + _, nextState, err := r.Execute(prevState, r.Settings(), moves) + return nextState, err } func PopulateHazardsRoyale(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { + if IsInitialization(b, settings, moves) { + return false, nil + } b.Hazards = []Point{} // Royale uses the current turn to generate hazards, not the previous turn that's in the board state @@ -82,6 +84,10 @@ func PopulateHazardsRoyale(b *BoardState, settings Settings, moves []SnakeMove) return false, nil } +func (r *RoyaleRuleset) IsGameOver(b *BoardState) (bool, error) { + return GameOverStandard(b, r.Settings(), nil) +} + func (r RoyaleRuleset) Settings() Settings { s := r.StandardRuleset.Settings() s.RoyaleSettings = RoyaleSettings{ @@ -90,8 +96,3 @@ func (r RoyaleRuleset) Settings() Settings { } return s } - -// Adaptor for integrating stages into RoyaleRuleset -func (r *RoyaleRuleset) callStageFunc(stage StageFunc, boardState *BoardState, moves []SnakeMove) (bool, error) { - return stage(boardState, r.Settings(), moves) -} diff --git a/royale_test.go b/royale_test.go index b1d60e4..59f71be 100644 --- a/royale_test.go +++ b/royale_test.go @@ -15,7 +15,7 @@ func TestRoyaleRulesetInterface(t *testing.T) { func TestRoyaleDefaultSanity(t *testing.T) { boardState := &BoardState{} r := RoyaleRuleset{StandardRuleset: StandardRuleset{HazardDamagePerTurn: 1}, ShrinkEveryNTurns: 0} - _, err := r.CreateNextBoardState(boardState, []SnakeMove{}) + _, err := r.CreateNextBoardState(boardState, []SnakeMove{{"", ""}}) require.Error(t, err) require.Equal(t, errors.New("royale game can't shrink more frequently than every turn"), err) @@ -102,7 +102,7 @@ func TestRoyaleHazards(t *testing.T) { ShrinkEveryNTurns: test.ShrinkEveryNTurns, } - err := r.populateHazards(b) + _, err := PopulateHazardsRoyale(b, r.Settings(), mockSnakeMoves()) require.Equal(t, test.Error, err) if err == nil { // Obstacles should match @@ -131,7 +131,7 @@ func TestRoyalDamageNextTurn(t *testing.T) { stateAfterTurn := func(prevState *BoardState, turn int32) *BoardState { nextState := prevState.Clone() nextState.Turn = turn - 1 - err := r.populateHazards(nextState) + _, err := PopulateHazardsRoyale(nextState, r.Settings(), nil) require.NoError(t, err) nextState.Turn = turn return nextState @@ -265,7 +265,16 @@ func TestRoyaleCreateNextBoardState(t *testing.T) { ShrinkEveryNTurns: 1, } rand.Seed(0) + rb := NewRulesetBuilder().WithParams(map[string]string{ + ParamGameType: GameTypeRoyale, + ParamHazardDamagePerTurn: "1", + ParamShrinkEveryNTurns: "1", + }) 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(GameTypeRoyale, NewPipeline(royaleRulesetStages...))) } } diff --git a/ruleset.go b/ruleset.go index b5b4477..a9af863 100644 --- a/ruleset.go +++ b/ruleset.go @@ -36,6 +36,9 @@ const ( ErrorNoRoomForFood = RulesetError("not enough space to place food") ErrorNoMoveFound = RulesetError("move not provided for snake") ErrorZeroLengthSnake = RulesetError("snake is length zero") + ErrorEmptyRegistry = RulesetError("empty registry") + ErrorNoStages = RulesetError("no stages") + ErrorStageNotFound = RulesetError("stage not found") // Ruleset / game type names GameTypeConstrictor = "constrictor" @@ -104,7 +107,7 @@ func (rb *rulesetBuilder) AddSnakeToSquad(snakeID, squadName string) *rulesetBui } // Ruleset constructs a customised ruleset using the parameters passed to the builder. -func (rb rulesetBuilder) Ruleset() Ruleset { +func (rb rulesetBuilder) Ruleset() PipelineRuleset { standardRuleset := &StandardRuleset{ FoodSpawnChance: paramsInt32(rb.params, ParamFoodSpawnChance, 0), MinimumFood: paramsInt32(rb.params, ParamMinimumFood, 0), @@ -138,13 +141,9 @@ func (rb rulesetBuilder) Ruleset() Ruleset { StandardRuleset: *standardRuleset, } case GameTypeSquad: - squadMap := map[string]string{} - for id, squad := range rb.squads { - squadMap[id] = squad - } return &SquadRuleset{ StandardRuleset: *standardRuleset, - SquadMap: squadMap, + SquadMap: rb.squadMap(), AllowBodyCollisions: paramsBool(rb.params, ParamAllowBodyCollisions, false), SharedElimination: paramsBool(rb.params, ParamSharedElimination, false), SharedHealth: paramsBool(rb.params, ParamSharedHealth, false), @@ -154,6 +153,42 @@ func (rb rulesetBuilder) Ruleset() Ruleset { return standardRuleset } +func (rb rulesetBuilder) squadMap() map[string]string { + squadMap := map[string]string{} + for id, squad := range rb.squads { + squadMap[id] = squad + } + return squadMap +} + +// PipelineRuleset provides an implementation of the Ruleset using a pipeline with a name. +// It is intended to facilitate transitioning away from legacy Ruleset implementations to Pipeline +// implementations. +func (rb rulesetBuilder) PipelineRuleset(name string, p Pipeline) PipelineRuleset { + return &pipelineRuleset{ + name: name, + pipeline: p, + settings: Settings{ + FoodSpawnChance: paramsInt32(rb.params, ParamFoodSpawnChance, 0), + MinimumFood: paramsInt32(rb.params, ParamMinimumFood, 0), + HazardDamagePerTurn: paramsInt32(rb.params, ParamHazardDamagePerTurn, 0), + HazardMap: rb.params[ParamHazardMap], + HazardMapAuthor: rb.params[ParamHazardMapAuthor], + RoyaleSettings: RoyaleSettings{ + seed: rb.seed, + ShrinkEveryNTurns: paramsInt32(rb.params, ParamShrinkEveryNTurns, 0), + }, + SquadSettings: SquadSettings{ + squadMap: rb.squadMap(), + AllowBodyCollisions: paramsBool(rb.params, ParamAllowBodyCollisions, false), + SharedElimination: paramsBool(rb.params, ParamSharedElimination, false), + SharedHealth: paramsBool(rb.params, ParamSharedHealth, false), + SharedLength: paramsBool(rb.params, ParamSharedLength, false), + }, + }, + } +} + // paramsBool returns the boolean value for the specified parameter. // If the parameter doesn't exist, the default value will be returned. // If the parameter does exist, but is not "true", false will be returned. @@ -239,3 +274,56 @@ type SquadSettings struct { // // Errors should be treated as meaning the stage failed and the board state is now invalid. type StageFunc func(*BoardState, Settings, []SnakeMove) (bool, error) + +// PipelineRuleset groups the Pipeline and Ruleset methods. +// It is intended to facilitate a transition from Ruleset legacy code to Pipeline code. +type PipelineRuleset interface { + Ruleset + Pipeline +} + +type pipelineRuleset struct { + pipeline Pipeline + name string + settings Settings +} + +// impl Ruleset +func (r pipelineRuleset) Settings() Settings { + return r.settings +} + +// impl Ruleset +func (r pipelineRuleset) Name() string { return r.name } + +// impl Ruleset +// IMPORTANT: this implementation of IsGameOver deviates from the previous Ruleset implementations +// in that it checks if the *NEXT* state results in game over, not the previous state. +// This is due to the design of pipelines / stage functions not having a distinction between +// checking for game over and producing a next state. +func (r *pipelineRuleset) IsGameOver(b *BoardState) (bool, error) { + gameover, _, err := r.Execute(b, r.Settings(), nil) // checks if next state is game over + return gameover, err +} + +// impl Ruleset +func (r pipelineRuleset) ModifyInitialBoardState(initialState *BoardState) (*BoardState, error) { + _, nextState, err := r.Execute(initialState, r.Settings(), nil) + return nextState, err +} + +// impl Pipeline +func (r pipelineRuleset) Execute(bs *BoardState, s Settings, sm []SnakeMove) (bool, *BoardState, error) { + return r.pipeline.Execute(bs, s, sm) +} + +// impl Ruleset +func (r pipelineRuleset) CreateNextBoardState(bs *BoardState, sm []SnakeMove) (*BoardState, error) { + _, nextState, err := r.Execute(bs, r.Settings(), sm) + return nextState, err +} + +// impl Pipeline +func (r pipelineRuleset) Err() error { + return r.pipeline.Err() +} diff --git a/solo.go b/solo.go index 907f642..68dc8c5 100644 --- a/solo.go +++ b/solo.go @@ -1,13 +1,27 @@ package rules +var soloRulesetStages = []string{ + StageMovementStandard, + StageStarvationStandard, + StageHazardDamageStandard, + StageFeedSnakesStandard, + StageSpawnFoodStandard, + StageEliminationStandard, + StageGameOverSoloSnake, +} + type SoloRuleset struct { StandardRuleset } func (r *SoloRuleset) Name() string { return GameTypeSolo } +func (r SoloRuleset) Execute(bs *BoardState, s Settings, sm []SnakeMove) (bool, *BoardState, error) { + return NewPipeline(soloRulesetStages...).Execute(bs, s, sm) +} + func (r *SoloRuleset) IsGameOver(b *BoardState) (bool, error) { - return r.callStageFunc(GameOverSolo, b, []SnakeMove{}) + return GameOverSolo(b, r.Settings(), nil) } func GameOverSolo(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { diff --git a/solo_test.go b/solo_test.go index 2155419..42e32c5 100644 --- a/solo_test.go +++ b/solo_test.go @@ -105,7 +105,14 @@ func TestSoloCreateNextBoardState(t *testing.T) { soloCaseNotOver, } r := SoloRuleset{} + rb := NewRulesetBuilder().WithParams(map[string]string{ + ParamGameType: GameTypeSolo, + }) 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, NewRulesetBuilder().PipelineRuleset(GameTypeSolo, NewPipeline(soloRulesetStages...))) } } diff --git a/squad.go b/squad.go index 1434f6c..0ee852d 100644 --- a/squad.go +++ b/squad.go @@ -4,6 +4,18 @@ import ( "errors" ) +var squadRulesetStages = []string{ + StageMovementStandard, + StageStarvationStandard, + StageHazardDamageStandard, + StageFeedSnakesStandard, + StageSpawnFoodStandard, + StageEliminationStandard, + StageEliminationResurrectSquadCollisions, + StageModifySnakesShareAttributes, + StageGameOverBySquad, +} + type SquadRuleset struct { StandardRuleset @@ -18,23 +30,13 @@ type SquadRuleset struct { func (r *SquadRuleset) Name() string { return GameTypeSquad } +func (r SquadRuleset) Execute(bs *BoardState, s Settings, sm []SnakeMove) (bool, *BoardState, error) { + return NewPipeline(squadRulesetStages...).Execute(bs, s, sm) +} + func (r *SquadRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) { - nextBoardState, err := r.StandardRuleset.CreateNextBoardState(prevState, moves) - if err != nil { - return nil, err - } - - err = r.resurrectSquadBodyCollisions(nextBoardState) - if err != nil { - return nil, err - } - - err = r.shareSquadAttributes(nextBoardState) - if err != nil { - return nil, err - } - - return nextBoardState, nil + _, nextState, err := r.Execute(prevState, r.Settings(), moves) + return nextState, err } func areSnakesOnSameSquad(squadMap map[string]string, snake *Snake, other *Snake) bool { @@ -45,12 +47,10 @@ func areSnakeIDsOnSameSquad(squadMap map[string]string, snakeID string, otherID return squadMap[snakeID] == squadMap[otherID] } -func (r *SquadRuleset) resurrectSquadBodyCollisions(b *BoardState) error { - _, err := r.callStageFunc(ResurrectSnakesSquad, b, []SnakeMove{}) - return err -} - func ResurrectSnakesSquad(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { + if IsInitialization(b, settings, moves) { + return false, nil + } if !settings.SquadSettings.AllowBodyCollisions { return false, nil } @@ -71,12 +71,10 @@ func ResurrectSnakesSquad(b *BoardState, settings Settings, moves []SnakeMove) ( return false, nil } -func (r *SquadRuleset) shareSquadAttributes(b *BoardState) error { - _, err := r.callStageFunc(ShareAttributesSquad, b, []SnakeMove{}) - return err -} - func ShareAttributesSquad(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { + if IsInitialization(b, settings, moves) { + return false, nil + } squadSettings := settings.SquadSettings if !(squadSettings.SharedElimination || squadSettings.SharedLength || squadSettings.SharedHealth) { @@ -120,7 +118,7 @@ func ShareAttributesSquad(b *BoardState, settings Settings, moves []SnakeMove) ( } func (r *SquadRuleset) IsGameOver(b *BoardState) (bool, error) { - return r.callStageFunc(GameOverSquad, b, []SnakeMove{}) + return GameOverSquad(b, r.Settings(), nil) } func GameOverSquad(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { @@ -152,8 +150,3 @@ func (r SquadRuleset) Settings() Settings { } return s } - -// Adaptor for integrating stages into SquadRuleset -func (r *SquadRuleset) callStageFunc(stage StageFunc, boardState *BoardState, moves []SnakeMove) (bool, error) { - return stage(boardState, r.Settings(), moves) -} diff --git a/squad_test.go b/squad_test.go index f39c759..af330a5 100644 --- a/squad_test.go +++ b/squad_test.go @@ -26,14 +26,14 @@ func TestSquadCreateNextBoardStateSanity(t *testing.T) { func TestSquadResurrectSquadBodyCollisionsSanity(t *testing.T) { boardState := &BoardState{} r := SquadRuleset{} - err := r.resurrectSquadBodyCollisions(boardState) + _, err := ResurrectSnakesSquad(boardState, r.Settings(), nil) require.NoError(t, err) } func TestSquadSharedAttributesSanity(t *testing.T) { boardState := &BoardState{} r := SquadRuleset{} - err := r.shareSquadAttributes(boardState) + _, err := ShareAttributesSquad(boardState, r.Settings(), nil) require.NoError(t, err) } @@ -77,7 +77,7 @@ func TestSquadAllowBodyCollisions(t *testing.T) { require.Equal(t, len(squadMap), len(boardState.Snakes), "squad map is wrong size, error in test setup") r := SquadRuleset{SquadMap: squadMap, AllowBodyCollisions: true} - err := r.resurrectSquadBodyCollisions(boardState) + _, err := ResurrectSnakesSquad(boardState, r.Settings(), mockSnakeMoves()) require.NoError(t, err) require.Equal(t, len(boardState.Snakes), len(testSnakes)) @@ -113,7 +113,7 @@ func TestSquadAllowBodyCollisionsEliminatedByNotSet(t *testing.T) { "2": "red", }, } - err := r.resurrectSquadBodyCollisions(boardState) + _, err := ResurrectSnakesSquad(boardState, r.Settings(), mockSnakeMoves()) require.Error(t, err) } @@ -152,7 +152,7 @@ func TestSquadShareSquadHealth(t *testing.T) { require.Equal(t, len(squadMap), len(boardState.Snakes), "squad map is wrong size, error in test setup") r := SquadRuleset{SharedHealth: true, SquadMap: squadMap} - err := r.shareSquadAttributes(boardState) + _, err := ShareAttributesSquad(boardState, r.Settings(), mockSnakeMoves()) require.NoError(t, err) require.Equal(t, len(boardState.Snakes), len(testSnakes)) @@ -202,7 +202,7 @@ func TestSquadSharedLength(t *testing.T) { require.Equal(t, len(squadMap), len(boardState.Snakes), "squad map is wrong size, error in test setup") r := SquadRuleset{SharedLength: true, SquadMap: squadMap} - err := r.shareSquadAttributes(boardState) + _, err := ShareAttributesSquad(boardState, r.Settings(), mockSnakeMoves()) require.NoError(t, err) require.Equal(t, len(boardState.Snakes), len(testSnakes)) @@ -255,7 +255,7 @@ func TestSquadSharedElimination(t *testing.T) { require.Equal(t, len(squadMap), len(boardState.Snakes), "squad map is wrong size, error in test setup") r := SquadRuleset{SharedElimination: true, SquadMap: squadMap} - err := r.shareSquadAttributes(boardState) + _, err := ShareAttributesSquad(boardState, r.Settings(), mockSnakeMoves()) require.NoError(t, err) require.Equal(t, len(boardState.Snakes), len(testSnakes)) @@ -291,7 +291,7 @@ func TestSquadSharedAttributesErrorLengthZero(t *testing.T) { "2": "red", }, } - err := r.shareSquadAttributes(boardState) + _, err := ShareAttributesSquad(boardState, r.Settings(), mockSnakeMoves()) require.Error(t, err) } @@ -547,8 +547,19 @@ func TestSquadCreateNextBoardState(t *testing.T) { }, } rand.Seed(0) + rb := NewRulesetBuilder().WithParams(map[string]string{ + ParamGameType: GameTypeSquad, + }) + rb.WithSeed(0) + for s, ss := range r.SquadMap { + rb = rb.AddSnakeToSquad(s, ss) + } for _, gc := range standardCases { 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(GameTypeSquad, NewPipeline(squadRulesetStages...))) } extendedCases := []gameTestCase{ @@ -557,7 +568,15 @@ func TestSquadCreateNextBoardState(t *testing.T) { } r.SharedHealth = true r.AllowBodyCollisions = true + rb = rb.WithParams(map[string]string{ + ParamSharedHealth: "true", + ParamAllowBodyCollisions: "true", + }) for _, gc := range extendedCases { 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(GameTypeSquad, NewPipeline(squadRulesetStages...))) } } diff --git a/standard.go b/standard.go index d1e9538..fa55b96 100644 --- a/standard.go +++ b/standard.go @@ -13,6 +13,16 @@ type StandardRuleset struct { HazardMapAuthor string // optional } +var standardRulesetStages = []string{ + StageMovementStandard, + StageStarvationStandard, + StageHazardDamageStandard, + StageFeedSnakesStandard, + StageSpawnFoodStandard, + StageEliminationStandard, + StageGameOverStandard, +} + func (r *StandardRuleset) Name() string { return GameTypeStandard } func (r *StandardRuleset) ModifyInitialBoardState(initialState *BoardState) (*BoardState, error) { @@ -20,55 +30,22 @@ func (r *StandardRuleset) ModifyInitialBoardState(initialState *BoardState) (*Bo return initialState, nil } -func (r *StandardRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) { - // We specifically want to copy prevState, so as not to alter it directly. - nextState := prevState.Clone() - - err := r.moveSnakes(nextState, moves) - if err != nil { - return nil, err - } - - err = r.reduceSnakeHealth(nextState) - if err != nil { - return nil, err - } - - err = r.maybeDamageHazards(nextState) - if err != nil { - return nil, err - } - - // bvanvugt: We specifically want this to happen before elimination for two reasons: - // 1) We want snakes to be able to eat on their very last turn and still survive. - // 2) So that head-to-head collisions on food still remove the food. - // This does create an artifact though, where head-to-head collisions - // of equal length actually show length + 1 and full health, as if both snakes ate. - err = r.maybeFeedSnakes(nextState) - if err != nil { - return nil, err - } - - err = r.maybeSpawnFood(nextState) - if err != nil { - return nil, err - } - - err = r.maybeEliminateSnakes(nextState) - if err != nil { - return nil, err - } - - return nextState, nil +// impl Pipeline +func (r StandardRuleset) Execute(bs *BoardState, s Settings, sm []SnakeMove) (bool, *BoardState, error) { + return NewPipeline(standardRulesetStages...).Execute(bs, s, sm) } -func (r *StandardRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error { - _, err := r.callStageFunc(MoveSnakesStandard, b, moves) - return err +func (r *StandardRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) { + _, nextState, err := r.Execute(prevState, r.Settings(), moves) + return nextState, err } func MoveSnakesStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { - // If no moves are passed, pass on modifying the initial board state + if IsInitialization(b, settings, moves) { + return false, nil + } + + // no-op when moves are empty if len(moves) == 0 { return false, nil } @@ -164,12 +141,10 @@ func getDefaultMove(snakeBody []Point) string { return MoveUp } -func (r *StandardRuleset) reduceSnakeHealth(b *BoardState) error { - _, err := r.callStageFunc(ReduceSnakeHealthStandard, b, []SnakeMove{}) - return err -} - func ReduceSnakeHealthStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { + if IsInitialization(b, settings, moves) { + return false, nil + } for i := 0; i < len(b.Snakes); i++ { if b.Snakes[i].EliminatedCause == NotEliminated { b.Snakes[i].Health = b.Snakes[i].Health - 1 @@ -178,12 +153,10 @@ func ReduceSnakeHealthStandard(b *BoardState, settings Settings, moves []SnakeMo return false, nil } -func (r *StandardRuleset) maybeDamageHazards(b *BoardState) error { - _, err := r.callStageFunc(DamageHazardsStandard, b, []SnakeMove{}) - return err -} - func DamageHazardsStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { + if IsInitialization(b, settings, moves) { + return false, nil + } for i := 0; i < len(b.Snakes); i++ { snake := &b.Snakes[i] if snake.EliminatedCause != NotEliminated { @@ -218,12 +191,10 @@ func DamageHazardsStandard(b *BoardState, settings Settings, moves []SnakeMove) return false, nil } -func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error { - _, err := r.callStageFunc(EliminateSnakesStandard, b, []SnakeMove{}) - return err -} - func EliminateSnakesStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { + if IsInitialization(b, settings, moves) { + return false, nil + } // First order snake indices by length. // In multi-collision scenarios we want to always attribute elimination to the longest snake. snakeIndicesByLength := make([]int, len(b.Snakes)) @@ -378,11 +349,6 @@ func snakeHasLostHeadToHead(s *Snake, other *Snake) bool { return false } -func (r *StandardRuleset) maybeFeedSnakes(b *BoardState) error { - _, err := r.callStageFunc(FeedSnakesStandard, b, []SnakeMove{}) - return err -} - func FeedSnakesStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { newFood := []Point{} for _, food := range b.Food { @@ -421,12 +387,10 @@ func growSnake(snake *Snake) { } } -func (r *StandardRuleset) maybeSpawnFood(b *BoardState) error { - _, err := r.callStageFunc(SpawnFoodStandard, b, []SnakeMove{}) - return err -} - func SpawnFoodStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { + if IsInitialization(b, settings, moves) { + return false, nil + } numCurrentFood := int32(len(b.Food)) if numCurrentFood < settings.MinimumFood { return false, PlaceFoodRandomly(b, settings.MinimumFood-numCurrentFood) @@ -438,7 +402,7 @@ func SpawnFoodStandard(b *BoardState, settings Settings, moves []SnakeMove) (boo } func (r *StandardRuleset) IsGameOver(b *BoardState) (bool, error) { - return r.callStageFunc(GameOverStandard, b, []SnakeMove{}) + return GameOverStandard(b, r.Settings(), nil) } func GameOverStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { @@ -461,7 +425,14 @@ func (r StandardRuleset) Settings() Settings { } } -// Adaptor for integrating stages into StandardRuleset -func (r *StandardRuleset) callStageFunc(stage StageFunc, boardState *BoardState, moves []SnakeMove) (bool, error) { - return stage(boardState, r.Settings(), moves) +// impl Pipeline +func (r StandardRuleset) Err() error { + return nil +} + +// IsInitialization checks whether the current state means the game is initialising. +func IsInitialization(b *BoardState, settings Settings, moves []SnakeMove) bool { + // We can safely assume that the game state is in the initialisation phase when + // the turn hasn't advanced and the moves are empty + return b.Turn <= 0 && len(moves) == 0 } diff --git a/standard_test.go b/standard_test.go index 5b1fb56..52bc1ce 100644 --- a/standard_test.go +++ b/standard_test.go @@ -218,8 +218,15 @@ func TestStandardCreateNextBoardState(t *testing.T) { standardMoveAndCollideMAD, } r := StandardRuleset{} + rb := NewRulesetBuilder().WithParams(map[string]string{ + ParamGameType: GameTypeStandard, + }) 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, NewRulesetBuilder().PipelineRuleset(GameTypeStandard, NewPipeline(standardRulesetStages...))) } } @@ -552,7 +559,7 @@ func TestMoveSnakes(t *testing.T) { {ID: "two", Move: test.MoveTwo}, {ID: "three", Move: test.MoveThree}, } - err := r.moveSnakes(b, moves) + _, err := MoveSnakesStandard(b, r.Settings(), moves) require.NoError(t, err) require.Len(t, b.Snakes, 3) @@ -597,7 +604,7 @@ func TestMoveSnakesWrongID(t *testing.T) { } r := StandardRuleset{} - err := r.moveSnakes(b, moves) + _, err := MoveSnakesStandard(b, r.Settings(), moves) require.Equal(t, ErrorNoMoveFound, err) } @@ -622,7 +629,7 @@ func TestMoveSnakesNotEnoughMoves(t *testing.T) { } r := StandardRuleset{} - err := r.moveSnakes(b, moves) + _, err := MoveSnakesStandard(b, r.Settings(), moves) require.Equal(t, ErrorNoMoveFound, err) } @@ -647,7 +654,7 @@ func TestMoveSnakesExtraMovesIgnored(t *testing.T) { } r := StandardRuleset{} - err := r.moveSnakes(b, moves) + _, err := MoveSnakesStandard(b, r.Settings(), moves) require.NoError(t, err) require.Equal(t, []Point{{1, 0}}, b.Snakes[0].Body) } @@ -699,7 +706,7 @@ func TestMoveSnakesDefault(t *testing.T) { } moves := []SnakeMove{{ID: "one", Move: test.Move}} - err := r.moveSnakes(b, moves) + _, err := MoveSnakesStandard(b, r.Settings(), moves) require.NoError(t, err) require.Len(t, b.Snakes, 1) require.Equal(t, len(test.Body), len(b.Snakes[0].Body)) @@ -795,25 +802,25 @@ func TestReduceSnakeHealth(t *testing.T) { } r := StandardRuleset{} - err := r.reduceSnakeHealth(b) + _, err := ReduceSnakeHealthStandard(b, r.Settings(), mockSnakeMoves()) require.NoError(t, err) require.Equal(t, b.Snakes[0].Health, int32(98)) require.Equal(t, b.Snakes[1].Health, int32(1)) require.Equal(t, b.Snakes[2].Health, int32(50)) - err = r.reduceSnakeHealth(b) + _, err = ReduceSnakeHealthStandard(b, r.Settings(), mockSnakeMoves()) require.NoError(t, err) require.Equal(t, b.Snakes[0].Health, int32(97)) require.Equal(t, b.Snakes[1].Health, int32(0)) require.Equal(t, b.Snakes[2].Health, int32(50)) - err = r.reduceSnakeHealth(b) + _, err = ReduceSnakeHealthStandard(b, r.Settings(), mockSnakeMoves()) require.NoError(t, err) require.Equal(t, b.Snakes[0].Health, int32(96)) require.Equal(t, b.Snakes[1].Health, int32(-1)) require.Equal(t, b.Snakes[2].Health, int32(50)) - err = r.reduceSnakeHealth(b) + _, err = ReduceSnakeHealthStandard(b, r.Settings(), mockSnakeMoves()) require.NoError(t, err) require.Equal(t, b.Snakes[0].Health, int32(95)) require.Equal(t, b.Snakes[1].Health, int32(-2)) @@ -1214,7 +1221,7 @@ func TestMaybeEliminateSnakes(t *testing.T) { Height: 10, Snakes: test.Snakes, } - err := r.maybeEliminateSnakes(b) + _, err := EliminateSnakesStandard(b, r.Settings(), mockSnakeMoves()) require.Equal(t, test.Err, err) for i, snake := range b.Snakes { require.Equal(t, test.ExpectedEliminatedCauses[i], snake.EliminatedCause) @@ -1254,7 +1261,7 @@ func TestMaybeEliminateSnakesPriority(t *testing.T) { r := StandardRuleset{} for _, test := range tests { b := &BoardState{Width: 10, Height: 10, Snakes: test.Snakes} - err := r.maybeEliminateSnakes(b) + _, err := EliminateSnakesStandard(b, r.Settings(), mockSnakeMoves()) require.NoError(t, err) for i, snake := range b.Snakes { require.Equal(t, test.ExpectedEliminatedCauses[i], snake.EliminatedCause, snake.ID) @@ -1320,7 +1327,7 @@ func TestMaybeDamageHazards(t *testing.T) { for _, test := range tests { b := &BoardState{Snakes: test.Snakes, Hazards: test.Hazards, Food: test.Food} r := StandardRuleset{HazardDamagePerTurn: 100} - err := r.maybeDamageHazards(b) + _, err := DamageHazardsStandard(b, r.Settings(), mockSnakeMoves()) require.NoError(t, err) for i, snake := range b.Snakes { @@ -1361,7 +1368,7 @@ func TestHazardDamagePerTurn(t *testing.T) { } r := StandardRuleset{HazardDamagePerTurn: test.HazardDamagePerTurn} - err := r.maybeDamageHazards(b) + _, err := DamageHazardsStandard(b, r.Settings(), mockSnakeMoves()) require.Equal(t, test.Error, err) require.Equal(t, test.ExpectedHealth, b.Snakes[0].Health) require.Equal(t, test.ExpectedEliminationCause, b.Snakes[0].EliminatedCause) @@ -1441,7 +1448,7 @@ func TestMaybeFeedSnakes(t *testing.T) { Snakes: test.Snakes, Food: test.Food, } - err := r.maybeFeedSnakes(b) + _, err := FeedSnakesStandard(b, r.Settings(), nil) require.NoError(t, err, test.Name) require.Equal(t, len(test.ExpectedSnakes), len(b.Snakes), test.Name) for i := 0; i < len(b.Snakes); i++ { @@ -1477,7 +1484,7 @@ func TestMaybeSpawnFoodMinimum(t *testing.T) { Food: test.Food, } - err := r.maybeSpawnFood(b) + _, err := SpawnFoodStandard(b, r.Settings(), mockSnakeMoves()) require.NoError(t, err) require.Equal(t, test.ExpectedFood, len(b.Food)) } @@ -1495,7 +1502,7 @@ func TestMaybeSpawnFoodZeroChance(t *testing.T) { Food: []Point{}, } for i := 0; i < 1000; i++ { - err := r.maybeSpawnFood(b) + _, err := SpawnFoodStandard(b, r.Settings(), nil) require.NoError(t, err) require.Equal(t, len(b.Food), 0) } @@ -1513,7 +1520,7 @@ func TestMaybeSpawnFoodHundredChance(t *testing.T) { Food: []Point{}, } for i := 1; i <= 22; i++ { - err := r.maybeSpawnFood(b) + _, err := SpawnFoodStandard(b, r.Settings(), mockSnakeMoves()) require.NoError(t, err) require.Equal(t, i, len(b.Food)) } @@ -1547,7 +1554,7 @@ func TestMaybeSpawnFoodHalfChance(t *testing.T) { } rand.Seed(test.Seed) - err := r.maybeSpawnFood(b) + _, err := SpawnFoodStandard(b, r.Settings(), mockSnakeMoves()) require.NoError(t, err) require.Equal(t, test.ExpectedFood, int32(len(b.Food)), "Seed %d", test.Seed) } diff --git a/wrapped.go b/wrapped.go index 77a2e23..51c60e5 100644 --- a/wrapped.go +++ b/wrapped.go @@ -1,53 +1,36 @@ package rules +var wrappedRulesetStages = []string{ + StageMovementWrapBoundaries, + StageStarvationStandard, + StageHazardDamageStandard, + StageFeedSnakesStandard, + StageSpawnFoodStandard, + StageEliminationStandard, + StageGameOverStandard, +} + type WrappedRuleset struct { StandardRuleset } func (r *WrappedRuleset) Name() string { return GameTypeWrapped } -func (r *WrappedRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) { - nextState := prevState.Clone() - - err := r.moveSnakes(nextState, moves) - if err != nil { - return nil, err - } - - err = r.reduceSnakeHealth(nextState) - if err != nil { - return nil, err - } - - err = r.maybeDamageHazards(nextState) - if err != nil { - return nil, err - } - - err = r.maybeFeedSnakes(nextState) - if err != nil { - return nil, err - } - - err = r.maybeSpawnFood(nextState) - if err != nil { - return nil, err - } - - err = r.maybeEliminateSnakes(nextState) - if err != nil { - return nil, err - } - - return nextState, nil +func (r WrappedRuleset) Execute(bs *BoardState, s Settings, sm []SnakeMove) (bool, *BoardState, error) { + return NewPipeline(wrappedRulesetStages...).Execute(bs, s, sm) } -func (r *WrappedRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error { - _, err := r.callStageFunc(MoveSnakesWrapped, b, moves) - return err +func (r *WrappedRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) { + _, nextState, err := r.Execute(prevState, r.Settings(), moves) + + return nextState, err } func MoveSnakesWrapped(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { + if IsInitialization(b, settings, moves) { + return false, nil + } + _, err := MoveSnakesStandard(b, settings, moves) if err != nil { return false, err @@ -65,6 +48,10 @@ func MoveSnakesWrapped(b *BoardState, settings Settings, moves []SnakeMove) (boo return false, nil } +func (r *WrappedRuleset) IsGameOver(b *BoardState) (bool, error) { + return GameOverStandard(b, r.Settings(), nil) +} + func wrap(value, min, max int32) int32 { if value < min { return max diff --git a/wrapped_test.go b/wrapped_test.go index fd41867..04994ac 100644 --- a/wrapped_test.go +++ b/wrapped_test.go @@ -331,7 +331,14 @@ func TestWrappedCreateNextBoardState(t *testing.T) { wrappedCaseMoveAndWrap, } r := WrappedRuleset{} + rb := NewRulesetBuilder().WithParams(map[string]string{ + ParamGameType: GameTypeWrapped, + }) 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, NewRulesetBuilder().PipelineRuleset(GameTypeWrapped, NewPipeline(wrappedRulesetStages...))) } }