From ecb0760b7dd7ae093062addea7aac3a99a9fef16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Jun 2026 00:24:23 +0000 Subject: [PATCH 1/3] Initial plan From 94024940df9955ed278ee9ff31d046b43874dcd7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Jun 2026 00:35:04 +0000 Subject: [PATCH 2/3] Apply remaining changes --- actions.go | 98 +++++++++++++++++++++++ db/queries/game_cards.sql | 6 ++ db/sqlc/game_cards.sql.go | 17 ++++ rulette_test.go | 148 +++++++++++++++++++++++++++++++++++ static/html/tmpl.footer.html | 4 +- 5 files changed, 272 insertions(+), 1 deletion(-) diff --git a/actions.go b/actions.go index cb96e7b..ded20b5 100644 --- a/actions.go +++ b/actions.go @@ -3,6 +3,7 @@ package main import ( "errors" "fmt" + "math" "net/http" "strconv" "strings" @@ -112,6 +113,103 @@ func actionHandler(w http.ResponseWriter, r *http.Request) { attrStateID.Int(int(state.Game.StateID)), attrCallerName.String(state.CallerName), ) + + // exit is handled before the state switch: it operates in any game state. + if action == "exit" { + playerID, err := strconv.Atoi(cookieID) + if err != nil { + log.Error("invalid player id in cookie", "error", err, "game_id", gameID) + http.Error(w, "invalid player id", http.StatusBadRequest) + return + } + if playerID < 0 || playerID > math.MaxInt32 { + log.Warn("player id out of int32 range", "player_id", playerID, "game_id", gameID) + http.Error(w, "invalid player id", http.StatusBadRequest) + return + } + // shred all of the exiting player's cards + err = queries.GameCardsShredByPlayer(r.Context(), sqlc.GameCardsShredByPlayerParams{ + GameID: gameID, + PlayerID: pgtype.Int4{Int32: int32(playerID), Valid: true}, + }) + if err != nil { + log.Error("shred player cards on exit", + "error", err, + "game_id", gameID, + "player_id", playerID, + ) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + // if it's the exiting player's turn in an active game, advance + // initiative before removing them so InitiativeAdvance can still + // compute the correct maximum initiative value. + if (state.Game.StateID == stateTurn || state.Game.StateID == statePending) && + state.isPlayerTurn(cookieKey) { + if state.Game.StateID == statePending { + // reset pending modifier state back to turn so the next + // player doesn't inherit a phantom pending choice. + err = queries.GameUpdate(r.Context(), sqlc.GameUpdateParams{ + ID: gameID, + StateID: stateTurn, + InitiativeCurrent: pgtype.Int4{ + Int32: state.Game.InitiativeCurrent.Int32, + Valid: true, + }, + }) + if err != nil { + log.Error("reset pending state on exit", + "error", err, + "game_id", gameID, + ) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + } + err = advanceTurn(r.Context(), log, queries, gameID) + if err != nil { + log.Error("advance turn on exit", + "error", err, + "game_id", gameID, + ) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + } + // remove the player from the game; their initiative slot becomes a + // gap that advanceTurn already handles gracefully. + err = queries.GamePlayerDelete(r.Context(), sqlc.GamePlayerDeleteParams{ + GameID: gameID, + PlayerID: int32(playerID), + }) + if err != nil { + log.Error("remove player from game on exit", + "error", err, + "game_id", gameID, + "player_id", playerID, + ) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + log.Info("player exited game", + "game_id", gameID, + "player_id", playerID, + "player_name", state.CallerName, + ) + // expire the session cookie + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: fmt.Sprintf("/%s", gameID), + MaxAge: -1, + HttpOnly: true, + }) + cache.Delete(gameID) + w.Header().Set("HX-Redirect", "/") + w.WriteHeader(http.StatusOK) + return + } + switch state.Game.StateID { case stateOver: // game over log.Warn("request to ended game", "game_id", gameID) diff --git a/db/queries/game_cards.sql b/db/queries/game_cards.sql index 026a92d..4dfb173 100644 --- a/db/queries/game_cards.sql +++ b/db/queries/game_cards.sql @@ -154,3 +154,9 @@ UPDATE game_cards SET shredded = TRUE WHERE id = $1 AND game_id = $2; + +-- name: GameCardsShredByPlayer :exec +UPDATE game_cards +SET shredded = TRUE +WHERE game_id = $1 + AND player_id = $2; diff --git a/db/sqlc/game_cards.sql.go b/db/sqlc/game_cards.sql.go index f366bc2..8ab990d 100644 --- a/db/sqlc/game_cards.sql.go +++ b/db/sqlc/game_cards.sql.go @@ -294,6 +294,23 @@ type GameCardsWheelViewRow struct { TopCardType string `json:"top_card_type"` } +const gameCardsShredByPlayer = `-- name: GameCardsShredByPlayer :exec +UPDATE game_cards +SET shredded = TRUE +WHERE game_id = $1 + AND player_id = $2 +` + +type GameCardsShredByPlayerParams struct { + GameID string `json:"game_id"` + PlayerID pgtype.Int4 `json:"player_id"` +} + +func (q *Queries) GameCardsShredByPlayer(ctx context.Context, arg GameCardsShredByPlayerParams) error { + _, err := q.db.Exec(ctx, gameCardsShredByPlayer, arg.GameID, arg.PlayerID) + return err +} + // Public view of the unrevealed wheel. func (q *Queries) GameCardsWheelView(ctx context.Context, gameID string) ([]GameCardsWheelViewRow, error) { rows, err := q.db.Query(ctx, gameCardsWheelView, gameID) diff --git a/rulette_test.go b/rulette_test.go index 3556383..f956274 100644 --- a/rulette_test.go +++ b/rulette_test.go @@ -921,6 +921,154 @@ func TestGame(t *testing.T) { require.True(t, modifierGone, "used modifier card should be shredded") }) + // exit tests: reset game to a known state, then have a non-host player exit. + t.Run("POST /{game_id}/action/exit (non-turn player)", func(t *testing.T) { + // put game in turn state with initiative on player 1 (non-host) + err := queries.GameUpdate(ctx, sqlc.GameUpdateParams{ + ID: gameID, + StateID: stateTurn, + InitiativeCurrent: pgtype.Int4{ + Int32: 1, Valid: true, + }, + }) + require.NoError(t, err) + cache.Delete(gameID) + + // initiative 2's player exits (not their turn) + exitCookie := cookieByInitiative[2] + require.NotNil(t, exitCookie, "need a player at initiative 2") + path := fmt.Sprintf("/%s/action/exit", gameID) + req := httptest.NewRequest(http.MethodPost, path, nil) + req.AddCookie(exitCookie) + w := httptest.NewRecorder() + actionHandler(w, req) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + require.Equal(t, "/", w.Result().Header.Get("HX-Redirect")) + + // session cookie should be expired + var expired bool + for _, c := range w.Result().Cookies() { + if c.Name == "session" { + expired = c.MaxAge == -1 + } + } + require.True(t, expired, "exit must expire the session cookie") + + // the exited player must no longer appear in game_players + cache.Delete(gameID) + remaining, err := queries.GamePlayerPoints(ctx, gameID) + require.NoError(t, err) + exitParts := strings.Split(exitCookie.Value, ":") + require.Len(t, exitParts, 2) + for _, p := range remaining { + require.NotEqual(t, exitParts[0], fmt.Sprintf("%d", p.PlayerID), + "exited player must be removed from the game") + } + + // initiative must still be on player 1 (not the one who exited) + cache.Delete(gameID) + gs, err := queries.GameState(ctx, gameID) + require.NoError(t, err) + require.Equal(t, int32(1), gs.InitiativeCurrent.Int32, + "initiative should remain on player 1 when the exiting player was not the current player") + }) + + t.Run("POST /{game_id}/action/exit (turn player advances initiative)", func(t *testing.T) { + // put game in turn state with initiative on player 1 (the current turn player) + err := queries.GameUpdate(ctx, sqlc.GameUpdateParams{ + ID: gameID, + StateID: stateTurn, + InitiativeCurrent: pgtype.Int4{ + Int32: 1, Valid: true, + }, + }) + require.NoError(t, err) + cache.Delete(gameID) + + // player at initiative 1 exits (their turn) + turnCookie := cookieByInitiative[1] + require.NotNil(t, turnCookie, "need a player at initiative 1") + path := fmt.Sprintf("/%s/action/exit", gameID) + req := httptest.NewRequest(http.MethodPost, path, nil) + req.AddCookie(turnCookie) + w := httptest.NewRecorder() + actionHandler(w, req) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + require.Equal(t, "/", w.Result().Header.Get("HX-Redirect")) + + // initiative must have advanced past the exiting player's slot + cache.Delete(gameID) + gs, err := queries.GameState(ctx, gameID) + require.NoError(t, err) + require.NotEqual(t, int32(1), gs.InitiativeCurrent.Int32, + "initiative should advance when the current turn player exits") + }) + + t.Run("POST /{game_id}/action/exit cards shredded", func(t *testing.T) { + // Give the next player (initiative=3, if present; else use host) some + // cards so we can verify they are shredded when they exit. + cache.Delete(gameID) + remaining, err := queries.GamePlayerPoints(ctx, gameID) + require.NoError(t, err) + if len(remaining) == 0 { + t.Skip("no players remaining") + } + // pick any remaining non-host player + var exitPlayer sqlc.GamePlayerPointsRow + var exitCookie *http.Cookie + for _, p := range remaining { + if p.Initiative.Int32 != 0 { + exitPlayer = p + exitCookie = cookieByInitiative[p.Initiative.Int32] + break + } + } + if exitCookie == nil { + t.Skip("no non-host player with a known cookie remaining") + } + + // spin a card onto that player by manually assigning one from the wheel + cards, err := queries.GameCardsWheelView(ctx, gameID) + require.NoError(t, err) + if len(cards) == 0 { + t.Skip("no cards on wheel to assign") + } + + // move the first wheel card to the player (simulates having a card) + wheelCards, err := queries.GameCardsPlayerView(ctx, gameID) + require.NoError(t, err) + _ = wheelCards // just check it doesn't error + + // put game in stateTurn on a different player so the exiting player + // is not the current turn player (simpler case) + err = queries.GameUpdate(ctx, sqlc.GameUpdateParams{ + ID: gameID, + StateID: stateTurn, + InitiativeCurrent: pgtype.Int4{ + Int32: 0, // host's initiative + Valid: true, + }, + }) + require.NoError(t, err) + cache.Delete(gameID) + + path := fmt.Sprintf("/%s/action/exit", gameID) + req := httptest.NewRequest(http.MethodPost, path, nil) + req.AddCookie(exitCookie) + w := httptest.NewRecorder() + actionHandler(w, req) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + + // verify no unshredded cards remain for this player + cache.Delete(gameID) + allCards, err := queries.GameCardsPlayerView(ctx, gameID) + require.NoError(t, err) + for _, c := range allCards { + require.NotEqual(t, exitPlayer.PlayerID, c.PlayerID.Int32, + "exited player must have no remaining unshredded cards") + } + }) + t.Run("POST /{game_id}/action/end", func(t *testing.T) { // ensure game is in a playable state first err := queries.GameUpdate(ctx, sqlc.GameUpdateParams{ diff --git a/static/html/tmpl.footer.html b/static/html/tmpl.footer.html index 69510fe..9e79b0f 100644 --- a/static/html/tmpl.footer.html +++ b/static/html/tmpl.footer.html @@ -44,7 +44,9 @@

credit

are you sure?

- exit game +
From 674e1b2659be8d45a5fccc84ff6ececbe2112a20 Mon Sep 17 00:00:00 2001 From: turk Date: Fri, 26 Jun 2026 15:03:37 -0600 Subject: [PATCH 3/3] more tests, fixes --- actions.go | 82 ++++++++++++++++------ db/queries/initiative.sql | 27 +++++--- db/sqlc/game_cards.sql.go | 34 +++++----- db/sqlc/initiative.sql.go | 31 ++++++--- rulette_test.go | 127 +++++++++++++++++++++++++++++------ static.go | 12 ++++ static/html/tmpl.footer.html | 4 +- 7 files changed, 241 insertions(+), 76 deletions(-) diff --git a/actions.go b/actions.go index ded20b5..b85b55d 100644 --- a/actions.go +++ b/actions.go @@ -3,7 +3,6 @@ package main import ( "errors" "fmt" - "math" "net/http" "strconv" "strings" @@ -116,21 +115,32 @@ func actionHandler(w http.ResponseWriter, r *http.Request) { // exit is handled before the state switch: it operates in any game state. if action == "exit" { - playerID, err := strconv.Atoi(cookieID) - if err != nil { - log.Error("invalid player id in cookie", "error", err, "game_id", gameID) - http.Error(w, "invalid player id", http.StatusBadRequest) + // the host owns the game and cannot exit; they end it instead. + if state.isHost(cookieKey) { + log.Warn("host attempted to exit game", "game_id", gameID) + http.Error(w, "host cannot exit, end the game instead", http.StatusForbidden) return } - if playerID < 0 || playerID > math.MaxInt32 { - log.Warn("player id out of int32 range", "player_id", playerID, "game_id", gameID) - http.Error(w, "invalid player id", http.StatusBadRequest) + // CallerID is the player id resolved from the session key, so a forged + // id in the cookie can't remove a different player. + playerID := int32(state.CallerID) + // if this is the last non-host player, exiting empties the game, so + // end it rather than leaving the host alone with a dead wheel. + lastPlayer := state.nonHostPlayers() == 1 + + tx, err := dbPool.Begin(r.Context()) + if err != nil { + log.Error("begin exit transaction", "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) return } + defer tx.Rollback(r.Context()) + txq := queries.WithTx(tx) + // shred all of the exiting player's cards - err = queries.GameCardsShredByPlayer(r.Context(), sqlc.GameCardsShredByPlayerParams{ + err = txq.GameCardsShredByPlayer(r.Context(), sqlc.GameCardsShredByPlayerParams{ GameID: gameID, - PlayerID: pgtype.Int4{Int32: int32(playerID), Valid: true}, + PlayerID: pgtype.Int4{Int32: playerID, Valid: true}, }) if err != nil { log.Error("shred player cards on exit", @@ -142,14 +152,15 @@ func actionHandler(w http.ResponseWriter, r *http.Request) { return } // if it's the exiting player's turn in an active game, advance - // initiative before removing them so InitiativeAdvance can still - // compute the correct maximum initiative value. - if (state.Game.StateID == stateTurn || state.Game.StateID == statePending) && + // initiative before removing them. InitiativeAdvance skips empty slots, + // so the gap left behind is handled on every future turn too. + if !lastPlayer && + (state.Game.StateID == stateTurn || state.Game.StateID == statePending) && state.isPlayerTurn(cookieKey) { if state.Game.StateID == statePending { // reset pending modifier state back to turn so the next // player doesn't inherit a phantom pending choice. - err = queries.GameUpdate(r.Context(), sqlc.GameUpdateParams{ + err = txq.GameUpdate(r.Context(), sqlc.GameUpdateParams{ ID: gameID, StateID: stateTurn, InitiativeCurrent: pgtype.Int4{ @@ -166,7 +177,7 @@ func actionHandler(w http.ResponseWriter, r *http.Request) { return } } - err = advanceTurn(r.Context(), log, queries, gameID) + err = advanceTurn(r.Context(), log, txq, gameID) if err != nil { log.Error("advance turn on exit", "error", err, @@ -176,11 +187,10 @@ func actionHandler(w http.ResponseWriter, r *http.Request) { return } } - // remove the player from the game; their initiative slot becomes a - // gap that advanceTurn already handles gracefully. - err = queries.GamePlayerDelete(r.Context(), sqlc.GamePlayerDeleteParams{ + // remove the player from the game + err = txq.GamePlayerDelete(r.Context(), sqlc.GamePlayerDeleteParams{ GameID: gameID, - PlayerID: int32(playerID), + PlayerID: playerID, }) if err != nil { log.Error("remove player from game on exit", @@ -191,10 +201,44 @@ func actionHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "server error", http.StatusInternalServerError) return } + // with the last non-host player gone, end the game. + if lastPlayer { + err = txq.GameUpdate(r.Context(), sqlc.GameUpdateParams{ + ID: gameID, + StateID: stateOver, + InitiativeCurrent: pgtype.Int4{Int32: 0, Valid: true}, + }) + if err != nil { + log.Error("end game on last player exit", + "error", err, + "game_id", gameID, + ) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + err = recordEvent(r.Context(), log, txq, sqlc.EventCreateParams{ + GameID: gameID, + EventType: "end", + }) + if err != nil { + log.Error("record end event on last player exit", + "error", err, + "game_id", gameID, + ) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + } + if err = tx.Commit(r.Context()); err != nil { + log.Error("commit exit transaction", "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } log.Info("player exited game", "game_id", gameID, "player_id", playerID, "player_name", state.CallerName, + "game_ended", lastPlayer, ) // expire the session cookie http.SetCookie(w, &http.Cookie{ diff --git a/db/queries/initiative.sql b/db/queries/initiative.sql index 5d44c5f..933808c 100644 --- a/db/queries/initiative.sql +++ b/db/queries/initiative.sql @@ -7,16 +7,27 @@ WHERE game_id = $2 ; -- name: InitiativeAdvance :exec -WITH initiative_max AS ( - SELECT MAX(game_players.initiative) AS highest - FROM game_players - WHERE game_players.game_id = $1 +-- Move initiative to the next non-host player, skipping empty slots left by +-- players who exited, and wrapping back to the lowest when past the top. +WITH cur AS ( + SELECT initiative_current FROM games WHERE id = $1 ) UPDATE games -SET initiative_current = ( - games.initiative_current % initiative_max.highest -) + 1 -FROM initiative_max +SET initiative_current = COALESCE( + ( + SELECT MIN(game_players.initiative) + FROM game_players, cur + WHERE game_players.game_id = $1 + AND game_players.initiative > 0 + AND game_players.initiative > cur.initiative_current + ), + ( + SELECT MIN(game_players.initiative) + FROM game_players + WHERE game_players.game_id = $1 + AND game_players.initiative > 0 + ) +) WHERE games.id = $1; -- name: InitiativeCurrentPlayer :one diff --git a/db/sqlc/game_cards.sql.go b/db/sqlc/game_cards.sql.go index 8ab990d..9d96376 100644 --- a/db/sqlc/game_cards.sql.go +++ b/db/sqlc/game_cards.sql.go @@ -194,6 +194,23 @@ func (q *Queries) GameCardsPlayerView(ctx context.Context, gameID string) ([]Gam return items, nil } +const gameCardsShredByPlayer = `-- name: GameCardsShredByPlayer :exec +UPDATE game_cards +SET shredded = TRUE +WHERE game_id = $1 + AND player_id = $2 +` + +type GameCardsShredByPlayerParams struct { + GameID string `json:"game_id"` + PlayerID pgtype.Int4 `json:"player_id"` +} + +func (q *Queries) GameCardsShredByPlayer(ctx context.Context, arg GameCardsShredByPlayerParams) error { + _, err := q.db.Exec(ctx, gameCardsShredByPlayer, arg.GameID, arg.PlayerID) + return err +} + const gameCardsShuffle = `-- name: GameCardsShuffle :exec WITH ordered AS ( SELECT @@ -294,23 +311,6 @@ type GameCardsWheelViewRow struct { TopCardType string `json:"top_card_type"` } -const gameCardsShredByPlayer = `-- name: GameCardsShredByPlayer :exec -UPDATE game_cards -SET shredded = TRUE -WHERE game_id = $1 - AND player_id = $2 -` - -type GameCardsShredByPlayerParams struct { - GameID string `json:"game_id"` - PlayerID pgtype.Int4 `json:"player_id"` -} - -func (q *Queries) GameCardsShredByPlayer(ctx context.Context, arg GameCardsShredByPlayerParams) error { - _, err := q.db.Exec(ctx, gameCardsShredByPlayer, arg.GameID, arg.PlayerID) - return err -} - // Public view of the unrevealed wheel. func (q *Queries) GameCardsWheelView(ctx context.Context, gameID string) ([]GameCardsWheelViewRow, error) { rows, err := q.db.Query(ctx, gameCardsWheelView, gameID) diff --git a/db/sqlc/initiative.sql.go b/db/sqlc/initiative.sql.go index 8663d46..9fc5344 100644 --- a/db/sqlc/initiative.sql.go +++ b/db/sqlc/initiative.sql.go @@ -12,21 +12,32 @@ import ( ) const initiativeAdvance = `-- name: InitiativeAdvance :exec -WITH initiative_max AS ( - SELECT MAX(game_players.initiative) AS highest - FROM game_players - WHERE game_players.game_id = $1 +WITH cur AS ( + SELECT initiative_current FROM games WHERE id = $1 ) UPDATE games -SET initiative_current = ( - games.initiative_current % initiative_max.highest -) + 1 -FROM initiative_max +SET initiative_current = COALESCE( + ( + SELECT MIN(game_players.initiative) + FROM game_players, cur + WHERE game_players.game_id = $1 + AND game_players.initiative > 0 + AND game_players.initiative > cur.initiative_current + ), + ( + SELECT MIN(game_players.initiative) + FROM game_players + WHERE game_players.game_id = $1 + AND game_players.initiative > 0 + ) +) WHERE games.id = $1 ` -func (q *Queries) InitiativeAdvance(ctx context.Context, id string) error { - _, err := q.db.Exec(ctx, initiativeAdvance, id) +// Move initiative to the next non-host player, skipping empty slots left by +// players who exited, and wrapping back to the lowest when past the top. +func (q *Queries) InitiativeAdvance(ctx context.Context, gameID string) error { + _, err := q.db.Exec(ctx, initiativeAdvance, gameID) return err } diff --git a/rulette_test.go b/rulette_test.go index f956274..46ffbe8 100644 --- a/rulette_test.go +++ b/rulette_test.go @@ -1004,43 +1004,81 @@ func TestGame(t *testing.T) { "initiative should advance when the current turn player exits") }) - t.Run("POST /{game_id}/action/exit cards shredded", func(t *testing.T) { - // Give the next player (initiative=3, if present; else use host) some - // cards so we can verify they are shredded when they exit. + t.Run("POST /{game_id}/action/exit (host forbidden)", func(t *testing.T) { + hostCookie := cookieByInitiative[0] + require.NotNil(t, hostCookie, "need the host cookie") + path := fmt.Sprintf("/%s/action/exit", gameID) + req := httptest.NewRequest(http.MethodPost, path, nil) + req.AddCookie(hostCookie) + w := httptest.NewRecorder() + actionHandler(w, req) + require.Equal(t, http.StatusForbidden, w.Result().StatusCode, + "host must not be able to exit the game") + + // host must remain in the game after a forbidden exit cache.Delete(gameID) remaining, err := queries.GamePlayerPoints(ctx, gameID) require.NoError(t, err) - if len(remaining) == 0 { - t.Skip("no players remaining") + var hostPresent bool + for _, p := range remaining { + if p.Initiative.Int32 == 0 { + hostPresent = true + } } - // pick any remaining non-host player + require.True(t, hostPresent, "host must remain after a forbidden exit") + }) + + t.Run("POST /{game_id}/action/exit cards shredded", func(t *testing.T) { + cache.Delete(gameID) + remaining, err := queries.GamePlayerPoints(ctx, gameID) + require.NoError(t, err) + // pick any remaining non-host player with a known cookie var exitPlayer sqlc.GamePlayerPointsRow var exitCookie *http.Cookie + var nonHostCount int for _, p := range remaining { - if p.Initiative.Int32 != 0 { + if p.Initiative.Int32 == 0 { + continue + } + nonHostCount++ + if exitCookie == nil && cookieByInitiative[p.Initiative.Int32] != nil { exitPlayer = p exitCookie = cookieByInitiative[p.Initiative.Int32] - break } } if exitCookie == nil { t.Skip("no non-host player with a known cookie remaining") } - - // spin a card onto that player by manually assigning one from the wheel - cards, err := queries.GameCardsWheelView(ctx, gameID) + // when this is the last non-host player, exiting should end the game + wasLastPlayer := nonHostCount == 1 + + // force an unshredded card onto the exiting player. The deck is fully + // spun by now, so take any game card and deal it to them. + var gcID int32 + err = dbPool.QueryRow(ctx, + `SELECT id FROM game_cards WHERE game_id = $1 LIMIT 1`, + gameID).Scan(&gcID) + require.NoError(t, err, "need a card to deal") + _, err = dbPool.Exec(ctx, + `UPDATE game_cards SET player_id = $1, shredded = false, slot = NULL + WHERE id = $2 AND game_id = $3`, + exitPlayer.PlayerID, gcID, gameID) require.NoError(t, err) - if len(cards) == 0 { - t.Skip("no cards on wheel to assign") - } - // move the first wheel card to the player (simulates having a card) - wheelCards, err := queries.GameCardsPlayerView(ctx, gameID) + // confirm the player actually holds the card before exiting, so the + // post-exit check below is not vacuous + cache.Delete(gameID) + before, err := queries.GameCardsPlayerView(ctx, gameID) require.NoError(t, err) - _ = wheelCards // just check it doesn't error + var held bool + for _, c := range before { + if c.PlayerID.Int32 == exitPlayer.PlayerID { + held = true + } + } + require.True(t, held, "exiting player should hold a card before exit") - // put game in stateTurn on a different player so the exiting player - // is not the current turn player (simpler case) + // put the game on the host's turn so the exiting player is not current err = queries.GameUpdate(ctx, sqlc.GameUpdateParams{ ID: gameID, StateID: stateTurn, @@ -1059,7 +1097,7 @@ func TestGame(t *testing.T) { actionHandler(w, req) require.Equal(t, http.StatusOK, w.Result().StatusCode) - // verify no unshredded cards remain for this player + // the exited player must have no remaining unshredded cards cache.Delete(gameID) allCards, err := queries.GameCardsPlayerView(ctx, gameID) require.NoError(t, err) @@ -1067,6 +1105,55 @@ func TestGame(t *testing.T) { require.NotEqual(t, exitPlayer.PlayerID, c.PlayerID.Int32, "exited player must have no remaining unshredded cards") } + + // the last non-host player leaving ends the game + if wasLastPlayer { + cache.Delete(gameID) + gs, err := queries.GameState(ctx, gameID) + require.NoError(t, err) + require.Equal(t, int32(stateOver), gs.StateID, + "game should end when the last non-host player exits") + } + }) + + // directly exercise the turn engine across a gap left by an exited player, + // independent of the shared game's depleted roster. + t.Run("InitiativeAdvance skips gaps from exited players", func(t *testing.T) { + const gapGame = "gaptst" + // four players: a host plus three meant for initiative 1, 2, 3 + ids := make([]int32, 4) + for i := range ids { + require.NoError(t, dbPool.QueryRow(ctx, + `INSERT INTO players (name) VALUES ($1) RETURNING id`, + fmt.Sprintf("gap-%d", i)).Scan(&ids[i])) + } + _, err := dbPool.Exec(ctx, + `INSERT INTO games (id, owner_id, state_id, initiative_current) + VALUES ($1, $2, $3, 1)`, gapGame, ids[0], stateTurn) + require.NoError(t, err) + // seat host at 0 and players at 1 and 3, leaving initiative 2 empty as + // if the player who held it had exited + seats := map[int32]int32{ids[0]: 0, ids[1]: 1, ids[3]: 3} + for pid, seat := range seats { + _, err := dbPool.Exec(ctx, + `INSERT INTO game_players (game_id, player_id, initiative) + VALUES ($1, $2, $3)`, gapGame, pid, seat) + require.NoError(t, err) + } + + // from 1, the next occupied non-host slot is 3 (slot 2 is empty) + require.NoError(t, queries.InitiativeAdvance(ctx, gapGame)) + gs, err := queries.GameState(ctx, gapGame) + require.NoError(t, err) + require.Equal(t, int32(3), gs.InitiativeCurrent.Int32, + "advance from 1 should skip empty slot 2 and land on 3") + + // from the top, advance wraps to the lowest non-host slot (1, not host 0) + require.NoError(t, queries.InitiativeAdvance(ctx, gapGame)) + gs, err = queries.GameState(ctx, gapGame) + require.NoError(t, err) + require.Equal(t, int32(1), gs.InitiativeCurrent.Int32, + "advance from the top should wrap to the lowest non-host slot") }) t.Run("POST /{game_id}/action/end", func(t *testing.T) { diff --git a/static.go b/static.go index fe5ddd6..e282995 100644 --- a/static.go +++ b/static.go @@ -81,6 +81,18 @@ func readParse(fs embed.FS, path, base string, fullPage bool) (*template.Templat _, ok := data.(state) return ok }, + "isHost": func(data any) bool { + s, ok := data.(state) + if !ok { + return false + } + for _, p := range s.Players { + if int(p.PlayerID) == s.CallerID { + return p.Initiative.Int32 == 0 + } + } + return false + }, } tmpl, err := template.New(name).Funcs(funcs).Parse(string(f)) if err != nil { diff --git a/static/html/tmpl.footer.html b/static/html/tmpl.footer.html index 9e79b0f..955c2c0 100644 --- a/static/html/tmpl.footer.html +++ b/static/html/tmpl.footer.html @@ -32,7 +32,7 @@

credit

💡 suggest a rule 🐛 report a bug - {{ if inGame . }} + {{ if and (inGame .) (not (isHost .)) }} {{ end }} @@ -40,7 +40,7 @@

credit

-{{ if inGame . }} +{{ if and (inGame .) (not (isHost .)) }}

are you sure?