From f0dc0bcb381b73dd9d9d72bff59fa4286fb1de50 Mon Sep 17 00:00:00 2001 From: Torben Date: Wed, 1 Jun 2022 11:39:31 -0700 Subject: [PATCH] DEV-1313: Add additional map types (#76) * add helper to draw a ring of hazards * refactor tests to not be internal tests * add "hz_inner_wall" map * add "hz_rings" map * fix map registry * fix: edge case bugs in drawRing * remove println * add "hz_columns" * add "hz_rivers_bridges" map * WIP: implementing spiral hazards map * finish basic testing of 'hz_spiral' * include first turn * add "hz_hazards" map * remove incorrect author * add "hz_grow_box" map * add "hz_expand_box" map * add "hz_expand_scatter" map * remove debug * document the new "Range" method * - use rules.RulesetError instead of generic error - use a rules.Point for map rivers and bridgets map key * use rules.RulesetError instead of errors.New * provide more detail about boundar conditions * fix documentation (max can be == min) * add unit tests --- maps/empty_test.go | 13 +- maps/hazards.go | 671 ++++++++++++++++++++++++++++++++++ maps/hazards_test.go | 217 +++++++++++ maps/helpers.go | 92 ++++- maps/helpers_internal_test.go | 104 ++++++ maps/helpers_test.go | 23 +- maps/standard_test.go | 13 +- rand.go | 20 + 8 files changed, 1129 insertions(+), 24 deletions(-) create mode 100644 maps/hazards.go create mode 100644 maps/hazards_test.go create mode 100644 maps/helpers_internal_test.go diff --git a/maps/empty_test.go b/maps/empty_test.go index f9ebea8..b962051 100644 --- a/maps/empty_test.go +++ b/maps/empty_test.go @@ -1,18 +1,19 @@ -package maps +package maps_test import ( "testing" "github.com/BattlesnakeOfficial/rules" + "github.com/BattlesnakeOfficial/rules/maps" "github.com/stretchr/testify/require" ) func TestEmptyMapInterface(t *testing.T) { - var _ GameMap = EmptyMap{} + var _ maps.GameMap = maps.EmptyMap{} } func TestEmptyMapSetupBoard(t *testing.T) { - m := EmptyMap{} + m := maps.EmptyMap{} settings := rules.Settings{} tests := []struct { @@ -122,7 +123,7 @@ func TestEmptyMapSetupBoard(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { nextBoardState := rules.NewBoardState(test.initialBoardState.Width, test.initialBoardState.Height) - editor := NewBoardStateEditor(nextBoardState) + editor := maps.NewBoardStateEditor(nextBoardState) settings := settings.WithRand(test.rand) err := m.SetupBoard(test.initialBoardState, settings, editor) @@ -137,7 +138,7 @@ func TestEmptyMapSetupBoard(t *testing.T) { } func TestEmptyMapUpdateBoard(t *testing.T) { - m := EmptyMap{} + m := maps.EmptyMap{} initialBoardState := &rules.BoardState{ Width: 2, Height: 2, @@ -151,7 +152,7 @@ func TestEmptyMapUpdateBoard(t *testing.T) { }.WithRand(rules.MaxRand) nextBoardState := initialBoardState.Clone() - err := m.UpdateBoard(initialBoardState.Clone(), settings, NewBoardStateEditor(nextBoardState)) + err := m.UpdateBoard(initialBoardState.Clone(), settings, maps.NewBoardStateEditor(nextBoardState)) require.NoError(t, err) require.Equal(t, &rules.BoardState{ diff --git a/maps/hazards.go b/maps/hazards.go new file mode 100644 index 0000000..e745897 --- /dev/null +++ b/maps/hazards.go @@ -0,0 +1,671 @@ +package maps + +import ( + "math" + + "github.com/BattlesnakeOfficial/rules" +) + +type InnerBorderHazardsMap struct{} + +func init() { + globalRegistry.RegisterMap("hz_inner_wall", InnerBorderHazardsMap{}) + globalRegistry.RegisterMap("hz_rings", ConcentricRingsHazardsMap{}) + globalRegistry.RegisterMap("hz_columns", ColumnsHazardsMap{}) + globalRegistry.RegisterMap("hz_rivers_bridges", RiverAndBridgesHazardsMap{}) + globalRegistry.RegisterMap("hz_spiral", SpiralHazardsMap{}) + globalRegistry.RegisterMap("hz_scatter", ScatterFillMap{}) + globalRegistry.RegisterMap("hz_grow_box", DirectionalExpandingBoxMap{}) + globalRegistry.RegisterMap("hz_expand_box", ExpandingBoxMap{}) + globalRegistry.RegisterMap("hz_expand_scatter", ExpandingScatterMap{}) +} + +func (m InnerBorderHazardsMap) ID() string { + return "hz_inner_wall" +} + +func (m InnerBorderHazardsMap) Meta() Metadata { + return Metadata{ + Name: "hz_inner_wall", + Description: "Creates a static map on turn 0 that is a 1-square wall of hazard that is inset 2 squares from the edge of the board", + Author: "Battlesnake", + } +} + +func (m InnerBorderHazardsMap) SetupBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + if err := (StandardMap{}).SetupBoard(lastBoardState, settings, editor); err != nil { + return err + } + + // draw the initial, single ring of hazards + hazards, err := drawRing(lastBoardState.Width, lastBoardState.Height, 2, 2) + if err != nil { + return err + } + + for _, p := range hazards { + editor.AddHazard(p) + } + + return nil +} + +func (m InnerBorderHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + return StandardMap{}.UpdateBoard(lastBoardState, settings, editor) +} + +type ConcentricRingsHazardsMap struct{} + +func (m ConcentricRingsHazardsMap) ID() string { + return "hz_rings" +} + +func (m ConcentricRingsHazardsMap) Meta() Metadata { + return Metadata{ + Name: "hz_rings", + Description: "Creates a static map where there are rings of hazard sauce starting from the center with a 1 square space between the rings that has no sauce", + Author: "Battlesnake", + } +} + +func (m ConcentricRingsHazardsMap) SetupBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + if err := (StandardMap{}).SetupBoard(lastBoardState, settings, editor); err != nil { + return err + } + + // draw concentric rings of hazards + for offset := 2; offset < lastBoardState.Width/2; offset += 2 { + hazards, err := drawRing(lastBoardState.Width, lastBoardState.Height, offset, offset) + if err != nil { + return err + } + for _, p := range hazards { + editor.AddHazard(p) + } + } + + return nil +} + +func (m ConcentricRingsHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + return StandardMap{}.UpdateBoard(lastBoardState, settings, editor) +} + +type ColumnsHazardsMap struct{} + +func (m ColumnsHazardsMap) ID() string { + return "hz_columns" +} + +func (m ColumnsHazardsMap) Meta() Metadata { + return Metadata{ + Name: "hz_columns", + Description: "Creates a static map on turn 0 that fills in odd squares, i.e. (1,1), (1,3), (3,3) ... with hazard sauce", + Author: "Battlesnake", + } +} + +func (m ColumnsHazardsMap) SetupBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + if err := (StandardMap{}).SetupBoard(lastBoardState, settings, editor); err != nil { + return err + } + + for x := 0; x < lastBoardState.Width; x++ { + for y := 0; y < lastBoardState.Height; y++ { + if x%2 == 1 && y%2 == 1 { + editor.AddHazard(rules.Point{X: x, Y: y}) + } + } + } + + return nil +} + +func (m ColumnsHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + return StandardMap{}.UpdateBoard(lastBoardState, settings, editor) +} + +type SpiralHazardsMap struct{} + +func (m SpiralHazardsMap) ID() string { + return "hz_spiral" +} + +func (m SpiralHazardsMap) Meta() Metadata { + return Metadata{ + Name: "hz_spiral", + Description: `Generates a dynamic hazard map that grows in a spiral pattern clockwise from a random point on + the map. Each 2 turns a new hazard square is added to the map`, + Author: "altersaddle", + } +} + +func (m SpiralHazardsMap) SetupBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + return (StandardMap{}).SetupBoard(lastBoardState, settings, editor) +} + +func (m SpiralHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + err := StandardMap{}.UpdateBoard(lastBoardState, settings, editor) + if err != nil { + return err + } + + currentTurn := lastBoardState.Turn + 1 + spawnEveryNTurns := 3 + + // no-op if we're not on a turn that spawns hazards + if currentTurn < spawnEveryNTurns || currentTurn%spawnEveryNTurns != 0 { + return nil + } + + rand := settings.GetRand(0) + spawnArea := 0.3 // Center spiral in the middle 0.6 of the board + + // randomly choose a location between the start point and the edge of the board + spawnOffsetX := int(math.Floor(float64(lastBoardState.Width) * spawnArea)) + maxX := lastBoardState.Width - 1 - spawnOffsetX + startX := rand.Range(spawnOffsetX, maxX) + spawnOffsetY := int(math.Floor(float64(lastBoardState.Height) * spawnArea)) + maxY := lastBoardState.Height - 1 - spawnOffsetY + startY := rand.Range(spawnOffsetY, maxY) + + if currentTurn == spawnEveryNTurns { + editor.AddHazard(rules.Point{X: startX, Y: startY}) + return nil + } + + // determine number of rings in spiral + numRings := maxInt(startX, startY, lastBoardState.Width-startX, lastBoardState.Height-startY) + + turnCtr := spawnEveryNTurns + for ring := 0; ring < numRings; ring++ { + offset := ring + 1 + x := startX - ring + y := startY + offset + + numSquaresInRing := 8 * offset + for i := 0; i < numSquaresInRing; i++ { + turnCtr += spawnEveryNTurns + + if turnCtr > currentTurn { + break + } + + if turnCtr == currentTurn && isOnBoard(lastBoardState.Width, lastBoardState.Height, x, y) { + editor.AddHazard(rules.Point{X: x, Y: y}) + } + + // move the "cursor" + if y == startY+offset && x < startX+offset { + // top line, move right + x += 1 + } else if x == startX+offset && y > startY-offset { + + // right side, go down + y -= 1 + } else if y == startY-offset && x > startX-offset { + // bottom line, move left + x -= 1 + } else if x == startX-offset && y < startY+offset { + y += 1 + } + } + } + + return nil +} + +type ScatterFillMap struct{} + +func (m ScatterFillMap) ID() string { + return "hz_scatter" +} + +func (m ScatterFillMap) Meta() Metadata { + return Metadata{ + Name: "hz_scatter", + Description: `Fills the entire board with hazard squares that are set to appear on regular turn schedule. Each square is picked at random.`, + } +} + +func (m ScatterFillMap) SetupBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + return (StandardMap{}).SetupBoard(lastBoardState, settings, editor) +} + +func (m ScatterFillMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + err := StandardMap{}.UpdateBoard(lastBoardState, settings, editor) + if err != nil { + return err + } + + currentTurn := lastBoardState.Turn + 1 + spawnEveryNTurns := 2 + + // no-op if we're not on a turn that spawns hazards + if currentTurn < spawnEveryNTurns || currentTurn%spawnEveryNTurns != 0 { + return nil + } + + positions := make([]rules.Point, 0, lastBoardState.Width*lastBoardState.Height) + for x := 0; x < lastBoardState.Width; x++ { + for y := 0; y < lastBoardState.Height; y++ { + positions = append(positions, rules.Point{X: x, Y: y}) + } + } + + rand := settings.GetRand(0) + rand.Shuffle(len(positions), func(i, j int) { + positions[i], positions[j] = positions[j], positions[i] + }) + + editor.AddHazard(positions[(currentTurn-2)/2]) + return nil +} + +type DirectionalExpandingBoxMap struct{} + +func (m DirectionalExpandingBoxMap) ID() string { + return "hz_grow_box" +} + +func (m DirectionalExpandingBoxMap) Meta() Metadata { + return Metadata{ + Name: "hz_grow_box", + Description: `Creates an area of hazard that expands from a point with one random side growing on a turn schedule.`, + } +} + +func (m DirectionalExpandingBoxMap) SetupBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + return (StandardMap{}).SetupBoard(lastBoardState, settings, editor) +} + +func (m DirectionalExpandingBoxMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + err := StandardMap{}.UpdateBoard(lastBoardState, settings, editor) + if err != nil { + return err + } + + currentTurn := lastBoardState.Turn + 1 + startTurn := 1 + spawnEveryNTurns := 15 + + // no-op if we're not on a turn that spawns hazards + if (currentTurn-startTurn)%spawnEveryNTurns != 0 { + return nil + } + + // no-op if we have spawned the entire board already + if len(lastBoardState.Hazards) == lastBoardState.Width*lastBoardState.Height { + return nil + } + + rand := settings.GetRand(0) + startX := rand.Range(2, lastBoardState.Width-2) + startY := rand.Range(2, lastBoardState.Height-2) + + if currentTurn == 1 { + editor.AddHazard(rules.Point{X: startX, Y: startY}) + return nil + } + + topLeft := rules.Point{X: startX, Y: startY} + bottomRight := rules.Point{X: startX, Y: startY} + + // var growthDirection string + maxTurns := (currentTurn - startTurn) / spawnEveryNTurns + for i := 0; i < maxTurns; i++ { + directions := []string{} + if topLeft.X > 0 { + directions = append(directions, "left") + } + if topLeft.Y < lastBoardState.Height-1 { + directions = append(directions, "up") + } + if bottomRight.X < lastBoardState.Width-1 { + directions = append(directions, "right") + } + if bottomRight.Y > 0 { + directions = append(directions, "down") + } + if len(directions) == 0 { + return nil + } + choice := rand.Intn(len(directions)) + growthDirection := directions[choice] + + addHazards := i == maxTurns-1 + + if growthDirection == "left" { + x := topLeft.X - 1 + if addHazards { + for y := bottomRight.Y; y < topLeft.Y+1; y++ { + editor.AddHazard(rules.Point{X: x, Y: y}) + } + } + topLeft.X = x + } else if growthDirection == "right" { + x := bottomRight.X + 1 + if addHazards { + for y := bottomRight.Y; y < topLeft.Y+1; y++ { + editor.AddHazard(rules.Point{X: x, Y: y}) + } + } + bottomRight.X = x + } else if growthDirection == "up" { + y := topLeft.Y + 1 + if addHazards { + for x := topLeft.X; x < bottomRight.X+1; x++ { + editor.AddHazard(rules.Point{X: x, Y: y}) + } + } + topLeft.Y = y + } else if growthDirection == "down" { + y := bottomRight.Y - 1 + if addHazards { + for x := topLeft.X; x < bottomRight.X+1; x++ { + editor.AddHazard(rules.Point{X: x, Y: y}) + } + } + bottomRight.Y = y + } + } + return nil +} + +type ExpandingBoxMap struct{} + +func (m ExpandingBoxMap) ID() string { + return "hz_expand_box" +} + +func (m ExpandingBoxMap) Meta() Metadata { + return Metadata{ + Name: "hz_expand_box", + Description: `Generates an area of hazard that expands from a random point on the board outward in concentric rings on a periodic turn schedule.`, + } +} + +func (m ExpandingBoxMap) SetupBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + return (StandardMap{}).SetupBoard(lastBoardState, settings, editor) +} + +func (m ExpandingBoxMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + err := StandardMap{}.UpdateBoard(lastBoardState, settings, editor) + if err != nil { + return err + } + + currentTurn := lastBoardState.Turn + 1 + startTurn := 1 // first hazard appears on turn 1 + spawnEveryNTurns := 20 + + // no-op if we're not on a turn that spawns hazards + if (currentTurn-startTurn)%spawnEveryNTurns != 0 { + return nil + } + + // no-op if we have spawned the entire board already + if len(lastBoardState.Hazards) == lastBoardState.Width*lastBoardState.Height { + return nil + } + + rand := settings.GetRand(0) + + startX := rand.Range(2, lastBoardState.Width-2) + startY := rand.Range(2, lastBoardState.Width-2) + + if currentTurn == startTurn { + editor.AddHazard(rules.Point{X: startX, Y: startY}) + return nil + } + + // determine number of rings in spiral + numRings := maxInt(startX, startY, lastBoardState.Width-startX, lastBoardState.Height-startY) + + // no-op when iterations exceed the max rings + if currentTurn/spawnEveryNTurns > numRings { + return nil + } + + ring := currentTurn/spawnEveryNTurns - 1 + offset := ring + 1 + + for x := startX - offset; x < startX+offset+1; x++ { + for y := startY - offset; y < startY+offset+1; y++ { + if isOnBoard(lastBoardState.Width, lastBoardState.Height, x, y) { + if ((x == startX-offset || x == startX+offset) && y >= startY-offset && y <= startY+offset) || ((y == startY-offset || y == startY+offset) && x >= startX-offset && x <= startX+offset) { + editor.AddHazard(rules.Point{X: x, Y: y}) + } + } + } + } + + return nil +} + +type ExpandingScatterMap struct{} + +func (m ExpandingScatterMap) ID() string { + return "hz_expand_scatter" +} + +func (m ExpandingScatterMap) Meta() Metadata { + return Metadata{ + Name: "hz_expand_scatter", + Description: `Builds an expanding hazard area that grows from a central point in rings that are randomly filled in on a regular turn schedule.`, + } +} + +func (m ExpandingScatterMap) SetupBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + return (StandardMap{}).SetupBoard(lastBoardState, settings, editor) +} + +func (m ExpandingScatterMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + err := StandardMap{}.UpdateBoard(lastBoardState, settings, editor) + if err != nil { + return err + } + + currentTurn := lastBoardState.Turn + 1 + startTurn := 1 // first hazard appears on turn 1 + spawnEveryNTurns := 2 + + // no-op if we're not on a turn that spawns hazards + if (currentTurn-startTurn)%spawnEveryNTurns != 0 { + return nil + } + + // no-op if we have spawned the entire board already + if len(lastBoardState.Hazards) == lastBoardState.Width*lastBoardState.Height { + return nil + } + + rand := settings.GetRand(0) + + startX := rand.Range(1, lastBoardState.Width-1) + startY := rand.Range(1, lastBoardState.Width-1) + + if currentTurn == startTurn { + editor.AddHazard(rules.Point{X: startX, Y: startY}) + return nil + } + + // determine number of rings in spiral + numRings := maxInt(startX, startY, lastBoardState.Width-startX, lastBoardState.Height-startY) + + allPositions := []rules.Point{} + for ring := 0; ring < numRings; ring++ { + offset := ring + 1 + positions := []rules.Point{} + for x := startX - offset; x < startX+offset+1; x++ { + for y := startY - offset; y < startY+offset+1; y++ { + if isOnBoard(lastBoardState.Width, lastBoardState.Height, x, y) { + if ((x == startX-offset || x == startX+offset) && y >= startY-offset && y <= startY+offset) || ((y == startY-offset || y == startY+offset) && x >= startX-offset && x <= startX+offset) { + positions = append(positions, rules.Point{X: x, Y: y}) + } + } + } + } + // shuffle the positions so they are added scattered/randomly + rand.Shuffle(len(positions), func(i, j int) { + positions[i], positions[j] = positions[j], positions[i] + }) + allPositions = append(allPositions, positions...) + } + + chosenPos := currentTurn/spawnEveryNTurns - 1 + editor.AddHazard(allPositions[chosenPos]) + + return nil +} + +type RiverAndBridgesHazardsMap struct{} + +func (m RiverAndBridgesHazardsMap) ID() string { + return "hz_rivers_bridges" +} + +func (m RiverAndBridgesHazardsMap) Meta() Metadata { + return Metadata{ + Name: "hz_rivers_bridges", + Description: `Creates fixed maps that have a lake of hazard in the middle with rivers going in the cardinal directions. +Each river has one or two 1-square "bridges" over them`, + Author: "Battlesnake", + } +} + +func (m RiverAndBridgesHazardsMap) SetupBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + if err := (StandardMap{}).SetupBoard(lastBoardState, settings, editor); err != nil { + return err + } + + hazards, ok := riversAndBridgesMaps[rules.Point{X: lastBoardState.Width, Y: lastBoardState.Height}] + if !ok { + return rules.RulesetError("Board size is not supported by this map") + } + for _, p := range hazards { + editor.AddHazard(p) + } + + return nil +} + +func (m RiverAndBridgesHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + return StandardMap{}.UpdateBoard(lastBoardState, settings, editor) +} + +var riversAndBridgesMaps = map[rules.Point][]rules.Point{ + {X: 11, Y: 11}: { + {X: 5, Y: 10}, + {X: 5, Y: 9}, + {X: 5, Y: 7}, + {X: 5, Y: 6}, + {X: 5, Y: 5}, + {X: 5, Y: 4}, + {X: 5, Y: 3}, + {X: 5, Y: 0}, + {X: 5, Y: 1}, + {X: 6, Y: 5}, + {X: 7, Y: 5}, + {X: 9, Y: 5}, + {X: 10, Y: 5}, + {X: 4, Y: 5}, + {X: 3, Y: 5}, + {X: 1, Y: 5}, + {X: 0, Y: 5}, + }, + {X: 19, Y: 19}: { + {X: 9, Y: 0}, + {X: 9, Y: 1}, + {X: 9, Y: 2}, + {X: 9, Y: 5}, + {X: 9, Y: 6}, + {X: 9, Y: 7}, + {X: 9, Y: 9}, + {X: 9, Y: 8}, + {X: 9, Y: 10}, + {X: 9, Y: 12}, + {X: 9, Y: 11}, + {X: 9, Y: 13}, + {X: 9, Y: 14}, + {X: 9, Y: 16}, + {X: 9, Y: 17}, + {X: 9, Y: 18}, + {X: 0, Y: 9}, + {X: 2, Y: 9}, + {X: 1, Y: 9}, + {X: 3, Y: 9}, + {X: 5, Y: 9}, + {X: 6, Y: 9}, + {X: 7, Y: 9}, + {X: 8, Y: 9}, + {X: 10, Y: 9}, + {X: 13, Y: 9}, + {X: 12, Y: 9}, + {X: 11, Y: 9}, + {X: 15, Y: 9}, + {X: 16, Y: 9}, + {X: 17, Y: 9}, + {X: 18, Y: 9}, + {X: 9, Y: 4}, + {X: 8, Y: 10}, + {X: 8, Y: 8}, + {X: 10, Y: 8}, + {X: 10, Y: 10}, + }, + {X: 25, Y: 25}: { + {X: 12, Y: 24}, + {X: 12, Y: 21}, + {X: 12, Y: 20}, + {X: 12, Y: 19}, + {X: 12, Y: 18}, + {X: 12, Y: 15}, + {X: 12, Y: 14}, + {X: 12, Y: 13}, + {X: 12, Y: 12}, + {X: 12, Y: 11}, + {X: 12, Y: 10}, + {X: 12, Y: 9}, + {X: 12, Y: 5}, + {X: 12, Y: 4}, + {X: 12, Y: 3}, + {X: 12, Y: 0}, + {X: 0, Y: 12}, + {X: 3, Y: 12}, + {X: 4, Y: 12}, + {X: 5, Y: 12}, + {X: 6, Y: 12}, + {X: 9, Y: 12}, + {X: 10, Y: 12}, + {X: 11, Y: 12}, + {X: 13, Y: 12}, + {X: 14, Y: 12}, + {X: 15, Y: 12}, + {X: 18, Y: 12}, + {X: 20, Y: 12}, + {X: 19, Y: 12}, + {X: 21, Y: 12}, + {X: 24, Y: 12}, + {X: 11, Y: 14}, + {X: 10, Y: 13}, + {X: 11, Y: 13}, + {X: 10, Y: 11}, + {X: 11, Y: 11}, + {X: 11, Y: 10}, + {X: 13, Y: 10}, + {X: 14, Y: 11}, + {X: 13, Y: 11}, + {X: 13, Y: 13}, + {X: 14, Y: 13}, + {X: 13, Y: 14}, + {X: 12, Y: 6}, + {X: 12, Y: 2}, + {X: 2, Y: 12}, + {X: 22, Y: 12}, + {X: 12, Y: 22}, + {X: 16, Y: 12}, + {X: 12, Y: 8}, + {X: 8, Y: 12}, + {X: 12, Y: 16}, + }, +} diff --git a/maps/hazards_test.go b/maps/hazards_test.go new file mode 100644 index 0000000..9dfd62c --- /dev/null +++ b/maps/hazards_test.go @@ -0,0 +1,217 @@ +package maps_test + +import ( + "fmt" + "testing" + + "github.com/BattlesnakeOfficial/rules" + "github.com/BattlesnakeOfficial/rules/maps" + "github.com/stretchr/testify/require" +) + +func TestInnerBorderHazardsMap(t *testing.T) { + + tests := []struct { + boardSize int + expectedHazards int + }{ + {11, 32}, + {19, 64}, + {25, 88}, + } + + for _, tc := range tests { + + t.Run(fmt.Sprintf("%dx%d", tc.boardSize, tc.boardSize), func(t *testing.T) { + m := maps.InnerBorderHazardsMap{} + state := rules.NewBoardState(tc.boardSize, tc.boardSize) + settings := rules.Settings{} + + // ensure the ring of hazards is added to the board at setup + editor := maps.NewBoardStateEditor(state) + require.Empty(t, state.Hazards) + err := m.SetupBoard(state, settings, editor) + require.NoError(t, err) + require.NotEmpty(t, state.Hazards) + require.Len(t, state.Hazards, tc.expectedHazards) + }) + } +} + +func TestConcentricRingsHazardsMap(t *testing.T) { + + tests := []struct { + boardSize int + expectedHazards int + }{ + {11, 48}, + } + + for _, tc := range tests { + + t.Run(fmt.Sprintf("%dx%d", tc.boardSize, tc.boardSize), func(t *testing.T) { + m := maps.ConcentricRingsHazardsMap{} + state := rules.NewBoardState(tc.boardSize, tc.boardSize) + settings := rules.Settings{} + + // ensure the ring of hazards is added to the board at setup + editor := maps.NewBoardStateEditor(state) + require.Empty(t, state.Hazards) + err := m.SetupBoard(state, settings, editor) + require.NoError(t, err) + require.NotEmpty(t, state.Hazards) + require.Len(t, state.Hazards, tc.expectedHazards) + }) + } +} + +func TestColumnsHazardsMap(t *testing.T) { + m := maps.ColumnsHazardsMap{} + state := rules.NewBoardState(11, 11) + settings := rules.Settings{} + + editor := maps.NewBoardStateEditor(state) + require.Empty(t, state.Hazards) + err := m.SetupBoard(state, settings, editor) + require.NoError(t, err) + require.NotEmpty(t, state.Hazards) + require.Len(t, state.Hazards, 25) + + // a few spot checks + require.Contains(t, state.Hazards, rules.Point{X: 1, Y: 1}) + require.Contains(t, state.Hazards, rules.Point{X: 1, Y: 5}) + require.Contains(t, state.Hazards, rules.Point{X: 9, Y: 1}) + require.Contains(t, state.Hazards, rules.Point{X: 9, Y: 9}) + require.NotContains(t, state.Hazards, rules.Point{X: 0, Y: 1}) + require.NotContains(t, state.Hazards, rules.Point{X: 8, Y: 4}) + require.NotContains(t, state.Hazards, rules.Point{X: 2, Y: 2}) + require.NotContains(t, state.Hazards, rules.Point{X: 4, Y: 9}) + require.NotContains(t, state.Hazards, rules.Point{X: 1, Y: 0}) +} + +func TestRiversAndBridgetsHazardsMap(t *testing.T) { + // check error handling + m := maps.RiverAndBridgesHazardsMap{} + settings := rules.Settings{} + + // check error for unsupported board sizes + state := rules.NewBoardState(9, 9) + editor := maps.NewBoardStateEditor(state) + err := m.SetupBoard(state, settings, editor) + require.Error(t, err) + + // check all the supported sizes + for _, size := range []int{11, 19, 25} { + state = rules.NewBoardState(size, size) + editor = maps.NewBoardStateEditor(state) + require.Empty(t, state.Hazards) + err = m.SetupBoard(state, settings, editor) + require.NoError(t, err) + require.NotEmpty(t, state.Hazards) + } + +} + +func TestSpiralHazardsMap(t *testing.T) { + // check error handling + m := maps.SpiralHazardsMap{} + settings := rules.Settings{} + settings = settings.WithSeed(10) + + state := rules.NewBoardState(11, 11) + editor := maps.NewBoardStateEditor(state) + err := m.SetupBoard(state, settings, editor) + require.NoError(t, err) + + for i := 0; i < 1000; i++ { + state.Turn = i + err = m.UpdateBoard(state, settings, editor) + require.NoError(t, err) + } + require.NotEmpty(t, state.Hazards) + require.Equal(t, 11*11, len(state.Hazards), "hazards should eventually fille the entire map") +} + +func TestScatterFillMap(t *testing.T) { + // check error handling + m := maps.ScatterFillMap{} + settings := rules.Settings{} + settings = settings.WithSeed(10) + + state := rules.NewBoardState(11, 11) + editor := maps.NewBoardStateEditor(state) + err := m.SetupBoard(state, settings, editor) + require.NoError(t, err) + + totalTurns := 11 * 11 * 2 + for i := 0; i < totalTurns; i++ { + state.Turn = i + err = m.UpdateBoard(state, settings, editor) + require.NoError(t, err) + } + require.NotEmpty(t, state.Hazards) + require.Equal(t, 11*11, len(state.Hazards), "hazards should eventually fill the entire map") +} + +func TestDirectionalExpandingBoxMap(t *testing.T) { + // check error handling + m := maps.DirectionalExpandingBoxMap{} + settings := rules.Settings{} + settings = settings.WithSeed(2) + + state := rules.NewBoardState(11, 11) + editor := maps.NewBoardStateEditor(state) + err := m.SetupBoard(state, settings, editor) + require.NoError(t, err) + + totalTurns := 1000 + for i := 0; i < totalTurns; i++ { + state.Turn = i + err = m.UpdateBoard(state, settings, editor) + require.NoError(t, err) + } + require.NotEmpty(t, state.Hazards) + require.Equal(t, 11*11, len(state.Hazards), "hazards should eventually fill the entire map") +} + +func TestExpandingBoxMap(t *testing.T) { + // check error handling + m := maps.ExpandingBoxMap{} + settings := rules.Settings{} + settings = settings.WithSeed(2) + + state := rules.NewBoardState(11, 11) + editor := maps.NewBoardStateEditor(state) + err := m.SetupBoard(state, settings, editor) + require.NoError(t, err) + + totalTurns := 1000 + for i := 0; i < totalTurns; i++ { + state.Turn = i + err = m.UpdateBoard(state, settings, editor) + require.NoError(t, err) + } + require.NotEmpty(t, state.Hazards) + require.Equal(t, 11*11, len(state.Hazards), "hazards should eventually fill the entire map") +} + +func TestExpandingScatterMap(t *testing.T) { + // check error handling + m := maps.ExpandingScatterMap{} + settings := rules.Settings{} + settings = settings.WithSeed(2) + + state := rules.NewBoardState(11, 11) + editor := maps.NewBoardStateEditor(state) + err := m.SetupBoard(state, settings, editor) + require.NoError(t, err) + + totalTurns := 1000 + for i := 0; i < totalTurns; i++ { + state.Turn = i + err = m.UpdateBoard(state, settings, editor) + require.NoError(t, err) + } + require.NotEmpty(t, state.Hazards) + require.Equal(t, 11*11, len(state.Hazards), "hazards should eventually fill the entire map") +} diff --git a/maps/helpers.go b/maps/helpers.go index ec46be7..7906625 100644 --- a/maps/helpers.go +++ b/maps/helpers.go @@ -1,6 +1,8 @@ package maps -import "github.com/BattlesnakeOfficial/rules" +import ( + "github.com/BattlesnakeOfficial/rules" +) // SetupBoard is a shortcut for looking up a map by ID and initializing a new board state with it. func SetupBoard(mapID string, settings rules.Settings, width, height int, snakeIDs []string) (*rules.BoardState, error) { @@ -87,3 +89,91 @@ func (m StubMap) UpdateBoard(previousBoardState *rules.BoardState, settings rule } return nil } + +// drawRing draws a ring of hazard points offset from the outer edge of the board +func drawRing(bw, bh, hOffset, vOffset int) ([]rules.Point, error) { + if bw < 1 { + return nil, rules.RulesetError("board width too small") + } + + if bh < 1 { + return nil, rules.RulesetError("board height too small") + } + + if hOffset >= bw-1 { + return nil, rules.RulesetError("horizontal offset too large") + } + + if vOffset >= bh-1 { + return nil, rules.RulesetError("vertical offset too large") + } + + if hOffset < 1 { + return nil, rules.RulesetError("horizontal offset too small") + } + + if vOffset < 1 { + return nil, rules.RulesetError("vertical offset too small") + } + + // calculate the start/end point of the horizontal borders + xStart := hOffset - 1 + xEnd := bw - hOffset + + // calculate start/end point of the vertical borders + yStart := vOffset - 1 + yEnd := bh - vOffset + + // we can pre-determine how many points will be in the ring and allocate a slice of exactly that size + numPoints := 2 * (xEnd - xStart + 1) // horizontal hazard points + + // Add vertical walls, if there are any. + // Sometimes there are no vertical walls when the ring height is only 2. + // In that case, the vertical walls are handled by the horizontal walls + if yEnd >= yStart { + numPoints += 2*(yEnd-yStart+1) - 4 + } + + hazards := make([]rules.Point, 0, numPoints) + + // draw horizontal walls + for x := xStart; x <= xEnd; x++ { + hazards = append(hazards, + rules.Point{X: x, Y: yStart}, + rules.Point{X: x, Y: yEnd}, + ) + } + + // draw vertical walls, but don't include corners that the horizontal walls already included + for y := yStart + 1; y <= yEnd-1; y++ { + hazards = append(hazards, + rules.Point{X: xStart, Y: y}, + rules.Point{X: xEnd, Y: y}, + ) + } + + return hazards, nil +} + +func maxInt(n1 int, n ...int) int { + max := n1 + for _, v := range n { + if v > max { + max = v + } + } + + return max +} + +func isOnBoard(w, h, x, y int) bool { + if x >= w || x < 0 { + return false + } + + if y >= h || y < 0 { + return false + } + + return true +} diff --git a/maps/helpers_internal_test.go b/maps/helpers_internal_test.go new file mode 100644 index 0000000..047ab5b --- /dev/null +++ b/maps/helpers_internal_test.go @@ -0,0 +1,104 @@ +package maps + +import ( + "testing" + + "github.com/BattlesnakeOfficial/rules" + "github.com/stretchr/testify/require" +) + +func TestMaxInt(t *testing.T) { + // simple case + n := maxInt(0, 1, 2, 3) + require.Equal(t, 3, n) + + // use negative, out of order + n = maxInt(0, -1, 200, 3) + require.Equal(t, 200, n) + + // use only 1 value, and negative + n = maxInt(-99) + require.Equal(t, -99, n) + + // use duplicate values + n = maxInt(3, 3, 3) + require.Equal(t, 3, n) + + // use duplicate and other values + n = maxInt(-1, 3, 5, 3, 3, 2) + require.Equal(t, 5, n) +} + +func TestIsOnBoard(t *testing.T) { + // a few spot checks + require.True(t, isOnBoard(11, 11, 0, 0)) + require.False(t, isOnBoard(11, 11, -1, 0)) + require.True(t, isOnBoard(11, 11, 10, 10)) + require.False(t, isOnBoard(11, 11, 11, 11)) + require.True(t, isOnBoard(2, 2, 1, 1)) + + // exhaustive check on a small, non-square board + for x := 0; x < 4; x++ { + for y := 0; y < 9; y++ { + require.True(t, isOnBoard(4, 9, x, y)) + } + } +} + +func TestDrawRing(t *testing.T) { + _, err := drawRing(0, 11, 2, 2) + require.Equal(t, "board width too small", err.Error()) + + _, err = drawRing(11, 0, 2, 2) + require.Equal(t, "board height too small", err.Error()) + + _, err = drawRing(11, 11, 10, 2) + require.Equal(t, "horizontal offset too large", err.Error()) + + _, err = drawRing(11, 11, 2, 10) + require.Equal(t, "vertical offset too large", err.Error()) + + _, err = drawRing(11, 11, 0, 2) + require.Equal(t, "horizontal offset too small", err.Error()) + + _, err = drawRing(11, 11, 2, 0) + require.Equal(t, "vertical offset too small", err.Error()) + + _, err = drawRing(19, 1, 4, 4) + require.Equal(t, "vertical offset too large", err.Error()) + + _, err = drawRing(19, 1, 6, 6) + require.Equal(t, "vertical offset too large", err.Error()) + + _, err = drawRing(14, 7, 6, 6) + require.Equal(t, "vertical offset too large", err.Error()) + + _, err = drawRing(18, 10, 8, 8) + require.NoError(t, err) + + ring, err := drawRing(11, 11, 2, 2) + require.NoError(t, err) + + // ring should not be empty + require.NotEmpty(t, ring) + + // should have exactly 32 points in this ring + require.Len(t, ring, 32) + + // ensure no duplicates + seen := map[rules.Point]struct{}{} + for _, p := range ring { + // _, ok := seen[p] + require.NotContains(t, seen, p) + seen[p] = struct{}{} + } + + // spot check a few known points + require.Contains(t, seen, rules.Point{X: 1, Y: 1}, "bottom left") + require.Contains(t, seen, rules.Point{X: 1, Y: 9}, "top left") + require.Contains(t, seen, rules.Point{X: 9, Y: 1}, "bottom right") + require.Contains(t, seen, rules.Point{X: 9, Y: 9}, "top right") + require.Contains(t, seen, rules.Point{X: 1, Y: 5}) + require.Contains(t, seen, rules.Point{X: 6, Y: 1}) + require.Contains(t, seen, rules.Point{X: 8, Y: 9}) +} diff --git a/maps/helpers_test.go b/maps/helpers_test.go index 5c37df2..1d4fc0d 100644 --- a/maps/helpers_test.go +++ b/maps/helpers_test.go @@ -1,32 +1,33 @@ -package maps +package maps_test import ( "errors" "testing" "github.com/BattlesnakeOfficial/rules" + "github.com/BattlesnakeOfficial/rules/maps" "github.com/stretchr/testify/require" ) func TestSetupBoard_NotFound(t *testing.T) { - _, err := SetupBoard("does_not_exist", rules.Settings{}, 10, 10, []string{}) + _, err := maps.SetupBoard("does_not_exist", rules.Settings{}, 10, 10, []string{}) require.EqualError(t, err, rules.ErrorMapNotFound.Error()) } func TestSetupBoard_Error(t *testing.T) { - testMap := StubMap{ + testMap := maps.StubMap{ Id: t.Name(), Error: errors.New("bad map update"), } - TestMap(testMap.ID(), testMap, func() { - _, err := SetupBoard(testMap.ID(), rules.Settings{}, 10, 10, []string{}) + maps.TestMap(testMap.ID(), testMap, func() { + _, err := maps.SetupBoard(testMap.ID(), rules.Settings{}, 10, 10, []string{}) require.EqualError(t, err, "bad map update") }) } func TestSetupBoard(t *testing.T) { - testMap := StubMap{ + testMap := maps.StubMap{ Id: t.Name(), SnakePositions: map[string]rules.Point{ "1": {X: 3, Y: 4}, @@ -42,8 +43,8 @@ func TestSetupBoard(t *testing.T) { }, } - TestMap(testMap.ID(), testMap, func() { - boardState, err := SetupBoard(testMap.ID(), rules.Settings{}, 10, 10, []string{"1", "2"}) + maps.TestMap(testMap.ID(), testMap, func() { + boardState, err := maps.SetupBoard(testMap.ID(), rules.Settings{}, 10, 10, []string{"1", "2"}) require.NoError(t, err) @@ -65,7 +66,7 @@ func TestSetupBoard(t *testing.T) { } func TestUpdateBoard(t *testing.T) { - testMap := StubMap{ + testMap := maps.StubMap{ Id: t.Name(), SnakePositions: map[string]rules.Point{ "1": {X: 3, Y: 4}, @@ -98,8 +99,8 @@ func TestUpdateBoard(t *testing.T) { }, } - TestMap(testMap.ID(), testMap, func() { - boardState, err := UpdateBoard(testMap.ID(), previousBoardState, rules.Settings{}) + maps.TestMap(testMap.ID(), testMap, func() { + boardState, err := maps.UpdateBoard(testMap.ID(), previousBoardState, rules.Settings{}) require.NoError(t, err) diff --git a/maps/standard_test.go b/maps/standard_test.go index 51abb1b..87dff36 100644 --- a/maps/standard_test.go +++ b/maps/standard_test.go @@ -1,19 +1,20 @@ -package maps +package maps_test import ( "fmt" "testing" "github.com/BattlesnakeOfficial/rules" + "github.com/BattlesnakeOfficial/rules/maps" "github.com/stretchr/testify/require" ) func TestStandardMapInterface(t *testing.T) { - var _ GameMap = StandardMap{} + var _ maps.GameMap = maps.StandardMap{} } func TestStandardMapSetupBoard(t *testing.T) { - m := StandardMap{} + m := maps.StandardMap{} settings := rules.Settings{} tests := []struct { @@ -143,7 +144,7 @@ func TestStandardMapSetupBoard(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { nextBoardState := rules.NewBoardState(test.initialBoardState.Width, test.initialBoardState.Height) - editor := NewBoardStateEditor(nextBoardState) + editor := maps.NewBoardStateEditor(nextBoardState) settings := settings.WithRand(test.rand) err := m.SetupBoard(test.initialBoardState, settings, editor) @@ -158,7 +159,7 @@ func TestStandardMapSetupBoard(t *testing.T) { } func TestStandardMapUpdateBoard(t *testing.T) { - m := StandardMap{} + m := maps.StandardMap{} tests := []struct { name string @@ -303,7 +304,7 @@ func TestStandardMapUpdateBoard(t *testing.T) { t.Run(test.name, func(t *testing.T) { nextBoardState := test.initialBoardState.Clone() settings := test.settings.WithRand(test.rand) - editor := NewBoardStateEditor(nextBoardState) + editor := maps.NewBoardStateEditor(nextBoardState) err := m.UpdateBoard(test.initialBoardState.Clone(), settings, editor) diff --git a/rand.go b/rand.go index 4febf79..acbfcaf 100644 --- a/rand.go +++ b/rand.go @@ -4,6 +4,10 @@ import "math/rand" type Rand interface { Intn(n int) int + // Range produces a random integer in the range of [min,max] (inclusive) + // For example, Range(1,3) could produce the values 1, 2 or 3. + // Panics if max < min (like how Intn(n) panics for n <=0) + Range(min, max int) int Shuffle(n int, swap func(i, j int)) } @@ -12,6 +16,10 @@ var GlobalRand globalRand type globalRand struct{} +func (globalRand) Range(min, max int) int { + return rand.Intn(max-min+1) + min +} + func (globalRand) Intn(n int) int { return rand.Intn(n) } @@ -36,6 +44,10 @@ func (s seedRand) Intn(n int) int { return s.rand.Intn(n) } +func (s seedRand) Range(min, max int) int { + return s.rand.Intn(max-min+1) + min +} + func (s seedRand) Shuffle(n int, swap func(i, j int)) { s.rand.Shuffle(n, swap) } @@ -51,6 +63,10 @@ func (minRand) Intn(n int) int { return 0 } +func (minRand) Range(min, max int) int { + return min +} + func (minRand) Shuffle(n int, swap func(i, j int)) { // no shuffling } @@ -64,6 +80,10 @@ func (maxRand) Intn(n int) int { return n - 1 } +func (maxRand) Range(min, max int) int { + return max +} + func (maxRand) Shuffle(n int, swap func(i, j int)) { // rotate by one element so every element is moved if n < 2 {