diff --git a/cli/commands/play.go b/cli/commands/play.go index 78b6867..ea7f1d3 100644 --- a/cli/commands/play.go +++ b/cli/commands/play.go @@ -57,11 +57,22 @@ var MinimumFood int32 var HazardDamagePerTurn int32 var ShrinkEveryNTurns int32 +var defaultConfig = map[string]string{ + // default to standard ruleset + rules.ParamGameType: "standard", + // squad settings default to true (not zero value) + rules.ParamSharedElimination: "true", + rules.ParamSharedHealth: "true", + rules.ParamSharedLength: "true", + rules.ParamAllowBodyCollisions: "true", +} + var playCmd = &cobra.Command{ - Use: "play", - Short: "Play a game of Battlesnake locally.", - Long: "Play a game of Battlesnake locally.", - Run: run, + Use: "play", + Short: "Play a game of Battlesnake locally.", + Long: "Play a game of Battlesnake locally.", + Run: run, + PreRun: playPreRun, } func init() { @@ -90,6 +101,10 @@ func init() { playCmd.Flags().SortFlags = false } +func playPreRun(cmd *cobra.Command, args []string) { + initialiseGameConfig() +} + var run = func(cmd *cobra.Command, args []string) { rand.Seed(Seed) @@ -174,54 +189,23 @@ var run = func(cmd *cobra.Command, args []string) { } } +func initialiseGameConfig() { + defaultConfig[rules.ParamGameType] = GameType + defaultConfig[rules.ParamFoodSpawnChance] = fmt.Sprint(FoodSpawnChance) + defaultConfig[rules.ParamMinimumFood] = fmt.Sprint(MinimumFood) + defaultConfig[rules.ParamHazardDamagePerTurn] = fmt.Sprint(HazardDamagePerTurn) + defaultConfig[rules.ParamShrinkEveryNTurns] = fmt.Sprint(ShrinkEveryNTurns) +} + func getRuleset(seed int64, snakeStates map[string]SnakeState) rules.Ruleset { - var ruleset rules.Ruleset - var royale rules.RoyaleRuleset + rb := rules.NewRulesetBuilder().WithSeed(seed).WithParams(defaultConfig) - standard := rules.StandardRuleset{ - FoodSpawnChance: FoodSpawnChance, - MinimumFood: MinimumFood, - HazardDamagePerTurn: 0, + for _, s := range snakeStates { + rb.AddSnakeToSquad(s.ID, s.Squad) } - switch GameType { - case "royale": - standard.HazardDamagePerTurn = HazardDamagePerTurn - royale = rules.RoyaleRuleset{ - StandardRuleset: standard, - Seed: seed, - ShrinkEveryNTurns: ShrinkEveryNTurns, - } - ruleset = &royale - case "squad": - squadMap := map[string]string{} - for _, snakeState := range snakeStates { - squadMap[snakeState.ID] = snakeState.Squad - } - ruleset = &rules.SquadRuleset{ - StandardRuleset: standard, - SquadMap: squadMap, - AllowBodyCollisions: true, - SharedElimination: true, - SharedHealth: true, - SharedLength: true, - } - case "solo": - ruleset = &rules.SoloRuleset{ - StandardRuleset: standard, - } - case "wrapped": - ruleset = &rules.WrappedRuleset{ - StandardRuleset: standard, - } - case "constrictor": - ruleset = &rules.ConstrictorRuleset{ - StandardRuleset: standard, - } - default: - ruleset = &standard - } - return ruleset + return rb.Ruleset() + } func initializeBoardFromArgs(ruleset rules.Ruleset, snakeStates map[string]SnakeState) *rules.BoardState { @@ -382,22 +366,9 @@ func serialiseSnakeRequest(snakeRequest client.SnakeRequest) []byte { func createClientGame(ruleset rules.Ruleset) client.Game { return client.Game{ID: GameId, Timeout: Timeout, Ruleset: client.Ruleset{ - Name: ruleset.Name(), - Version: "cli", // TODO: Use GitHub Release Version - Settings: client.RulesetSettings{ - HazardDamagePerTurn: HazardDamagePerTurn, - FoodSpawnChance: FoodSpawnChance, - MinimumFood: MinimumFood, - RoyaleSettings: client.RoyaleSettings{ - ShrinkEveryNTurns: ShrinkEveryNTurns, - }, - SquadSettings: client.SquadSettings{ - AllowBodyCollisions: true, - SharedElimination: true, - SharedHealth: true, - SharedLength: true, - }, - }, + Name: ruleset.Name(), + Version: "cli", // TODO: Use GitHub Release Version + Settings: ruleset.Settings(), }} } diff --git a/cli/commands/play_test.go b/cli/commands/play_test.go index 856dfe4..5c39a0f 100644 --- a/cli/commands/play_test.go +++ b/cli/commands/play_test.go @@ -1,6 +1,7 @@ package commands import ( + "fmt" "testing" "github.com/BattlesnakeOfficial/rules" @@ -35,8 +36,69 @@ func TestGetIndividualBoardStateForSnake(t *testing.T) { s1State.ID: s1State, s2State.ID: s2State, } - snakeRequest := getIndividualBoardStateForSnake(state, s1State, snakeStates, &rules.StandardRuleset{}) + initialiseGameConfig() // initialise default config + snakeRequest := getIndividualBoardStateForSnake(state, s1State, snakeStates, getRuleset(0, snakeStates)) requestBody := serialiseSnakeRequest(snakeRequest) test.RequireJSONMatchesFixture(t, "testdata/snake_request_body.json", string(requestBody)) } + +func TestSettingsRequestSerialization(t *testing.T) { + s1 := rules.Snake{ID: "one", Body: []rules.Point{{X: 3, Y: 3}}} + s2 := rules.Snake{ID: "two", Body: []rules.Point{{X: 4, Y: 3}}} + state := &rules.BoardState{ + Height: 11, + Width: 11, + Snakes: []rules.Snake{s1, s2}, + } + s1State := SnakeState{ + ID: "one", + Name: "ONE", + URL: "http://example1.com", + Head: "safe", + Tail: "curled", + Color: "#123456", + } + s2State := SnakeState{ + ID: "two", + Name: "TWO", + URL: "http://example2.com", + Head: "silly", + Tail: "bolt", + Color: "#654321", + } + snakeStates := map[string]SnakeState{s1State.ID: s1State, s2State.ID: s2State} + + rsb := rules.NewRulesetBuilder(). + WithParams(map[string]string{ + // standard + rules.ParamFoodSpawnChance: "11", + rules.ParamMinimumFood: "7", + rules.ParamHazardDamagePerTurn: "19", + rules.ParamHazardMap: "hz_spiral", + rules.ParamHazardMapAuthor: "altersaddle", + // squad + rules.ParamAllowBodyCollisions: "true", + rules.ParamSharedElimination: "false", + rules.ParamSharedHealth: "true", + rules.ParamSharedLength: "false", + // royale + rules.ParamShrinkEveryNTurns: "17", + }) + + for _, gt := range []string{ + rules.GameTypeStandard, rules.GameTypeRoyale, rules.GameTypeSolo, + rules.GameTypeWrapped, rules.GameTypeSquad, rules.GameTypeConstrictor, + } { + t.Run(gt, func(t *testing.T) { + // apply game type + ruleset := rsb.WithParams(map[string]string{rules.ParamGameType: gt}).Ruleset() + + snakeRequest := getIndividualBoardStateForSnake(state, s1State, snakeStates, ruleset) + requestBody := serialiseSnakeRequest(snakeRequest) + t.Log(string(requestBody)) + + test.RequireJSONMatchesFixture(t, fmt.Sprintf("testdata/snake_request_body_%s.json", gt), string(requestBody)) + }) + } +} diff --git a/cli/commands/testdata/snake_request_body.json b/cli/commands/testdata/snake_request_body.json index 608d55e..a10f89b 100644 --- a/cli/commands/testdata/snake_request_body.json +++ b/cli/commands/testdata/snake_request_body.json @@ -11,13 +11,13 @@ "hazardMap": "", "hazardMapAuthor": "", "royale": { - "shrinkEveryNTurns": 25 + "shrinkEveryNTurns": 0 }, "squad": { - "allowBodyCollisions": true, - "sharedElimination": true, - "sharedHealth": true, - "sharedLength": true + "allowBodyCollisions": false, + "sharedElimination": false, + "sharedHealth": false, + "sharedLength": false } } }, diff --git a/cli/commands/testdata/snake_request_body_constrictor.json b/cli/commands/testdata/snake_request_body_constrictor.json new file mode 100644 index 0000000..15a2051 --- /dev/null +++ b/cli/commands/testdata/snake_request_body_constrictor.json @@ -0,0 +1,108 @@ +{ + "game": { + "id": "", + "ruleset": { + "name": "constrictor", + "version": "cli", + "settings": { + "foodSpawnChance": 11, + "minimumFood": 7, + "hazardDamagePerTurn": 19, + "hazardMap": "hz_spiral", + "hazardMapAuthor": "altersaddle", + "royale": { + "shrinkEveryNTurns": 0 + }, + "squad": { + "allowBodyCollisions": false, + "sharedElimination": false, + "sharedHealth": false, + "sharedLength": false + } + } + }, + "timeout": 500, + "source": "" + }, + "turn": 0, + "board": { + "height": 11, + "width": 11, + "snakes": [ + { + "id": "one", + "name": "ONE", + "latency": "0", + "health": 0, + "body": [ + { + "x": 3, + "y": 3 + } + ], + "head": { + "x": 3, + "y": 3 + }, + "length": 1, + "shout": "", + "squad": "", + "customizations": { + "color": "#123456", + "head": "safe", + "tail": "curled" + } + }, + { + "id": "two", + "name": "TWO", + "latency": "0", + "health": 0, + "body": [ + { + "x": 4, + "y": 3 + } + ], + "head": { + "x": 4, + "y": 3 + }, + "length": 1, + "shout": "", + "squad": "", + "customizations": { + "color": "#654321", + "head": "silly", + "tail": "bolt" + } + } + ], + "food": [], + "hazards": [] + }, + "you": { + "id": "one", + "name": "ONE", + "latency": "0", + "health": 0, + "body": [ + { + "x": 3, + "y": 3 + } + ], + "head": { + "x": 3, + "y": 3 + }, + "length": 1, + "shout": "", + "squad": "", + "customizations": { + "color": "#123456", + "head": "safe", + "tail": "curled" + } + } + } \ No newline at end of file diff --git a/cli/commands/testdata/snake_request_body_royale.json b/cli/commands/testdata/snake_request_body_royale.json new file mode 100644 index 0000000..ea9a762 --- /dev/null +++ b/cli/commands/testdata/snake_request_body_royale.json @@ -0,0 +1,108 @@ +{ + "game": { + "id": "", + "ruleset": { + "name": "royale", + "version": "cli", + "settings": { + "foodSpawnChance": 11, + "minimumFood": 7, + "hazardDamagePerTurn": 19, + "hazardMap": "hz_spiral", + "hazardMapAuthor": "altersaddle", + "royale": { + "shrinkEveryNTurns": 17 + }, + "squad": { + "allowBodyCollisions": false, + "sharedElimination": false, + "sharedHealth": false, + "sharedLength": false + } + } + }, + "timeout": 500, + "source": "" + }, + "turn": 0, + "board": { + "height": 11, + "width": 11, + "snakes": [ + { + "id": "one", + "name": "ONE", + "latency": "0", + "health": 0, + "body": [ + { + "x": 3, + "y": 3 + } + ], + "head": { + "x": 3, + "y": 3 + }, + "length": 1, + "shout": "", + "squad": "", + "customizations": { + "color": "#123456", + "head": "safe", + "tail": "curled" + } + }, + { + "id": "two", + "name": "TWO", + "latency": "0", + "health": 0, + "body": [ + { + "x": 4, + "y": 3 + } + ], + "head": { + "x": 4, + "y": 3 + }, + "length": 1, + "shout": "", + "squad": "", + "customizations": { + "color": "#654321", + "head": "silly", + "tail": "bolt" + } + } + ], + "food": [], + "hazards": [] + }, + "you": { + "id": "one", + "name": "ONE", + "latency": "0", + "health": 0, + "body": [ + { + "x": 3, + "y": 3 + } + ], + "head": { + "x": 3, + "y": 3 + }, + "length": 1, + "shout": "", + "squad": "", + "customizations": { + "color": "#123456", + "head": "safe", + "tail": "curled" + } + } +} \ No newline at end of file diff --git a/cli/commands/testdata/snake_request_body_solo.json b/cli/commands/testdata/snake_request_body_solo.json new file mode 100644 index 0000000..dc4a4f7 --- /dev/null +++ b/cli/commands/testdata/snake_request_body_solo.json @@ -0,0 +1,108 @@ +{ + "game": { + "id": "", + "ruleset": { + "name": "solo", + "version": "cli", + "settings": { + "foodSpawnChance": 11, + "minimumFood": 7, + "hazardDamagePerTurn": 19, + "hazardMap": "hz_spiral", + "hazardMapAuthor": "altersaddle", + "royale": { + "shrinkEveryNTurns": 0 + }, + "squad": { + "allowBodyCollisions": false, + "sharedElimination": false, + "sharedHealth": false, + "sharedLength": false + } + } + }, + "timeout": 500, + "source": "" + }, + "turn": 0, + "board": { + "height": 11, + "width": 11, + "snakes": [ + { + "id": "one", + "name": "ONE", + "latency": "0", + "health": 0, + "body": [ + { + "x": 3, + "y": 3 + } + ], + "head": { + "x": 3, + "y": 3 + }, + "length": 1, + "shout": "", + "squad": "", + "customizations": { + "color": "#123456", + "head": "safe", + "tail": "curled" + } + }, + { + "id": "two", + "name": "TWO", + "latency": "0", + "health": 0, + "body": [ + { + "x": 4, + "y": 3 + } + ], + "head": { + "x": 4, + "y": 3 + }, + "length": 1, + "shout": "", + "squad": "", + "customizations": { + "color": "#654321", + "head": "silly", + "tail": "bolt" + } + } + ], + "food": [], + "hazards": [] + }, + "you": { + "id": "one", + "name": "ONE", + "latency": "0", + "health": 0, + "body": [ + { + "x": 3, + "y": 3 + } + ], + "head": { + "x": 3, + "y": 3 + }, + "length": 1, + "shout": "", + "squad": "", + "customizations": { + "color": "#123456", + "head": "safe", + "tail": "curled" + } + } +} \ No newline at end of file diff --git a/cli/commands/testdata/snake_request_body_squad.json b/cli/commands/testdata/snake_request_body_squad.json new file mode 100644 index 0000000..04c0bfa --- /dev/null +++ b/cli/commands/testdata/snake_request_body_squad.json @@ -0,0 +1,108 @@ +{ + "game": { + "id": "", + "ruleset": { + "name": "squad", + "version": "cli", + "settings": { + "foodSpawnChance": 11, + "minimumFood": 7, + "hazardDamagePerTurn": 19, + "hazardMap": "hz_spiral", + "hazardMapAuthor": "altersaddle", + "royale": { + "shrinkEveryNTurns": 0 + }, + "squad": { + "allowBodyCollisions": true, + "sharedElimination": false, + "sharedHealth": true, + "sharedLength": false + } + } + }, + "timeout": 500, + "source": "" + }, + "turn": 0, + "board": { + "height": 11, + "width": 11, + "snakes": [ + { + "id": "one", + "name": "ONE", + "latency": "0", + "health": 0, + "body": [ + { + "x": 3, + "y": 3 + } + ], + "head": { + "x": 3, + "y": 3 + }, + "length": 1, + "shout": "", + "squad": "", + "customizations": { + "color": "#123456", + "head": "safe", + "tail": "curled" + } + }, + { + "id": "two", + "name": "TWO", + "latency": "0", + "health": 0, + "body": [ + { + "x": 4, + "y": 3 + } + ], + "head": { + "x": 4, + "y": 3 + }, + "length": 1, + "shout": "", + "squad": "", + "customizations": { + "color": "#654321", + "head": "silly", + "tail": "bolt" + } + } + ], + "food": [], + "hazards": [] + }, + "you": { + "id": "one", + "name": "ONE", + "latency": "0", + "health": 0, + "body": [ + { + "x": 3, + "y": 3 + } + ], + "head": { + "x": 3, + "y": 3 + }, + "length": 1, + "shout": "", + "squad": "", + "customizations": { + "color": "#123456", + "head": "safe", + "tail": "curled" + } + } +} \ No newline at end of file diff --git a/cli/commands/testdata/snake_request_body_standard.json b/cli/commands/testdata/snake_request_body_standard.json new file mode 100644 index 0000000..f828c2c --- /dev/null +++ b/cli/commands/testdata/snake_request_body_standard.json @@ -0,0 +1,108 @@ +{ + "game": { + "id": "", + "ruleset": { + "name": "standard", + "version": "cli", + "settings": { + "foodSpawnChance": 11, + "minimumFood": 7, + "hazardDamagePerTurn": 19, + "hazardMap": "hz_spiral", + "hazardMapAuthor": "altersaddle", + "royale": { + "shrinkEveryNTurns": 0 + }, + "squad": { + "allowBodyCollisions": false, + "sharedElimination": false, + "sharedHealth": false, + "sharedLength": false + } + } + }, + "timeout": 500, + "source": "" + }, + "turn": 0, + "board": { + "height": 11, + "width": 11, + "snakes": [ + { + "id": "one", + "name": "ONE", + "latency": "0", + "health": 0, + "body": [ + { + "x": 3, + "y": 3 + } + ], + "head": { + "x": 3, + "y": 3 + }, + "length": 1, + "shout": "", + "squad": "", + "customizations": { + "color": "#123456", + "head": "safe", + "tail": "curled" + } + }, + { + "id": "two", + "name": "TWO", + "latency": "0", + "health": 0, + "body": [ + { + "x": 4, + "y": 3 + } + ], + "head": { + "x": 4, + "y": 3 + }, + "length": 1, + "shout": "", + "squad": "", + "customizations": { + "color": "#654321", + "head": "silly", + "tail": "bolt" + } + } + ], + "food": [], + "hazards": [] + }, + "you": { + "id": "one", + "name": "ONE", + "latency": "0", + "health": 0, + "body": [ + { + "x": 3, + "y": 3 + } + ], + "head": { + "x": 3, + "y": 3 + }, + "length": 1, + "shout": "", + "squad": "", + "customizations": { + "color": "#123456", + "head": "safe", + "tail": "curled" + } + } +} \ No newline at end of file diff --git a/cli/commands/testdata/snake_request_body_wrapped.json b/cli/commands/testdata/snake_request_body_wrapped.json new file mode 100644 index 0000000..74978bc --- /dev/null +++ b/cli/commands/testdata/snake_request_body_wrapped.json @@ -0,0 +1,108 @@ +{ + "game": { + "id": "", + "ruleset": { + "name": "wrapped", + "version": "cli", + "settings": { + "foodSpawnChance": 11, + "minimumFood": 7, + "hazardDamagePerTurn": 19, + "hazardMap": "hz_spiral", + "hazardMapAuthor": "altersaddle", + "royale": { + "shrinkEveryNTurns": 0 + }, + "squad": { + "allowBodyCollisions": false, + "sharedElimination": false, + "sharedHealth": false, + "sharedLength": false + } + } + }, + "timeout": 500, + "source": "" + }, + "turn": 0, + "board": { + "height": 11, + "width": 11, + "snakes": [ + { + "id": "one", + "name": "ONE", + "latency": "0", + "health": 0, + "body": [ + { + "x": 3, + "y": 3 + } + ], + "head": { + "x": 3, + "y": 3 + }, + "length": 1, + "shout": "", + "squad": "", + "customizations": { + "color": "#123456", + "head": "safe", + "tail": "curled" + } + }, + { + "id": "two", + "name": "TWO", + "latency": "0", + "health": 0, + "body": [ + { + "x": 4, + "y": 3 + } + ], + "head": { + "x": 4, + "y": 3 + }, + "length": 1, + "shout": "", + "squad": "", + "customizations": { + "color": "#654321", + "head": "silly", + "tail": "bolt" + } + } + ], + "food": [], + "hazards": [] + }, + "you": { + "id": "one", + "name": "ONE", + "latency": "0", + "health": 0, + "body": [ + { + "x": 3, + "y": 3 + } + ], + "head": { + "x": 3, + "y": 3 + }, + "length": 1, + "shout": "", + "squad": "", + "customizations": { + "color": "#123456", + "head": "safe", + "tail": "curled" + } + } +} \ No newline at end of file diff --git a/client/fixtures_test.go b/client/fixtures_test.go index 6322ff7..a6a84c7 100644 --- a/client/fixtures_test.go +++ b/client/fixtures_test.go @@ -1,5 +1,7 @@ package client +import "github.com/BattlesnakeOfficial/rules" + func exampleSnakeRequest() SnakeRequest { return SnakeRequest{ Game: Game{ @@ -72,18 +74,18 @@ func exampleSnakeRequest() SnakeRequest { } } -var exampleRulesetSettings = RulesetSettings{ +var exampleRulesetSettings = rules.Settings{ FoodSpawnChance: 10, MinimumFood: 20, HazardDamagePerTurn: 30, HazardMap: "hz_spiral", HazardMapAuthor: "altersaddle", - RoyaleSettings: RoyaleSettings{ + RoyaleSettings: rules.RoyaleSettings{ ShrinkEveryNTurns: 40, }, - SquadSettings: SquadSettings{ + SquadSettings: rules.SquadSettings{ AllowBodyCollisions: true, SharedElimination: true, SharedHealth: true, diff --git a/client/models.go b/client/models.go index 2c272a7..e7ba495 100644 --- a/client/models.go +++ b/client/models.go @@ -48,31 +48,19 @@ type Customizations struct { } type Ruleset struct { - Name string `json:"name"` - Version string `json:"version"` - Settings RulesetSettings `json:"settings"` + Name string `json:"name"` + Version string `json:"version"` + Settings rules.Settings `json:"settings"` } -type RulesetSettings struct { - FoodSpawnChance int32 `json:"foodSpawnChance"` - MinimumFood int32 `json:"minimumFood"` - HazardDamagePerTurn int32 `json:"hazardDamagePerTurn"` - HazardMap string `json:"hazardMap"` - HazardMapAuthor string `json:"hazardMapAuthor"` - RoyaleSettings RoyaleSettings `json:"royale"` - SquadSettings SquadSettings `json:"squad"` -} +// RulesetSettings is deprecated: use rules.Settings instead +type RulesetSettings rules.Settings -type RoyaleSettings struct { - ShrinkEveryNTurns int32 `json:"shrinkEveryNTurns"` -} +// RoyaleSettings is deprecated: use rules.RoyaleSettings instead +type RoyaleSettings rules.RoyaleSettings -type SquadSettings struct { - AllowBodyCollisions bool `json:"allowBodyCollisions"` - SharedElimination bool `json:"sharedElimination"` - SharedHealth bool `json:"sharedHealth"` - SharedLength bool `json:"sharedLength"` -} +// SquadSettings is deprecated: use rules.SquadSettings instead +type SquadSettings rules.SquadSettings // Coord represents a point on the board type Coord struct { diff --git a/client/models_test.go b/client/models_test.go index 5253867..a00e5a5 100644 --- a/client/models_test.go +++ b/client/models_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "testing" + "github.com/BattlesnakeOfficial/rules" "github.com/BattlesnakeOfficial/rules/test" "github.com/stretchr/testify/require" ) @@ -18,7 +19,7 @@ func TestBuildSnakeRequestJSON(t *testing.T) { func TestBuildSnakeRequestJSONEmptyRulesetSettings(t *testing.T) { snakeRequest := exampleSnakeRequest() - snakeRequest.Game.Ruleset.Settings = RulesetSettings{} + snakeRequest.Game.Ruleset.Settings = rules.Settings{} data, err := json.MarshalIndent(snakeRequest, "", " ") require.NoError(t, err) diff --git a/constrictor.go b/constrictor.go index 65220db..4598858 100644 --- a/constrictor.go +++ b/constrictor.go @@ -4,29 +4,32 @@ type ConstrictorRuleset struct { StandardRuleset } -func (r *ConstrictorRuleset) Name() string { return "constrictor" } +func (r *ConstrictorRuleset) Name() string { return GameTypeConstrictor } func (r *ConstrictorRuleset) ModifyInitialBoardState(initialBoardState *BoardState) (*BoardState, error) { initialBoardState, err := r.StandardRuleset.ModifyInitialBoardState(initialBoardState) if err != nil { return nil, err } - newBoardState := initialBoardState.Clone() - err = r.applyConstrictorRules(newBoardState) + + r.removeFood(initialBoardState) + + err = r.applyConstrictorRules(initialBoardState) if err != nil { return nil, err } - return newBoardState, nil + return initialBoardState, nil } func (r *ConstrictorRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) { - nextState, err := r.StandardRuleset.CreateNextBoardState(prevState, moves) if err != nil { return nil, err } + r.removeFood(nextState) + err = r.applyConstrictorRules(nextState) if err != nil { return nil, err @@ -35,10 +38,23 @@ func (r *ConstrictorRuleset) CreateNextBoardState(prevState *BoardState, moves [ return nextState, nil } -func (r *ConstrictorRuleset) applyConstrictorRules(b *BoardState) error { +func (r *ConstrictorRuleset) removeFood(b *BoardState) { + _, _ = r.callStageFunc(RemoveFoodConstrictor, b, []SnakeMove{}) +} + +func RemoveFoodConstrictor(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { // Remove all food from the board b.Food = []Point{} + 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++ { b.Snakes[i].Health = SnakeMaxHealth @@ -46,9 +62,9 @@ func (r *ConstrictorRuleset) applyConstrictorRules(b *BoardState) error { tail := b.Snakes[i].Body[len(b.Snakes[i].Body)-1] subTail := b.Snakes[i].Body[len(b.Snakes[i].Body)-2] if tail != subTail { - r.growSnake(&b.Snakes[i]) + growSnake(&b.Snakes[i]) } } - return nil + return false, nil } diff --git a/royale.go b/royale.go index ec8a645..e7ac868 100644 --- a/royale.go +++ b/royale.go @@ -13,7 +13,7 @@ type RoyaleRuleset struct { ShrinkEveryNTurns int32 } -func (r *RoyaleRuleset) Name() string { return "royale" } +func (r *RoyaleRuleset) Name() string { return GameTypeRoyale } func (r *RoyaleRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) { if r.StandardRuleset.HazardDamagePerTurn < 1 { @@ -26,7 +26,7 @@ func (r *RoyaleRuleset) CreateNextBoardState(prevState *BoardState, moves []Snak } // Royale's only job is now to populate the hazards for next turn - StandardRuleset takes care of applying hazard damage. - err = r.populateHazards(nextBoardState, prevState.Turn+1) + err = r.populateHazards(nextBoardState) if err != nil { return nil, err } @@ -34,20 +34,28 @@ func (r *RoyaleRuleset) CreateNextBoardState(prevState *BoardState, moves []Snak return nextBoardState, nil } -func (r *RoyaleRuleset) populateHazards(b *BoardState, turn int32) error { +func (r *RoyaleRuleset) populateHazards(b *BoardState) error { + _, err := r.callStageFunc(PopulateHazardsRoyale, b, []SnakeMove{}) + return err +} + +func PopulateHazardsRoyale(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { b.Hazards = []Point{} - if r.ShrinkEveryNTurns < 1 { - return errors.New("royale game can't shrink more frequently than every turn") + // Royale uses the current turn to generate hazards, not the previous turn that's in the board state + turn := b.Turn + 1 + + if settings.RoyaleSettings.ShrinkEveryNTurns < 1 { + return false, errors.New("royale game can't shrink more frequently than every turn") } - if turn < r.ShrinkEveryNTurns { - return nil + if turn < settings.RoyaleSettings.ShrinkEveryNTurns { + return false, nil } - randGenerator := rand.New(rand.NewSource(r.Seed)) + randGenerator := rand.New(rand.NewSource(settings.RoyaleSettings.seed)) - numShrinks := turn / r.ShrinkEveryNTurns + numShrinks := turn / settings.RoyaleSettings.ShrinkEveryNTurns minX, maxX := int32(0), b.Width-1 minY, maxY := int32(0), b.Height-1 for i := int32(0); i < numShrinks; i++ { @@ -71,5 +79,19 @@ func (r *RoyaleRuleset) populateHazards(b *BoardState, turn int32) error { } } - return nil + return false, nil +} + +func (r RoyaleRuleset) Settings() Settings { + s := r.StandardRuleset.Settings() + s.RoyaleSettings = RoyaleSettings{ + seed: r.Seed, + ShrinkEveryNTurns: r.ShrinkEveryNTurns, + } + 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 b074172..b1d60e4 100644 --- a/royale_test.go +++ b/royale_test.go @@ -90,7 +90,7 @@ func TestRoyaleHazards(t *testing.T) { for _, test := range tests { b := &BoardState{ - Turn: test.Turn, + Turn: test.Turn - 1, Width: test.Width, Height: test.Height, } @@ -102,7 +102,7 @@ func TestRoyaleHazards(t *testing.T) { ShrinkEveryNTurns: test.ShrinkEveryNTurns, } - err := r.populateHazards(b, test.Turn) + err := r.populateHazards(b) 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, turn) + err := r.populateHazards(nextState) require.NoError(t, err) nextState.Turn = turn return nextState diff --git a/ruleset.go b/ruleset.go index 63c3216..b5b4477 100644 --- a/ruleset.go +++ b/ruleset.go @@ -1,5 +1,9 @@ package rules +import ( + "strconv" +) + type RulesetError string func (err RulesetError) Error() string { return string(err) } @@ -24,6 +28,7 @@ const ( EliminatedByOutOfHealth = "out-of-health" EliminatedByHeadToHeadCollision = "head-collision" EliminatedByOutOfBounds = "wall-collision" + EliminatedBySquad = "squad-eliminated" // TODO - Error consts ErrorTooManySnakes = RulesetError("too many snakes for fixed start positions") @@ -31,8 +36,147 @@ const ( ErrorNoRoomForFood = RulesetError("not enough space to place food") ErrorNoMoveFound = RulesetError("move not provided for snake") ErrorZeroLengthSnake = RulesetError("snake is length zero") + + // Ruleset / game type names + GameTypeConstrictor = "constrictor" + GameTypeRoyale = "royale" + GameTypeSolo = "solo" + GameTypeSquad = "squad" + GameTypeStandard = "standard" + GameTypeWrapped = "wrapped" + + // Game creation parameter names + ParamGameType = "name" + ParamFoodSpawnChance = "foodSpawnChance" + ParamMinimumFood = "minimumFood" + ParamHazardDamagePerTurn = "damagePerTurn" + ParamHazardMap = "hazardMap" + ParamHazardMapAuthor = "hazardMapAuthor" + ParamShrinkEveryNTurns = "shrinkEveryNTurns" + ParamAllowBodyCollisions = "allowBodyCollisions" + ParamSharedElimination = "sharedElimination" + ParamSharedHealth = "sharedHealth" + ParamSharedLength = "sharedLength" ) +type rulesetBuilder struct { + params map[string]string // game customisation parameters + seed int64 // used for random events in games + squads map[string]string // Snake ID -> Squad Name +} + +// NewRulesetBuilder returns an instance of a builder for the Ruleset types. +func NewRulesetBuilder() *rulesetBuilder { + return &rulesetBuilder{ + params: map[string]string{}, + squads: map[string]string{}, + } +} + +// WithParams accepts a map of game parameters for customizing games. +// +// Parameters are copied. If called multiple times, parameters are merged such that: +// - existing keys in both maps get overwritten by the new ones +// - existing keys not present in the new map will be retained +// - non-existing keys only in the new map will be added +// +// Unrecognised parameters will be ignored and default values will be used. +// Invalid parameters (i.e. a non-numerical value where one is expected), will be ignored +// and default values will be used. +func (rb *rulesetBuilder) WithParams(params map[string]string) *rulesetBuilder { + for k, v := range params { + rb.params[k] = v + } + return rb +} + +// WithSeed sets the seed used for randomisation by certain game modes. +func (rb *rulesetBuilder) WithSeed(seed int64) *rulesetBuilder { + rb.seed = seed + return rb +} + +// AddSnakeToSquad adds the specified snake (by ID) to a squad with the given name. +// This configuration may be ignored by game modes if they do not support squads. +func (rb *rulesetBuilder) AddSnakeToSquad(snakeID, squadName string) *rulesetBuilder { + rb.squads[snakeID] = squadName + return rb +} + +// Ruleset constructs a customised ruleset using the parameters passed to the builder. +func (rb rulesetBuilder) Ruleset() Ruleset { + standardRuleset := &StandardRuleset{ + 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], + } + + name, ok := rb.params[ParamGameType] + if !ok { + return standardRuleset + } + + switch name { + case GameTypeConstrictor: + return &ConstrictorRuleset{ + StandardRuleset: *standardRuleset, + } + case GameTypeRoyale: + return &RoyaleRuleset{ + StandardRuleset: *standardRuleset, + Seed: rb.seed, + ShrinkEveryNTurns: paramsInt32(rb.params, ParamShrinkEveryNTurns, 0), + } + case GameTypeSolo: + return &SoloRuleset{ + StandardRuleset: *standardRuleset, + } + case GameTypeWrapped: + return &WrappedRuleset{ + StandardRuleset: *standardRuleset, + } + case GameTypeSquad: + squadMap := map[string]string{} + for id, squad := range rb.squads { + squadMap[id] = squad + } + return &SquadRuleset{ + StandardRuleset: *standardRuleset, + SquadMap: 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), + } + } + return standardRuleset +} + +// 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. +func paramsBool(params map[string]string, paramName string, defaultValue bool) bool { + if val, ok := params[paramName]; ok { + return val == "true" + } + return defaultValue +} + +// paramsInt32 returns the int32 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 a valid int, the default value will be returned. +func paramsInt32(params map[string]string, paramName string, defaultValue int32) int32 { + if val, ok := params[paramName]; ok { + i, err := strconv.Atoi(val) + if err == nil { + return int32(i) + } + } + return defaultValue +} + type Point struct { X int32 Y int32 @@ -57,4 +201,41 @@ type Ruleset interface { ModifyInitialBoardState(initialState *BoardState) (*BoardState, error) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) IsGameOver(state *BoardState) (bool, error) + // Settings provides the game settings that are relevant to the ruleset. + Settings() Settings } + +// Settings contains all settings relevant to a game. +// It is used by game logic to take a previous game state and produce a next game state. +type Settings struct { + FoodSpawnChance int32 `json:"foodSpawnChance"` + MinimumFood int32 `json:"minimumFood"` + HazardDamagePerTurn int32 `json:"hazardDamagePerTurn"` + HazardMap string `json:"hazardMap"` + HazardMapAuthor string `json:"hazardMapAuthor"` + RoyaleSettings RoyaleSettings `json:"royale"` + SquadSettings SquadSettings `json:"squad"` +} + +// RoyaleSettings contains settings that are specific to the "royale" game mode +type RoyaleSettings struct { + seed int64 + ShrinkEveryNTurns int32 `json:"shrinkEveryNTurns"` +} + +// SquadSettings contains settings that are specific to the "squad" game mode +type SquadSettings struct { + squadMap map[string]string + AllowBodyCollisions bool `json:"allowBodyCollisions"` + SharedElimination bool `json:"sharedElimination"` + SharedHealth bool `json:"sharedHealth"` + SharedLength bool `json:"sharedLength"` +} + +// StageFunc represents a single stage of an ordered pipeline and applies custom logic to the board state each turn. +// It is expected to modify the boardState directly. +// The return values are a boolean (to indicate whether the game has ended as a result of the stage) +// and an error if any errors occurred during the stage. +// +// Errors should be treated as meaning the stage failed and the board state is now invalid. +type StageFunc func(*BoardState, Settings, []SnakeMove) (bool, error) diff --git a/ruleset_internal_test.go b/ruleset_internal_test.go new file mode 100644 index 0000000..e4dfd2e --- /dev/null +++ b/ruleset_internal_test.go @@ -0,0 +1,85 @@ +package rules + +import ( + "testing" + + "github.com/stretchr/testify/require" + + // included to allow using -update-fixtures for every package without errors + + _ "github.com/BattlesnakeOfficial/rules/test" +) + +func TestParamInt32(t *testing.T) { + require.Equal(t, int32(5), paramsInt32(nil, "test", 5), "nil map") + require.Equal(t, int32(10), paramsInt32(map[string]string{}, "foo", 10), "empty map") + require.Equal(t, int32(10), paramsInt32(map[string]string{"hullo": "there"}, "hullo", 10), "invalid value") + require.Equal(t, int32(20), paramsInt32(map[string]string{"bonjour": "20"}, "bonjour", 20), "valid value") +} + +func TestParamBool(t *testing.T) { + // missing values default to specified value + require.Equal(t, true, paramsBool(nil, "test", true), "nil map true") + require.Equal(t, false, paramsBool(nil, "test", false), "nil map false") + + // missing values default to specified value + require.Equal(t, true, paramsBool(map[string]string{}, "foo", true), "empty map true") + require.Equal(t, false, paramsBool(map[string]string{}, "foo", false), "empty map false") + + // invalid values (exist but not booL) default to false + require.Equal(t, false, paramsBool(map[string]string{"hullo": "there"}, "hullo", true), "invalid value default true") + require.Equal(t, false, paramsBool(map[string]string{"hullo": "there"}, "hullo", false), "invalid value default false") + + // valid values ignore defaults + require.Equal(t, false, paramsBool(map[string]string{"bonjour": "false"}, "bonjour", false), "valid value false") + require.Equal(t, true, paramsBool(map[string]string{"bonjour": "true"}, "bonjour", false), "valid value true") +} + +func TestRulesetError(t *testing.T) { + err := (error)(RulesetError("test error string")) + require.Equal(t, "test error string", err.Error()) +} + +func TestRulesetBuilderInternals(t *testing.T) { + + // test Royale with seed + rsb := NewRulesetBuilder().WithSeed(3).WithParams(map[string]string{ParamGameType: GameTypeRoyale}) + require.Equal(t, int64(3), rsb.seed) + require.Equal(t, GameTypeRoyale, rsb.Ruleset().Name()) + require.Equal(t, int64(3), rsb.Ruleset().(*RoyaleRuleset).Seed) + + // test squad configuration + rsb = NewRulesetBuilder(). + WithParams(map[string]string{ + ParamGameType: GameTypeSquad, + }). + AddSnakeToSquad("snek1", "squad1"). + AddSnakeToSquad("snek2", "squad1"). + AddSnakeToSquad("snek3", "squad2"). + AddSnakeToSquad("snek4", "squad2") + + require.NotNil(t, rsb.Ruleset()) + require.Equal(t, GameTypeSquad, rsb.Ruleset().Name()) + require.Equal(t, 4, len(rsb.squads)) + require.Equal(t, "squad1", rsb.Ruleset().(*SquadRuleset).SquadMap["snek1"]) + require.Equal(t, "squad1", rsb.Ruleset().(*SquadRuleset).SquadMap["snek2"]) + require.Equal(t, "squad2", rsb.Ruleset().(*SquadRuleset).SquadMap["snek3"]) + require.Equal(t, "squad2", rsb.Ruleset().(*SquadRuleset).SquadMap["snek4"]) + + // test parameter merging + rsb = NewRulesetBuilder(). + WithParams(map[string]string{ + "someSetting": "some value", + "anotherSetting": "another value", + }). + WithParams(map[string]string{ + "anotherSetting": "overridden value", + "aNewSetting": "a new value", + }) + + require.Equal(t, map[string]string{ + "someSetting": "some value", + "anotherSetting": "overridden value", + "aNewSetting": "a new value", + }, rsb.params, "multiple calls to WithParams should merge parameters") +} diff --git a/ruleset_test.go b/ruleset_test.go index f5192b8..b90642b 100644 --- a/ruleset_test.go +++ b/ruleset_test.go @@ -1,15 +1,193 @@ -package rules +package rules_test import ( "testing" + "github.com/BattlesnakeOfficial/rules" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - // included to allow using -update-fixtures for every package without errors - _ "github.com/BattlesnakeOfficial/rules/test" ) -func TestRulesetError(t *testing.T) { - err := (error)(RulesetError("test error string")) - require.Equal(t, "test error string", err.Error()) +func TestStandardRulesetSettings(t *testing.T) { + ruleset := rules.StandardRuleset{ + MinimumFood: 5, + FoodSpawnChance: 10, + HazardDamagePerTurn: 10, + HazardMap: "hz_spiral", + HazardMapAuthor: "altersaddle", + } + assert.Equal(t, ruleset.MinimumFood, ruleset.Settings().MinimumFood) + assert.Equal(t, ruleset.FoodSpawnChance, ruleset.Settings().FoodSpawnChance) + assert.Equal(t, ruleset.HazardDamagePerTurn, ruleset.Settings().HazardDamagePerTurn) + assert.Equal(t, ruleset.HazardMap, ruleset.Settings().HazardMap) + assert.Equal(t, ruleset.HazardMapAuthor, ruleset.Settings().HazardMapAuthor) +} + +func TestWrappedRulesetSettings(t *testing.T) { + ruleset := rules.WrappedRuleset{ + StandardRuleset: rules.StandardRuleset{ + MinimumFood: 5, + FoodSpawnChance: 10, + HazardDamagePerTurn: 10, + HazardMap: "hz_spiral", + HazardMapAuthor: "altersaddle", + }, + } + assert.Equal(t, ruleset.MinimumFood, ruleset.Settings().MinimumFood) + assert.Equal(t, ruleset.FoodSpawnChance, ruleset.Settings().FoodSpawnChance) + assert.Equal(t, ruleset.HazardDamagePerTurn, ruleset.Settings().HazardDamagePerTurn) + assert.Equal(t, ruleset.HazardMap, ruleset.Settings().HazardMap) + assert.Equal(t, ruleset.HazardMapAuthor, ruleset.Settings().HazardMapAuthor) +} + +func TestSoloRulesetSettings(t *testing.T) { + ruleset := rules.SoloRuleset{ + StandardRuleset: rules.StandardRuleset{ + MinimumFood: 5, + FoodSpawnChance: 10, + HazardDamagePerTurn: 10, + HazardMap: "hz_spiral", + HazardMapAuthor: "altersaddle", + }, + } + assert.Equal(t, ruleset.MinimumFood, ruleset.Settings().MinimumFood) + assert.Equal(t, ruleset.FoodSpawnChance, ruleset.Settings().FoodSpawnChance) + assert.Equal(t, ruleset.HazardDamagePerTurn, ruleset.Settings().HazardDamagePerTurn) + assert.Equal(t, ruleset.HazardMap, ruleset.Settings().HazardMap) + assert.Equal(t, ruleset.HazardMapAuthor, ruleset.Settings().HazardMapAuthor) +} + +func TestRoyaleRulesetSettings(t *testing.T) { + ruleset := rules.RoyaleRuleset{ + Seed: 30, + ShrinkEveryNTurns: 12, + StandardRuleset: rules.StandardRuleset{ + MinimumFood: 5, + FoodSpawnChance: 10, + HazardDamagePerTurn: 10, + HazardMap: "hz_spiral", + HazardMapAuthor: "altersaddle", + }, + } + assert.Equal(t, ruleset.ShrinkEveryNTurns, ruleset.Settings().RoyaleSettings.ShrinkEveryNTurns) + assert.Equal(t, ruleset.MinimumFood, ruleset.Settings().MinimumFood) + assert.Equal(t, ruleset.FoodSpawnChance, ruleset.Settings().FoodSpawnChance) + assert.Equal(t, ruleset.HazardDamagePerTurn, ruleset.Settings().HazardDamagePerTurn) + assert.Equal(t, ruleset.HazardMap, ruleset.Settings().HazardMap) + assert.Equal(t, ruleset.HazardMapAuthor, ruleset.Settings().HazardMapAuthor) +} + +func TestConstrictorRulesetSettings(t *testing.T) { + ruleset := rules.ConstrictorRuleset{ + StandardRuleset: rules.StandardRuleset{ + MinimumFood: 5, + FoodSpawnChance: 10, + HazardDamagePerTurn: 10, + HazardMap: "hz_spiral", + HazardMapAuthor: "altersaddle", + }, + } + assert.Equal(t, ruleset.MinimumFood, ruleset.Settings().MinimumFood) + assert.Equal(t, ruleset.FoodSpawnChance, ruleset.Settings().FoodSpawnChance) + assert.Equal(t, ruleset.HazardDamagePerTurn, ruleset.Settings().HazardDamagePerTurn) + assert.Equal(t, ruleset.HazardMap, ruleset.Settings().HazardMap) + assert.Equal(t, ruleset.HazardMapAuthor, ruleset.Settings().HazardMapAuthor) +} + +func TestSquadRulesetSettings(t *testing.T) { + ruleset := rules.SquadRuleset{ + AllowBodyCollisions: true, + SharedElimination: false, + SharedHealth: true, + SharedLength: false, + StandardRuleset: rules.StandardRuleset{ + MinimumFood: 5, + FoodSpawnChance: 10, + HazardDamagePerTurn: 10, + HazardMap: "hz_spiral", + HazardMapAuthor: "altersaddle", + }, + } + assert.Equal(t, ruleset.AllowBodyCollisions, ruleset.Settings().SquadSettings.AllowBodyCollisions) + assert.Equal(t, ruleset.SharedElimination, ruleset.Settings().SquadSettings.SharedElimination) + assert.Equal(t, ruleset.SharedHealth, ruleset.Settings().SquadSettings.SharedHealth) + assert.Equal(t, ruleset.SharedLength, ruleset.Settings().SquadSettings.SharedLength) + + assert.Equal(t, ruleset.MinimumFood, ruleset.Settings().MinimumFood) + assert.Equal(t, ruleset.FoodSpawnChance, ruleset.Settings().FoodSpawnChance) + assert.Equal(t, ruleset.HazardDamagePerTurn, ruleset.Settings().HazardDamagePerTurn) + assert.Equal(t, ruleset.HazardMap, ruleset.Settings().HazardMap) + assert.Equal(t, ruleset.HazardMapAuthor, ruleset.Settings().HazardMapAuthor) +} + +func TestRulesetBuilder(t *testing.T) { + // Test that a fresh instance can produce a Ruleset + require.NotNil(t, rules.NewRulesetBuilder().Ruleset()) + require.Equal(t, rules.GameTypeStandard, rules.NewRulesetBuilder().Ruleset().Name(), "should default to standard game") + + // test nil safety / defaults + require.NotNil(t, rules.NewRulesetBuilder().Ruleset()) + + // make sure it works okay for lots of game types + expectedResults := []struct { + GameType string + Snakes map[string]string + }{ + {GameType: rules.GameTypeStandard}, + {GameType: rules.GameTypeWrapped}, + {GameType: rules.GameTypeRoyale}, + {GameType: rules.GameTypeSolo}, + {GameType: rules.GameTypeSquad, Snakes: map[string]string{ + "one": "s1", + "two": "s1", + "three": "s2", + "four": "s2", + "five": "s3", + "six": "s3", + "seven": "s4", + "eight": "s4", + }}, + {GameType: rules.GameTypeConstrictor}, + } + + for _, expected := range expectedResults { + t.Run(expected.GameType, func(t *testing.T) { + rsb := rules.NewRulesetBuilder() + + rsb.WithParams(map[string]string{ + // apply the standard rule params + rules.ParamGameType: expected.GameType, + rules.ParamFoodSpawnChance: "10", + rules.ParamMinimumFood: "5", + rules.ParamHazardDamagePerTurn: "12", + rules.ParamHazardMap: "test", + rules.ParamHazardMapAuthor: "tester", + }) + + // add any snake squads + for id, squad := range expected.Snakes { + rsb = rsb.AddSnakeToSquad(id, squad) + } + + require.NotNil(t, rsb.Ruleset()) + require.Equal(t, expected.GameType, rsb.Ruleset().Name()) + // All the standard settings should always be copied over + require.Equal(t, int32(10), rsb.Ruleset().Settings().FoodSpawnChance) + require.Equal(t, int32(12), rsb.Ruleset().Settings().HazardDamagePerTurn) + require.Equal(t, int32(5), rsb.Ruleset().Settings().MinimumFood) + require.Equal(t, "test", rsb.Ruleset().Settings().HazardMap) + require.Equal(t, "tester", rsb.Ruleset().Settings().HazardMapAuthor) + }) + } +} + +func TestStageFuncContract(t *testing.T) { + //nolint:gosimple + var stage rules.StageFunc + stage = func(bs *rules.BoardState, s rules.Settings, sm []rules.SnakeMove) (bool, error) { + return true, nil + } + ended, err := stage(nil, rules.NewRulesetBuilder().Ruleset().Settings(), nil) + require.NoError(t, err) + require.True(t, ended) } diff --git a/solo.go b/solo.go index 18ad8a6..907f642 100644 --- a/solo.go +++ b/solo.go @@ -4,9 +4,13 @@ type SoloRuleset struct { StandardRuleset } -func (r *SoloRuleset) Name() string { return "solo" } +func (r *SoloRuleset) Name() string { return GameTypeSolo } func (r *SoloRuleset) IsGameOver(b *BoardState) (bool, error) { + return r.callStageFunc(GameOverSolo, b, []SnakeMove{}) +} + +func GameOverSolo(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { for i := 0; i < len(b.Snakes); i++ { if b.Snakes[i].EliminatedCause == NotEliminated { return false, nil diff --git a/squad.go b/squad.go index 55591be..1434f6c 100644 --- a/squad.go +++ b/squad.go @@ -16,9 +16,7 @@ type SquadRuleset struct { SharedLength bool } -const EliminatedBySquad = "squad-eliminated" - -func (r *SquadRuleset) Name() string { return "squad" } +func (r *SquadRuleset) Name() string { return GameTypeSquad } func (r *SquadRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) { nextBoardState, err := r.StandardRuleset.CreateNextBoardState(prevState, moves) @@ -26,13 +24,11 @@ func (r *SquadRuleset) CreateNextBoardState(prevState *BoardState, moves []Snake return nil, err } - // TODO: LOG? err = r.resurrectSquadBodyCollisions(nextBoardState) if err != nil { return nil, err } - // TODO: LOG? err = r.shareSquadAttributes(nextBoardState) if err != nil { return nil, err @@ -41,38 +37,50 @@ func (r *SquadRuleset) CreateNextBoardState(prevState *BoardState, moves []Snake return nextBoardState, nil } -func (r *SquadRuleset) areSnakesOnSameSquad(snake *Snake, other *Snake) bool { - return r.areSnakeIDsOnSameSquad(snake.ID, other.ID) +func areSnakesOnSameSquad(squadMap map[string]string, snake *Snake, other *Snake) bool { + return areSnakeIDsOnSameSquad(squadMap, snake.ID, other.ID) } -func (r *SquadRuleset) areSnakeIDsOnSameSquad(snakeID string, otherID string) bool { - return r.SquadMap[snakeID] == r.SquadMap[otherID] +func areSnakeIDsOnSameSquad(squadMap map[string]string, snakeID string, otherID string) bool { + return squadMap[snakeID] == squadMap[otherID] } func (r *SquadRuleset) resurrectSquadBodyCollisions(b *BoardState) error { - if !r.AllowBodyCollisions { - return nil + _, err := r.callStageFunc(ResurrectSnakesSquad, b, []SnakeMove{}) + return err +} + +func ResurrectSnakesSquad(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { + if !settings.SquadSettings.AllowBodyCollisions { + return false, nil } for i := 0; i < len(b.Snakes); i++ { snake := &b.Snakes[i] if snake.EliminatedCause == EliminatedByCollision { if snake.EliminatedBy == "" { - return errors.New("snake eliminated by collision and eliminatedby is not set") + return false, errors.New("snake eliminated by collision and eliminatedby is not set") } - if snake.ID != snake.EliminatedBy && r.areSnakeIDsOnSameSquad(snake.ID, snake.EliminatedBy) { + if snake.ID != snake.EliminatedBy && areSnakeIDsOnSameSquad(settings.SquadSettings.squadMap, snake.ID, snake.EliminatedBy) { snake.EliminatedCause = NotEliminated snake.EliminatedBy = "" } } } - return nil + return false, nil } func (r *SquadRuleset) shareSquadAttributes(b *BoardState) error { - if !(r.SharedElimination || r.SharedLength || r.SharedHealth) { - return nil + _, err := r.callStageFunc(ShareAttributesSquad, b, []SnakeMove{}) + return err +} + +func ShareAttributesSquad(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { + squadSettings := settings.SquadSettings + + if !(squadSettings.SharedElimination || squadSettings.SharedLength || squadSettings.SharedHealth) { + return false, nil } for i := 0; i < len(b.Snakes); i++ { @@ -83,21 +91,21 @@ func (r *SquadRuleset) shareSquadAttributes(b *BoardState) error { for j := 0; j < len(b.Snakes); j++ { other := &b.Snakes[j] - if r.areSnakesOnSameSquad(snake, other) { - if r.SharedHealth { + if areSnakesOnSameSquad(squadSettings.squadMap, snake, other) { + if squadSettings.SharedHealth { if snake.Health < other.Health { snake.Health = other.Health } } - if r.SharedLength { + if squadSettings.SharedLength { if len(snake.Body) == 0 || len(other.Body) == 0 { - return errors.New("found snake of zero length") + return false, errors.New("found snake of zero length") } for len(snake.Body) < len(other.Body) { - r.growSnake(snake) + growSnake(snake) } } - if r.SharedElimination { + if squadSettings.SharedElimination { if snake.EliminatedCause == NotEliminated && other.EliminatedCause != NotEliminated { snake.EliminatedCause = EliminatedBySquad // We intentionally do not set snake.EliminatedBy because there might be multiple culprits. @@ -108,10 +116,14 @@ func (r *SquadRuleset) shareSquadAttributes(b *BoardState) error { } } - return nil + return false, nil } func (r *SquadRuleset) IsGameOver(b *BoardState) (bool, error) { + return r.callStageFunc(GameOverSquad, b, []SnakeMove{}) +} + +func GameOverSquad(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { snakesRemaining := []*Snake{} for i := 0; i < len(b.Snakes); i++ { if b.Snakes[i].EliminatedCause == NotEliminated { @@ -120,7 +132,7 @@ func (r *SquadRuleset) IsGameOver(b *BoardState) (bool, error) { } for i := 0; i < len(snakesRemaining); i++ { - if !r.areSnakesOnSameSquad(snakesRemaining[i], snakesRemaining[0]) { + if !areSnakesOnSameSquad(settings.SquadSettings.squadMap, snakesRemaining[i], snakesRemaining[0]) { // There are multiple squads remaining return false, nil } @@ -128,3 +140,20 @@ func (r *SquadRuleset) IsGameOver(b *BoardState) (bool, error) { // no snakes or single squad remaining return true, nil } + +func (r SquadRuleset) Settings() Settings { + s := r.StandardRuleset.Settings() + s.SquadSettings = SquadSettings{ + squadMap: r.SquadMap, + AllowBodyCollisions: r.AllowBodyCollisions, + SharedElimination: r.SharedElimination, + SharedHealth: r.SharedHealth, + SharedLength: r.SharedLength, + } + 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 0cc0fb2..f39c759 100644 --- a/squad_test.go +++ b/squad_test.go @@ -102,8 +102,8 @@ func TestSquadAllowBodyCollisions(t *testing.T) { func TestSquadAllowBodyCollisionsEliminatedByNotSet(t *testing.T) { boardState := &BoardState{ Snakes: []Snake{ - Snake{ID: "1", EliminatedCause: EliminatedByCollision}, - Snake{ID: "2"}, + {ID: "1", EliminatedCause: EliminatedByCollision}, + {ID: "2"}, }, } r := SquadRuleset{ @@ -280,8 +280,8 @@ func TestSquadSharedElimination(t *testing.T) { func TestSquadSharedAttributesErrorLengthZero(t *testing.T) { boardState := &BoardState{ Snakes: []Snake{ - Snake{ID: "1"}, - Snake{ID: "2"}, + {ID: "1"}, + {ID: "2"}, }, } r := SquadRuleset{ diff --git a/standard.go b/standard.go index 96e2573..d1e9538 100644 --- a/standard.go +++ b/standard.go @@ -9,9 +9,11 @@ type StandardRuleset struct { FoodSpawnChance int32 // [0, 100] MinimumFood int32 HazardDamagePerTurn int32 + HazardMap string // optional + HazardMapAuthor string // optional } -func (r *StandardRuleset) Name() string { return "standard" } +func (r *StandardRuleset) Name() string { return GameTypeStandard } func (r *StandardRuleset) ModifyInitialBoardState(initialState *BoardState) (*BoardState, error) { // No-op @@ -22,15 +24,11 @@ func (r *StandardRuleset) CreateNextBoardState(prevState *BoardState, moves []Sn // We specifically want to copy prevState, so as not to alter it directly. nextState := prevState.Clone() - // TODO: Gut check the BoardState? - - // TODO: LOG? err := r.moveSnakes(nextState, moves) if err != nil { return nil, err } - // TODO: LOG? err = r.reduceSnakeHealth(nextState) if err != nil { return nil, err @@ -41,7 +39,6 @@ func (r *StandardRuleset) CreateNextBoardState(prevState *BoardState, moves []Sn return nil, err } - // TODO: LOG? // 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. @@ -52,13 +49,11 @@ func (r *StandardRuleset) CreateNextBoardState(prevState *BoardState, moves []Sn return nil, err } - // TODO: LOG? err = r.maybeSpawnFood(nextState) if err != nil { return nil, err } - // TODO: LOG? err = r.maybeEliminateSnakes(nextState) if err != nil { return nil, err @@ -68,6 +63,16 @@ func (r *StandardRuleset) CreateNextBoardState(prevState *BoardState, moves []Sn } func (r *StandardRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error { + _, err := r.callStageFunc(MoveSnakesStandard, b, moves) + return err +} + +func MoveSnakesStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { + // If no moves are passed, pass on modifying the initial board state + if len(moves) == 0 { + return false, nil + } + // Sanity check that all non-eliminated snakes have moves and bodies. for i := 0; i < len(b.Snakes); i++ { snake := &b.Snakes[i] @@ -76,7 +81,7 @@ func (r *StandardRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error { } if len(snake.Body) == 0 { - return ErrorZeroLengthSnake + return false, ErrorZeroLengthSnake } moveFound := false for _, move := range moves { @@ -86,7 +91,7 @@ func (r *StandardRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error { } } if !moveFound { - return ErrorNoMoveFound + return false, ErrorNoMoveFound } } @@ -103,7 +108,7 @@ func (r *StandardRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error { case MoveUp, MoveDown, MoveRight, MoveLeft: break default: - appliedMove = r.getDefaultMove(snake.Body) + appliedMove = getDefaultMove(snake.Body) } newHead := Point{} @@ -128,10 +133,10 @@ func (r *StandardRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error { } } } - return nil + return false, nil } -func (r *StandardRuleset) getDefaultMove(snakeBody []Point) string { +func getDefaultMove(snakeBody []Point) string { if len(snakeBody) >= 2 { // Use neck to determine last move made head, neck := snakeBody[0], snakeBody[1] @@ -160,15 +165,25 @@ func (r *StandardRuleset) getDefaultMove(snakeBody []Point) string { } 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) { for i := 0; i < len(b.Snakes); i++ { if b.Snakes[i].EliminatedCause == NotEliminated { b.Snakes[i].Health = b.Snakes[i].Health - 1 } } - return nil + 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) { for i := 0; i < len(b.Snakes); i++ { snake := &b.Snakes[i] if snake.EliminatedCause != NotEliminated { @@ -189,21 +204,26 @@ func (r *StandardRuleset) maybeDamageHazards(b *BoardState) error { } // Snake is in a hazard, reduce health - snake.Health = snake.Health - r.HazardDamagePerTurn + snake.Health = snake.Health - settings.HazardDamagePerTurn if snake.Health < 0 { snake.Health = 0 } - if r.snakeIsOutOfHealth(snake) { + if snakeIsOutOfHealth(snake) { snake.EliminatedCause = EliminatedByOutOfHealth } } } } - return nil + 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) { // 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)) @@ -224,15 +244,15 @@ func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error { continue } if len(snake.Body) <= 0 { - return ErrorZeroLengthSnake + return false, ErrorZeroLengthSnake } - if r.snakeIsOutOfHealth(snake) { + if snakeIsOutOfHealth(snake) { snake.EliminatedCause = EliminatedByOutOfHealth continue } - if r.snakeIsOutOfBounds(snake, b.Width, b.Height) { + if snakeIsOutOfBounds(snake, b.Width, b.Height) { snake.EliminatedCause = EliminatedByOutOfBounds continue } @@ -252,11 +272,11 @@ func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error { continue } if len(snake.Body) <= 0 { - return ErrorZeroLengthSnake + return false, ErrorZeroLengthSnake } // Check for self-collisions first - if r.snakeHasBodyCollided(snake, snake) { + if snakeHasBodyCollided(snake, snake) { collisionEliminations = append(collisionEliminations, CollisionElimination{ ID: snake.ID, Cause: EliminatedBySelfCollision, @@ -272,7 +292,7 @@ func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error { if other.EliminatedCause != NotEliminated { continue } - if snake.ID != other.ID && r.snakeHasBodyCollided(snake, other) { + if snake.ID != other.ID && snakeHasBodyCollided(snake, other) { collisionEliminations = append(collisionEliminations, CollisionElimination{ ID: snake.ID, Cause: EliminatedByCollision, @@ -293,7 +313,7 @@ func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error { if other.EliminatedCause != NotEliminated { continue } - if snake.ID != other.ID && r.snakeHasLostHeadToHead(snake, other) { + if snake.ID != other.ID && snakeHasLostHeadToHead(snake, other) { collisionEliminations = append(collisionEliminations, CollisionElimination{ ID: snake.ID, Cause: EliminatedByHeadToHeadCollision, @@ -320,14 +340,14 @@ func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error { } } - return nil + return false, nil } -func (r *StandardRuleset) snakeIsOutOfHealth(s *Snake) bool { +func snakeIsOutOfHealth(s *Snake) bool { return s.Health <= 0 } -func (r *StandardRuleset) snakeIsOutOfBounds(s *Snake, boardWidth int32, boardHeight int32) bool { +func snakeIsOutOfBounds(s *Snake, boardWidth int32, boardHeight int32) bool { for _, point := range s.Body { if (point.X < 0) || (point.X >= boardWidth) { return true @@ -339,7 +359,7 @@ func (r *StandardRuleset) snakeIsOutOfBounds(s *Snake, boardWidth int32, boardHe return false } -func (r *StandardRuleset) snakeHasBodyCollided(s *Snake, other *Snake) bool { +func snakeHasBodyCollided(s *Snake, other *Snake) bool { head := s.Body[0] for i, body := range other.Body { if i == 0 { @@ -351,7 +371,7 @@ func (r *StandardRuleset) snakeHasBodyCollided(s *Snake, other *Snake) bool { return false } -func (r *StandardRuleset) snakeHasLostHeadToHead(s *Snake, other *Snake) bool { +func snakeHasLostHeadToHead(s *Snake, other *Snake) bool { if s.Body[0].X == other.Body[0].X && s.Body[0].Y == other.Body[0].Y { return len(s.Body) <= len(other.Body) } @@ -359,6 +379,11 @@ func (r *StandardRuleset) snakeHasLostHeadToHead(s *Snake, other *Snake) bool { } 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 { foodHasBeenEaten := false @@ -371,7 +396,7 @@ func (r *StandardRuleset) maybeFeedSnakes(b *BoardState) error { } if snake.Body[0].X == food.X && snake.Body[0].Y == food.Y { - r.feedSnake(snake) + feedSnake(snake) foodHasBeenEaten = true } } @@ -382,31 +407,41 @@ func (r *StandardRuleset) maybeFeedSnakes(b *BoardState) error { } b.Food = newFood - return nil + return false, nil } -func (r *StandardRuleset) feedSnake(snake *Snake) { - r.growSnake(snake) +func feedSnake(snake *Snake) { + growSnake(snake) snake.Health = SnakeMaxHealth } -func (r *StandardRuleset) growSnake(snake *Snake) { +func growSnake(snake *Snake) { if len(snake.Body) > 0 { snake.Body = append(snake.Body, snake.Body[len(snake.Body)-1]) } } 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) { numCurrentFood := int32(len(b.Food)) - if numCurrentFood < r.MinimumFood { - return PlaceFoodRandomly(b, r.MinimumFood-numCurrentFood) - } else if r.FoodSpawnChance > 0 && int32(rand.Intn(100)) < r.FoodSpawnChance { - return PlaceFoodRandomly(b, 1) + if numCurrentFood < settings.MinimumFood { + return false, PlaceFoodRandomly(b, settings.MinimumFood-numCurrentFood) } - return nil + if settings.FoodSpawnChance > 0 && int32(rand.Intn(100)) < settings.FoodSpawnChance { + return false, PlaceFoodRandomly(b, 1) + } + return false, nil } func (r *StandardRuleset) IsGameOver(b *BoardState) (bool, error) { + return r.callStageFunc(GameOverStandard, b, []SnakeMove{}) +} + +func GameOverStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { numSnakesRemaining := 0 for i := 0; i < len(b.Snakes); i++ { if b.Snakes[i].EliminatedCause == NotEliminated { @@ -415,3 +450,18 @@ func (r *StandardRuleset) IsGameOver(b *BoardState) (bool, error) { } return numSnakesRemaining <= 1, nil } + +func (r StandardRuleset) Settings() Settings { + return Settings{ + FoodSpawnChance: r.FoodSpawnChance, + MinimumFood: r.MinimumFood, + HazardDamagePerTurn: r.HazardDamagePerTurn, + HazardMap: r.HazardMap, + HazardMapAuthor: r.HazardMapAuthor, + } +} + +// Adaptor for integrating stages into StandardRuleset +func (r *StandardRuleset) callStageFunc(stage StageFunc, boardState *BoardState, moves []SnakeMove) (bool, error) { + return stage(boardState, r.Settings(), moves) +} diff --git a/standard_test.go b/standard_test.go index aec06dd..5b1fb56 100644 --- a/standard_test.go +++ b/standard_test.go @@ -64,7 +64,9 @@ var standardCaseErrNoMoveFound = gameTestCase{ Food: []Point{{0, 0}, {1, 0}}, Hazards: []Point{}, }, - []SnakeMove{}, + []SnakeMove{ + {ID: "one", Move: MoveUp}, + }, ErrorNoMoveFound, nil, } @@ -767,9 +769,8 @@ func TestGetDefaultMove(t *testing.T) { }, } - r := StandardRuleset{} for _, test := range tests { - actualMove := r.getDefaultMove(test.SnakeBody) + actualMove := getDefaultMove(test.SnakeBody) require.Equal(t, test.ExpectedMove, actualMove) } } @@ -835,10 +836,9 @@ func TestSnakeIsOutOfHealth(t *testing.T) { {Health: math.MaxInt32, Expected: false}, } - r := StandardRuleset{} for _, test := range tests { s := &Snake{Health: test.Health} - require.Equal(t, test.Expected, r.snakeIsOutOfHealth(s), "Health: %+v", test.Health) + require.Equal(t, test.Expected, snakeIsOutOfHealth(s), "Health: %+v", test.Health) } } @@ -877,14 +877,13 @@ func TestSnakeIsOutOfBounds(t *testing.T) { {Point{X: math.MaxInt32, Y: math.MaxInt32}, true}, } - r := StandardRuleset{} for _, test := range tests { // Test with point as head s := Snake{Body: []Point{test.Point}} - require.Equal(t, test.Expected, r.snakeIsOutOfBounds(&s, boardWidth, boardHeight), "Head%+v", test.Point) + require.Equal(t, test.Expected, snakeIsOutOfBounds(&s, boardWidth, boardHeight), "Head%+v", test.Point) // Test with point as body s = Snake{Body: []Point{{0, 0}, {0, 0}, test.Point}} - require.Equal(t, test.Expected, r.snakeIsOutOfBounds(&s, boardWidth, boardHeight), "Body%+v", test.Point) + require.Equal(t, test.Expected, snakeIsOutOfBounds(&s, boardWidth, boardHeight), "Body%+v", test.Point) } } @@ -915,10 +914,9 @@ func TestSnakeHasBodyCollidedSelf(t *testing.T) { {[]Point{{3, 3}, {3, 4}, {3, 3}, {4, 4}, {4, 5}}, true}, } - r := StandardRuleset{} for _, test := range tests { s := Snake{Body: test.Body} - require.Equal(t, test.Expected, r.snakeHasBodyCollided(&s, &s), "Body%q", s.Body) + require.Equal(t, test.Expected, snakeHasBodyCollided(&s, &s), "Body%q", s.Body) } } @@ -966,11 +964,10 @@ func TestSnakeHasBodyCollidedOther(t *testing.T) { }, } - r := StandardRuleset{} for _, test := range tests { s := &Snake{Body: test.SnakeBody} o := &Snake{Body: test.OtherBody} - require.Equal(t, test.Expected, r.snakeHasBodyCollided(s, o), "Snake%q Other%q", s.Body, o.Body) + require.Equal(t, test.Expected, snakeHasBodyCollided(s, o), "Snake%q Other%q", s.Body, o.Body) } } @@ -1031,12 +1028,11 @@ func TestSnakeHasLostHeadToHead(t *testing.T) { }, } - r := StandardRuleset{} for _, test := range tests { s := Snake{Body: test.SnakeBody} o := Snake{Body: test.OtherBody} - require.Equal(t, test.Expected, r.snakeHasLostHeadToHead(&s, &o), "Snake%q Other%q", s.Body, o.Body) - require.Equal(t, test.ExpectedOpposite, r.snakeHasLostHeadToHead(&o, &s), "Snake%q Other%q", s.Body, o.Body) + require.Equal(t, test.Expected, snakeHasLostHeadToHead(&s, &o), "Snake%q Other%q", s.Body, o.Body) + require.Equal(t, test.ExpectedOpposite, snakeHasLostHeadToHead(&o, &s), "Snake%q Other%q", s.Body, o.Body) } } diff --git a/wrapped.go b/wrapped.go index 222a5b8..77a2e23 100644 --- a/wrapped.go +++ b/wrapped.go @@ -4,17 +4,7 @@ type WrappedRuleset struct { StandardRuleset } -func (r *WrappedRuleset) Name() string { return "wrapped" } - -func replace(value, min, max int32) int32 { - if value < min { - return max - } - if value > max { - return min - } - return value -} +func (r *WrappedRuleset) Name() string { return GameTypeWrapped } func (r *WrappedRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) { nextState := prevState.Clone() @@ -53,9 +43,14 @@ func (r *WrappedRuleset) CreateNextBoardState(prevState *BoardState, moves []Sna } func (r *WrappedRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error { - err := r.StandardRuleset.moveSnakes(b, moves) + _, err := r.callStageFunc(MoveSnakesWrapped, b, moves) + return err +} + +func MoveSnakesWrapped(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { + _, err := MoveSnakesStandard(b, settings, moves) if err != nil { - return err + return false, err } for i := 0; i < len(b.Snakes); i++ { @@ -63,9 +58,19 @@ func (r *WrappedRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error { if snake.EliminatedCause != NotEliminated { continue } - snake.Body[0].X = replace(snake.Body[0].X, 0, b.Width-1) - snake.Body[0].Y = replace(snake.Body[0].Y, 0, b.Height-1) + snake.Body[0].X = wrap(snake.Body[0].X, 0, b.Width-1) + snake.Body[0].Y = wrap(snake.Body[0].Y, 0, b.Height-1) } - return nil + return false, nil +} + +func wrap(value, min, max int32) int32 { + if value < min { + return max + } + if value > max { + return min + } + return value } diff --git a/wrapped_test.go b/wrapped_test.go index 59c0a4f..fd41867 100644 --- a/wrapped_test.go +++ b/wrapped_test.go @@ -3,6 +3,7 @@ package rules import ( "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -247,6 +248,20 @@ func TestEdgeCrossingEating(t *testing.T) { } } +func TestWrap(t *testing.T) { + // no wrap + assert.Equal(t, int32(0), wrap(0, 0, 0)) + assert.Equal(t, int32(0), wrap(0, 1, 0)) + assert.Equal(t, int32(0), wrap(0, 0, 1)) + assert.Equal(t, int32(1), wrap(1, 0, 1)) + + // wrap to min + assert.Equal(t, int32(0), wrap(2, 0, 1)) + + // wrap to max + assert.Equal(t, int32(1), wrap(-1, 0, 1)) +} + // Checks that snakes moving out of bounds get wrapped to the other side. var wrappedCaseMoveAndWrap = gameTestCase{ "Wrapped Case Move and Wrap",