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
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 .)) }}