diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..e69de29b diff --git a/a_star_search_test.py b/a_star_search_test.py new file mode 100644 index 00000000..3ef85e5c --- /dev/null +++ b/a_star_search_test.py @@ -0,0 +1,135 @@ +game = { + "game": { + "id": "totally-unique-game-id", + "ruleset": { + "name": "standard", + "version": "v1.1.15", + "settings": { + "foodSpawnChance": 15, + "minimumFood": 1, + "hazardDamagePerTurn": 14 + } + }, + "map": "standard", + "source": "league", + "timeout": 500 + }, + "turn": 14, + "board": { + "height": 11, + "width": 11, + "food": [ + {"x": 2, "y": 0}, + {"x": 1, "y": 9}, + {"x": 4, "y": 6} + ], + "hazards": [ + {"x": 3, "y": 2} + ], + "snakes": [ + { + "id": "snake-508e96ac-94ad-11ea-bb37", + "name": "My Snake", + "health": 54, + "body": [ + {"x": 5, "y": 4}, + {"x": 5, "y": 5}, + {"x": 5, "y": 6}, + {"x": 5, "y": 7}, + {"x": 5, "y": 8}, + {"x": 5, "y": 9}, + {"x": 5, "y": 10}, + # {"x": 5, "y": 7}, + # {"x": 5, "y": 8}, + # {"x": 4, "y": 9} + ], + "latency": "111", + "head": {"x": 0, "y": 0}, + "length": 3, + "shout": "why are we shouting??", + "customizations":{ + "color":"#FF0000", + "head":"pixel", + "tail":"pixel" + } + }, + # { + # "id": "snake-b67f4906-94ae-11ea-bb37", + # "name": "Another Snake", + # "health": 16, + # "body": [ + # {"x": 5, "y": 4}, + # {"x": 5, "y": 3}, + # {"x": 6, "y": 3}, + # {"x": 6, "y": 2} + # ], + # "latency": "222", + # "head": {"x": 5, "y": 4}, + # "length": 4, + # "shout": "I'm not really sure...", + # "customizations":{ + # "color":"#26CF04", + # "head":"silly", + # "tail":"curled" + # } + # } + ] + }, + "you": { + "id": "snake-508e96ac-94ad-11ea-bb37", + "name": "My Snake", + "health": 54, + "body": [ + {"x": 1, "y": 0}, + {"x": 1, "y": 1}, + {"x": 1, "y": 2}, + {"x": 1, "y": 3}, + {"x": 1, "y": 4}, + {"x": 2, "y": 4}, + {"x": 3, "y": 4}, + {"x": 4, "y": 4}, + {"x": 4, "y": 3}, + {"x": 4, "y": 2}, + {"x": 4, "y": 1}, + {"x": 5, "y": 1}, + {"x": 5, "y": 0}, + {"x": 6, "y": 0}, + ], + "latency": "111", + "head": {"x": 1, "y": 0}, + "length": 3, + "shout": "why are we shouting??", + "customizations": { + "color":"#FF0000", + "head":"pixel", + "tail":"pixel" + } + } +} + + +from helper_battlesnake import * + +my_head = {"x": 1, "y": 0} +safe_moves = obvious_moves(game, my_head) +for safe in safe_moves: + f = flood_fill(game, look_ahead(my_head, safe)) + print(safe, f) +print("=" * 50) + +food = game['board']['food'] +food_dist = [] +food_moves = [] +# Big idea: loop through all food and find the shortest path using A* search +for food_loc in food: + best_path, best_dist = a_star_search(game, my_head.copy(), food_loc) + print(f"Food: {food_loc}") + print(f"Shortest dist: {best_dist}") + if best_path is not None: + food_dist.append(best_dist) + lesgo = snake_compass(my_head, best_path[-2]) + food_moves.append(lesgo) + + f = flood_fill(game, look_ahead(my_head, lesgo)) + print(f"Direction {lesgo} at {f}") + print(best_dist * 11 * 11 / f ** 2.25) \ No newline at end of file diff --git a/battlesnake.exe b/battlesnake.exe new file mode 100644 index 00000000..5a4eb17c Binary files /dev/null and b/battlesnake.exe differ diff --git a/battlesnake_test.py b/battlesnake_test.py new file mode 100644 index 00000000..19769d1f --- /dev/null +++ b/battlesnake_test.py @@ -0,0 +1,49 @@ +from snake_engine import * + +# PROBLEM + +# Takes too long +game_state = {"game":{"id":"1940ebec-c2f4-472e-96db-ad5e659b4f7d","ruleset":{"name":"standard","version":"cli","settings":{"foodSpawnChance":15,"minimumFood":1,"hazardDamagePerTurn":14,"hazardMap":"","hazardMapAuthor":"","royale":{"shrinkEveryNTurns":25},"squad":{"allowBodyCollisions":False,"sharedElimination":False,"sharedHealth":False,"sharedLength":False}}},"map":"standard","timeout":500,"source":""},"turn":14,"board":{"height":11,"width":11,"snakes":[{"id":"262d660a-3a3b-4e78-994f-5561814d9eb1","name":"ricksnek2","latency":"131","health":90,"body":[{"x":5,"y":3},{"x":6,"y":3},{"x":7,"y":3},{"x":7,"y":4}],"head":{"x":5,"y":3},"length":4,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"7bb6b728-6e52-45c9-adeb-fc103c09d5f7","name":"Nightwing","latency":"513","health":96,"body":[{"x":2,"y":4},{"x":3,"y":4},{"x":4,"y":4},{"x":5,"y":4},{"x":5,"y":5}],"head":{"x":2,"y":4},"length":5,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}},{"id":"d14b067d-9758-4a50-9f49-03af72175426","name":"ricksnek","latency":"119","health":88,"body":[{"x":5,"y":9},{"x":5,"y":8},{"x":6,"y":8},{"x":6,"y":7}],"head":{"x":5,"y":9},"length":4,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}}],"food":[{"x":0,"y":9},{"x":8,"y":2}],"hazards":[]},"you":{"id":"7bb6b728-6e52-45c9-adeb-fc103c09d5f7","name":"Nightwing","latency":"513","health":96,"body":[{"x":2,"y":4},{"x":3,"y":4},{"x":4,"y":4},{"x":5,"y":4},{"x":5,"y":5}],"head":{"x":2,"y":4},"length":5,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}}} +# WRONG FOOD DISTANCE +game_state = {"game":{"id":"e09873d8-253e-47de-b7d3-306271293b37","ruleset":{"name":"standard","version":"cli","settings":{"foodSpawnChance":15,"minimumFood":1,"hazardDamagePerTurn":14,"hazardMap":"","hazardMapAuthor":"","royale":{"shrinkEveryNTurns":25},"squad":{"allowBodyCollisions":False,"sharedElimination":False,"sharedHealth":False,"sharedLength":False}}},"map":"standard","timeout":500,"source":""},"turn":3,"board":{"height":11,"width":11,"snakes":[{"id":"a1aa30f6-71dd-4178-85b0-e4bbe5b9616a","name":"Rick2","latency":"127","health":97,"body":[{"x":2,"y":9},{"x":2,"y":8},{"x":1,"y":8}],"head":{"x":2,"y":9},"length":3,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"3486f795-e277-4b07-827a-108f4968d738","name":"Matt2","latency":"68","health":97,"body":[{"x":4,"y":1},{"x":3,"y":1},{"x":2,"y":1}],"head":{"x":4,"y":1},"length":3,"shout":"","squad":"","customizations":{"color":"#1f9490","head":"default","tail":"default"}},{"id":"dd05201b-ef6c-4fb2-ad43-c35a59a2c8a0","name":"JonK","latency":"38","health":99,"body":[{"x":10,"y":7},{"x":10,"y":8},{"x":9,"y":8},{"x":9,"y":9}],"head":{"x":10,"y":7},"length":4,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}},{"id":"04205291-ba81-4516-802b-ef95896b2f90","name":"Glynn","latency":"30","health":97,"body":[{"x":9,"y":4},{"x":9,"y":3},{"x":9,"y":2}],"head":{"x":9,"y":4},"length":3,"shout":"","squad":"","customizations":{"color":"#6600ff","head":"all-seeing","tail":"weight"}},{"id":"4598a63c-2955-48cb-8102-55a50b36ec43","name":"Jesse","latency":"24","health":97,"body":[{"x":5,"y":6},{"x":5,"y":7},{"x":5,"y":8}],"head":{"x":5,"y":6},"length":3,"shout":"","squad":"","customizations":{"color":"#E04C07","head":"missile","tail":"nr-booster"}},{"id":"501d3c4e-4c09-4eab-9063-00c843470ee7","name":"Nightwing","latency":"47","health":99,"body":[{"x":5,"y":0},{"x":6,"y":0},{"x":6,"y":1},{"x":5,"y":1}],"head":{"x":5,"y":0},"length":4,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}},{"id":"4c480fdc-cda4-43b7-9c87-70aea5b6bc07","name":"Rick","latency":"131","health":97,"body":[{"x":1,"y":6},{"x":2,"y":6},{"x":2,"y":5}],"head":{"x":1,"y":6},"length":3,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"481bf56c-28f0-4460-8c7a-228d37728923","name":"Matt","latency":"60","health":97,"body":[{"x":8,"y":7},{"x":8,"y":6},{"x":9,"y":6}],"head":{"x":8,"y":7},"length":3,"shout":"","squad":"","customizations":{"color":"#1f9490","head":"default","tail":"default"}}],"food":[{"x":2,"y":10},{"x":0,"y":2},{"x":10,"y":2},{"x":6,"y":10},{"x":0,"y":6},{"x":10,"y":6},{"x":5,"y":5},{"x":9,"y":0}],"hazards":[]},"you":{"id":"501d3c4e-4c09-4eab-9063-00c843470ee7","name":"Nightwing","latency":"47","health":99,"body":[{"x":5,"y":0},{"x":6,"y":0},{"x":6,"y":1},{"x":5,"y":1}],"head":{"x":5,"y":0},"length":4,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}}} + +# Takes too long +game_state = {"game":{"id":"81b5ca01-ab36-469c-bbc2-14d6e4b8c27d","ruleset":{"name":"standard","version":"cli","settings":{"foodSpawnChance":15,"minimumFood":1,"hazardDamagePerTurn":14,"hazardMap":"","hazardMapAuthor":"","royale":{"shrinkEveryNTurns":25},"squad":{"allowBodyCollisions":False,"sharedElimination":False,"sharedHealth":False,"sharedLength":False}}},"map":"standard","timeout":500,"source":""},"turn":140,"board":{"height":11,"width":11,"snakes":[{"id":"94a0619f-d2be-4368-a81c-d96a705521e2","name":"Rick2","latency":"115","health":84,"body":[{"x":7,"y":7},{"x":8,"y":7},{"x":8,"y":6},{"x":7,"y":6},{"x":7,"y":5},{"x":7,"y":4},{"x":6,"y":4},{"x":6,"y":3},{"x":7,"y":3},{"x":8,"y":3},{"x":9,"y":3}],"head":{"x":7,"y":7},"length":11,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"823c30e6-6a64-495f-a367-59a63f9be1d5","name":"JonK","latency":"22","health":94,"body":[{"x":5,"y":1},{"x":4,"y":1},{"x":3,"y":1},{"x":2,"y":1},{"x":2,"y":2},{"x":3,"y":2},{"x":4,"y":2},{"x":4,"y":3},{"x":3,"y":3},{"x":3,"y":4},{"x":4,"y":4},{"x":4,"y":5},{"x":4,"y":6},{"x":3,"y":6},{"x":3,"y":5},{"x":2,"y":5},{"x":2,"y":4},{"x":2,"y":3},{"x":1,"y":3}],"head":{"x":5,"y":1},"length":19,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}},{"id":"21944443-10df-4a2b-aa5e-500dcbfd9b2b","name":"Nightwing","latency":"512","health":95,"body":[{"x":3,"y":9},{"x":2,"y":9},{"x":1,"y":9},{"x":0,"y":9},{"x":0,"y":10},{"x":1,"y":10},{"x":2,"y":10},{"x":3,"y":10},{"x":4,"y":10},{"x":5,"y":10},{"x":6,"y":10},{"x":7,"y":10}],"head":{"x":3,"y":9},"length":12,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}}],"food":[{"x":9,"y":2},{"x":10,"y":2},{"x":9,"y":1}],"hazards":[]},"you":{"id":"823c30e6-6a64-495f-a367-59a63f9be1d5","name":"JonK","latency":"22","health":94,"body":[{"x":5,"y":1},{"x":4,"y":1},{"x":3,"y":1},{"x":2,"y":1},{"x":2,"y":2},{"x":3,"y":2},{"x":4,"y":2},{"x":4,"y":3},{"x":3,"y":3},{"x":3,"y":4},{"x":4,"y":4},{"x":4,"y":5},{"x":4,"y":6},{"x":3,"y":6},{"x":3,"y":5},{"x":2,"y":5},{"x":2,"y":4},{"x":2,"y":3},{"x":1,"y":3}],"head":{"x":5,"y":1},"length":19,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}}} +# Should be up +game_state = {"game":{"id":"08dcbbfb-b839-4ddd-b589-9e0d5b76f4ab","ruleset":{"name":"standard","version":"cli","settings":{"foodSpawnChance":15,"minimumFood":1,"hazardDamagePerTurn":14,"hazardMap":"","hazardMapAuthor":"","royale":{"shrinkEveryNTurns":25},"squad":{"allowBodyCollisions":False,"sharedElimination":False,"sharedHealth":False,"sharedLength":False}}},"map":"standard","timeout":500,"source":""},"turn":20,"board":{"height":11,"width":11,"snakes":[{"id":"903489a9-e047-4152-b542-0d5b60367fd9","name":"Nightwing","latency":"53","health":82,"body":[{"x":0,"y":2},{"x":1,"y":2},{"x":2,"y":2},{"x":2,"y":3}],"head":{"x":0,"y":2},"length":4,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}},{"id":"0f503392-eb50-4fa0-805c-241e53044e73","name":"JonK2","latency":"29","health":97,"body":[{"x":5,"y":3},{"x":5,"y":2},{"x":5,"y":1},{"x":5,"y":0},{"x":6,"y":0},{"x":6,"y":1}],"head":{"x":5,"y":3},"length":6,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}},{"id":"e7f21738-4695-40a2-a93e-59d09e9239ea","name":"Rick2","latency":"148","health":94,"body":[{"x":1,"y":9},{"x":1,"y":10},{"x":2,"y":10},{"x":3,"y":10},{"x":4,"y":10},{"x":5,"y":10}],"head":{"x":1,"y":9},"length":6,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"be84dacb-0ad5-4324-8cec-3d4b9160e4f0","name":"Rick3","latency":"148","health":82,"body":[{"x":3,"y":3},{"x":3,"y":4},{"x":3,"y":5},{"x":3,"y":6}],"head":{"x":3,"y":3},"length":4,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"1729c7b5-369b-4e12-bfa8-aee147d53eae","name":"JonK","latency":"25","health":91,"body":[{"x":7,"y":5},{"x":7,"y":4},{"x":6,"y":4},{"x":5,"y":4},{"x":5,"y":5},{"x":5,"y":6}],"head":{"x":7,"y":5},"length":6,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}},{"id":"bb5a3a3f-901e-44cd-b8cb-00693f884b68","name":"JonK3","latency":"25","health":88,"body":[{"x":7,"y":7},{"x":6,"y":7},{"x":5,"y":7},{"x":5,"y":8},{"x":4,"y":8}],"head":{"x":7,"y":7},"length":5,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}}],"food":[{"x":8,"y":7},{"x":7,"y":8}],"hazards":[]},"you":{"id":"1729c7b5-369b-4e12-bfa8-aee147d53eae","name":"JonK","latency":"25","health":91,"body":[{"x":7,"y":5},{"x":7,"y":4},{"x":6,"y":4},{"x":5,"y":4},{"x":5,"y":5},{"x":5,"y":6}],"head":{"x":7,"y":5},"length":6,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}}} +# COULD BE MORE AGGRESSIVE +game_state = {"game":{"id":"8533c7c3-8372-4d85-acc4-4954d5c0aa4b","ruleset":{"name":"standard","version":"cli","settings":{"foodSpawnChance":15,"minimumFood":1,"hazardDamagePerTurn":14,"hazardMap":"","hazardMapAuthor":"","royale":{"shrinkEveryNTurns":25},"squad":{"allowBodyCollisions":False,"sharedElimination":False,"sharedHealth":False,"sharedLength":False}}},"map":"standard","timeout":500,"source":""},"turn":139,"board":{"height":11,"width":11,"snakes":[{"id":"30b31eb5-c9cf-48e7-9c40-b3824fff2cc3","name":"Nightwing","latency":"122","health":83,"body":[{"x":5,"y":6},{"x":4,"y":6},{"x":3,"y":6},{"x":3,"y":5},{"x":4,"y":5},{"x":5,"y":5},{"x":5,"y":4},{"x":4,"y":4},{"x":4,"y":3},{"x":3,"y":3},{"x":3,"y":2},{"x":2,"y":2},{"x":2,"y":3}],"head":{"x":5,"y":6},"length":13,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}},{"id":"46b6ee1d-6ed1-472b-ab03-53fff9c83431","name":"JonK","latency":"109","health":91,"body":[{"x":6,"y":3},{"x":6,"y":2},{"x":5,"y":2},{"x":5,"y":1},{"x":5,"y":0},{"x":6,"y":0},{"x":6,"y":1},{"x":7,"y":1},{"x":7,"y":0},{"x":8,"y":0},{"x":8,"y":1},{"x":8,"y":2},{"x":7,"y":2},{"x":7,"y":3},{"x":7,"y":4},{"x":7,"y":5}],"head":{"x":6,"y":3},"length":16,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}}],"food":[{"x":10,"y":0}],"hazards":[]},"you":{"id":"b424bc68-1da5-4793-8540-7412ae9398a6","name":"Rick2","latency":"141","health":65,"body":[{"x":4,"y":5},{"x":3,"y":5},{"x":2,"y":5},{"x":2,"y":6},{"x":3,"y":6}],"head":{"x":4,"y":5},"length":5,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}}} +# COMPLICATED STRATEGY SHEESH +game_state ={"game":{"id":"b0db4c02-9877-4cf2-a7ed-70765262b3e4","ruleset":{"name":"standard","version":"cli","settings":{"foodSpawnChance":15,"minimumFood":1,"hazardDamagePerTurn":14,"hazardMap":"","hazardMapAuthor":"","royale":{"shrinkEveryNTurns":25},"squad":{"allowBodyCollisions":False,"sharedElimination":False,"sharedHealth":False,"sharedLength":False}}},"map":"standard","timeout":500,"source":""},"turn":241,"board":{"height":11,"width":11,"snakes":[{"id":"30ed5540-d9f4-4417-b1b7-a476d002d39f","name":"JonK","latency":"20","health":98,"body":[{"x":7,"y":8},{"x":8,"y":8},{"x":8,"y":9},{"x":7,"y":9},{"x":6,"y":9},{"x":6,"y":8},{"x":6,"y":7},{"x":6,"y":6},{"x":6,"y":5},{"x":6,"y":4},{"x":6,"y":3},{"x":6,"y":2},{"x":7,"y":2},{"x":8,"y":2},{"x":9,"y":2},{"x":10,"y":2},{"x":10,"y":1},{"x":10,"y":0},{"x":9,"y":0},{"x":8,"y":0},{"x":7,"y":0},{"x":6,"y":0},{"x":5,"y":0},{"x":4,"y":0},{"x":3,"y":0},{"x":2,"y":0},{"x":1,"y":0},{"x":1,"y":1},{"x":0,"y":1},{"x":0,"y":2},{"x":0,"y":3}],"head":{"x":7,"y":8},"length":31,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}},{"id":"d1497216-bbe9-42b5-b0ec-bfc2fd571266","name":"Nightwing","latency":"69","health":95,"body":[{"x":9,"y":10},{"x":8,"y":10},{"x":7,"y":10},{"x":6,"y":10},{"x":5,"y":10},{"x":5,"y":9},{"x":5,"y":8},{"x":5,"y":7},{"x":5,"y":6},{"x":5,"y":5},{"x":5,"y":4},{"x":5,"y":3},{"x":4,"y":3},{"x":4,"y":4},{"x":3,"y":4},{"x":3,"y":5},{"x":4,"y":5},{"x":4,"y":6},{"x":3,"y":6},{"x":3,"y":7},{"x":3,"y":8},{"x":2,"y":8},{"x":1,"y":8},{"x":1,"y":9},{"x":2,"y":9}],"head":{"x":9,"y":10},"length":25,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}}],"food":[{"x":4,"y":2}],"hazards":[]},"you":{"id":"d1497216-bbe9-42b5-b0ec-bfc2fd571266","name":"Nightwing","latency":"69","health":95,"body":[{"x":9,"y":10},{"x":8,"y":10},{"x":7,"y":10},{"x":6,"y":10},{"x":5,"y":10},{"x":5,"y":9},{"x":5,"y":8},{"x":5,"y":7},{"x":5,"y":6},{"x":5,"y":5},{"x":5,"y":4},{"x":5,"y":3},{"x":4,"y":3},{"x":4,"y":4},{"x":3,"y":4},{"x":3,"y":5},{"x":4,"y":5},{"x":4,"y":6},{"x":3,"y":6},{"x":3,"y":7},{"x":3,"y":8},{"x":2,"y":8},{"x":1,"y":8},{"x":1,"y":9},{"x":2,"y":9}],"head":{"x":9,"y":10},"length":25,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}}} + +# tricky loss +game_state={"game":{"id":"8b28ba61-a8f8-48ef-87ba-de779f9a60ba","ruleset":{"name":"standard","version":"cli","settings":{"foodSpawnChance":15,"minimumFood":1,"hazardDamagePerTurn":14,"hazardMap":"","hazardMapAuthor":"","royale":{"shrinkEveryNTurns":25},"squad":{"allowBodyCollisions":False,"sharedElimination":False,"sharedHealth":False,"sharedLength":False}}},"map":"standard","timeout":500,"source":""},"turn":13,"board":{"height":11,"width":11,"snakes":[{"id":"f7611ab5-0fa8-49ab-8b44-f8bed2674960","name":"Matt","latency":"47","health":96,"body":[{"x":10,"y":5},{"x":9,"y":5},{"x":9,"y":4},{"x":9,"y":3}],"head":{"x":10,"y":5},"length":4,"shout":"","squad":"","customizations":{"color":"#1f9490","head":"default","tail":"default"}},{"id":"f140c786-7d14-4540-a2f7-0be9c7663104","name":"Nightwing","latency":"65","health":89,"body":[{"x":8,"y":7},{"x":8,"y":6},{"x":7,"y":6},{"x":7,"y":5}],"head":{"x":8,"y":7},"length":4,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}},{"id":"f5e14a0c-6746-44b0-be7a-e24f39169dcd","name":"Rick","latency":"139","health":95,"body":[{"x":5,"y":4},{"x":5,"y":3},{"x":4,"y":3},{"x":4,"y":4}],"head":{"x":5,"y":4},"length":4,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"c99a2cd1-504c-4cdd-934d-7c62c95bab9b","name":"JonK2","latency":"25","health":97,"body":[{"x":3,"y":4},{"x":3,"y":5},{"x":2,"y":5},{"x":2,"y":6},{"x":3,"y":6}],"head":{"x":3,"y":4},"length":5,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}},{"id":"5b64a149-553a-4efa-8e7d-c34df45fae5a","name":"Rick3","latency":"145","health":89,"body":[{"x":2,"y":9},{"x":1,"y":9},{"x":1,"y":8},{"x":1,"y":7}],"head":{"x":2,"y":9},"length":4,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"3082f705-6062-4161-bb98-22414c13df4a","name":"JonK","latency":"23","health":89,"body":[{"x":6,"y":9},{"x":6,"y":8},{"x":5,"y":8},{"x":5,"y":7}],"head":{"x":6,"y":9},"length":4,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}},{"id":"734381ef-aabf-4ee5-bf53-5004d3faa262","name":"JonK3","latency":"27","health":93,"body":[{"x":7,"y":4},{"x":7,"y":3},{"x":7,"y":2},{"x":8,"y":2},{"x":8,"y":1}],"head":{"x":7,"y":4},"length":5,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}}],"food":[{"x":7,"y":9},{"x":0,"y":4}],"hazards":[]},"you":{"id":"3082f705-6062-4161-bb98-22414c13df4a","name":"JonK","latency":"23","health":89,"body":[{"x":6,"y":9},{"x":6,"y":8},{"x":5,"y":8},{"x":5,"y":7}],"head":{"x":6,"y":9},"length":4,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}}} +game_state={"game":{"id":"8b28ba61-a8f8-48ef-87ba-de779f9a60ba","ruleset":{"name":"standard","version":"cli","settings":{"foodSpawnChance":15,"minimumFood":1,"hazardDamagePerTurn":14,"hazardMap":"","hazardMapAuthor":"","royale":{"shrinkEveryNTurns":25},"squad":{"allowBodyCollisions":False,"sharedElimination":False,"sharedHealth":False,"sharedLength":False}}},"map":"standard","timeout":500,"source":""},"turn":6,"board":{"height":11,"width":11,"snakes":[{"id":"f7611ab5-0fa8-49ab-8b44-f8bed2674960","name":"Matt","latency":"42","health":94,"body":[{"x":10,"y":0},{"x":10,"y":1},{"x":9,"y":1}],"head":{"x":10,"y":0},"length":3,"shout":"","squad":"","customizations":{"color":"#1f9490","head":"default","tail":"default"}},{"id":"f140c786-7d14-4540-a2f7-0be9c7663104","name":"Nightwing","latency":"40","health":96,"body":[{"x":4,"y":4},{"x":3,"y":4},{"x":2,"y":4},{"x":1,"y":4}],"head":{"x":4,"y":4},"length":4,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}},{"id":"f5e14a0c-6746-44b0-be7a-e24f39169dcd","name":"Rick","latency":"145","health":94,"body":[{"x":7,"y":5},{"x":8,"y":5},{"x":9,"y":5}],"head":{"x":7,"y":5},"length":3,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"c99a2cd1-504c-4cdd-934d-7c62c95bab9b","name":"JonK2","latency":"17","health":96,"body":[{"x":5,"y":7},{"x":5,"y":8},{"x":5,"y":9},{"x":5,"y":10}],"head":{"x":5,"y":7},"length":4,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}},{"id":"5b64a149-553a-4efa-8e7d-c34df45fae5a","name":"Rick3","latency":"117","health":96,"body":[{"x":3,"y":7},{"x":2,"y":7},{"x":1,"y":7},{"x":0,"y":7}],"head":{"x":3,"y":7},"length":4,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"3082f705-6062-4161-bb98-22414c13df4a","name":"JonK","latency":"27","health":96,"body":[{"x":8,"y":6},{"x":9,"y":6},{"x":10,"y":6},{"x":10,"y":7}],"head":{"x":8,"y":6},"length":4,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}},{"id":"734381ef-aabf-4ee5-bf53-5004d3faa262","name":"JonK3","latency":"21","health":100,"body":[{"x":6,"y":0},{"x":5,"y":0},{"x":4,"y":0},{"x":3,"y":0},{"x":3,"y":0}],"head":{"x":6,"y":0},"length":5,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}}],"food":[{"x":5,"y":5}],"hazards":[]},"you":{"id":"5b64a149-553a-4efa-8e7d-c34df45fae5a","name":"Rick3","latency":"117","health":96,"body":[{"x":3,"y":7},{"x":2,"y":7},{"x":1,"y":7},{"x":0,"y":7}],"head":{"x":3,"y":7},"length":4,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}}} +# edge kill +game_state ={"game":{"id":"ebd7e984-bd7c-483f-9ab9-dd1941a2f627","ruleset":{"name":"standard","version":"cli","settings":{"foodSpawnChance":15,"minimumFood":1,"hazardDamagePerTurn":14,"hazardMap":"","hazardMapAuthor":"","royale":{"shrinkEveryNTurns":25},"squad":{"allowBodyCollisions":False,"sharedElimination":False,"sharedHealth":False,"sharedLength":False}}},"map":"standard","timeout":500,"source":""},"turn":23,"board":{"height":11,"width":11,"snakes":[{"id":"59149fd6-c5d1-468d-8858-0121f071ea9f","name":"Rick2","latency":"141","health":81,"body":[{"x":5,"y":10},{"x":4,"y":10},{"x":3,"y":10},{"x":2,"y":10}],"head":{"x":5,"y":10},"length":4,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"1ea02430-c413-4093-96c8-8e32503ad9ea","name":"Rick3","latency":"160","health":81,"body":[{"x":6,"y":7},{"x":6,"y":8},{"x":6,"y":9},{"x":5,"y":9}],"head":{"x":6,"y":7},"length":4,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"20b643cf-33a0-47d2-8a9b-bf7467009309","name":"JonK","latency":"27","health":94,"body":[{"x":4,"y":5},{"x":4,"y":6},{"x":3,"y":6},{"x":3,"y":7},{"x":2,"y":7}],"head":{"x":4,"y":5},"length":5,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}},{"id":"a3ca9d01-053d-4cb3-ba87-2b3f98065f76","name":"JonK3","latency":"32","health":89,"body":[{"x":2,"y":1},{"x":2,"y":0},{"x":3,"y":0},{"x":4,"y":0},{"x":5,"y":0},{"x":6,"y":0}],"head":{"x":2,"y":1},"length":6,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}},{"id":"3b5358d1-b7f7-4be1-b81c-35f62a67ff2b","name":"Nightwing","latency":"62","health":100,"body":[{"x":0,"y":5},{"x":1,"y":5},{"x":1,"y":4},{"x":1,"y":3},{"x":1,"y":2},{"x":2,"y":2},{"x":3,"y":2},{"x":3,"y":2}],"head":{"x":0,"y":5},"length":8,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}},{"id":"af15dcfa-39c7-438f-a02b-21192ac7ebff","name":"Rick","latency":"193","health":81,"body":[{"x":8,"y":5},{"x":8,"y":4},{"x":8,"y":3},{"x":8,"y":2}],"head":{"x":8,"y":5},"length":4,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"a0ee3c4b-0096-4fb2-bcdc-4b8a7dd7499a","name":"JonK2","latency":"22","health":85,"body":[{"x":1,"y":6},{"x":2,"y":6},{"x":2,"y":5},{"x":2,"y":4},{"x":3,"y":4},{"x":4,"y":4}],"head":{"x":1,"y":6},"length":6,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}}],"food":[{"x":10,"y":5}],"hazards":[]},"you":{"id":"3b5358d1-b7f7-4be1-b81c-35f62a67ff2b","name":"Nightwing","latency":"62","health":100,"body":[{"x":0,"y":5},{"x":1,"y":5},{"x":1,"y":4},{"x":1,"y":3},{"x":1,"y":2},{"x":2,"y":2},{"x":3,"y":2},{"x":3,"y":2}],"head":{"x":0,"y":5},"length":8,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}}} +game_state = {"game":{"id":"556f6b59-4d97-454d-a97c-016c07a8b425","ruleset":{"name":"standard","version":"cli","settings":{"foodSpawnChance":15,"minimumFood":1,"hazardDamagePerTurn":14,"hazardMap":"","hazardMapAuthor":"","royale":{"shrinkEveryNTurns":25},"squad":{"allowBodyCollisions":False,"sharedElimination":False,"sharedHealth":False,"sharedLength":False}}},"map":"standard","timeout":500,"source":""},"turn":30,"board":{"height":11,"width":11,"snakes":[{"id":"64c2d864-0678-46e1-baab-ba2fde7db6b1","name":"Nightwing","latency":"46","health":72,"body":[{"x":0,"y":4},{"x":0,"y":3},{"x":0,"y":2},{"x":0,"y":1}],"head":{"x":0,"y":4},"length":4,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}},{"id":"16462988-8a8f-4a55-b37d-e660b01c1f2e","name":"Rick","latency":"166","health":72,"body":[{"x":5,"y":5},{"x":5,"y":6},{"x":5,"y":7},{"x":5,"y":8}],"head":{"x":5,"y":5},"length":4,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"a0e4c39d-1af5-46f9-9ef8-eb261743a747","name":"Rick2","latency":"166","health":97,"body":[{"x":1,"y":1},{"x":1,"y":0},{"x":2,"y":0},{"x":3,"y":0},{"x":4,"y":0},{"x":5,"y":0},{"x":6,"y":0}],"head":{"x":1,"y":1},"length":7,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"08a9a6e0-ecd9-4d85-b712-f4eb7a42a614","name":"Rick3","latency":"115","health":100,"body":[{"x":7,"y":3},{"x":7,"y":4},{"x":7,"y":5},{"x":7,"y":6},{"x":7,"y":6}],"head":{"x":7,"y":3},"length":5,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"e9fbfce4-6625-4e75-add9-10e43bdf9035","name":"JonK","latency":"22","health":92,"body":[{"x":3,"y":3},{"x":2,"y":3},{"x":2,"y":2},{"x":2,"y":1},{"x":3,"y":1},{"x":3,"y":2}],"head":{"x":3,"y":3},"length":6,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}}],"food":[{"x":8,"y":5}],"hazards":[]},"you":{"id":"12684240-ff08-4da5-9023-8cdfcdb62455","name":"JonK3","latency":"25","health":76,"body":[{"x":4,"y":0},{"x":4,"y":1},{"x":4,"y":2},{"x":4,"y":3}],"head":{"x":4,"y":0},"length":4,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}}} +game_state={"game":{"id":"05c3c37b-4861-476a-bbc2-b0ce4d30a8af","ruleset":{"name":"standard","version":"cli","settings":{"foodSpawnChance":15,"minimumFood":1,"hazardDamagePerTurn":14,"hazardMap":"","hazardMapAuthor":"","royale":{"shrinkEveryNTurns":25},"squad":{"allowBodyCollisions":False,"sharedElimination":False,"sharedHealth":False,"sharedLength":False}}},"map":"standard","timeout":500,"source":""},"turn":47,"board":{"height":11,"width":11,"snakes":[{"id":"8e6c3427-ebf7-49ed-9b23-700342aef7f6","name":"JonK3","latency":"22","health":88,"body":[{"x":9,"y":4},{"x":8,"y":4},{"x":8,"y":3},{"x":7,"y":3},{"x":7,"y":4},{"x":7,"y":5},{"x":7,"y":6}],"head":{"x":9,"y":4},"length":7,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}},{"id":"2d41193c-8534-400f-9717-d4ee9272ea4f","name":"Rick4","latency":"154","health":85,"body":[{"x":8,"y":1},{"x":9,"y":1},{"x":9,"y":2},{"x":8,"y":2},{"x":7,"y":2}],"head":{"x":8,"y":1},"length":5,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"9110ff66-e559-46ce-9a35-63628ba2ac2b","name":"Nightwing","latency":"44","health":97,"body":[{"x":0,"y":1},{"x":0,"y":2},{"x":1,"y":2},{"x":1,"y":3},{"x":0,"y":3},{"x":0,"y":4},{"x":0,"y":5},{"x":0,"y":6},{"x":0,"y":7}],"head":{"x":0,"y":1},"length":9,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}},{"id":"571a52e2-2e02-4b77-91f2-e8ad7af0c2f1","name":"Rick","latency":"113","health":76,"body":[{"x":3,"y":2},{"x":3,"y":3},{"x":3,"y":4},{"x":3,"y":5},{"x":3,"y":6},{"x":3,"y":7}],"head":{"x":3,"y":2},"length":6,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"4e3cc686-d592-40c1-bd25-3be41bccd872","name":"Rick2","latency":"146","health":92,"body":[{"x":4,"y":3},{"x":5,"y":3},{"x":6,"y":3},{"x":6,"y":4},{"x":6,"y":5},{"x":6,"y":6},{"x":5,"y":6},{"x":4,"y":6}],"head":{"x":4,"y":3},"length":8,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}}],"food":[{"x":0,"y":0}],"hazards":[]},"you":{"id":"8e6c3427-ebf7-49ed-9b23-700342aef7f6","name":"JonK3","latency":"22","health":88,"body":[{"x":9,"y":4},{"x":8,"y":4},{"x":8,"y":3},{"x":7,"y":3},{"x":7,"y":4},{"x":7,"y":5},{"x":7,"y":6}],"head":{"x":9,"y":4},"length":7,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}}} + +game_state={"game":{"id":"761b9761-208b-4e5a-85e1-5cc684fa36b2","ruleset":{"name":"standard","version":"cli","settings":{"foodSpawnChance":15,"minimumFood":1,"hazardDamagePerTurn":14,"hazardMap":"","hazardMapAuthor":"","royale":{"shrinkEveryNTurns":25},"squad":{"allowBodyCollisions":False,"sharedElimination":False,"sharedHealth":False,"sharedLength":False}}},"map":"standard","timeout":500,"source":""},"turn":239,"board":{"height":11,"width":11,"snakes":[{"id":"e8ed007b-4735-46f5-ba97-fe1ab42a57a4","name":"Rick2","latency":"119","health":96,"body":[{"x":3,"y":2},{"x":4,"y":2},{"x":5,"y":2},{"x":6,"y":2},{"x":6,"y":1},{"x":5,"y":1},{"x":4,"y":1},{"x":3,"y":1},{"x":2,"y":1},{"x":1,"y":1},{"x":0,"y":1},{"x":0,"y":2},{"x":0,"y":3},{"x":0,"y":4},{"x":0,"y":5},{"x":0,"y":6},{"x":1,"y":6}],"head":{"x":3,"y":2},"length":17,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"15f698bc-799e-4184-9834-c4ab4fa7b098","name":"Nightwing","latency":"79","health":89,"body":[{"x":4,"y":3},{"x":5,"y":3},{"x":6,"y":3},{"x":7,"y":3},{"x":8,"y":3},{"x":9,"y":3},{"x":10,"y":3},{"x":10,"y":4},{"x":10,"y":5},{"x":10,"y":6},{"x":10,"y":7},{"x":10,"y":8},{"x":9,"y":8},{"x":8,"y":8},{"x":7,"y":8},{"x":6,"y":8},{"x":5,"y":8},{"x":5,"y":7},{"x":6,"y":7}],"head":{"x":4,"y":3},"length":19,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}}],"food":[{"x":10,"y":10},{"x":0,"y":8}],"hazards":[]},"you":{"id":"c3885f87-770b-4c8f-95dd-f8d9e80d44ae","name":"Rick4","latency":"159","health":100,"body":[{"x":5,"y":10},{"x":4,"y":10},{"x":3,"y":10},{"x":3,"y":9},{"x":3,"y":9}],"head":{"x":5,"y":10},"length":5,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}}} + +game = Battlesnake(game_state, debugging=True) + +print("STARTING POSITION") +game.display_board() +next_move = game.optimal_move() +print(next_move) +# +# import cProfile, pstats +# profiler = cProfile.Profile() +# profiler.enable() +# game.minimax_move() +# profiler.disable() +# stats = pstats.Stats(profiler).sort_stats('tottime') +# stats.print_stats() + +# import timeit +# start = timeit.default_timer() +# print("The start time is :", start) +# next_move = game.flood_fill(game.my_id, confined_area="left") +# print("The difference of time is :", +# timeit.default_timer() - start) \ No newline at end of file diff --git a/main.py b/main.py index 4ca21971..d0a248a6 100644 --- a/main.py +++ b/main.py @@ -5,91 +5,48 @@ # | | \ / __ \| | | | | |_\ ___/ \___ \| | \/ __ \| <\ ___/ # |________/(______/__| |__| |____/\_____>______>___|__(______/__|__\\_____> # -# This file can be a nice home for your Battlesnake logic and helper functions. -# -# To get you started we've included code to prevent your Battlesnake from moving backwards. -# For more info see docs.battlesnake.com -import random import typing +from snake_engine import Battlesnake -# info is called when you create your Battlesnake on play.battlesnake.com -# and controls your Battlesnake's appearance -# TIP: If you open your Battlesnake URL in a browser you should see this data def info() -> typing.Dict: + """ + info is called when you create your Battlesnake on play.battlesnake.com + and controls your Battlesnake's appearance + TIP: If you open your Battlesnake URL in a browser you should see this data + """ print("INFO") return { "apiversion": "1", - "author": "", # TODO: Your Battlesnake Username - "color": "#888888", # TODO: Choose color - "head": "default", # TODO: Choose head - "tail": "default", # TODO: Choose tail + "author": "G", + "color": "#3333ff", + "head": "ski", + "tail": "mystic-moon", } -# start is called when your Battlesnake begins a game def start(game_state: typing.Dict): + """start is called when your Battlesnake begins a game""" print("GAME START") -# end is called when your Battlesnake finishes a game def end(game_state: typing.Dict): + """end is called when your Battlesnake finishes a game""" print("GAME OVER\n") -# move is called on every turn and returns your next move -# Valid moves are "up", "down", "left", or "right" -# See https://docs.battlesnake.com/api/example-move for available data def move(game_state: typing.Dict) -> typing.Dict: - - is_move_safe = {"up": True, "down": True, "left": True, "right": True} - - # We've included code to prevent your Battlesnake from moving backwards - my_head = game_state["you"]["body"][0] # Coordinates of your head - my_neck = game_state["you"]["body"][1] # Coordinates of your "neck" - - if my_neck["x"] < my_head["x"]: # Neck is left of head, don't move left - is_move_safe["left"] = False - - elif my_neck["x"] > my_head["x"]: # Neck is right of head, don't move right - is_move_safe["right"] = False - - elif my_neck["y"] < my_head["y"]: # Neck is below head, don't move down - is_move_safe["down"] = False - - elif my_neck["y"] > my_head["y"]: # Neck is above head, don't move up - is_move_safe["up"] = False - - # TODO: Step 1 - Prevent your Battlesnake from moving out of bounds - # board_width = game_state['board']['width'] - # board_height = game_state['board']['height'] - - # TODO: Step 2 - Prevent your Battlesnake from colliding with itself - # my_body = game_state['you']['body'] - - # TODO: Step 3 - Prevent your Battlesnake from colliding with other Battlesnakes - # opponents = game_state['board']['snakes'] - - # Are there any safe moves left? - safe_moves = [] - for move, isSafe in is_move_safe.items(): - if isSafe: - safe_moves.append(move) - - if len(safe_moves) == 0: - print(f"MOVE {game_state['turn']}: No safe moves detected! Moving down") - return {"move": "down"} - - # Choose a random move from the safe ones - next_move = random.choice(safe_moves) - - # TODO: Step 4 - Move towards food instead of random, to regain health and survive longer - # food = game_state['board']['food'] - - print(f"MOVE {game_state['turn']}: {next_move}") - return {"move": next_move} + """ + move is called on every turn and returns your next move + Valid moves are "up", "down", "left", or "right" + See https://docs.battlesnake.com/api/example-move for available data + """ + game = Battlesnake(game_state) + optimal_move = game.optimal_move() + print(f"MOVE {game_state['turn']}: {optimal_move}") + return {"move": optimal_move} # Start server when `python main.py` is run diff --git a/networkx_tree.py b/networkx_tree.py new file mode 100644 index 00000000..0da9db73 --- /dev/null +++ b/networkx_tree.py @@ -0,0 +1,125 @@ +import networkx as nx +import random + + +def hierarchy_pos(G, root=None, width=25., vert_gap=0.025, vert_loc=0, leaf_vs_root_factor=1): + ''' + If the graph is a tree this will return the positions to plot this in a + hierarchical layout. + + Based on Joel's answer at https://stackoverflow.com/a/29597209/2966723, + but with some modifications. + + We include this because it may be useful for plotting transmission trees, + and there is currently no networkx equivalent (though it may be coming soon). + + There are two basic approaches we think of to allocate the horizontal + location of a node. + + - Top down: we allocate horizontal space to a node. Then its ``k`` + descendants split up that horizontal space equally. This tends to result + in overlapping nodes when some have many descendants. + - Bottom up: we allocate horizontal space to each leaf node. A node at a + higher level gets the entire space allocated to its descendant leaves. + Based on this, leaf nodes at higher levels get the same space as leaf + nodes very deep in the tree. + + We use both of these approaches simultaneously with ``leaf_vs_root_factor`` + determining how much of the horizontal space is based on the bottom up + or top down approaches. ``0`` gives pure bottom up, while 1 gives pure top + down. + + + :Arguments: + + **G** the graph (must be a tree) + + **root** the root node of the tree + - if the tree is directed and this is not given, the root will be found and used + - if the tree is directed and this is given, then the positions will be + just for the descendants of this node. + - if the tree is undirected and not given, then a random choice will be used. + + **width** horizontal space allocated for this branch - avoids overlap with other branches + + **vert_gap** gap between levels of hierarchy + + **vert_loc** vertical location of root + + **leaf_vs_root_factor** + + xcenter: horizontal location of root + ''' + if not nx.is_tree(G): + raise TypeError('cannot use hierarchy_pos on a graph that is not a tree') + + if root is None: + if isinstance(G, nx.DiGraph): + root = next(iter(nx.topological_sort(G))) # allows back compatibility with nx version 1.11 + else: + root = random.choice(list(G.nodes)) + + def _hierarchy_pos(G, root, leftmost, width, leafdx=2, vert_gap=0.2, vert_loc=0, + xcenter=0.5, rootpos=None, + leafpos=None, parent=None): + ''' + see hierarchy_pos docstring for most arguments + + pos: a dict saying where all nodes go if they have been assigned + parent: parent of this branch. - only affects it if non-directed + + ''' + + if rootpos is None: + rootpos = {root: (xcenter, vert_loc)} + else: + rootpos[root] = (xcenter, vert_loc) + if leafpos is None: + leafpos = {} + children = list(G.neighbors(root)) + leaf_count = 0 + if not isinstance(G, nx.DiGraph) and parent is not None: + children.remove(parent) + if len(children) != 0: + node_size = width / 50 + + rootdx = width / len(children) + nextx = xcenter - width / 2 - rootdx / 2 + for child in children: + nextx += rootdx + rootpos, leafpos, newleaves = _hierarchy_pos(G, child, leftmost + leaf_count * leafdx, + width=rootdx, leafdx=leafdx, + vert_gap=vert_gap, vert_loc=vert_loc - vert_gap, + xcenter=nextx, rootpos=rootpos, leafpos=leafpos, + parent=root) + leaf_count += newleaves + + leftmostchild = min((x for x, y in [leafpos[child] for child in children])) + rightmostchild = max((x for x, y in [leafpos[child] for child in children])) + leafpos[root] = ((leftmostchild + rightmostchild) / 2, vert_loc) + else: + leaf_count = 1 + leafpos[root] = (leftmost, vert_loc) + # pos[root] = (leftmost + (leaf_count-1)*dx/2., vert_loc) + # print(leaf_count) + return rootpos, leafpos, leaf_count + + xcenter = width / 2. + if isinstance(G, nx.DiGraph): + leafcount = len([node for node in nx.descendants(G, root) if G.out_degree(node) == 0]) + elif isinstance(G, nx.Graph): + leafcount = len([node for node in nx.node_connected_component(G, root) if G.degree(node) == 1 and node != root]) + rootpos, leafpos, leaf_count = _hierarchy_pos(G, root, 0, width, + leafdx=width * 1. / leafcount, + vert_gap=vert_gap, + vert_loc=vert_loc, + xcenter=xcenter) + pos = {} + for node in rootpos: + pos[node] = ( + leaf_vs_root_factor * leafpos[node][0] + (1 - leaf_vs_root_factor) * rootpos[node][0], leafpos[node][1]) + # pos = {node:(leaf_vs_root_factor*x1+(1-leaf_vs_root_factor)*x2, y1) for ((x1,y1), (x2,y2)) in (leafpos[node], rootpos[node]) for node in rootpos} + xmax = max(x for x, y in pos.values()) + for node in pos: + pos[node] = (pos[node][0] * width / xmax, pos[node][1]) + return pos diff --git a/requirements.txt b/requirements.txt index 5aad892a..f3f4adb1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,6 @@ Flask==2.3.2 + +numpy~=1.26.0 +pandas~=2.1.1 +matplotlib~=3.8.0 +networkx~=3.1 \ No newline at end of file diff --git a/snake_engine.py b/snake_engine.py new file mode 100644 index 00000000..493f4e75 --- /dev/null +++ b/snake_engine.py @@ -0,0 +1,1167 @@ +from __future__ import annotations +import copy +import itertools +import logging +import matplotlib.pyplot as plt +import networkx as nx +import numpy as np +import sys +import time +from battlesnake_utils.battlesnake_utils.battlesnake import Pos, Snake +from collections import Counter +from networkx_tree import hierarchy_pos +from typing import Optional + +my_name = "Nightwing" +# Use these global variables to add data for visualising the minimax decision tree +# tree_tracker = {4: [], 3: [], 2: [], 1: [], 0: []} +tree_tracker = {6: [], 5: [], 4: [], 3: [], 2: [], 1: [], 0: []} +tree_edges = [] +tree_nodes = [] +tree_node_counter = 1 +# Random variables for timing runtime +tot_time_graph = 0 +counter_graph = 0 +copy_graph = 0 + + +class Battlesnake: + def __init__(self, game_state: dict, debugging: Optional[bool] = False): + """ + Represents our Battlesnake in any given game state and includes all our decision-making methods + + :param game_state: The move API request (https://docs.battlesnake.com/api/example-move#move-api-response) + :param debugging: Set to True if you want to view a log of what's happening behind the minimax algorithm + """ + # General game data + self.turn = game_state["turn"] + self.board_width = game_state["board"]["width"] + self.board_height = game_state["board"]["height"] + self.food = [Pos(xy) for xy in game_state["board"]["food"]] + self.hazards = [Pos(xy) for xy in game_state["board"]["hazards"]] + self.board = np.full((self.board_width, self.board_height), " ") + self.graph = nx.grid_2d_graph(self.board_width, self.board_height) + + # Process our snake using Rick's Snake class + self.you = Snake(game_state["you"]) + # Process all snakes as a dictionary of Snake objects + self.all_snakes_dict: dict[str, Snake] = {} + for snake_dict in game_state["board"]["snakes"]: + self.all_snakes_dict[snake_dict["id"]] = Snake(snake_dict) # "food_eaten": snake["food_eaten"] if "food_eaten" in snake.keys() else None + # Weird cases when running locally where the "you" snake is not our actual snake + if game_state["you"]["name"] != my_name and snake_dict["name"] == my_name: + self.you = Snake(snake_dict) + + # Another weird edge case when running locally where our snake is not in the "snakes" field + if self.you.id not in self.all_snakes_dict.keys(): + self.all_snakes_dict[self.you.id] = self.you + + # Opponent snakes + self.opponents = self.all_snakes_dict.copy() + self.opponents.pop(self.you.id) + + # Finish up our constructor + self.update_board() + self.minimax_search_depth = 6 # Depth for minimax algorithm + self.peripheral_size = 3 # Length of our snake's "peripheral vision" + self.debugging = debugging + logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout) + if not self.debugging: + logging.disable(logging.INFO) + + def update_board(self): + """ + Fill in the board with the locations of all snakes. Our snake will be displayed like "oo£" where "o" represents + the body and "£" represents the head. Opponents will be displayed as "xx£" in the same manner. + + Also update the graph representation of the board to remove nodes occupied by our snake's body (but not head), + opponent snakes, and hazards. + """ + global tot_time_graph + global counter_graph + for opponent in self.opponents.values(): + opp_body = opponent.body + for num, pos in enumerate(opp_body): + self.board[pos.x, pos.y] = "$" if num == 0 else "x" + clock_in = time.time_ns() + # Remove nodes on the graph occupied by opponent snakes since they shouldn't be reached + self.graph.remove_nodes_from([pos.as_tuple()]) + counter_graph += 1 + tot_time_graph += round((time.time_ns() - clock_in) / 1000000, 3) + for num, pos in enumerate(self.you.body): + self.board[pos.x, pos.y] = "£" if num == 0 else "o" + # Remove nodes on the graph occupied by our snake's body, but not our head since we have to traverse the + # graph from our head + if num > 0: + clock_in = time.time_ns() + self.graph.remove_nodes_from([pos.as_tuple()]) + counter_graph += 1 + tot_time_graph += round((time.time_ns() - clock_in) / 1000000, 3) + + def display_board(self, board: Optional[np.array] = None, return_string: Optional[bool] = False): + """ + Print out a nicely formatted board for convenient debugging e.g. + + | | | | | | | | | | | + | | | | | | | | | | | + | | | | | o| o| o| o| o| o| + | | | | | o| o| | | | o| + x| x| | | | | | | | | o| + x| | | | | | | | | | o| + x| | | | | | | | | | o| + x| | | | £| o| o| o| o| o| o| + x| | | $| x| x| x| | | | | + x| x| x| x| x| x| x| | | | | + | | | | | | | | | | | + + :param board: Calling display_board() will print out the current board, but for debugging purposes, you can feed + in a different board variable to display + :param return_string: You can optionally choose to return the board as a string for you to print later + """ + render_board = board if board is not None else self.board + for j in range(1, len(render_board[0]) + 1): + display_row = "" + for i in range(0, len(render_board)): + display_row += f"{render_board[i][-j]}| " + if self.debugging: + logging.info(display_row) + else: + print(display_row) + + # Return the board as a string instead of printing it out + if return_string: + board_str = "" + for j in range(1, len(render_board[0]) + 1): + display_row = "" + for i in range(0, len(render_board)): + if render_board[i][-j] == " ": # Adjust for difference in sizes between spaces and x/o's + display_row += f" | " + else: + display_row += f"{render_board[i][-j]}| " + board_str += display_row + "\n" + return board_str + + def closest_distance(self, start: Pos, end: Pos) -> int: + closest = self.dijkstra_shortest_path(start, end) + if closest == 1e6: + closest = self.manhattan_distance(start, end) + return closest + + @staticmethod + def manhattan_distance(start: Pos, end: Pos) -> int: + """ + Return the Manhattan distance between two positions + + :param start: A location on the board as a Pos object e.g. Pos({"x": 5, "y": 10}) + :param end: A different location on the board + + :return: The Manhattan distance between the start and end inputs + """ + return sum(abs(value1 - value2) for value1, value2 in zip(start.as_tuple(), end.as_tuple())) + + def dijkstra_shortest_path(self, start: Pos, end: Pos) -> int: + """ + Return the shortest path between two positions using Dijkstra's algorithm implemented in networkx + + :param start: A location on the board as a Pos object e.g. Pos({"x": 5, "y": 10}) + :param end: A different location on the board + + :return: The shortest distance between the start and end inputs. 1e6 if no path could be found + """ + start = start.as_tuple() + end = end.as_tuple() + temp_graph, temp_added_nodes = self.check_missing_nodes(self.graph, [start, end]) + + # Run networkx's Dijkstra method (it'll error out if no path is possible) + try: + path = nx.shortest_path(temp_graph, start, end) + shortest = len(path) + except nx.exception.NetworkXNoPath: + shortest = 1e6 + + for temp_nodes in temp_added_nodes: + temp_graph.remove_node(temp_nodes) + return shortest + + def stall_path(self, start: Pos, end: Pos) -> int: + """ + Return the longest path between two positions using algorithm implemented in networkx + + :param start: A location on the board as a Pos object e.g. Pos({"x": 5, "y": 10}) + :param end: A different location on the board + + :return: The longest distance between the start and end inputs. 1e6 if no path could be found + """ + start = start.as_tuple() + end = end.as_tuple() + temp_graph, temp_added_nodes = self.check_missing_nodes(self.graph, [start, end]) + + find_longest = [path for path in nx.all_simple_paths(temp_graph, start, end)] + if len(find_longest) > 0: + longest_path = max(find_longest, key=lambda path: len(path)) + longest = len(longest_path) - 1 + else: + longest = 1e6 + for temp_nodes in temp_added_nodes: + temp_graph.remove_node(temp_nodes) + return longest + + @staticmethod + def check_missing_nodes(G, nodes: list[tuple]): + global copy_graph + clock_in = time.time_ns() + # If the desired location is on a hazard/snake, then it's absent from the graph, and we want to add in the node + added = [] + for num, node in enumerate(nodes): + if node not in G.nodes(): + added.append(node) + G.add_node(node) + x, y = node + # Include edges to connect the added node to surrounding nodes if possible + possible_edges = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)] + for e in possible_edges: + if e in G.nodes: + G.add_edge(node, e) + copy_graph += round((time.time_ns() - clock_in) / 1000000, 3) + return G, added + + @staticmethod + def snake_compass(head: dict, neck: dict) -> str: + """ + Return the direction a snake is facing in the current board + + :param head: The location of the snake's head as a dictionary e.g. {"x": 5, "y": 10} + :param neck: The location of the snake's neck as a dictionary e.g. {"x": 6, "y": 10} + + :return: Either "left", "right", "up", or "down" e.g. "right" for the above inputs + """ + if neck["x"] < head["x"]: + direction = "right" + elif neck["x"] > head["x"]: + direction = "left" + elif neck["y"] < head["y"]: + direction = "up" + elif neck["y"] > head["y"]: + direction = "down" + else: + direction = "none" # At the beginning of the game when snakes are coiled + return direction + + def peripheral_vision(self, snake_id: str, direction: str) -> tuple[tuple, tuple, dict]: + """ + Calculate our snake's peripheral vision aka the portion of the board that is closest to our snake in a certain + direction. E.g. the space bounded by [x1, x2] and [y1, y2] in the following example board. Notice that the space + extends 3 squares above, below, and to the left of the snake, assuming we specified direction="left". Also + notice that the head of the snake doesn't actually enter the 3x7 peripheral field - the hypothetical new head is + returned as an output for convenience. + + | | | + | | | + | | | + | | | £| + | | $| + | x| x| + | | | + + :param snake_id: The ID of the desired snake we want to find the peripheral field for + :param direction: The direction you'd like to point the snake towards (either "left", "right", "up", or "down", + but use "auto" if you want to just use the direction the snake is facing in the current board) + + :return: + [x1, x2] of a portion of the board that functions as the snake's peripheral vision + [y1, y2] of the same portion + The position of the snake's head if it hypothetically moved into its peripheral field (used to perform + flood-fill on the peripheral field) + """ + # Our peripheral field of vision when scanning for moves + head = self.all_snakes_dict[snake_id].head.as_dict() + neck = self.all_snakes_dict[snake_id].body_dict[1] + dim = self.peripheral_size + + # Got to figure out the direction ourselves + if direction == "auto": + direction = self.snake_compass(head, neck) + head = neck.copy() # Roll back our head location + + # Construct the bounds of the peripheral field depending on the requested direction + if direction == "right": + peripheral_box_x = head["x"] + 1, min(head["x"] + dim + 1, self.board_width) + peripheral_box_y = max(head["y"] - dim, 0), min(head["y"] + dim + 1, self.board_height) + head["x"], head["y"] = 0, head["y"] - peripheral_box_y[0] + elif direction == "left": + peripheral_box_x = max(head["x"] - dim, 0), head["x"] + peripheral_box_y = max(head["y"] - dim, 0), min(head["y"] + dim + 1, self.board_height) + head["x"], head["y"] = max(head["x"] - peripheral_box_x[0] - 1, 0), head["y"] - peripheral_box_y[0] + elif direction == "up": + peripheral_box_x = max(head["x"] - dim, 0), min(head["x"] + dim + 1, self.board_width) + peripheral_box_y = head["y"] + 1, min(head["y"] + dim + 1, self.board_height) + head["x"], head["y"] = head["x"] - peripheral_box_x[0], 0 + elif direction == "down": + peripheral_box_x = max(head["x"] - dim, 0), min(head["x"] + dim + 1, self.board_width) + peripheral_box_y = max(head["y"] - dim, 0), head["y"] + head["x"], head["y"] = head["x"] - peripheral_box_x[0], max(head["y"] - peripheral_box_y[0] - 1, 0) + else: # Centre it around our snake's head + peripheral_box_x = max(head["x"] - dim, 0), min(head["x"] + dim + 1, self.board_width) + peripheral_box_y = max(head["y"] - dim, 0), min(head["y"] + dim + 1, self.board_height) + head["x"], head["y"] = head["x"] - peripheral_box_x[0], head["y"] - peripheral_box_y[0] + + return peripheral_box_x, peripheral_box_y, Pos(head) + + def is_move_safe( + self, + move: Pos, + snake_id: str, + turn: Optional[str] = "done" + ) -> tuple[bool, bool]: + """ + Determine if a location on the board is safe (e.g. if it's out-of-bounds or hits a different snake) or risky + (e.g. if there's a chance of a head-to-head collision). Can be used in the middle of running the minimax + algorithm (but make sure to specify the "turn_over" parameter). + + :param move: The location of the snake's hypothetical head as a Pos object e.g. Pos({"x": 5, "y": 10}) + :param snake_id: The ID of the desired snake we're evaluating a move for + :param turn: Either "ours", "opponents", or "done". Addresses nuances with running this function during the + minimax algorithm or outside of it. If "ours", this means we're at a depth where our snake has to make a + move. If "opponents", then we're at a depth where we've made a move but the opponent snakes haven't. If + "done", then both our snake and the opponent snakes have made moves (and one complete turn + has been completed). + + :return: + True if the square is safe, False otherwise + True if the square is risky, False otherwise + """ + # Prevent snake from moving out of bounds + if move.x < 0 or move.x >= self.board_width: + return False, True + if move.y < 0 or move.y >= self.board_height: + return False, True + + # Prevent snake from colliding with other snakes + length = self.all_snakes_dict[snake_id].length + risky_flag = False + for opp_id, opp_snake in self.all_snakes_dict.items(): + + # Different rules apply during the middle of running minimax, depending on whose turn it is since our snake + # makes moves separately from opponent snakes + if turn == "ours": + # We can run into the tail of any snake since it will have to move forward + if move in opp_snake.body[:-1] and snake_id != opp_id: + return False, True + # We cannot hit our own head + elif move in opp_snake.body[1:-1] and snake_id == opp_id: + return False, True + # Flag a move as risky if it could lead to a losing head-to-head collision + elif (snake_id != opp_id # Skip the same snake we're evaluating + and length <= opp_snake.length # Only if the other snake is the same length or longer + and self.manhattan_distance(move, opp_snake.head) == 1): # Only if we're collision-bound + risky_flag = True + + elif turn == "opponents": + if opp_id == self.you.id: + # Our snake's tail is off-limits since we will already have moved + if move in opp_snake.body[1:]: + return False, True + # Avoid losing head-to-head collisions with our snake, but suicidal collisions are fine if our snake + # is the same length and also dies + elif move == opp_snake.head and length < opp_snake.length: + return False, True + else: + # Tail is fine against other opponents + if move in opp_snake.body[:-1]: + return False, True + # Flag a move as risky if it could lead to a losing head-to-head collision + elif (snake_id != opp_id # Skip the same snake we're evaluating + and length <= opp_snake.length # Only if the other snake is the same length or longer + and self.manhattan_distance(move, opp_snake.head) == 1): # Only if we're collision-bound + risky_flag = True + + elif turn == "done": + # Move is invalid if it collides with the body of a snake + if move in opp_snake.body[1:]: + return False, True + # Move is invalid if it collides with the head of a snake that is the same length or longer + elif snake_id != opp_id and move == opp_snake.head and length <= opp_snake.length: + return False, True + + elif turn == "flood_fill": + if move in opp_snake.body: + return False, True + + return True, risky_flag + + def get_obvious_moves( + self, + snake_id: str, + risk_averse: Optional[bool] = True, + sort_by_dist_to: Optional[str] = None, + sort_by_peripheral: Optional[bool] = False + ) -> list: + """ + Return a list of valid moves for any hypothetical snake. + + :param snake_id: The ID of the desired snake we want to find moves for (can also be any opponent snake). + :param risk_averse: Return possible moves that avoid death-inducing collisions (essentially we're assuming our + opponents are out to get us, but only if we're shorter). Set False to include any risky moves towards other + snakes that might kill us. + :param sort_by_dist_to: Input any snake ID here. This will return all possible moves, but sort the moves by the + distance from our snake (after making the move) to the head of the snake whose ID was inputted. Very useful + for discerning which moves are more threatening/bring us closer to a different snake. + :param sort_by_peripheral: If True, return all possible moves, but sort the moves by the amount of space that + each move will give us in our "peripheral vision". Very useful for discerning which moves allow us more + immediate space. + + :return: A list of possible moves for the given snake + """ + # Loop through possible moves and remove from consideration if it's invalid + possible_moves = ["up", "down", "left", "right"] + risky_moves = [] + head = self.all_snakes_dict[snake_id].head + for move in possible_moves.copy(): + is_safe, is_risky = self.is_move_safe( + head.moved_to(move), + snake_id, + turn="ours" if snake_id == self.you.id else "opponents" + ) + if not is_safe or (risk_averse and is_risky): + possible_moves.remove(move) + if is_risky: + risky_moves.append(move) + + if sort_by_dist_to is not None: + head2 = self.all_snakes_dict[sort_by_dist_to].head + possible_moves = sorted(possible_moves, + key=lambda move2: self.closest_distance(head2, head.moved_to(move2))) + if sort_by_peripheral: + possible_moves = sorted(possible_moves, + key=lambda move2: self.flood_fill(snake_id, confined_area=move2), + reverse=True) + # De-prioritise any risky moves and send them to the back + if len(risky_moves) > 0: + for risky in risky_moves: + if risky in possible_moves: + possible_moves.append(possible_moves.pop(possible_moves.index(risky))) + + logging.info(f"Found obvious moves {possible_moves}") + return possible_moves + + def is_game_over(self, for_snake_id: str | list, depth: Optional[int] = None) -> tuple[bool, bool]: + """ + Determine if the game ended for certain snakes or not. Mostly use to know whether our snake died, but can + optionally be used to determine any number of opponent snakes' statuses. + + :param for_snake_id: The ID of the desired snake we want to know died or not + :param depth: During minimax, things get complicated when we call this function right after making a move for + our snake but before the opponent snakes have made moves. We only want to return True when a complete turn + is done (e.g. our snake made a move and our opponents did as well). Thus, we need the current depth that + minimax is on to determine this. + + :return: + True if the overall game has a winner or if our snake is dead, False otherwise + True if the snake associated with the input snake ID is alive, False otherwise + """ + # Skip if we're at the beginning of the game when all snakes are still coiled up + if self.turn == 0: + return False, True + + snake_monitor = {} # A dictionary for each snake showing whether they're alive + for snake_id, snake in self.all_snakes_dict.items(): + # Check if each snake's head is in a safe square, depending on if we're at a depth where only we made a move + is_safe, _ = self.is_move_safe(snake.head, snake_id, turn="done" if depth % 2 == 0 else "ours") + snake_monitor[snake_id] = is_safe + + # Game is over if there's only one snake remaining or if our snake died + game_over = True if (sum(snake_monitor.values()) == 1 or not snake_monitor[self.you.id]) else False + # See if a specific snake is alive or not + if isinstance(for_snake_id, (list, tuple)): + snake_still_alive = [snake_monitor[snake_id] for snake_id in for_snake_id] + else: + snake_still_alive = snake_monitor[self.you.id] if for_snake_id is None else snake_monitor[for_snake_id] + + return game_over, snake_still_alive + + def simulate_move(self, move_dict: dict, evaluate_deaths: Optional[bool] = False) -> Battlesnake: + """ + Create a new Battlesnake instance that simulates a game turn and makes moves for a set of desired snake IDs. To + increase speed, this function builds a new game_state dictionary from scratch to generate the new instance + without affecting the original instance. + + :param move_dict: A dictionary containing moves that we'd like to simulate for a set of snake IDs e.g. + {self.you.id: "left", other_snake_id: "right"} + :param evaluate_deaths: If True, remove any snakes that died as a result of the simulated move (exclusively via + head-to-head collisions) + + :return: A Battlesnake instance incorporating the simulated move in a new game state + """ + # Initialise our snake's data + you = { + "id": self.you.id, + "name": self.you.name, + "health": self.you.health, + "body": self.you.body_dict, + "head": self.you.head.as_dict(), + "length": self.you.length + } + + # Loop through all snakes and simulate a move if provided + all_snakes = [] + for snake_id, snake in self.all_snakes_dict.items(): + if snake_id in move_dict: + # Update the head, body, and health of the snake to reflect the simulated move + new_head: dict = snake.head.moved_to(move_dict[snake_id]).as_dict() + all_snakes.append({ + "id": snake_id, + "name": snake.name, + "head": new_head, + "body": [new_head] + snake.body_dict[:-1], + "length": snake.length, + "health": snake.health - 1, + "food_eaten": new_head if Pos(new_head) in self.food else None + }) + # Repeat for our snake's specific attributes + if snake_id == self.you.id: + you["head"] = new_head + you["body"] = [new_head] + self.you.body_dict[:-1] + you["health"] = self.you.health - 1 + else: + # Add the snake without any changes + all_snakes.append({ + "id": snake_id, + "name": snake.name, + "head": snake.head.as_dict(), + "body": snake.body_dict, + "length": snake.length, + "health": snake.health, + "food_eaten": getattr(snake, "food_eaten", None) + }) + + board = { + "height": self.board_height, + "width": self.board_width, + "food": [xy.as_dict() for xy in self.food], + "hazards": self.hazards.copy(), + "snakes": all_snakes + } + + # Check if any snakes died from this simulated move and remove them from the game + if evaluate_deaths: + # First update snake lengths from any food eaten + for snake_num, snake_dict in enumerate(all_snakes): + if snake_dict["food_eaten"] is not None: + all_snakes[snake_num]["length"] += 1 + all_snakes[snake_num]["health"] = 100 + all_snakes[snake_num]["body"] += [all_snakes[snake_num]["body"][-1]] + board["food"] = [food for food in board["food"] # Remove the food from the board + if not (food["x"] == snake_dict["food_eaten"]["x"] + and food["y"] == snake_dict["food_eaten"]["y"])] + if snake_dict["id"] == self.you.id: + you["length"] += 1 + you["body"] += [you["body"][-1]] + you["health"] = 100 + # Reset the food tracker + all_snakes[snake_num]["food_eaten"] = None + + # Did any snakes die from head-to-head collisions? + all_heads = [(snake["head"]["x"], snake["head"]["y"]) for snake in all_snakes] + count_heads = Counter(all_heads) + butt_heads = [k for k, v in count_heads.items() if v > 1] # Any square where > 1 heads collided + rm_snake_indices = [] + for butt_head in butt_heads: + overlapping_snakes = np.array([ + (snake["id"], snake["length"]) for snake in all_snakes + if (snake["head"]["x"] == butt_head[0] and snake["head"]["y"] == butt_head[1]) + ]) + lengths = overlapping_snakes[:, 1].astype(int) + # If our snake died, don't remove it just yet + # Special cases where the snake committed suicide and also killed our snake => don't remove + if not (self.you.id in overlapping_snakes[:, 0]): + indices_largest_snakes = np.argwhere(lengths == lengths.max()).flatten().tolist() + if len(indices_largest_snakes) > 1: # No winner if the snakes are the same length + winner_id = None + else: + winner_id = overlapping_snakes[:, 0][indices_largest_snakes[0]] + # Remove any dead snakes + for rm_id in overlapping_snakes[:, 0]: + if rm_id != winner_id: + rm_snake_indices.extend([i for i in range(len(all_snakes)) if all_snakes[i]["id"] == rm_id]) + + for i in sorted(rm_snake_indices, reverse=True): + del all_snakes[i] + + # logging.info(f"Done with simulation in {round((time.time_ns() - clock_in) / 1000000, 3)} ms") + new_game = Battlesnake({"turn": self.turn, "board": board, "you": you}, debugging=self.debugging) + return new_game + + def flood_fill( + self, + snake_id: str, + confined_area: Optional[str] = None, + risk_averse: Optional[bool] = False, + fast_forward: Optional[int] = 0, + return_touching_opps: Optional[bool] = False + ) -> int | tuple[int, list]: + """ + Recursive function to get the total available space for a given snake. Basically, count how many £ symbols + we can fill while avoiding any $, o, and x symbols (obstacles). + + :param snake_id: The ID of the desired snake we want to do flood fill for + :param confined_area: Tells the function to do flood fill for only on one side of the snake (either "left", + "right", "up", or "down") to represent its peripheral vision + :param risk_averse: If True, flood fill will avoid any squares that directly border an opponent's head + :param fast_forward: Hypothetical scenarios where we want to see how much space we still have after moving + X turns ahead. E.g. if we set it to 5, then we remove 5 squares from all snake's tails before doing flood + fill - this is only useful when we suspect we'll be trapped by an opponent snake. + :param return_touching_opps: Option to return a list of other snakes whose heads our flood fill is touching + + :return: The total area of the flood fill selection + """ + head = self.all_snakes_dict[snake_id].head + + if snake_id == self.you.id: # Assume we're doing flood fill for our snake + board = copy.deepcopy(self.board) + # See how flood fill changes when all snakes fast-forward X turns + if fast_forward > 0: + for snake in self.all_snakes_dict.values(): + to_remove = max(-(len(snake.body) - 1), -fast_forward) + tail_removed = snake.body[to_remove:] + for remove in tail_removed: + board[remove.x][remove.y] = " " + # Try to avoid any squares that our enemy can go to + if risk_averse: + threats = [other.head for other in self.opponents.values() if other.length >= self.you.length] + for threat in threats: + x, y = threat.x, threat.y + avoid_sq = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)] + for n in avoid_sq: + if not (n[0] == head.x and n[1] == head.y) and \ + (0 <= n[0] < self.board_width and 0 <= n[1] < self.board_height): + board[n[0]][n[1]] = "x" + else: # Otherwise, generate a new board and pretend the opponent snake is our snake + board = np.full((self.board_width, self.board_height), " ") + for num, square in enumerate(self.all_snakes_dict[snake_id].body): + board[square.x, square.y] = "£" if num == 0 else "o" + for other_id, other_snake in self.all_snakes_dict.items(): + if other_id != snake_id: + for num, other_square in enumerate(other_snake.body): + board[other_square.x, other_square.y] = "$" if num == 0 else "x" + + # Narrow down a portion of the board that represents the snake's peripheral vision + if confined_area is not None: + xs, ys, head = self.peripheral_vision(snake_id, confined_area) + board = board[xs[0]:xs[1], ys[0]:ys[1]] + + def fill(x, y, board, initial_square): + if board[x][y] == "$": # Opponent snake heads + opp_heads_in_contact.append(Pos(x, y)) + return + if board[x][y] in ["x", "o"]: # Off-limit squares + return + if board[x][y] == "£" and not initial_square: # Already filled + return + board[x][y] = "£" + neighbours = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)] + for n in neighbours: + if 0 <= n[0] < len(board) and 0 <= n[1] < len(board[0]): + fill(n[0], n[1], board, initial_square=False) + + opp_heads_in_contact = [] + fill(head.x, head.y, board, initial_square=True) + filled = sum((row == "£").sum() for row in board) + flood_fill = max(filled - 1, 0) # Exclude the head from the count, but cannot ever be negative + + if return_touching_opps: + return flood_fill, opp_heads_in_contact + else: + return flood_fill + + def dist_to_nearest_food(self) -> int: + """ + Return the shortest distance to food for our snake, but only if we're closer to it than an opponent snake + """ + best_dist = np.inf + for food in self.food: + dist = self.dijkstra_shortest_path(food, self.you.head) + # If an enemy snake is longer than ours, and we're both 2 squares away from food, then they're technically + # closer to it since they'd win the head-to-head battle. + dist_enemy = min([self.dijkstra_shortest_path(food, snake.head) if snake.length < self.you.length + else self.dijkstra_shortest_path(food, snake.head) - 1 + for snake in self.opponents.values()]) + if dist < best_dist and dist_enemy >= dist: + best_dist = dist + return best_dist + + def edge_kill_detection(self): + """ + Determine if our snake is in a position where it can get edge-killed + """ + # # Ignore if we're not on the edge of the board + # if 0 < self.my_head["x"] < self.board_width - 1 and 0 < self.my_head["y"] < self.board_height - 1: + # return False + + possible_moves = self.get_obvious_moves(self.you.id, risk_averse=True) + direction = self.you.facing_direction() + dir_dict = { + "vertical": { + "bounds": [0, self.board_width], + "escape_dirs": ["left", "right"], + "axis": "x", + "axis_dir": "y", + "scan_dir": +1 if direction == "up" else -1 + }, + "horizontal": { + "bounds": [0, self.board_height], + "escape_dirs": ["down", "up"], + "axis": "y", + "axis_dir": "x", + "scan_dir": +1 if direction == "right" else -1 + }, + } + dir_data = dir_dict["horizontal"] if direction in ["left", "right"] else dir_dict["vertical"] + bounds = dir_data["bounds"] + escape_dirs = dir_data["escape_dirs"] + ax = dir_data["axis"] + ax_dir = dir_data["axis_dir"] + scan_dir = dir_data["scan_dir"] + + # If we can't escape (e.g. we're heading right, but can't move up or down) + trapped_sides = [False, False] + if len(set(escape_dirs).intersection(possible_moves)) == 0: + for num, escape_dir in enumerate(escape_dirs): + look = -1 if num == 0 else +1 + # Scan the column/row to each side of us in ascending order + if escape_dir in ["left", "right"]: + esc_attempt = self.you.head.as_dict()[ax] + look + if bounds[0] <= esc_attempt < bounds[1]: + # Look at the space in the column/row ahead of us + strip = self.board[esc_attempt, self.you.head.as_dict()[ax_dir]:] if scan_dir == +1 \ + else self.board[esc_attempt, :self.you.head.as_dict()[ax_dir]][::-1] + danger_strip = strip[:np.where(strip == "$")[0][0]] if "$" in strip else strip + # Check if there's free space ahead of us, we're trapped if there's none + if np.count_nonzero(danger_strip == " ") == 0: + trapped_sides[num] = True + else: + trapped_sides[num] = True + # If both sides of the snake are blocked in, then we're trapped + return True if sum(trapped_sides) == 2 else False + + def heuristic(self, depth_number: int) -> tuple[float, dict]: + """ + Evaluate the current board for our snake (the higher, the better) + + :param depth_number: The current depth our snake is at in the minimax search tree + + :return: + The computed heuristic value + A dictionary of select metrics to be used for debugging + """ + # Determine how many layers deep in the game tree we are + layers_deep = self.minimax_search_depth - depth_number + + # If an opponent snake dies :) + opponents_left = len(self.opponents) + + # Determine available space via flood fill + available_space = self.flood_fill(self.you.id, risk_averse=True) + available_space_ra = self.flood_fill(self.you.id, risk_averse=False) + if available_space_ra < 4: + space_penalty = -500 + elif available_space < 4: + space_penalty = -200 + else: + space_penalty = 0 + + # ARE WE TRAPPED??? + edge_kill_check = True + if available_space_ra <= 15: + fast_forward_space, opp_heads = self.flood_fill(self.you.id, fast_forward=available_space, return_touching_opps=True) + trap_space = available_space - fast_forward_space + to_remove = max(-(self.you.length - 1), -available_space) + stalling_path = self.stall_path(self.you.head, self.you.body[to_remove]) + if stalling_path < available_space: + space_penalty = -1e7 + trapped = True + + if len(opp_heads) > 0: + dist_to_trapped_opp = self.dijkstra_shortest_path(self.you.head, opp_heads[0]) + trapped_opp_length = [opp_snake.length for opp_snake in self.opponents.values() if opp_snake.head == opp_heads[0]][0] + + if trap_space == 0: + trapped = True + if len(opp_heads) > 0: + if dist_to_trapped_opp % 2 == 1 and self.you.length > trapped_opp_length: + trapped = False + + # Shoot we're trapped + if trapped: + space_penalty = -1e7 # We'd prefer getting killed than getting trapped, so penalise this more + print("WE'RE TRAPPED") + + else: + # ARE WE GOING TO GET EDGE-KILLED??? + possible_edged = self.edge_kill_detection() + if possible_edged: + space_penalty = -1e7 + self.edge_kill_detection() + + # Estimate the space we have in our peripheral vision + available_peripheral = self.flood_fill(self.you.id, confined_area="auto") + + # We want to minimise available space for our opponents via flood fill (but only when there are fewer snakes in + # our vicinity) + if len(self.opponents) <= 3: + # and sum([dist < (self.board_width // 2) for dist in self.dist_from_enemies()]) <= 3 \ + # and len(self.opponents) == sum([self.you.length > s["length"] for s in self.opponents.values()]): + self.peripheral_size = 4 + closest_enemy = sorted(self.opponents.keys(), key=lambda opp_id: self.dijkstra_shortest_path(self.you.head, self.opponents[opp_id].head))[0] + available_enemy_space = self.flood_fill(closest_enemy, confined_area="General") + if available_enemy_space < 4: + kill_bonus = 1000 + else: + kill_bonus = 0 + else: + available_enemy_space = 0 + kill_bonus = 0 + + # Get closer to enemy snakes if we're longer by 3 + if 2 >= len(self.opponents) == sum([self.you.length > s.length + 3 for s in self.opponents.values()]): + dist_from_enemies = sorted([self.dijkstra_shortest_path(self.you.head, opp.head) for opp in self.opponents.values()]) + dist_to_enemy = dist_from_enemies[0] + else: + dist_to_enemy = 0 + + # If we're getting too close to enemy snakes that are longer, RETREAT + threats = [self.dijkstra_shortest_path(self.you.head, opp.head) for opp in self.opponents.values() if opp.length >= self.you.length] + num_threats = (np.count_nonzero(np.array(threats) <= 2) * 2 + + np.count_nonzero(np.array(threats) == 3) * 1) + + # Determine the closest safe distance to food + dist_food = self.dist_to_nearest_food() + health_flag = True if self.you.health < 40 else False + shortest_flag = True if sum([self.you.length <= snake.length for snake in self.opponents.values()]) >= min([2, len(self.opponents)]) else False + longest_flag = True if sum([self.you.length > snake.length for snake in self.opponents.values()]) == len(self.opponents) else False + + # Are we in the centre of the board? Maximise control + centre = range(self.board_width // 2 - 2, self.board_width // 2 + 3) + in_centre = (self.you.head.as_dict()["x"] in centre and self.you.head.as_dict()["x"] in centre) and (len(self.opponents) <= 2) + + # Heuristic formula + space_weight = 1 + peripheral_weight = 2 + enemy_left_weight = 1000 + enemy_restriction_weight = 75 if len(self.opponents) > 2 else 200 + food_weight = 250 if health_flag else 175 if shortest_flag else 25 if longest_flag else 50 + depth_weight = 25 + length_weight = 300 + centre_control_weight = 10 + aggression_weight = 250 if dist_to_enemy > 0 else 0 + threat_proximity_weight = -25 + + logging.info(f"Available space: {available_space}") + logging.info(f"Available peripheral: {available_peripheral}") + logging.info(f"Enemies left: {opponents_left}") + logging.info(f"Threats within 3 squares of us: {num_threats}") + logging.info(f"Distance to nearest enemy: {dist_to_enemy}") + logging.info(f"Distance to nearest food: {dist_food}") + logging.info(f"Layers deep in search tree: {layers_deep}") + logging.info(f"Available enemy space: {available_enemy_space}") + logging.info(f"Kill bonus: {kill_bonus}") + logging.info(f"In centre: {in_centre}") + logging.info(f"Length: {self.you.length}") + + h = (available_space * space_weight) + space_penalty + \ + (peripheral_weight * available_peripheral) + \ + (enemy_left_weight / (opponents_left + 1)) + \ + (threat_proximity_weight * num_threats) + \ + (food_weight / (dist_food + 1)) + \ + (layers_deep * depth_weight) + \ + (self.you.length * length_weight) + \ + in_centre * centre_control_weight + \ + aggression_weight / (dist_to_enemy + 1) + \ + (enemy_restriction_weight / (available_enemy_space + 1)) + kill_bonus + + return h, {"Heur": round(h, 2), + "Space": available_space, + "Penalty": space_penalty, + "Periph": available_peripheral, + "Food Dist": dist_food, + "Enemy Dist": dist_to_enemy, + "Enemy Kill": available_enemy_space + kill_bonus, + "Threats": num_threats, + "Length": self.you.length} + + @staticmethod + def update_tree_visualisation(depth, add_edges=False, add_nodes=False, node_data=None, insert_index=None): + global tree_node_counter + global tree_tracker + if add_edges: + # Add the node that we'll be creating the edge to + tree_tracker[depth].append(tree_node_counter) + # Tuple of (node_1, node_2, node_attributes) where the edge is created between node_1 and node_2 + global tree_edges + tree_edges.append((tree_tracker[depth + 1][-1], tree_tracker[depth][-1], {"colour": "k", "width": 1})) + # Now we're going to be on the next node + tree_node_counter += 1 + return len(tree_edges) - 1 + + if add_nodes: + global tree_nodes + if insert_index is not None: + node_move = tree_nodes[insert_index][1] + formatted_dict = str(node_data).replace(", ", "\n").replace("{", "").replace("}", "").replace("'", "") + tree_nodes[insert_index] = (tree_tracker[depth][-1], node_move + "\n" + formatted_dict) + else: + tree_nodes.append((tree_tracker[depth][-1], str(node_data).replace("'", ""))) + return len(tree_nodes) - 1 + + def minimax(self, depth, alpha, beta, maximising_snake): + """ + Implement the minimax algorithm with alpha-beta pruning + + :param depth: + :param alpha: + :param beta: + :param maximising_snake: + + :return: + """ + if depth != self.minimax_search_depth: + # Check if our snake died + game_over, still_alive = self.is_game_over(for_snake_id=self.you.id, depth=depth) + if not still_alive: + logging.info("Our snake died...") + heuristic = -1e6 + (self.minimax_search_depth - depth) # Reward slower deaths + return heuristic, None, {"Heur": heuristic} + # Otherwise, if our snake is ALIVE and is the winner :) + elif game_over: + logging.info("OUR SNAKE WON") + heuristic = 1e6 + depth # Reward faster kills + return heuristic, None, heuristic + + # At the bottom of the decision tree or if we won/lost the game + if depth == 0: + logging.info("=" * 50) + logging.info(f"DEPTH = {depth}") + heuristic, heuristic_data = self.heuristic(depth_number=depth) + logging.info(f"Heuristic = {heuristic} at terminal node") + return heuristic, None, heuristic_data + + global tree_edges + # Minimax on our snake + if maximising_snake: + logging.info("=" * 50) + logging.info(f"DEPTH = {depth} OUR SNAKE") + logging.info(f"ALPHA = {alpha} | beta = {beta}") + + clock_in = time.time_ns() + possible_moves = self.get_obvious_moves( # If > 6 opponents, we'll do depth = 2 and risk_averse = True + self.you.id, risk_averse=(len(self.opponents) > 6), sort_by_peripheral=True) + if len(possible_moves) == 0 and len(self.opponents) > 6: # Try again, but do any risky move + possible_moves = self.get_obvious_moves(self.you.id, risk_averse=False, sort_by_peripheral=True) + if len(possible_moves) == 0: # RIP + possible_moves = ["down"] + + best_val, best_move = -np.inf, None + best_node_data, best_edge = None, None + for num, move in enumerate(possible_moves): + SIMULATED_BOARD_INSTANCE = self.simulate_move({self.you.id: move}) + + logging.info(f"{len(possible_moves)} CHILD NODES: VISITING {num + 1} OF {len(possible_moves)}") + logging.info(f"Running minimax for OUR SNAKE moving {move}") + if self.debugging: + SIMULATED_BOARD_INSTANCE.display_board() + + clock_in2 = time.time_ns() + edge_added = self.update_tree_visualisation(add_edges=True, depth=depth - 1) + node_added = self.update_tree_visualisation(add_nodes=True, depth=depth - 1, node_data=move) + node_val, node_move, node_data = SIMULATED_BOARD_INSTANCE.minimax(depth - 1, alpha, beta, False) + self.update_tree_visualisation(add_nodes=True, depth=depth - 1, node_data=node_data, insert_index=node_added) + + logging.info("=" * 50) + logging.info(f"BACK AT DEPTH = {depth} OUR SNAKE") + logging.info(f"ALPHA = {alpha} | beta = {beta}") + + # Update best score and best move + if np.argmax([best_val, node_val]) == 1: + best_move = move + best_node_data, best_edge = node_data, edge_added + best_val = max(best_val, node_val) + old_alpha = alpha + alpha = max(alpha, best_val) + + logging.info(f"Updated ALPHA from {old_alpha} to {alpha}") + logging.info(f"Identified best move so far = {best_move} in {round((time.time_ns() - clock_in2) / 1000000, 3)} ms") + + # Check to see if we can prune + if alpha >= beta: + logging.info(f"PRUNED!!! Alpha = {alpha} >= Beta = {beta}") + break + + tree_edges[best_edge][2]["colour"] = "r" + tree_edges[best_edge][2]["width"] = 4 + logging.info(f"FINISHED MINIMAX LAYER on our snake in {round((time.time_ns() - clock_in) / 1000000, 3)} ms") + return best_val, best_move, best_node_data + + # Minimax on opponent snakes + else: + logging.info("=" * 50) + logging.info(f"DEPTH = {depth} OPPONENT SNAKES") + logging.info(f"BETA = {beta} | alpha = {alpha}") + + clock_in = time.time_ns() + + # Only simulate full set of opponent moves if they're within a reasonable distance of our snake + if len(self.opponents) == 1: + search_within = self.board_width * self.board_height + elif len(self.opponents) <= 3: + search_within = self.board_width + else: + search_within = self.board_width // 2 + 1 + + # Grab all possible opponent moves + opps_nearby = 0 # Counter for opponents in our vicinity + opps_moves = {} # Store possible moves for each snake id + for opp_id, opp_snake in self.opponents.items(): + opp_move = self.get_obvious_moves(opp_id, risk_averse=False, sort_by_dist_to=self.you.id) + if len(opp_move) == 0: # If the snake has no legal moves, move down and die + opp_move = ["down"] + + # Save time by only using full opponent move sets if they're within a certain range + dist_opp_to_us = self.manhattan_distance(self.you.head, opp_snake.head) + if dist_opp_to_us <= search_within: + opps_moves[opp_id] = opp_move # Put all of their moves + opps_nearby += 1 + else: + opps_moves[opp_id] = [opp_move[0]] + + sorted_by_dists = sorted(self.opponents.keys(), + key=lambda opp_id2: self.manhattan_distance( + self.you.head, self.opponents[opp_id2].head)) + opps_moves = dict(sorted(opps_moves.items(), key=lambda pair: sorted_by_dists.index(pair[0]))) + + logging.info(f"Found {opps_nearby} of {len(self.opponents)} OPPONENT SNAKES within {search_within} " + f"squares of us in {round((time.time_ns() - clock_in) / 1000000, 3)} ms") + + clock_in = time.time_ns() + # If >= 3 board simulations, then randomly sample 3 of them based on how threatening the position is to our + # snake to cut down on runtime + all_opp_combos = list(itertools.product(*opps_moves.values())) + if len(all_opp_combos) > 2 and len(self.opponents) > 2: + logging.info(f"FOUND {len(all_opp_combos)} BOARDS BUT CUTTING DOWN TO 2") + cutoff = 3 + elif len(all_opp_combos) > 3: + logging.info(f"FOUND {len(all_opp_combos)} BOARDS BUT CUTTING DOWN TO 3") + cutoff = 3 + else: + cutoff = 3 + + if len(opps_moves) > 0 and len(all_opp_combos) > cutoff: + covered_ids = [list(opps_moves.keys())[0]] + all_opp_combos2 = [] + while len(all_opp_combos2) < cutoff: + combo_counter = 1 + for s_id, s in opps_moves.items(): + if s_id not in covered_ids: + combo_counter = combo_counter * len(s) + index_getter = np.arange(len(covered_ids) - 1, len(all_opp_combos), combo_counter) + getter = [all_opp_combos[i] for i in index_getter] + all_opp_combos2.extend(getter) + + all_opp_combos = all_opp_combos2[:cutoff] + + possible_movesets = [] + possible_sims = [] + # Get all possible boards by simulating moves for each opponent snake, one at a time + # SIMULATED_BOARD_INSTANCE = self.__copy__() + for move_combo in all_opp_combos: + opp_move_dict = {} + for num, move in enumerate(move_combo): + # evaluate_flag = (num + 1 == len(move_combo)) + opp_move_dict[list(opps_moves.keys())[num]] = move + + SIMULATED_BOARD_INSTANCE2 = self.simulate_move(opp_move_dict, evaluate_deaths=True) + possible_sims.append(SIMULATED_BOARD_INSTANCE2) + possible_movesets.append(move_combo) + + logging.info(f"SIMULATED {len(possible_sims)} POSSIBLE BOARDS OF OPPONENT MOVE COMBOS in " + f"{round((time.time_ns() - clock_in) / 1000000, 3)} ms") + + clock_in = time.time_ns() + best_val, best_move = np.inf, None + best_node_data, best_edge = None, None + for num, SIMULATED_BOARD_INSTANCE in enumerate(possible_sims): + logging.info(f"{len(possible_sims)} CHILD NODES: VISITING {num + 1} OF {len(possible_sims)}") + logging.info(f"Running minimax for OPPONENT SNAKES moving {possible_movesets[num]}") + if self.debugging: + SIMULATED_BOARD_INSTANCE.display_board() + clock_in2 = time.time_ns() + edge_added = self.update_tree_visualisation(add_edges=True, depth=depth - 1) + node_added = self.update_tree_visualisation(add_nodes=True, depth=depth - 1, node_data=str(possible_movesets[num])) + node_val, node_move, node_data = SIMULATED_BOARD_INSTANCE.minimax(depth - 1, alpha, beta, True) + self.update_tree_visualisation(add_nodes=True, depth=depth - 1, node_data=node_data, + insert_index=node_added) + + logging.info("=" * 50) + logging.info(f"BACK AT DEPTH = {depth} OPPONENT SNAKES") + logging.info(f"BETA = {beta} | alpha = {alpha}") + + # Update best score and best move + if np.argmin([best_val, node_val]) == 1: + best_move = possible_movesets[num] + best_node_data, best_edge = node_data, edge_added + best_val = min(best_val, node_val) + old_beta = beta + beta = min(beta, best_val) + + logging.info(f"Updated BETA from {old_beta} to {beta}") + logging.info(f"Identified best move so far = {best_move} in {round((time.time_ns() - clock_in2) / 1000000, 3)} ms") + + # Check to see if we can prune + if beta <= alpha: + logging.info(f"PRUNED!!! Beta = {beta} <= Alpha = {alpha}") + break + + tree_edges[best_edge][2]["colour"] = "r" + tree_edges[best_edge][2]["width"] = 4 + logging.info(f"FINISHED MINIMAX LAYER on opponents in {(time.time_ns() - clock_in) // 1000000} ms") + return best_val, best_move, best_node_data + + def optimal_move(self): + """Let's run the minimax algorithm with alpha-beta pruning!""" + # Compute the best score of each move using the minimax algorithm with alpha-beta pruning + if self.turn < 3: # Our first 3 moves are super self-explanatory tbh + search_depth = 2 + elif len(self.opponents) > 6: + search_depth = 4 # TODO should be risk-averse + elif len(self.opponents) >= 4: + search_depth = 4 + else: + search_depth = self.minimax_search_depth + + tree_tracker[search_depth].append(0) + _, best_move, _ = self.minimax(depth=search_depth, alpha=-np.inf, beta=np.inf, maximising_snake=True) + + print("GRAPH") + print(f"Total time graphs: {tot_time_graph}") + print(f"Count of runs: {counter_graph}") + print(f"Time copying graphs: {copy_graph}") + + # Output a visualisation of the minimax decision tree for debugging + if self.debugging: + import networkx as nx + G = nx.Graph() + node_labels = {} + for node in tree_nodes: + G.add_node(node[0]) + node_labels[node[0]] = node[1] + G.add_node(0) + node_labels[0] = self.display_board(return_string=True) + G.add_edges_from(tree_edges) + pos = hierarchy_pos(G, 0) + edge_colours = [G[u][v]["colour"] for u, v in G.edges()] + edge_widths = [G[u][v]["width"] for u, v in G.edges()] + + fig = plt.figure(figsize=(50, 25)) + nx.draw(G, pos=pos, node_color=["white"] * G.number_of_nodes(), edge_color=edge_colours, width=edge_widths, + labels=node_labels, with_labels=True, node_size=40000, font_size=20) + plt.savefig("minimax_tree.png", bbox_inches="tight", pad_inches=0) + + return best_move \ No newline at end of file diff --git a/tests.py b/tests.py new file mode 100644 index 00000000..ab0b34cd --- /dev/null +++ b/tests.py @@ -0,0 +1,24 @@ +from snake_engine import Battlesnake + + +# Should've gotten food +game_state = {"game":{"id":"8533c7c3-8372-4d85-acc4-4954d5c0aa4b","ruleset":{"name":"standard","version":"cli","settings":{"foodSpawnChance":15,"minimumFood":1,"hazardDamagePerTurn":14,"hazardMap":"","hazardMapAuthor":"","royale":{"shrinkEveryNTurns":25},"squad":{"allowBodyCollisions":False,"sharedElimination":False,"sharedHealth":False,"sharedLength":False}}},"map":"standard","timeout":500,"source":""},"turn":178,"board":{"height":11,"width":11,"snakes":[{"id":"30b31eb5-c9cf-48e7-9c40-b3824fff2cc3","name":"Nightwing","latency":"351","health":100,"body":[{"x":6,"y":8},{"x":5,"y":8},{"x":4,"y":8},{"x":3,"y":8},{"x":3,"y":7},{"x":2,"y":7},{"x":1,"y":7},{"x":0,"y":7},{"x":0,"y":6},{"x":0,"y":5},{"x":0,"y":4},{"x":0,"y":3},{"x":0,"y":2},{"x":1,"y":2},{"x":1,"y":3},{"x":1,"y":3}],"head":{"x":6,"y":8},"length":16,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}},{"id":"46b6ee1d-6ed1-472b-ab03-53fff9c83431","name":"JonK","latency":"23","health":96,"body":[{"x":9,"y":3},{"x":9,"y":2},{"x":9,"y":1},{"x":10,"y":1},{"x":10,"y":0},{"x":9,"y":0},{"x":8,"y":0},{"x":7,"y":0},{"x":6,"y":0},{"x":5,"y":0},{"x":4,"y":0},{"x":4,"y":1},{"x":4,"y":2},{"x":4,"y":3},{"x":4,"y":4},{"x":5,"y":4},{"x":6,"y":4},{"x":7,"y":4}],"head":{"x":9,"y":3},"length":18,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}}],"food":[{"x":9,"y":10},{"x":10,"y":9}],"hazards":[]},"you":{"id":"ad40b1ef-e988-45ab-a06d-ad3f0e060f91","name":"Glynn","latency":"135","health":88,"body":[{"x":6,"y":2},{"x":5,"y":2},{"x":4,"y":2}],"head":{"x":6,"y":2},"length":3,"shout":"","squad":"","customizations":{"color":"#6600ff","head":"all-seeing","tail":"weight"}}} +next_move = Battlesnake(game_state).optimal_move() +assert next_move in ["up", "right"] + +# Avoid getting trapped +game_state = {"game":{"id":"8533c7c3-8372-4d85-acc4-4954d5c0aa4b","ruleset":{"name":"standard","version":"cli","settings":{"foodSpawnChance":15,"minimumFood":1,"hazardDamagePerTurn":14,"hazardMap":"","hazardMapAuthor":"","royale":{"shrinkEveryNTurns":25},"squad":{"allowBodyCollisions":False,"sharedElimination":False,"sharedHealth":False,"sharedLength":False}}},"map":"standard","timeout":500,"source":""},"turn":246,"board":{"height":11,"width":11,"snakes":[{"id":"30b31eb5-c9cf-48e7-9c40-b3824fff2cc3","name":"Nightwing","latency":"255","health":89,"body":[{"x":10,"y":4},{"x":9,"y":4},{"x":9,"y":3},{"x":9,"y":2},{"x":9,"y":1},{"x":9,"y":0},{"x":8,"y":0},{"x":8,"y":1},{"x":8,"y":2},{"x":8,"y":3},{"x":8,"y":4},{"x":8,"y":5},{"x":7,"y":5},{"x":7,"y":4},{"x":7,"y":3},{"x":7,"y":2},{"x":7,"y":1}],"head":{"x":10,"y":4},"length":17,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}},{"id":"46b6ee1d-6ed1-472b-ab03-53fff9c83431","name":"JonK","latency":"24","health":100,"body":[{"x":9,"y":7},{"x":8,"y":7},{"x":7,"y":7},{"x":6,"y":7},{"x":6,"y":8},{"x":5,"y":8},{"x":5,"y":9},{"x":5,"y":10},{"x":4,"y":10},{"x":3,"y":10},{"x":2,"y":10},{"x":2,"y":9},{"x":1,"y":9},{"x":0,"y":9},{"x":0,"y":8},{"x":0,"y":7},{"x":0,"y":6},{"x":0,"y":5},{"x":0,"y":4},{"x":1,"y":4},{"x":2,"y":4},{"x":2,"y":5},{"x":2,"y":6},{"x":1,"y":6},{"x":1,"y":7},{"x":1,"y":7}],"head":{"x":9,"y":7},"length":26,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}}],"food":[{"x":5,"y":5}],"hazards":[]},"you":{"id":"ad40b1ef-e988-45ab-a06d-ad3f0e060f91","name":"Glynn","latency":"135","health":88,"body":[{"x":6,"y":2},{"x":5,"y":2},{"x":4,"y":2}],"head":{"x":6,"y":2},"length":3,"shout":"","squad":"","customizations":{"color":"#6600ff","head":"all-seeing","tail":"weight"}}} +game_state = {"game":{"id":"93583e37-a5d1-4f1a-b682-57883240be6a","ruleset":{"name":"standard","version":"cli","settings":{"foodSpawnChance":15,"minimumFood":1,"hazardDamagePerTurn":14,"hazardMap":"","hazardMapAuthor":"","royale":{"shrinkEveryNTurns":25},"squad":{"allowBodyCollisions":False,"sharedElimination":False,"sharedHealth":False,"sharedLength":False}}},"map":"standard","timeout":500,"source":""},"turn":219,"board":{"height":11,"width":11,"snakes":[{"id":"3d44d587-99e5-4340-8ae4-143247074998","name":"JonK","latency":"21","health":98,"body":[{"x":9,"y":6},{"x":9,"y":5},{"x":10,"y":5},{"x":10,"y":4},{"x":9,"y":4},{"x":8,"y":4},{"x":7,"y":4},{"x":7,"y":3},{"x":6,"y":3},{"x":5,"y":3},{"x":5,"y":2},{"x":4,"y":2},{"x":3,"y":2},{"x":3,"y":3},{"x":4,"y":3},{"x":4,"y":4},{"x":5,"y":4},{"x":5,"y":5},{"x":6,"y":5},{"x":7,"y":5}],"head":{"x":9,"y":6},"length":20,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}},{"id":"b4346106-cf74-4809-ab5d-ee2d1fc4686f","name":"Nightwing","latency":"39","health":100,"body":[{"x":10,"y":1},{"x":9,"y":1},{"x":8,"y":1},{"x":7,"y":1},{"x":6,"y":1},{"x":5,"y":1},{"x":4,"y":1},{"x":3,"y":1},{"x":2,"y":1},{"x":1,"y":1},{"x":1,"y":2},{"x":0,"y":2},{"x":0,"y":3},{"x":1,"y":3},{"x":1,"y":4},{"x":1,"y":5},{"x":2,"y":5},{"x":2,"y":6},{"x":3,"y":6},{"x":3,"y":7},{"x":3,"y":7}],"head":{"x":10,"y":1},"length":21,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}}],"food":[{"x":5,"y":9}],"hazards":[]},"you":{"id":"362c38e0-ccb9-43f0-8190-fdae1b1ff8a6","name":"JonK3","latency":"32","health":48,"body":[{"x":8,"y":6},{"x":9,"y":6},{"x":9,"y":7},{"x":10,"y":7},{"x":10,"y":6}],"head":{"x":8,"y":6},"length":5,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}}} +next_move = Battlesnake(game_state).optimal_move() +assert next_move in ["down"] + + +# More space +game_state = {"game":{"id":"b71bf7c8-36a9-47ca-b269-f512597a2527","ruleset":{"name":"standard","version":"cli","settings":{"foodSpawnChance":15,"minimumFood":1,"hazardDamagePerTurn":14,"hazardMap":"","hazardMapAuthor":"","royale":{"shrinkEveryNTurns":25},"squad":{"allowBodyCollisions":False,"sharedElimination":False,"sharedHealth":False,"sharedLength":False}}},"map":"standard","timeout":500,"source":""},"turn":28,"board":{"height":11,"width":11,"snakes":[{"id":"e82444ff-0970-4b12-b65f-a6336f5a32cc","name":"Rick2","latency":"137","health":98,"body":[{"x":5,"y":7},{"x":5,"y":6},{"x":4,"y":6},{"x":4,"y":5},{"x":4,"y":4},{"x":4,"y":3}],"head":{"x":5,"y":7},"length":6,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"bd22d84f-5c11-4cb2-96e9-5867ec8b59db","name":"Glynn","latency":"44","health":72,"body":[{"x":7,"y":7},{"x":8,"y":7},{"x":8,"y":8}],"head":{"x":7,"y":7},"length":3,"shout":"","squad":"","customizations":{"color":"#6600ff","head":"all-seeing","tail":"weight"}},{"id":"8cb70727-46d9-4b4c-a5be-d5b2a4bf753e","name":"Jesse","latency":"25","health":78,"body":[{"x":6,"y":8},{"x":5,"y":8},{"x":5,"y":9},{"x":6,"y":9}],"head":{"x":6,"y":8},"length":4,"shout":"","squad":"","customizations":{"color":"#E04C07","head":"missile","tail":"nr-booster"}},{"id":"091cc519-8e82-4b29-bc00-1d08b0c5489e","name":"Nightwing","latency":"103","health":96,"body":[{"x":0,"y":8},{"x":1,"y":8},{"x":1,"y":9},{"x":2,"y":9},{"x":2,"y":8},{"x":3,"y":8},{"x":3,"y":7},{"x":3,"y":6}],"head":{"x":0,"y":8},"length":8,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}},{"id":"478b4690-3f18-4846-af95-e0d96b894b32","name":"Rick","latency":"135","health":92,"body":[{"x":2,"y":4},{"x":2,"y":3},{"x":2,"y":2},{"x":2,"y":1},{"x":2,"y":0},{"x":3,"y":0},{"x":4,"y":0},{"x":5,"y":0}],"head":{"x":2,"y":4},"length":8,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}}],"food":[{"x":4,"y":10},{"x":5,"y":10}],"hazards":[]},"you":{"id":"e9c795aa-9235-4b12-9325-a449cc75b19f","name":"Matt2","latency":"258","health":90,"body":[{"x":10,"y":8},{"x":9,"y":8},{"x":8,"y":8}],"head":{"x":10,"y":8},"length":3,"shout":"","squad":"","customizations":{"color":"#1f9490","head":"default","tail":"default"}}} +next_move = Battlesnake(game_state).optimal_move() +assert next_move in ["up"] + +# don't need food +game_state={"game":{"id":"05c3c37b-4861-476a-bbc2-b0ce4d30a8af","ruleset":{"name":"standard","version":"cli","settings":{"foodSpawnChance":15,"minimumFood":1,"hazardDamagePerTurn":14,"hazardMap":"","hazardMapAuthor":"","royale":{"shrinkEveryNTurns":25},"squad":{"allowBodyCollisions":False,"sharedElimination":False,"sharedHealth":False,"sharedLength":False}}},"map":"standard","timeout":500,"source":""},"turn":45,"board":{"height":11,"width":11,"snakes":[{"id":"8e6c3427-ebf7-49ed-9b23-700342aef7f6","name":"JonK3","latency":"26","health":90,"body":[{"x":8,"y":3},{"x":7,"y":3},{"x":7,"y":4},{"x":7,"y":5},{"x":7,"y":6},{"x":7,"y":7},{"x":7,"y":8}],"head":{"x":8,"y":3},"length":7,"shout":"","squad":"","customizations":{"color":"#B7410E","head":"sleepy","tail":"offroad"}},{"id":"2d41193c-8534-400f-9717-d4ee9272ea4f","name":"Rick4","latency":"158","health":87,"body":[{"x":9,"y":2},{"x":8,"y":2},{"x":7,"y":2},{"x":7,"y":1},{"x":6,"y":1}],"head":{"x":9,"y":2},"length":5,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"9110ff66-e559-46ce-9a35-63628ba2ac2b","name":"Nightwing","latency":"72","health":99,"body":[{"x":1,"y":2},{"x":1,"y":3},{"x":0,"y":3},{"x":0,"y":4},{"x":0,"y":5},{"x":0,"y":6},{"x":0,"y":7},{"x":1,"y":7},{"x":1,"y":8}],"head":{"x":1,"y":2},"length":9,"shout":"","squad":"","customizations":{"color":"#3333ff","head":"ski","tail":"mystic-moon"}},{"id":"571a52e2-2e02-4b77-91f2-e8ad7af0c2f1","name":"Rick","latency":"151","health":78,"body":[{"x":3,"y":4},{"x":3,"y":5},{"x":3,"y":6},{"x":3,"y":7},{"x":3,"y":8},{"x":4,"y":8}],"head":{"x":3,"y":4},"length":6,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}},{"id":"4e3cc686-d592-40c1-bd25-3be41bccd872","name":"Rick2","latency":"114","health":94,"body":[{"x":6,"y":3},{"x":6,"y":4},{"x":6,"y":5},{"x":6,"y":6},{"x":5,"y":6},{"x":4,"y":6},{"x":4,"y":5},{"x":4,"y":4}],"head":{"x":6,"y":3},"length":8,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}}],"food":[{"x":0,"y":0}],"hazards":[]},"you":{"id":"571a52e2-2e02-4b77-91f2-e8ad7af0c2f1","name":"Rick","latency":"151","health":78,"body":[{"x":3,"y":4},{"x":3,"y":5},{"x":3,"y":6},{"x":3,"y":7},{"x":3,"y":8},{"x":4,"y":8}],"head":{"x":3,"y":4},"length":6,"shout":"","squad":"","customizations":{"color":"#00ff00","head":"caffeine","tail":"coffee"}}} +next_move = Battlesnake(game_state).optimal_move() +assert next_move in ["right"] \ No newline at end of file