DEV-1096 - add a new "pipeline" concept (#67)

* 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
This commit is contained in:
Torben 2022-04-19 15:52:57 -07:00 committed by GitHub
parent 86ef6ad068
commit d378759d58
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 723 additions and 235 deletions

View file

@ -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,

View file

@ -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"},
}
}

View file

@ -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]

View file

@ -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...)))
}
}

190
pipeline.go Normal file
View file

@ -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
}

98
pipeline_internal_test.go Normal file
View file

@ -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
}
}

91
pipeline_test.go Normal file
View file

@ -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
}
}

View file

@ -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)
}

View file

@ -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...)))
}
}

View file

@ -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()
}

16
solo.go
View file

@ -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) {

View file

@ -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...)))
}
}

View file

@ -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)
}

View file

@ -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...)))
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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

View file

@ -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...)))
}
}