From 2d45df6f7c50511a2941b1c17b0b13cc066fff2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trung=20Ki=C3=AAn=20Nguy=C3=AAn?= Date: Sun, 9 Nov 2025 23:03:58 +0100 Subject: [PATCH] implemented limited vision filters for standard map --- cli/commands/play.go | 14 ++- cli/commands/state_filter.go | 101 +++++++++++++++ maps/limit_info.go | 107 ++++++++++++++++ maps/limit_info_test.go | 236 +++++++++++++++++++++++++++++++++++ 4 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 cli/commands/state_filter.go create mode 100644 maps/limit_info.go create mode 100644 maps/limit_info_test.go diff --git a/cli/commands/play.go b/cli/commands/play.go index caabe2d..27c0ab7 100644 --- a/cli/commands/play.go +++ b/cli/commands/play.go @@ -65,6 +65,7 @@ type GameState struct { MinimumFood int HazardDamagePerTurn int ShrinkEveryNTurns int + ViewRadius int // Internal game state settings map[string]string @@ -111,6 +112,8 @@ func NewPlayCommand() *cobra.Command { playCmd.Flags().BoolVar(&gameState.ViewInBrowser, "browser", false, "View the game in the browser using the Battlesnake game board") playCmd.Flags().StringVar(&gameState.BoardURL, "board-url", "https://board.battlesnake.com", "Base URL for the game board when using --browser") + playCmd.Flags().IntVarP(&gameState.ViewRadius, "viewRadius", "i", -1, "View Radius of Snake") + playCmd.Flags().IntVar(&gameState.FoodSpawnChance, "foodSpawnChance", 15, "Percentage chance of spawning a new food every round") playCmd.Flags().IntVar(&gameState.MinimumFood, "minimumFood", 1, "Minimum food to keep on the board every turn") playCmd.Flags().IntVar(&gameState.HazardDamagePerTurn, "hazardDamagePerTurn", 14, "Health damage a snake will take when ending its turn in a hazard") @@ -137,6 +140,9 @@ func (gameState *GameState) Initialize() error { } // Load game map + if gameState.ViewRadius != -1 { + gameState.MapName = "limitInfo" + } gameMap, err := maps.GetMap(gameState.MapName) if err != nil { return fmt.Errorf("Failed to load game map %#v: %v", gameState.MapName, err) @@ -532,10 +538,16 @@ func (gameState *GameState) getRequestBodyForSnake(boardState *rules.BoardState, break } } + + filteredState := boardState + if gameState.ViewRadius >= 0 { + filteredState = FilterBoardStateForSnake(boardState, snakeState, gameState.ViewRadius) + } + request := client.SnakeRequest{ Game: gameState.createClientGame(), Turn: boardState.Turn, - Board: convertStateToBoard(boardState, gameState.snakeStates), + Board: convertStateToBoard(filteredState, gameState.snakeStates), You: convertRulesSnake(youSnake, snakeState), } return request diff --git a/cli/commands/state_filter.go b/cli/commands/state_filter.go new file mode 100644 index 0000000..029cab4 --- /dev/null +++ b/cli/commands/state_filter.go @@ -0,0 +1,101 @@ +package commands + +import ( + "fmt" + "strconv" + + "github.com/BattlesnakeOfficial/rules" +) + +func manhattan_d(a, b rules.Point) int { + dx := a.X - b.X + if dx < 0 { + dx = -dx + } + dy := a.Y - b.Y + if dy < 0 { + dy = -dy + } + return dx + dy +} + +func FilterBoardStateForSnake(boardState *rules.BoardState, self SnakeState, viewRadius int) *rules.BoardState { + filtered := &rules.BoardState{ + Turn: boardState.Turn, + Height: boardState.Height, + Width: boardState.Width, + GameState: boardState.GameState, + Food: []rules.Point{}, + Hazards: []rules.Point{}, + Snakes: []rules.Snake{}, + } + + // find the head of self snake + var head rules.Point + for _, s := range boardState.Snakes { + if s.ID == self.ID && len(s.Body) > 0 { + head = s.Body[0] + break + } + } + + // FILTER FOOD on view radius or spawn turn + for _, f := range boardState.Food { + visible := manhattan_d(f, head) <= viewRadius + key := fmt.Sprintf("food_spawn_%d_%d", f.X, f.Y) + spawnTurnStr, spawned := boardState.GameState[key] + + spawnVisible := false + if spawned { + spawnTurn, _ := strconv.Atoi(spawnTurnStr) + spawnVisible = spawnTurn == boardState.Turn + } + + if visible || spawnVisible { + filtered.Food = append(filtered.Food, f) + } + } + + // FILTER HAZARDS with view radius + for _, h := range boardState.Hazards { + if manhattan_d(h, head) <= viewRadius { + filtered.Hazards = append(filtered.Hazards, h) + } + } + + // FILTER SNAKE bodies + for _, s := range boardState.Snakes { + if s.ID == self.ID { + // self snake sees whole body + filtered.Snakes = append(filtered.Snakes, rules.Snake{ + ID: s.ID, + Body: append([]rules.Point(nil), s.Body...), + Health: s.Health, + }) + continue + } + + filteredBody := []rules.Point{} + for i, seg := range s.Body { + if manhattan_d(seg, head) <= viewRadius { + filteredBody = append(filteredBody, seg) + } else if i == 0 || (i > 0 && manhattan_d(s.Body[i-1], head) <= viewRadius) { + // mark end with -1 + filteredBody = append(filteredBody, rules.Point{X: -1, Y: -1}) + } + } + + if len(filteredBody) == 0 { + // mark end with -1 + filteredBody = append(filteredBody, rules.Point{X: -1, Y: -1}) + } + + filtered.Snakes = append(filtered.Snakes, rules.Snake{ + ID: s.ID, + Body: filteredBody, + Health: 0, + }) + } + + return filtered +} diff --git a/maps/limit_info.go b/maps/limit_info.go new file mode 100644 index 0000000..57b776e --- /dev/null +++ b/maps/limit_info.go @@ -0,0 +1,107 @@ +package maps + +import ( + "fmt" + "strconv" + "strings" + + "github.com/BattlesnakeOfficial/rules" +) + +type LimitInfoMap struct{} + +func init() { + globalRegistry.RegisterMap("limitInfo", LimitInfoMap{}) +} + +func (m LimitInfoMap) ID() string { + return "limitInfo" +} + +func (m LimitInfoMap) Meta() Metadata { + return Metadata{ + Name: "Standard with per snake view range", + Description: "Standard snake placement and food spawning but limited vision/information", + Author: "Kien Nguyen & Yannik Mahlau", + Version: 1, + MinPlayers: 1, + MaxPlayers: 16, + BoardSizes: OddSizes(rules.BoardSizeSmall, rules.BoardSizeXXLarge), + Tags: []string{}, + } +} + +func (m LimitInfoMap) SetupBoard(initialBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + rand := settings.GetRand(0) + + if len(initialBoardState.Snakes) > int(m.Meta().MaxPlayers) { + return rules.ErrorTooManySnakes + } + + snakeIDs := make([]string, 0, len(initialBoardState.Snakes)) + for _, snake := range initialBoardState.Snakes { + snakeIDs = append(snakeIDs, snake.ID) + } + + tempBoardState, err := rules.CreateDefaultBoardState(rand, initialBoardState.Width, initialBoardState.Height, snakeIDs) + if err != nil { + return err + } + + // Copy food from temp board state + for _, food := range tempBoardState.Food { + editor.AddFood(food) + } + + // Copy snakes from temp board state + for _, snake := range tempBoardState.Snakes { + editor.PlaceSnake(snake.ID, snake.Body, snake.Health) + } + + return nil +} + +func (m LimitInfoMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + return nil +} + +func (m LimitInfoMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + rand := settings.GetRand(lastBoardState.Turn) + + foodNeeded := checkFoodNeedingPlacement(rand, settings, lastBoardState) + if foodNeeded > 0 { + placeFoodRandomlySaveSpawn(rand, lastBoardState, editor, foodNeeded) + } + + for k, v := range editor.GameState() { + if strings.HasPrefix(k, "food_spawn_") { + spawnTurn, _ := strconv.Atoi(v) + if spawnTurn < lastBoardState.Turn { + delete(editor.GameState(), k) + } + } + } + + return nil +} + +func placeFoodRandomlySaveSpawn(rand rules.Rand, b *rules.BoardState, editor Editor, n int) { + unoccupiedPoints := rules.GetUnoccupiedPoints(b, false, false) + placeFoodRandomlyAtPositionsSaveSpawn(rand, b, editor, n, unoccupiedPoints) +} + +func placeFoodRandomlyAtPositionsSaveSpawn(rand rules.Rand, b *rules.BoardState, editor Editor, n int, positions []rules.Point) { + if len(positions) < n { + n = len(positions) + } + + rand.Shuffle(len(positions), func(i int, j int) { + positions[i], positions[j] = positions[j], positions[i] + }) + + for i := 0; i < n; i++ { + editor.AddFood(positions[i]) + key := fmt.Sprintf("food_spawn_%d_%d", positions[i].X, positions[i].Y) + editor.GameState()[key] = fmt.Sprintf("%d", b.Turn+1) + } +} diff --git a/maps/limit_info_test.go b/maps/limit_info_test.go new file mode 100644 index 0000000..3fc9765 --- /dev/null +++ b/maps/limit_info_test.go @@ -0,0 +1,236 @@ +package maps_test + +import ( + "testing" + + "github.com/BattlesnakeOfficial/rules" + "github.com/BattlesnakeOfficial/rules/maps" + "github.com/stretchr/testify/require" +) + +func TestLimitInfoMapInterface(t *testing.T) { + var _ maps.GameMap = maps.LimitInfoMap{} +} + +func TestLimitInfoMapSetupBoard(t *testing.T) { + m := maps.LimitInfoMap{} + settings := rules.Settings{} + + tests := []struct { + name string + initialBoardState *rules.BoardState + rand rules.Rand + + expected *rules.BoardState + err error + }{ + { + "empty 7x7", + rules.NewBoardState(7, 7), + rules.MinRand, + rules.NewBoardState(7, 7).WithFood([]rules.Point{{X: 3, Y: 3}}), + nil, + }, + { + "not enough room for snakes 7x7", + rules.NewBoardState(7, 7).WithSnakes(generateSnakes(17)), + rules.MinRand, + nil, + rules.ErrorTooManySnakes, + }, + { + "not enough room for snakes 5x5", + rules.NewBoardState(5, 5).WithSnakes(generateSnakes(14)), + rules.MinRand, + nil, + rules.ErrorTooManySnakes, + }, + { + "full 11x11 min", + rules.NewBoardState(11, 11).WithSnakes(generateSnakes(8)), + rules.MinRand, + rules.NewBoardState(11, 11). + WithFood([]rules.Point{ + {X: 0, Y: 2}, + {X: 0, Y: 8}, + {X: 8, Y: 0}, + {X: 8, Y: 10}, + {X: 0, Y: 4}, + {X: 4, Y: 0}, + {X: 4, Y: 10}, + {X: 10, Y: 4}, + {X: 5, Y: 5}, + }). + WithSnakes([]rules.Snake{ + {ID: "1", Body: []rules.Point{{X: 1, Y: 1}, {X: 1, Y: 1}, {X: 1, Y: 1}}, Health: 100}, + {ID: "2", Body: []rules.Point{{X: 1, Y: 9}, {X: 1, Y: 9}, {X: 1, Y: 9}}, Health: 100}, + {ID: "3", Body: []rules.Point{{X: 9, Y: 1}, {X: 9, Y: 1}, {X: 9, Y: 1}}, Health: 100}, + {ID: "4", Body: []rules.Point{{X: 9, Y: 9}, {X: 9, Y: 9}, {X: 9, Y: 9}}, Health: 100}, + {ID: "5", Body: []rules.Point{{X: 1, Y: 5}, {X: 1, Y: 5}, {X: 1, Y: 5}}, Health: 100}, + {ID: "6", Body: []rules.Point{{X: 5, Y: 1}, {X: 5, Y: 1}, {X: 5, Y: 1}}, Health: 100}, + {ID: "7", Body: []rules.Point{{X: 5, Y: 9}, {X: 5, Y: 9}, {X: 5, Y: 9}}, Health: 100}, + {ID: "8", Body: []rules.Point{{X: 9, Y: 5}, {X: 9, Y: 5}, {X: 9, Y: 5}}, Health: 100}, + }), + nil, + }, + { + "full 11x11 max", + rules.NewBoardState(11, 11).WithSnakes(generateSnakes(8)), + rules.MaxRand, + rules.NewBoardState(11, 11). + WithFood([]rules.Point{ + {X: 6, Y: 0}, + {X: 6, Y: 10}, + {X: 10, Y: 6}, + {X: 0, Y: 6}, + {X: 2, Y: 10}, + {X: 10, Y: 2}, + {X: 10, Y: 8}, + {X: 2, Y: 0}, + {X: 5, Y: 5}, + }). + WithSnakes([]rules.Snake{ + {ID: "1", Body: []rules.Point{{X: 5, Y: 1}, {X: 5, Y: 1}, {X: 5, Y: 1}}, Health: 100}, + {ID: "2", Body: []rules.Point{{X: 5, Y: 9}, {X: 5, Y: 9}, {X: 5, Y: 9}}, Health: 100}, + {ID: "3", Body: []rules.Point{{X: 9, Y: 5}, {X: 9, Y: 5}, {X: 9, Y: 5}}, Health: 100}, + {ID: "4", Body: []rules.Point{{X: 1, Y: 5}, {X: 1, Y: 5}, {X: 1, Y: 5}}, Health: 100}, + {ID: "5", Body: []rules.Point{{X: 1, Y: 9}, {X: 1, Y: 9}, {X: 1, Y: 9}}, Health: 100}, + {ID: "6", Body: []rules.Point{{X: 9, Y: 1}, {X: 9, Y: 1}, {X: 9, Y: 1}}, Health: 100}, + {ID: "7", Body: []rules.Point{{X: 9, Y: 9}, {X: 9, Y: 9}, {X: 9, Y: 9}}, Health: 100}, + {ID: "8", Body: []rules.Point{{X: 1, Y: 1}, {X: 1, Y: 1}, {X: 1, Y: 1}}, Health: 100}, + }), + nil, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + nextBoardState := rules.NewBoardState(test.initialBoardState.Width, test.initialBoardState.Height) + editor := maps.NewBoardStateEditor(nextBoardState) + settings := settings.WithRand(test.rand) + + err := m.SetupBoard(test.initialBoardState, settings, editor) + + if test.err != nil { + require.Equal(t, test.err, err) + } else { + require.Equalf(t, test.expected, nextBoardState, "%#v", nextBoardState.Food) + } + }) + } +} + +func TestLimitInfoMapUpdateBoard(t *testing.T) { + m := maps.LimitInfoMap{} + + tests := []struct { + name string + initialBoardState *rules.BoardState + settings rules.Settings + rand rules.Rand + + expected *rules.BoardState + }{ + { + "empty no food", + rules.NewBoardState(2, 2), + rules.NewSettingsWithParams(rules.ParamFoodSpawnChance, "0", rules.ParamMinimumFood, "0"), + rules.MinRand, + rules.NewBoardState(2, 2), + }, + { + "empty MinimumFood", + rules.NewBoardState(2, 2), + rules.NewSettingsWithParams(rules.ParamFoodSpawnChance, "0", rules.ParamMinimumFood, "2"), + rules.MinRand, + rules.NewBoardState(2, 2).WithFood([]rules.Point{{X: 0, Y: 0}, {X: 0, Y: 1}}).WithGameState(map[string]string{"food_spawn_0_0": "1", "food_spawn_0_1": "1"}), + }, + { + "not empty MinimumFood", + rules.NewBoardState(2, 2).WithFood([]rules.Point{{X: 0, Y: 1}}).WithGameState(map[string]string{"food_spawn_0_1": "1"}), + rules.NewSettingsWithParams(rules.ParamFoodSpawnChance, "0", rules.ParamMinimumFood, "2"), + rules.MinRand, + rules.NewBoardState(2, 2).WithFood([]rules.Point{{X: 0, Y: 1}, {X: 0, Y: 0}}).WithGameState(map[string]string{"food_spawn_0_0": "1", "food_spawn_0_1": "1"}), + }, + { + "empty FoodSpawnChance inactive", + rules.NewBoardState(2, 2), + rules.NewSettingsWithParams(rules.ParamFoodSpawnChance, "50", rules.ParamMinimumFood, "0"), + rules.MinRand, + rules.NewBoardState(2, 2), + }, + { + "empty FoodSpawnChance active", + rules.NewBoardState(2, 2), + rules.NewSettingsWithParams(rules.ParamFoodSpawnChance, "50", rules.ParamMinimumFood, "0"), + rules.MaxRand, + rules.NewBoardState(2, 2).WithFood([]rules.Point{{X: 0, Y: 1}}).WithGameState(map[string]string{"food_spawn_0_1": "1"}), + }, + { + "not empty FoodSpawnChance active", + rules.NewBoardState(2, 2).WithFood([]rules.Point{{X: 0, Y: 0}}).WithGameState(map[string]string{"food_spawn_0_0": "1"}), + rules.NewSettingsWithParams(rules.ParamFoodSpawnChance, "50", rules.ParamMinimumFood, "0"), + rules.MaxRand, + rules.NewBoardState(2, 2).WithFood([]rules.Point{{X: 0, Y: 0}, {X: 1, Y: 0}}).WithGameState(map[string]string{"food_spawn_0_0": "1", "food_spawn_1_0": "1"}), + }, + { + "not empty FoodSpawnChance no room", + rules.NewBoardState(2, 2).WithFood([]rules.Point{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 1, Y: 0}, {X: 1, Y: 1}}), + rules.NewSettingsWithParams(rules.ParamFoodSpawnChance, "50", rules.ParamMinimumFood, "0"), + rules.MaxRand, + rules.NewBoardState(2, 2).WithFood([]rules.Point{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 1, Y: 0}, {X: 1, Y: 1}}), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + nextBoardState := test.initialBoardState.Clone() + settings := test.settings.WithRand(test.rand) + editor := maps.NewBoardStateEditor(nextBoardState) + + err := m.PostUpdateBoard(test.initialBoardState.Clone(), settings, editor) + + require.NoError(t, err) + require.Equal(t, test.expected, nextBoardState) + }) + } +} + +func TestLimitInfoMapCleanGameState(t *testing.T) { + m := maps.LimitInfoMap{} + state := rules.NewBoardState(3, 3) + state.GameState = map[string]string{ + "food_spawn_1_1": "9", + "food_spawn_2_2": "10", + "food_spawn_3_3": "11", + "other": "abc", + } + state.Turn = 10 + editor := maps.NewBoardStateEditor(state) + r := rules.MinRand + settings := rules.Settings{}.WithRand(r) + + // call exported func to trigger cleaning + err := m.PostUpdateBoard(state, settings, editor) + require.NoError(t, err) + + cleaned_gamestate := editor.GameState() + + // first entry should be gone + if _, exists := cleaned_gamestate["food_spawn_1_1"]; exists { + t.Errorf("expected old food_spawn_1_1 to be deleted") + } + + // entry of current turn stays + if _, exists := cleaned_gamestate["food_spawn_2_2"]; !exists { + t.Errorf("expected food_spawn_2_2 to still exist") + } + + //future entries stay + if _, exists := cleaned_gamestate["food_spawn_3_3"]; !exists { + t.Errorf("expected food_spawn_3_3 to still exist") + } + + // other entry stays + if _, exists := cleaned_gamestate["other"]; !exists { + t.Errorf("expected other to still exist") + } +}