implement game turn logic

This commit is contained in:
2026-11-02 21:24:32 +03:00
parent 153dc74ff6
commit 189b027a11
7 changed files with 896 additions and 13 deletions
+340
View File
@@ -644,3 +644,343 @@ func TestPlaceShipsBeforeMatchStarts(t *testing.T) {
t.Fatalf("got err = %v, want ErrMatchNotStarted", err)
}
}
func setupStartedGame(t *testing.T) (*Manager, *Match, *fakeConn, *fakeConn) {
t.Helper()
m := NewManager(time.Minute)
gc := claims(time.Now().Add(time.Hour))
c1, c2 := &fakeConn{}, &fakeConn{}
match, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID)
if err != nil {
t.Fatalf("join p1: %v", err)
}
if _, _, err := m.Join(context.Background(), c2, gc, gc.Player2ID); err != nil {
t.Fatalf("join p2: %v", err)
}
ships := validTestShips()
if err := m.PlaceShips(context.Background(), match, 0, ships); err != nil {
t.Fatalf("p1 place ships: %v", err)
}
if err := m.PlaceShips(context.Background(), match, 1, ships); err != nil {
t.Fatalf("p2 place ships: %v", err)
}
if match.State() != StatePlayer1Turn {
t.Fatalf("state = %d, want StatePlayer1Turn", match.State())
}
return m, match, c1, c2
}
func TestFireMissTransitionsTurn(t *testing.T) {
m, match, c1, c2 := setupStartedGame(t)
// In validTestShips, cell (9, 9) has no ships.
res, err := m.Fire(context.Background(), match, 0, 9, 9)
if err != nil {
t.Fatalf("Fire: %v", err)
}
if res != protocol.FireResultMiss {
t.Fatalf("res = %d, want FireResultMiss", res)
}
// Shooter receives FIRE_RESULT with miss (1)
c1Msgs := c1.messages()
// c1 got: [STATE 1], [STATE 3], [STATE 4], [FIRE_RESULT 1], [STATE 5]
fireMsg := parseMsg(t, c1Msgs[len(c1Msgs)-2])
if fireMsg[0] != float64(protocol.OpFireResult) || fireMsg[2] != float64(protocol.FireResultMiss) {
t.Fatalf("unexpected fireMsg: %v", fireMsg)
}
// Turn transitions to Player 2
if match.State() != StatePlayer2Turn {
t.Fatalf("state = %d, want StatePlayer2Turn", match.State())
}
c1Last := parseMsg(t, c1Msgs[len(c1Msgs)-1])
if c1Last[0] != float64(protocol.OpState) || c1Last[2] != float64(protocol.StateWaitOpponentTurn) {
t.Fatalf("c1 last state = %v, want StateWaitOpponentTurn", c1Last)
}
c2Msgs := c2.messages()
c2Last := parseMsg(t, c2Msgs[len(c2Msgs)-1])
if c2Last[0] != float64(protocol.OpState) || c2Last[2] != float64(protocol.StateYourTurn) {
t.Fatalf("c2 last state = %v, want StateYourTurn", c2Last)
}
// Target board cell (9, 9) marked struck
struck, ok := match.Struck(1)
if !ok || !struck[9][9] {
t.Fatalf("expected p2 cell (9, 9) to be struck")
}
}
func TestFireHitKeepsTurn(t *testing.T) {
m, match, c1, c2 := setupStartedGame(t)
// Ship 9 is 4 cells horizontally at (0, 0), (1, 0), (2, 0), (3, 0)
res, err := m.Fire(context.Background(), match, 0, 0, 0)
if err != nil {
t.Fatalf("Fire: %v", err)
}
if res != protocol.FireResultHit {
t.Fatalf("res = %d, want FireResultHit", res)
}
c1Msgs := c1.messages()
fireMsg := parseMsg(t, c1Msgs[len(c1Msgs)-1])
if fireMsg[0] != float64(protocol.OpFireResult) || fireMsg[2] != float64(protocol.FireResultHit) {
t.Fatalf("unexpected fireMsg: %v", fireMsg)
}
// Turn stays with Player 1
if match.State() != StatePlayer1Turn {
t.Fatalf("state = %d, want StatePlayer1Turn", match.State())
}
// Player 2 should not have received any new messages
c2Msgs := c2.messages()
// c2 messages before: [STATE 1], [STATE 2], [STATE 5]
if len(c2Msgs) != 3 {
t.Fatalf("p2 received unexpected message: %d messages", len(c2Msgs))
}
}
func TestFireSunkMarksSurroundings(t *testing.T) {
m, match, c1, _ := setupStartedGame(t)
// Ship 0 is 1 cell at (9, 4)
res, err := m.Fire(context.Background(), match, 0, 9, 4)
if err != nil {
t.Fatalf("Fire: %v", err)
}
if res != protocol.FireResultSunk {
t.Fatalf("res = %d, want FireResultSunk", res)
}
c1Msgs := c1.messages()
fireMsg := parseMsg(t, c1Msgs[len(c1Msgs)-1])
if fireMsg[0] != float64(protocol.OpFireResult) || fireMsg[2] != float64(protocol.FireResultSunk) {
t.Fatalf("unexpected fireMsg: %v", fireMsg)
}
if match.ShipsRemaining(1) != 9 {
t.Fatalf("shipsRemaining = %d, want 9", match.ShipsRemaining(1))
}
// Check that (9, 4) and surrounding cells (8,3), (8,4), (8,5), (9,3), (9,5) are struck
struck, _ := match.Struck(1)
expectedCells := [][2]int{
{9, 4},
{8, 3}, {8, 4}, {8, 5},
{9, 3}, {9, 5},
}
for _, pt := range expectedCells {
if !struck[pt[1]][pt[0]] {
t.Fatalf("expected cell (%d, %d) to be struck", pt[0], pt[1])
}
}
// Turn stays with Player 1 because hit/sunk does not pass turn unless game over
if match.State() != StatePlayer1Turn {
t.Fatalf("state = %d, want StatePlayer1Turn", match.State())
}
}
func TestFireInvalidPosition(t *testing.T) {
m, match, c1, _ := setupStartedGame(t)
t.Run("out of bounds negative", func(t *testing.T) {
res, err := m.Fire(context.Background(), match, 0, -1, 5)
if err != nil {
t.Fatalf("Fire: %v", err)
}
if res != protocol.FireResultInvalidPosition {
t.Fatalf("res = %d, want FireResultInvalidPosition", res)
}
msgs := c1.messages()
msg := parseMsg(t, msgs[len(msgs)-1])
if msg[0] != float64(protocol.OpFireResult) || msg[2] != float64(protocol.FireResultInvalidPosition) {
t.Fatalf("unexpected msg: %v", msg)
}
})
t.Run("out of bounds over 9", func(t *testing.T) {
res, err := m.Fire(context.Background(), match, 0, 10, 0)
if err != nil {
t.Fatalf("Fire: %v", err)
}
if res != protocol.FireResultInvalidPosition {
t.Fatalf("res = %d, want FireResultInvalidPosition", res)
}
})
t.Run("already struck cell", func(t *testing.T) {
// First shoot (9, 4) - sinks single ship
res, err := m.Fire(context.Background(), match, 0, 9, 4)
if err != nil || res != protocol.FireResultSunk {
t.Fatalf("initial fire: res=%d, err=%v", res, err)
}
// Shooting (9, 4) again returns InvalidPosition
res, err = m.Fire(context.Background(), match, 0, 9, 4)
if err != nil {
t.Fatalf("repeat fire: %v", err)
}
if res != protocol.FireResultInvalidPosition {
t.Fatalf("res = %d, want FireResultInvalidPosition", res)
}
// Shooting diagonal neighbor (8, 5) which was marked struck due to sunk ship also returns InvalidPosition
res, err = m.Fire(context.Background(), match, 0, 8, 5)
if err != nil {
t.Fatalf("halo fire: %v", err)
}
if res != protocol.FireResultInvalidPosition {
t.Fatalf("res = %d, want FireResultInvalidPosition", res)
}
})
}
func TestFireIgnoredWhenNotYourTurn(t *testing.T) {
m, match, c1, c2 := setupStartedGame(t)
// It's player 1's turn, player 2 tries to shoot
c2CountBefore := len(c2.messages())
res, err := m.Fire(context.Background(), match, 1, 0, 0)
if err != ErrNotYourTurn {
t.Fatalf("p2 fire: got err = %v, want ErrNotYourTurn", err)
}
if res != 0 {
t.Fatalf("res = %d, want 0", res)
}
if len(c2.messages()) != c2CountBefore {
t.Fatal("expected no messages sent to p2 on ignored fire")
}
// Match state unchanged
if match.State() != StatePlayer1Turn {
t.Fatalf("state = %d, want StatePlayer1Turn", match.State())
}
_ = c1
}
func TestFireWinCondition(t *testing.T) {
m, match, c1, c2 := setupStartedGame(t)
gc := claims(time.Now().Add(time.Hour))
// Player 1 destroys all 10 ships of Player 2
ships := validTestShips()
for i, ship := range ships {
cells := ship.Cells()
for j, c := range cells {
res, err := m.Fire(context.Background(), match, 0, c.X, c.Y)
if err != nil {
t.Fatalf("ship %d cell %d fire: %v", i, j, err)
}
if j < len(cells)-1 {
if res != protocol.FireResultHit {
t.Fatalf("ship %d cell %d: res = %d, want Hit", i, j, res)
}
} else {
if res != protocol.FireResultSunk {
t.Fatalf("ship %d cell %d: res = %d, want Sunk", i, j, res)
}
}
}
}
// Match should be removed from m.matches (preventing memory leak)
if m.GetMatch(match.ID) != nil {
t.Fatal("expected match to be removed from m.matches")
}
// Check END messages sent to both players
c1Msgs := c1.messages()
// c1 receives FIRE_RESULT (sunk) followed by END (victory)
c1End := parseMsg(t, c1Msgs[len(c1Msgs)-1])
if c1End[0] != float64(protocol.OpEnd) || c1End[2] != float64(protocol.EndCodeVictory) || c1End[3] != "victory" {
t.Fatalf("p1 got %v, want END with EndCodeVictory (1) 'victory'", c1End)
}
c2Msgs := c2.messages()
c2End := parseMsg(t, c2Msgs[len(c2Msgs)-1])
if c2End[0] != float64(protocol.OpEnd) || c2End[2] != float64(protocol.EndCodeDefeat) || c2End[3] != "defeat" {
t.Fatalf("p2 got %v, want END with EndCodeDefeat (2) 'defeat'", c2End)
}
// Connections must be closed immediately with CloseCodeNormal
if closed, code := c1.isClosed(); !closed || code != protocol.CloseCodeNormal {
t.Fatalf("c1 closed=%v code=%d, want closed with %d", closed, code, protocol.CloseCodeNormal)
}
if closed, code := c2.isClosed(); !closed || code != protocol.CloseCodeNormal {
t.Fatalf("c2 closed=%v code=%d, want closed with %d", closed, code, protocol.CloseCodeNormal)
}
// Late reconnects get the respective END messages replayed
c1Rejoin := &fakeConn{}
_, _, err := m.Join(context.Background(), c1Rejoin, gc, gc.Player1ID)
if err != ErrMatchFinished {
t.Fatalf("rejoin p1: got err = %v, want ErrMatchFinished", err)
}
msgs := c1Rejoin.messages()
if len(msgs) != 1 {
t.Fatalf("p1 got %d replay messages, want 1", len(msgs))
}
if v := parseMsg(t, msgs[0]); v[0] != float64(protocol.OpEnd) || v[2] != float64(protocol.EndCodeVictory) {
t.Fatalf("p1 replayed msg = %v, want END victory", v)
}
c2Rejoin := &fakeConn{}
_, _, err = m.Join(context.Background(), c2Rejoin, gc, gc.Player2ID)
if err != ErrMatchFinished {
t.Fatalf("rejoin p2: got err = %v, want ErrMatchFinished", err)
}
msgs = c2Rejoin.messages()
if len(msgs) != 1 {
t.Fatalf("p2 got %d replay messages, want 1", len(msgs))
}
if v := parseMsg(t, msgs[0]); v[0] != float64(protocol.OpEnd) || v[2] != float64(protocol.EndCodeDefeat) {
t.Fatalf("p2 replayed msg = %v, want END defeat", v)
}
}
func TestFireAckAndReplay(t *testing.T) {
m, match, _, _ := setupStartedGame(t)
// In setupStartedGame, c1 received 3 STATE messages (seq 1, 2, 3)
// Player 1 fires: generates FIRE_RESULT with seq 4
res, err := m.Fire(context.Background(), match, 0, 0, 0)
if err != nil || res != protocol.FireResultHit {
t.Fatalf("fire: res=%d, err=%v", res, err)
}
// Ack seq 4
if err := m.Ack(match, 0, 4); err != nil {
t.Fatalf("ack: %v", err)
}
// Player 1 fires again at (1, 0): generates FIRE_RESULT with seq 5
res, err = m.Fire(context.Background(), match, 0, 1, 0)
if err != nil || res != protocol.FireResultHit {
t.Fatalf("fire 2: res=%d, err=%v", res, err)
}
// Reconnect player 1 without ACKing seq 5
fresh := &fakeConn{}
gc := claims(time.Now().Add(time.Hour))
if _, _, err := m.Join(context.Background(), fresh, gc, gc.Player1ID); err != nil {
t.Fatalf("rejoin: %v", err)
}
freshMsgs := fresh.messages()
if len(freshMsgs) != 1 {
t.Fatalf("rejoined player got %d messages, want 1", len(freshMsgs))
}
v := parseMsg(t, freshMsgs[0])
// Should be replayed FIRE_RESULT [7, 5, 2]
if v[0] != float64(protocol.OpFireResult) || v[1] != float64(5) || v[2] != float64(protocol.FireResultHit) {
t.Fatalf("replayed msg = %v, want [7, 5, 2]", v)
}
}