DEV-1761: New rules API (#118)

* DEV-1761: Clean up Ruleset interface (#115)

* remove legacy ruleset types and simplify ruleset interface

* remove unnecessary settings argument from Ruleset interface

* decouple rules.Settings from client API and store settings as strings

* DEV 1761: Add new BoardState and Point fields (#117)

* add Point.TTL, Point.Value, GameState and PointState to BoardState

* allow maps to access BoardState.GameState,PointState

* add PreUpdateBoard and refactor snail_mode with it

* fix bug where an extra turn was printed to the console

* fix formatting

* fix lint errors

Co-authored-by: JonathanArns <jonathan.arns@googlemail.com>
This commit is contained in:
Rob O'Dwyer 2022-10-28 16:49:49 -07:00 committed by GitHub
parent 639362ef46
commit 82e1999126
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 1349 additions and 1610 deletions

View file

@ -88,7 +88,9 @@ func NewPlayCommand() *cobra.Command {
if err := gameState.Initialize(); err != nil {
log.ERROR.Fatalf("Error initializing game: %v", err)
}
gameState.Run()
if err := gameState.Run(); err != nil {
log.ERROR.Fatalf("Error running game: %v", err)
}
},
}
@ -143,7 +145,6 @@ func (gameState *GameState) Initialize() error {
// Create settings object
gameState.settings = map[string]string{
rules.ParamGameType: gameState.GameType,
rules.ParamFoodSpawnChance: fmt.Sprint(gameState.FoodSpawnChance),
rules.ParamMinimumFood: fmt.Sprint(gameState.MinimumFood),
rules.ParamHazardDamagePerTurn: fmt.Sprint(gameState.HazardDamagePerTurn),
@ -155,7 +156,7 @@ func (gameState *GameState) Initialize() error {
WithSeed(gameState.Seed).
WithParams(gameState.settings).
WithSolo(len(gameState.URLs) < 2).
Ruleset()
NamedRuleset(gameState.GameType)
gameState.ruleset = ruleset
// Initialize snake states as empty until we can ping the snake URLs
@ -173,13 +174,22 @@ func (gameState *GameState) Initialize() error {
}
// Setup and run a full game.
func (gameState *GameState) Run() {
func (gameState *GameState) Run() error {
var gameOver bool
var err error
// Setup local state for snakes
gameState.snakeStates = gameState.buildSnakesFromOptions()
gameState.snakeStates, err = gameState.buildSnakesFromOptions()
if err != nil {
return fmt.Errorf("Error getting snake metadata: %w", err)
}
rand.Seed(gameState.Seed)
boardState := gameState.initializeBoardFromArgs()
gameOver, boardState, err := gameState.initializeBoardFromArgs()
if err != nil {
return fmt.Errorf("Error initializing board: %w", err)
}
gameExporter := GameExporter{
game: gameState.createClientGame(),
@ -209,7 +219,7 @@ func (gameState *GameState) Run() {
if gameState.ViewInBrowser {
serverURL, err := boardServer.Listen()
if err != nil {
log.ERROR.Fatalf("Error starting HTTP server: %v", err)
return fmt.Errorf("Error starting HTTP server: %w", err)
}
defer boardServer.Shutdown()
log.INFO.Printf("Board server listening on %s", serverURL)
@ -233,29 +243,37 @@ func (gameState *GameState) Run() {
gameState.printState(boardState)
}
// Export game first, if enabled, so that we capture the request for turn zero.
if exportGame {
// The output file was designed in a way so that (nearly) every entry is equivalent to a valid API request.
// This is meant to help unlock further development of tools such as replaying a saved game by simply copying each line and sending it as a POST request.
// There was a design choice to be made here: the difference between SnakeRequest and BoardState is the `you` key.
// We could choose to either store the SnakeRequest of each snake OR to omit the `you` key OR fill the `you` key with one of the snakes
// In all cases the API request is technically non-compliant with how the actual API request should be.
// The third option (filling the `you` key with an arbitrary snake) is the closest to the actual API request that would need the least manipulation to
// be adjusted to look like an API call for a specific snake in the game.
for _, snakeState := range gameState.snakeStates {
snakeRequest := gameState.getRequestBodyForSnake(boardState, snakeState)
gameExporter.AddSnakeRequest(snakeRequest)
break
}
}
var endTime time.Time
for v := false; !v; v, _ = gameState.ruleset.IsGameOver(boardState) {
for !gameOver {
if gameState.TurnDuration > 0 {
endTime = time.Now().Add(time.Duration(gameState.TurnDuration) * time.Millisecond)
}
// Export game first, if enabled, so that we save the board on turn zero
if exportGame {
// The output file was designed in a way so that (nearly) every entry is equivalent to a valid API request.
// This is meant to help unlock further development of tools such as replaying a saved game by simply copying each line and sending it as a POST request.
// There was a design choice to be made here: the difference between SnakeRequest and BoardState is the `you` key.
// We could choose to either store the SnakeRequest of each snake OR to omit the `you` key OR fill the `you` key with one of the snakes
// In all cases the API request is technically non-compliant with how the actual API request should be.
// The third option (filling the `you` key with an arbitrary snake) is the closest to the actual API request that would need the least manipulation to
// be adjusted to look like an API call for a specific snake in the game.
for _, snakeState := range gameState.snakeStates {
snakeRequest := gameState.getRequestBodyForSnake(boardState, snakeState)
gameExporter.AddSnakeRequest(snakeRequest)
break
}
gameOver, boardState, err = gameState.createNextBoardState(boardState)
if err != nil {
return fmt.Errorf("Error processing game: %w", err)
}
boardState = gameState.createNextBoardState(boardState)
if gameOver {
// Stop processing here - because game over is detected at the start of the pipeline, nothing will have changed.
break
}
if gameState.ViewMap {
gameState.printMap(boardState)
@ -274,14 +292,13 @@ func (gameState *GameState) Run() {
if gameState.ViewInBrowser {
boardServer.SendEvent(gameState.buildFrameEvent(boardState))
}
}
// Export final turn
if exportGame {
for _, snakeState := range gameState.snakeStates {
snakeRequest := gameState.getRequestBodyForSnake(boardState, snakeState)
gameExporter.AddSnakeRequest(snakeRequest)
break
if exportGame {
for _, snakeState := range gameState.snakeStates {
snakeRequest := gameState.getRequestBodyForSnake(boardState, snakeState)
gameExporter.AddSnakeRequest(snakeRequest)
break
}
}
}
@ -320,24 +337,26 @@ func (gameState *GameState) Run() {
if exportGame {
lines, err := gameExporter.FlushToFile(gameState.outputFile)
if err != nil {
log.ERROR.Fatalf("Unable to export game. Reason: %v", err)
return fmt.Errorf("Unable to export game: %w", err)
}
log.INFO.Printf("Wrote %d lines to output file: %s", lines, gameState.OutputPath)
}
return nil
}
func (gameState *GameState) initializeBoardFromArgs() *rules.BoardState {
func (gameState *GameState) initializeBoardFromArgs() (bool, *rules.BoardState, error) {
snakeIds := []string{}
for _, snakeState := range gameState.snakeStates {
snakeIds = append(snakeIds, snakeState.ID)
}
boardState, err := maps.SetupBoard(gameState.gameMap.ID(), gameState.ruleset.Settings(), gameState.Width, gameState.Height, snakeIds)
if err != nil {
log.ERROR.Fatalf("Error Initializing Board State: %v", err)
return false, nil, fmt.Errorf("Error initializing BoardState with map: %w", err)
}
boardState, err = gameState.ruleset.ModifyInitialBoardState(boardState)
gameOver, boardState, err := gameState.ruleset.Execute(boardState, nil)
if err != nil {
log.ERROR.Fatalf("Error Initializing Board State: %v", err)
return false, nil, fmt.Errorf("Error initializing BoardState with ruleset: %w", err)
}
for _, snakeState := range gameState.snakeStates {
@ -351,12 +370,18 @@ func (gameState *GameState) initializeBoardFromArgs() *rules.BoardState {
log.WARN.Printf("Request to %v failed", u.String())
}
}
return boardState
return gameOver, boardState, nil
}
func (gameState *GameState) createNextBoardState(boardState *rules.BoardState) *rules.BoardState {
stateUpdates := make(chan SnakeState, len(gameState.snakeStates))
func (gameState *GameState) createNextBoardState(boardState *rules.BoardState) (bool, *rules.BoardState, error) {
// apply PreUpdateBoard before making requests to snakes
boardState, err := maps.PreUpdateBoard(gameState.gameMap, boardState, gameState.ruleset.Settings())
if err != nil {
return false, boardState, fmt.Errorf("Error pre-updating board with game map: %w", err)
}
// get moves from snakes
stateUpdates := make(chan SnakeState, len(gameState.snakeStates))
if gameState.Sequential {
for _, snakeState := range gameState.snakeStates {
for _, snake := range boardState.Snakes {
@ -393,19 +418,20 @@ func (gameState *GameState) createNextBoardState(boardState *rules.BoardState) *
moves = append(moves, rules.SnakeMove{ID: snakeState.ID, Move: snakeState.LastMove})
}
boardState, err := gameState.ruleset.CreateNextBoardState(boardState, moves)
gameOver, boardState, err := gameState.ruleset.Execute(boardState, moves)
if err != nil {
log.ERROR.Fatalf("Error producing next board state: %v", err)
return false, boardState, fmt.Errorf("Error updating board state from ruleset: %w", err)
}
boardState, err = maps.UpdateBoard(gameState.gameMap.ID(), boardState, gameState.ruleset.Settings())
// apply PostUpdateBoard after ruleset operates on snake moves
boardState, err = maps.PostUpdateBoard(gameState.gameMap, boardState, gameState.ruleset.Settings())
if err != nil {
log.ERROR.Fatalf("Error updating board with game map: %v", err)
return false, boardState, fmt.Errorf("Error post-updating board with game map: %w", err)
}
boardState.Turn += 1
return boardState
return gameOver, boardState, nil
}
func (gameState *GameState) getSnakeUpdate(boardState *rules.BoardState, snakeState SnakeState) SnakeState {
@ -522,13 +548,13 @@ func (gameState *GameState) createClientGame() client.Game {
Ruleset: client.Ruleset{
Name: gameState.ruleset.Name(),
Version: "cli", // TODO: Use GitHub Release Version
Settings: gameState.ruleset.Settings(),
Settings: client.ConvertRulesetSettings(gameState.ruleset.Settings()),
},
Map: gameState.gameMap.ID(),
}
}
func (gameState *GameState) buildSnakesFromOptions() map[string]SnakeState {
func (gameState *GameState) buildSnakesFromOptions() (map[string]SnakeState, error) {
bodyChars := []rune{'■', '⌀', '●', '☻', '◘', '☺', '□', '⍟'}
var numSnakes int
snakes := map[string]SnakeState{}
@ -560,11 +586,11 @@ func (gameState *GameState) buildSnakesFromOptions() map[string]SnakeState {
if i < numURLs {
u, err := url.ParseRequestURI(gameState.URLs[i])
if err != nil {
log.ERROR.Fatalf("URL %v is not valid: %v", gameState.URLs[i], err)
return nil, fmt.Errorf("URL %v is not valid: %w", gameState.URLs[i], err)
}
snakeURL = u.String()
} else {
log.ERROR.Fatalf("URL for name %v is missing", gameState.Names[i])
return nil, fmt.Errorf("URL for name %v is missing", gameState.Names[i])
}
snakeState := SnakeState{
@ -573,25 +599,25 @@ func (gameState *GameState) buildSnakesFromOptions() map[string]SnakeState {
var snakeErr error
res, _, err := gameState.httpClient.Get(snakeURL)
if err != nil {
log.ERROR.Fatalf("Snake metadata request to %v failed: %v", snakeURL, err)
return nil, fmt.Errorf("Snake metadata request to %v failed: %w", snakeURL, err)
}
snakeState.StatusCode = res.StatusCode
if res.Body == nil {
log.ERROR.Fatalf("Empty response body from snake metadata URL: %v", snakeURL)
return nil, fmt.Errorf("Empty response body from snake metadata URL: %v", snakeURL)
}
defer res.Body.Close()
body, readErr := ioutil.ReadAll(res.Body)
if readErr != nil {
log.ERROR.Fatalf("Error reading from snake metadata URL %v: %v", snakeURL, readErr)
return nil, fmt.Errorf("Error reading from snake metadata URL %v: %w", snakeURL, readErr)
}
pingResponse := client.SnakeMetadataResponse{}
jsonErr := json.Unmarshal(body, &pingResponse)
if jsonErr != nil {
log.ERROR.Fatalf("Failed to parse response from %v: %v", snakeURL, jsonErr)
return nil, fmt.Errorf("Failed to parse response from %v: %w", snakeURL, jsonErr)
}
snakeState.Head = pingResponse.Head
@ -608,7 +634,7 @@ func (gameState *GameState) buildSnakesFromOptions() map[string]SnakeState {
log.INFO.Printf("Snake ID: %v URL: %v, Name: \"%v\"", snakeState.ID, snakeURL, snakeState.Name)
}
return snakes
return snakes, nil
}
func (gameState *GameState) printState(boardState *rules.BoardState) {
@ -762,7 +788,8 @@ func (gameState *GameState) buildFrameEvent(boardState *rules.BoardState) board.
func serialiseSnakeRequest(snakeRequest client.SnakeRequest) []byte {
requestJSON, err := json.Marshal(snakeRequest)
if err != nil {
log.ERROR.Fatalf("Error marshalling JSON from State: %v", err)
// This is likely to be a programming error like a unsupported type or cyclical reference
log.ERROR.Panicf("Error marshalling JSON from State: %v", err)
}
return requestJSON
}

View file

@ -45,11 +45,10 @@ func buildDefaultGameState() *GameState {
func TestGetIndividualBoardStateForSnake(t *testing.T) {
s1 := rules.Snake{ID: "one", Body: []rules.Point{{X: 3, Y: 3}}}
s2 := rules.Snake{ID: "two", Body: []rules.Point{{X: 4, Y: 3}}}
state := &rules.BoardState{
Height: 11,
Width: 11,
Snakes: []rules.Snake{s1, s2},
}
state := rules.NewBoardState(11, 11).
WithSnakes(
[]rules.Snake{s1, s2},
)
s1State := SnakeState{
ID: "one",
Name: "ONE",
@ -85,11 +84,8 @@ func TestGetIndividualBoardStateForSnake(t *testing.T) {
func TestSettingsRequestSerialization(t *testing.T) {
s1 := rules.Snake{ID: "one", Body: []rules.Point{{X: 3, Y: 3}}}
s2 := rules.Snake{ID: "two", Body: []rules.Point{{X: 4, Y: 3}}}
state := &rules.BoardState{
Height: 11,
Width: 11,
Snakes: []rules.Snake{s1, s2},
}
state := rules.NewBoardState(11, 11).
WithSnakes([]rules.Snake{s1, s2})
s1State := SnakeState{
ID: "one",
Name: "ONE",
@ -255,12 +251,11 @@ func TestBuildFrameEvent(t *testing.T) {
},
{
name: "snake fields",
boardState: &rules.BoardState{
Turn: 99,
Height: 19,
Width: 25,
Food: []rules.Point{{X: 9, Y: 4}},
Snakes: []rules.Snake{
boardState: rules.NewBoardState(19, 25).
WithTurn(99).
WithFood([]rules.Point{{X: 9, Y: 4}}).
WithHazards([]rules.Point{{X: 8, Y: 6}}).
WithSnakes([]rules.Snake{
{
ID: "1",
Body: []rules.Point{
@ -273,9 +268,7 @@ func TestBuildFrameEvent(t *testing.T) {
EliminatedOnTurn: 45,
EliminatedBy: "1",
},
},
Hazards: []rules.Point{{X: 8, Y: 6}},
},
}),
snakeStates: map[string]SnakeState{
"1": {
URL: "http://example.com",
@ -326,18 +319,15 @@ func TestBuildFrameEvent(t *testing.T) {
},
{
name: "snake errors",
boardState: &rules.BoardState{
Height: 19,
Width: 25,
Snakes: []rules.Snake{
boardState: rules.NewBoardState(19, 25).
WithSnakes([]rules.Snake{
{
ID: "bad_status",
},
{
ID: "connection_error",
},
},
},
}),
snakeStates: map[string]SnakeState{
"bad_status": {
StatusCode: 504,
@ -366,6 +356,8 @@ func TestBuildFrameEvent(t *testing.T) {
Error: "0:Error communicating with server",
},
},
Food: []rules.Point{},
Hazards: []rules.Point{},
},
},
},
@ -384,11 +376,7 @@ func TestBuildFrameEvent(t *testing.T) {
func TestGetMoveForSnake(t *testing.T) {
s1 := rules.Snake{ID: "one", Body: []rules.Point{{X: 3, Y: 3}}}
s2 := rules.Snake{ID: "two", Body: []rules.Point{{X: 4, Y: 3}}}
boardState := &rules.BoardState{
Height: 11,
Width: 11,
Snakes: []rules.Snake{s1, s2},
}
boardState := rules.NewBoardState(11, 11).WithSnakes([]rules.Snake{s1, s2})
tests := []struct {
name string
@ -530,11 +518,7 @@ func TestGetMoveForSnake(t *testing.T) {
func TestCreateNextBoardState(t *testing.T) {
s1 := rules.Snake{ID: "one", Body: []rules.Point{{X: 3, Y: 3}}}
boardState := &rules.BoardState{
Height: 11,
Width: 11,
Snakes: []rules.Snake{s1},
}
boardState := rules.NewBoardState(11, 11).WithSnakes([]rules.Snake{s1})
snakeState := SnakeState{
ID: s1.ID,
URL: "http://example.com",
@ -549,7 +533,9 @@ func TestCreateNextBoardState(t *testing.T) {
gameState.snakeStates = map[string]SnakeState{s1.ID: snakeState}
gameState.httpClient = stubHTTPClient{nil, 200, func(_ string) string { return `{"move": "right"}` }, 54 * time.Millisecond}
nextBoardState := gameState.createNextBoardState(boardState)
gameOver, nextBoardState, err := gameState.createNextBoardState(boardState)
require.NoError(t, err)
require.False(t, gameOver)
snakeState = gameState.snakeStates[s1.ID]
require.NotNil(t, nextBoardState)
@ -593,16 +579,18 @@ func TestOutputFile(t *testing.T) {
outputFile := new(closableBuffer)
gameState.outputFile = outputFile
gameState.ruleset = StubRuleset{maxTurns: 1, settings: rules.Settings{
FoodSpawnChance: 1,
MinimumFood: 2,
HazardDamagePerTurn: 3,
RoyaleSettings: rules.RoyaleSettings{
ShrinkEveryNTurns: 4,
},
}}
gameState.ruleset = StubRuleset{
maxTurns: 1,
settings: rules.NewSettings(map[string]string{
rules.ParamFoodSpawnChance: "1",
rules.ParamMinimumFood: "2",
rules.ParamHazardDamagePerTurn: "3",
rules.ParamShrinkEveryNTurns: "4",
}),
}
gameState.Run()
err = gameState.Run()
require.NoError(t, err)
lines := strings.Split(outputFile.String(), "\n")
require.Len(t, lines, 5)
@ -626,14 +614,8 @@ type StubRuleset struct {
func (ruleset StubRuleset) Name() string { return "standard" }
func (ruleset StubRuleset) Settings() rules.Settings { return ruleset.settings }
func (ruleset StubRuleset) ModifyInitialBoardState(initialState *rules.BoardState) (*rules.BoardState, error) {
return initialState, nil
}
func (ruleset StubRuleset) CreateNextBoardState(prevState *rules.BoardState, moves []rules.SnakeMove) (*rules.BoardState, error) {
return prevState, nil
}
func (ruleset StubRuleset) IsGameOver(state *rules.BoardState) (bool, error) {
return state.Turn >= ruleset.maxTurns, nil
func (ruleset StubRuleset) Execute(prevState *rules.BoardState, moves []rules.SnakeMove) (bool, *rules.BoardState, error) {
return prevState.Turn >= ruleset.maxTurns, prevState, nil
}
type stubHTTPClient struct {