diff --git a/actions.go b/actions.go index ac22ff4..18b1ce2 100644 --- a/actions.go +++ b/actions.go @@ -1,6 +1,7 @@ package main import ( + "context" "errors" "fmt" "net/http" @@ -68,6 +69,40 @@ func modifierNotPending( } } +// resumeAfterChallenge moves the game out of a resolved challenge and updates +// the game state: back to challenge if more infractions are queued (so the host +// keeps getting prompted), to pending if this challenge interrupted a modifier +// choice, otherwise to normal turn play. Shared by the decide handler and the +// post-affirm card transfer so they pick the next state the same way. +func resumeAfterChallenge( + ctx context.Context, + q *sqlc.Queries, + s *state, + gameID string, +) error { + remaining, err := q.InfractionsActiveCount(ctx, gameID) + if err != nil { + return fmt.Errorf("count active infractions: %w", err) + } + nextState := int32(stateTurn) + if remaining > 0 { + nextState = stateChallenge + } else if s.hasPendingModifier() { + nextState = statePending + } + if err := q.GameUpdate(ctx, sqlc.GameUpdateParams{ + ID: gameID, + StateID: nextState, + InitiativeCurrent: pgtype.Int4{ + Int32: s.Game.InitiativeCurrent.Int32, + Valid: true, + }, + }); err != nil { + return fmt.Errorf("transition state after challenge: %w", err) + } + return nil +} + func actionHandler(w http.ResponseWriter, r *http.Request) { pathLong := strings.TrimPrefix(r.URL.Path, "/") parts := strings.Split(pathLong, "/") @@ -242,7 +277,7 @@ func actionHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, ErrActionInvalid.Error(), http.StatusTooEarly) return } - case stateEnding, stateChallenge, statePrompt, statePending, stateTurn, stateReady: // in progress (7 = deck spent, host to end) + case stateEnding, stateChallenge, statePrompt, statePending, stateTurn, stateReady, statePromptShred, stateAccusationTransfer: // in progress (7 = deck spent, host to end) switch action { case "spin": if state.Game.StateID != stateTurn { @@ -571,27 +606,107 @@ func actionHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "only host can advance", http.StatusForbidden) return } - if state.Game.StateID != stateTurn { - log.Warn("advance requires turn state", + // advance doubles as the host's escape hatch out of the two card + // chooser states: if the spinner or accuser never acts, the host + // skips on their behalf so play can't wedge. + switch state.Game.StateID { + case stateTurn: + if !state.AwaitingAck { + log.Warn("advance requires pending acknowledgement", + "game_id", gameID, + ) + http.Error(w, "nothing to advance", http.StatusConflict) + return + } + if err := advanceTurn(r.Context(), log, queries, gameID); err != nil { + log.Error("advance turn by host", "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + log.Info("host advanced initiative", "game_id", gameID) + case statePromptShred: + // the spinner never shredded: skip for them and advance. + tx, err := dbPool.Begin(r.Context()) + if err != nil { + log.Error("begin transaction", "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + txq := queries.WithTx(tx) + if err := txq.GameUpdate(r.Context(), sqlc.GameUpdateParams{ + ID: gameID, + StateID: stateTurn, + InitiativeCurrent: pgtype.Int4{ + Int32: state.Game.InitiativeCurrent.Int32, + Valid: true, + }, + }); err != nil { + log.Error("host skip prompt shred", "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + if err := advanceTurn(r.Context(), log, txq, gameID); err != nil { + log.Error("advance after host prompt-shred skip", + "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + if err := tx.Commit(r.Context()); err != nil { + log.Error("commit host prompt-shred skip", + "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + log.Info("host skipped prompt shred", "game_id", gameID) + case stateAccusationTransfer: + // the accuser never gave a card: skip for them and resume. + inf, lookupErr := queries.InfractionTransferPending(r.Context(), gameID) + if lookupErr != nil && !errors.Is(lookupErr, pgx.ErrNoRows) { + log.Error("get transfer-pending infraction for host skip", + "error", lookupErr, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + tx, err := dbPool.Begin(r.Context()) + if err != nil { + log.Error("begin transaction", "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + txq := queries.WithTx(tx) + if lookupErr == nil { + if err := txq.InfractionTransferResolve(r.Context(), inf.ID); err != nil { + log.Error("resolve transfer for host skip", + "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + } + if err := resumeAfterChallenge( + r.Context(), txq, &state, gameID, + ); err != nil { + log.Error("resume after host transfer skip", + "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + if err := tx.Commit(r.Context()); err != nil { + log.Error("commit host transfer skip", + "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + log.Info("host skipped accusation transfer", "game_id", gameID) + default: + log.Warn("advance requires turn or chooser state", "game_id", gameID, "state_id", state.Game.StateID, ) http.Error(w, "cannot advance in current state", http.StatusConflict) return } - if !state.AwaitingAck { - log.Warn("advance requires pending acknowledgement", - "game_id", gameID, - ) - http.Error(w, "nothing to advance", http.StatusConflict) - return - } - if err := advanceTurn(r.Context(), log, queries, gameID); err != nil { - log.Error("advance turn by host", "error", err, "game_id", gameID) - http.Error(w, "server error", http.StatusInternalServerError) - return - } - log.Info("host advanced initiative", "game_id", gameID) cache.Delete(gameID) w.Header().Set("HX-Trigger", "refreshTable") w.WriteHeader(http.StatusOK) @@ -1739,45 +1854,8 @@ func actionHandler(w http.ResponseWriter, r *http.Request) { } } - // stay in challenge while infractions remain queued, so the - // host keeps getting prompted for the next one; otherwise return - // to turn state - remaining, err := txq.InfractionsActiveCount(r.Context(), gameID) - if err != nil { - log.Error("count active infractions", - "error", err, - "game_id", gameID, - ) - http.Error(w, "server error", http.StatusInternalServerError) - return - } - nextState := int32(stateTurn) // turn - if remaining > 0 { - nextState = stateChallenge // challenge - } else if state.hasPendingModifier() { - // this challenge interrupted a pending modifier choice; - // resume it instead of ending the turn, so the player can - // still resolve the modifier they drew. - nextState = statePending - } - err = txq.GameUpdate(r.Context(), sqlc.GameUpdateParams{ - ID: gameID, - StateID: nextState, - InitiativeCurrent: pgtype.Int4{ - Int32: state.Game.InitiativeCurrent.Int32, - Valid: true, - }, - }) - if err != nil { - log.Error("transition state after decide", - "error", err, - "game_id", gameID, - ) - http.Error(w, "server error", http.StatusInternalServerError) - return - } - - // add an event for the verdict (feed + the accuser's sound) + // record the verdict (feed + the accuser's sound) before choosing + // the next state. if err := writeEvent(w, r, log, txq, sqlc.EventCreateParams{ GameID: gameID, EventType: "decide", @@ -1787,6 +1865,48 @@ func actionHandler(w http.ResponseWriter, r *http.Request) { return } + // an upheld accusation lets the accuser give one of their own rule + // cards to the accused. if they hold a rule, hold the game in the + // transfer state so their chooser can open; the resume logic runs + // once they give a card or skip. otherwise resume now. + accuserHasRule := false + if affirmed { + for _, c := range state.CardsPlayers { + if c.PlayerID.Int32 == infraction.Accuser && c.Type == "rule" { + accuserHasRule = true + break + } + } + } + if accuserHasRule { + if err := txq.InfractionTransferQueue(r.Context(), int32(infID)); err != nil { + log.Error("queue transfer", "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + if err := txq.GameUpdate(r.Context(), sqlc.GameUpdateParams{ + ID: gameID, + StateID: stateAccusationTransfer, + InitiativeCurrent: pgtype.Int4{ + Int32: state.Game.InitiativeCurrent.Int32, + Valid: true, + }, + }); err != nil { + log.Error("transition to accusation-transfer", + "error", err, + "game_id", gameID, + ) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + } else if err := resumeAfterChallenge( + r.Context(), txq, &state, gameID, + ); err != nil { + log.Error("resume after decide", "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + err = tx.Commit(r.Context()) if err != nil { log.Error("commit decide transaction", @@ -1949,27 +2069,37 @@ func actionHandler(w http.ResponseWriter, r *http.Request) { } } - // the challenge is over: return to normal play and pass the turn - // on, just as acknowledging a drawn rule would. + // a completed prompt earns the spinner the chance to shred one of + // their own rule cards. hold in the prompt-shred state so their + // chooser can open; the turn advances only once they shred or skip. + // otherwise (a fail, or nothing to shred) return to normal play and + // pass the turn on, just as acknowledging a drawn rule would. + holdForShred := action == "succeed" && rulesHeld > 0 + nextState := int32(stateTurn) + if holdForShred { + nextState = statePromptShred + } if err := txq.GameUpdate(r.Context(), sqlc.GameUpdateParams{ ID: gameID, - StateID: stateTurn, + StateID: nextState, InitiativeCurrent: pgtype.Int4{ Int32: state.Game.InitiativeCurrent.Int32, Valid: true, }, }); err != nil { - log.Error("return to turn after prompt", + log.Error("transition state after prompt", "error", err, "game_id", gameID, ) http.Error(w, "server error", http.StatusInternalServerError) return } - if err := advanceTurn(r.Context(), log, txq, gameID); err != nil { - log.Error("advance after prompt", "error", err, "game_id", gameID) - http.Error(w, "server error", http.StatusInternalServerError) - return + if !holdForShred { + if err := advanceTurn(r.Context(), log, txq, gameID); err != nil { + log.Error("advance after prompt", "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } } if err := tx.Commit(r.Context()); err != nil { @@ -1985,6 +2115,266 @@ func actionHandler(w http.ResponseWriter, r *http.Request) { cache.Delete(gameID) w.Header().Set("HX-Trigger", "refreshTable") w.WriteHeader(http.StatusOK) + case "prompt-shred": + // the spinner's bonus after a succeeded prompt: shred one of their + // own rule cards, or skip. either way the turn then advances. + if state.Game.StateID != statePromptShred { + log.Warn("prompt-shred requires prompt-shred state", + "game_id", gameID, + "state_id", state.Game.StateID, + ) + http.Error(w, "no prompt shred pending", http.StatusConflict) + return + } + if !state.isPlayerTurn(cookieKey) { + log.Warn("prohibiting non-turn player from prompt shred") + http.Error(w, "not your turn", http.StatusForbidden) + return + } + shredderID, err := strconv.Atoi(cookieID) + if err != nil { + log.Error("invalid player id", "error", err, "game_id", gameID) + http.Error(w, "invalid player id", http.StatusBadRequest) + return + } + // a card was chosen (skip omits it): validate before the + // transaction so we can bail early on bad input. + var cardID int + shredCard := false + if cardStr := r.URL.Query().Get("game_card_id"); cardStr != "" { + var err error + cardID, err = strconv.Atoi(cardStr) + if err != nil { + log.Error("invalid game_card_id", "error", err, "game_id", gameID) + http.Error(w, "invalid game_card_id", http.StatusBadRequest) + return + } + var owned bool + for _, c := range state.CardsPlayers { + if c.ID == int32(cardID) && + c.PlayerID.Int32 == int32(shredderID) && + c.Type == "rule" { + owned = true + break + } + } + if !owned { + log.Warn("prompt-shred: card not a rule owned by player", + "game_id", gameID, + "game_card_id", cardID, + "player_id", shredderID, + ) + http.Error(w, "card not a rule owned by player", http.StatusForbidden) + return + } + shredCard = true + } + + tx, err := dbPool.Begin(r.Context()) + if err != nil { + log.Error("begin transaction", "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + txq := queries.WithTx(tx) + + if shredCard { + if err := txq.GameCardShred(r.Context(), sqlc.GameCardShredParams{ + ID: int32(cardID), + GameID: gameID, + }); err != nil { + log.Error("shred card after prompt", + "error", err, + "game_id", gameID, + "game_card_id", cardID, + ) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + if err := writeEvent(w, r, log, txq, sqlc.EventCreateParams{ + GameID: gameID, + EventType: "shred", + ActorID: pgInt(int32(shredderID)), + GameCardID: pgInt(int32(cardID)), + }); err != nil { + return + } + log.Info("card shredded after prompt", + "game_id", gameID, + "card_id", cardID, + "player_id", shredderID, + ) + } else { + log.Info("prompt shred skipped", + "game_id", gameID, + "player_id", shredderID, + ) + } + // the bonus is resolved: back to turn state and pass initiative on. + if err := txq.GameUpdate(r.Context(), sqlc.GameUpdateParams{ + ID: gameID, + StateID: stateTurn, + InitiativeCurrent: pgtype.Int4{ + Int32: state.Game.InitiativeCurrent.Int32, + Valid: true, + }, + }); err != nil { + log.Error("transition to turn after prompt shred", + "error", err, + "game_id", gameID, + ) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + if err := advanceTurn(r.Context(), log, txq, gameID); err != nil { + log.Error("advance after prompt shred", "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + if err := tx.Commit(r.Context()); err != nil { + log.Error("commit prompt shred transaction", + "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + cache.Delete(gameID) + w.Header().Set("HX-Trigger", "refreshTable") + w.WriteHeader(http.StatusOK) + case "accusation-transfer": + // after an upheld accusation, the accuser gives one of their own + // rule cards to the accused, or skips. either way play resumes. + if state.Game.StateID != stateAccusationTransfer { + log.Warn("accusation-transfer requires its state", + "game_id", gameID, + "state_id", state.Game.StateID, + ) + http.Error(w, "no transfer pending", http.StatusConflict) + return + } + inf, err := queries.InfractionTransferPending(r.Context(), gameID) + if errors.Is(err, pgx.ErrNoRows) { + log.Warn("no transfer-pending infraction", "game_id", gameID) + http.Error(w, "no transfer pending", http.StatusConflict) + return + } + if err != nil { + log.Error("get transfer-pending infraction", + "error", err, + "game_id", gameID, + ) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + callerID, err := strconv.Atoi(cookieID) + if err != nil { + log.Error("invalid player id", "error", err, "game_id", gameID) + http.Error(w, "invalid player id", http.StatusBadRequest) + return + } + if int32(callerID) != inf.Accuser { + log.Warn("prohibiting non-accuser from transfer", + "game_id", gameID, + "player_id", callerID, + "accuser", inf.Accuser, + ) + http.Error(w, "only the accuser may give a card", http.StatusForbidden) + return + } + + tx, err := dbPool.Begin(r.Context()) + if err != nil { + log.Error("begin transaction", "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + txq := queries.WithTx(tx) + + // a card was chosen (skip omits it): give it to the accused after + // confirming the caller owns it and it's a rule. + if cardStr := r.URL.Query().Get("game_card_id"); cardStr != "" { + cardID, err := strconv.Atoi(cardStr) + if err != nil { + log.Error("invalid game_card_id", "error", err, "game_id", gameID) + http.Error(w, "invalid game_card_id", http.StatusBadRequest) + return + } + var owned bool + for _, c := range state.CardsPlayers { + if c.ID == int32(cardID) && + c.PlayerID.Int32 == int32(callerID) && + c.Type == "rule" { + owned = true + break + } + } + if !owned { + log.Warn("transfer: card not a rule owned by accuser", + "game_id", gameID, + "game_card_id", cardID, + "player_id", callerID, + ) + http.Error(w, "card not a rule owned by you", http.StatusForbidden) + return + } + if err := txq.GameCardMove(r.Context(), sqlc.GameCardMoveParams{ + ID: int32(cardID), + GameID: gameID, + PlayerID: pgtype.Int4{ + Int32: inf.Accused, + Valid: true, + }, + }); err != nil { + log.Error("give card to accused", + "error", err, + "game_id", gameID, + "game_card_id", cardID, + ) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + if err := writeEvent(w, r, log, txq, sqlc.EventCreateParams{ + GameID: gameID, + EventType: "transfer", + ActorID: pgInt(int32(callerID)), + TargetID: pgInt(inf.Accused), + GameCardID: pgInt(int32(cardID)), + }); err != nil { + return + } + log.Info("card given to accused after accusation", + "game_id", gameID, + "card_id", cardID, + "accuser", callerID, + "accused", inf.Accused, + ) + } else { + log.Info("accusation transfer skipped", + "game_id", gameID, + "accuser", callerID, + ) + } + if err := txq.InfractionTransferResolve(r.Context(), inf.ID); err != nil { + log.Error("resolve transfer", "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + if err := resumeAfterChallenge( + r.Context(), txq, &state, gameID, + ); err != nil { + log.Error("resume after transfer", "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + if err := tx.Commit(r.Context()); err != nil { + log.Error("commit transfer transaction", "error", err, "game_id", gameID) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + cache.Delete(gameID) + w.Header().Set("HX-Trigger", "refreshTable") + w.WriteHeader(http.StatusOK) case "end": if !state.isHost(cookieKey) { log.Warn("prohibiting non-host from ending game") diff --git a/db/queries/infractions.sql b/db/queries/infractions.sql index 8a5af50..be47db0 100644 --- a/db/queries/infractions.sql +++ b/db/queries/infractions.sql @@ -16,7 +16,7 @@ SELECT * FROM infractions WHERE id = $1; -- name: InfractionsByGame :many -SELECT id, game_id, game_card_id, accused, accuser, created, active, affirmed +SELECT id, game_id, game_card_id, accused, accuser, created, active, affirmed, transfer_pending FROM infractions WHERE game_id = $1 ORDER BY created DESC; @@ -25,3 +25,22 @@ ORDER BY created DESC; SELECT COUNT(*) FROM infractions WHERE game_id = $1 AND active = TRUE; + +-- name: InfractionTransferQueue :exec +UPDATE infractions +SET transfer_pending = TRUE +WHERE id = $1; + +-- name: InfractionTransferResolve :exec +UPDATE infractions +SET transfer_pending = FALSE +WHERE id = $1; + +-- name: InfractionTransferPending :one +SELECT id, game_card_id, accused, accuser +FROM infractions +WHERE game_id = $1 + AND affirmed = TRUE + AND transfer_pending = TRUE +ORDER BY created DESC +LIMIT 1; diff --git a/db/schema.sql b/db/schema.sql index 678dc79..10a394a 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -20,7 +20,9 @@ VALUES (5, 'challenge', 'a points challenge is pending'), (6, 'prompt', 'a prompt challenge is pending'), (7, 'ending', 'deck exhausted, waiting on host to end the game'), -(8, 'end', 'game over') +(8, 'end', 'game over'), +(9, 'prompt-shred', 'spinner may shred a rule card after a succeeded prompt'), +(10, 'accusation-transfer', 'accuser may give a rule card to the accused after an affirmed accusation') ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description; @@ -294,6 +296,9 @@ CREATE TABLE IF NOT EXISTS infractions ( created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, active BOOLEAN DEFAULT TRUE, -- active until decided affirmed BOOLEAN DEFAULT FALSE, + -- set when an affirmed accusation still owes a card transfer from the + -- accuser to the accused; cleared once they give a card or skip. + transfer_pending BOOLEAN DEFAULT FALSE, -- points changes are recorded in point_changes, not here FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE, FOREIGN KEY (game_card_id) REFERENCES game_cards(id) ON DELETE CASCADE, @@ -301,6 +306,10 @@ CREATE TABLE IF NOT EXISTS infractions ( FOREIGN KEY (accuser) REFERENCES players(id) ON DELETE CASCADE ); +-- add transfer_pending on any live database, since CREATE TABLE above is a +-- no-op once infractions exists. idempotent: a no-op once the column is there. +ALTER TABLE infractions ADD COLUMN IF NOT EXISTS transfer_pending BOOLEAN DEFAULT FALSE; + CREATE UNLOGGED TABLE IF NOT EXISTS game_cache ( game_id VARCHAR(6) PRIMARY KEY, value JSONB, diff --git a/db/sqlc/infractions.sql.go b/db/sqlc/infractions.sql.go index edade22..b768c5d 100644 --- a/db/sqlc/infractions.sql.go +++ b/db/sqlc/infractions.sql.go @@ -58,7 +58,7 @@ func (q *Queries) InfractionDecide(ctx context.Context, arg InfractionDecidePara } const infractionGet = `-- name: InfractionGet :one -SELECT id, game_id, game_card_id, accused, accuser, created, active, affirmed FROM infractions +SELECT id, game_id, game_card_id, accused, accuser, created, active, affirmed, transfer_pending FROM infractions WHERE id = $1 ` @@ -74,10 +74,62 @@ func (q *Queries) InfractionGet(ctx context.Context, id int32) (Infractions, err &i.Created, &i.Active, &i.Affirmed, + &i.TransferPending, ) return i, err } +const infractionTransferPending = `-- name: InfractionTransferPending :one +SELECT id, game_card_id, accused, accuser +FROM infractions +WHERE game_id = $1 + AND affirmed = TRUE + AND transfer_pending = TRUE +ORDER BY created DESC +LIMIT 1 +` + +type InfractionTransferPendingRow struct { + ID int32 `json:"id"` + GameCardID int32 `json:"game_card_id"` + Accused int32 `json:"accused"` + Accuser int32 `json:"accuser"` +} + +func (q *Queries) InfractionTransferPending(ctx context.Context, gameID string) (InfractionTransferPendingRow, error) { + row := q.db.QueryRow(ctx, infractionTransferPending, gameID) + var i InfractionTransferPendingRow + err := row.Scan( + &i.ID, + &i.GameCardID, + &i.Accused, + &i.Accuser, + ) + return i, err +} + +const infractionTransferQueue = `-- name: InfractionTransferQueue :exec +UPDATE infractions +SET transfer_pending = TRUE +WHERE id = $1 +` + +func (q *Queries) InfractionTransferQueue(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, infractionTransferQueue, id) + return err +} + +const infractionTransferResolve = `-- name: InfractionTransferResolve :exec +UPDATE infractions +SET transfer_pending = FALSE +WHERE id = $1 +` + +func (q *Queries) InfractionTransferResolve(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, infractionTransferResolve, id) + return err +} + const infractionsActiveCount = `-- name: InfractionsActiveCount :one SELECT COUNT(*) FROM infractions WHERE game_id = $1 @@ -92,7 +144,7 @@ func (q *Queries) InfractionsActiveCount(ctx context.Context, gameID string) (in } const infractionsByGame = `-- name: InfractionsByGame :many -SELECT id, game_id, game_card_id, accused, accuser, created, active, affirmed +SELECT id, game_id, game_card_id, accused, accuser, created, active, affirmed, transfer_pending FROM infractions WHERE game_id = $1 ORDER BY created DESC @@ -116,6 +168,7 @@ func (q *Queries) InfractionsByGame(ctx context.Context, gameID string) ([]Infra &i.Created, &i.Active, &i.Affirmed, + &i.TransferPending, ); err != nil { return nil, err } diff --git a/db/sqlc/models.go b/db/sqlc/models.go index 4c901b3..1ce60f3 100644 --- a/db/sqlc/models.go +++ b/db/sqlc/models.go @@ -88,14 +88,15 @@ type Games struct { } type Infractions struct { - ID int32 `json:"id"` - GameID string `json:"game_id"` - GameCardID int32 `json:"game_card_id"` - Accused int32 `json:"accused"` - Accuser int32 `json:"accuser"` - Created pgtype.Timestamp `json:"created"` - Active pgtype.Bool `json:"active"` - Affirmed pgtype.Bool `json:"affirmed"` + ID int32 `json:"id"` + GameID string `json:"game_id"` + GameCardID int32 `json:"game_card_id"` + Accused int32 `json:"accused"` + Accuser int32 `json:"accuser"` + Created pgtype.Timestamp `json:"created"` + Active pgtype.Bool `json:"active"` + Affirmed pgtype.Bool `json:"affirmed"` + TransferPending pgtype.Bool `json:"transfer_pending"` } type ModifierEffects struct { diff --git a/game.go b/game.go index 3920f13..2017d63 100644 --- a/game.go +++ b/game.go @@ -197,13 +197,13 @@ func dataHandler(w http.ResponseWriter, r *http.Request) { // still serve the log so the final events and the history // modal work, but with 286 so the feed stops polling renderEvents(w, r, gameID, stopPolling) - case "status", "table", "infraction", "prompt": + case "status", "table", "infraction", "prompt", "promptshred", "transfer": w.WriteHeader(stopPolling) default: http.Error(w, "game over", http.StatusGone) } return - case stateEnding, stateChallenge, statePrompt, statePending, stateTurn, stateReady, stateInviting, stateCreated: // in progress (7 = deck spent, host to end) + case stateEnding, stateChallenge, statePrompt, statePending, stateTurn, stateReady, stateInviting, stateCreated, statePromptShred, stateAccusationTransfer: // in progress switch topic { case "players": filepath := path.Join("static", "html", "tmpl.players.html") @@ -260,6 +260,15 @@ func dataHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal server error", http.StatusInternalServerError) } return + case "promptshred": + // the spinner's chooser after a succeeded prompt; the fragment + // renders only for the turn player, so it's empty for everyone else. + filepath := path.Join("static", "html", "tmpl.prompt_shred.html") + if err := renderTemplate(r.Context(), w, filepath, state); err != nil { + log.Error("render template", "error", err, "template", filepath) + http.Error(w, "internal server error", http.StatusInternalServerError) + } + return case "prompt": // host-only poll: while a prompt challenge is live, hand the host // the spinner's name, the prompt text, and how many seconds have @@ -343,6 +352,50 @@ func dataHandler(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusNoContent) return + case "transfer": + // accuser-only poll: while an affirmed accusation owes a card, + // hand the accuser the accused's name and the accuser's own rule + // cards so their "give a card" popup can open. + if state.Game.StateID != stateAccusationTransfer { + w.WriteHeader(http.StatusNoContent) + return + } + inf, err := queries.InfractionTransferPending(r.Context(), gameID) + if err != nil { + log.Debug("no transfer-pending infraction for poll", + "game_id", gameID, "error", err) + w.WriteHeader(http.StatusNoContent) + return + } + if int32(state.CallerID) != inf.Accuser { + w.WriteHeader(http.StatusNoContent) + return + } + accusedName := "" + for _, p := range state.Players { + if p.PlayerID == inf.Accused { + accusedName = p.Name + break + } + } + type transferCard struct { + ID int32 `json:"id"` + Content string `json:"content"` + } + cards := []transferCard{} + for _, c := range state.CardsPlayers { + if c.PlayerID.Int32 == int32(state.CallerID) && c.Type == "rule" { + content, _ := c.Content.(string) + cards = append(cards, transferCard{ID: c.ID, Content: content}) + } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "infraction_id": inf.ID, + "accused": accusedName, + "cards": cards, + }) + return default: log.Warn(ErrTopicInvalid.Error()) http.Error(w, ErrTopicInvalid.Error(), http.StatusBadRequest) diff --git a/main.go b/main.go index 571990c..b560905 100644 --- a/main.go +++ b/main.go @@ -45,15 +45,17 @@ const ( // game.state_id values, mirroring the game_states rows in db/schema.sql. const ( - stateCreated = 0 // game created, no members joined - stateInviting = 1 // at least one player has joined - stateReady = 2 // joining closed, ready to start (or paused) - stateTurn = 3 // a player is mid-turn - statePending = 4 // a rule modifier choice is pending - stateChallenge = 5 // a points challenge is pending - statePrompt = 6 // a prompt challenge is pending - stateEnding = 7 // deck spent, waiting on host to end - stateOver = 8 // game over + stateCreated = 0 // game created, no members joined + stateInviting = 1 // at least one player has joined + stateReady = 2 // joining closed, ready to start (or paused) + stateTurn = 3 // a player is mid-turn + statePending = 4 // a rule modifier choice is pending + stateChallenge = 5 // a points challenge is pending + statePrompt = 6 // a prompt challenge is pending + stateEnding = 7 // deck spent, waiting on host to end + stateOver = 8 // game over + statePromptShred = 9 // successful prompts pause to allow shredding a card + stateAccusationTransfer = 10 // affirmed accusations pause to allow transferring a card to the accused ) //go:embed db/schema.sql diff --git a/rulette_test.go b/rulette_test.go index bfadfab..5f72852 100644 --- a/rulette_test.go +++ b/rulette_test.go @@ -554,6 +554,19 @@ func TestGame(t *testing.T) { require.Equal(t, before+rulesHeld+1, after, "spin %d: prompt should award 1 + rules held", i) + // a succeeded prompt with rules held holds the turn in the + // prompt-shred state for the spinner's bonus shred; skip it + // here so the turn advances and the deck-walk continues. + if rulesHeld > 0 { + shredReq := httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/%s/action/prompt-shred?skip=1", gameID), nil) + shredReq.AddCookie(c) + shredW := httptest.NewRecorder() + cache.Delete(gameID) + actionHandler(shredW, shredReq) + require.Equal(t, http.StatusOK, shredW.Result().StatusCode, "spin %d prompt-shred skip failed", i) + } + current = (current % maxInit) + 1 continue } @@ -798,13 +811,16 @@ func TestGame(t *testing.T) { actionHandler(w, req) require.Equal(t, http.StatusOK, w.Result().StatusCode) - // verify game returned to turn state + // an upheld accusation lets the accuser give a rule card to the accused; + // since the accuser holds a rule, the game holds in the transfer state + // until they give a card or skip. cache.Delete(gameID) gs, err := queries.GameState(ctx, gameID) require.NoError(t, err) - require.Equal(t, int32(stateTurn), gs.StateID, "expected turn state") + require.Equal(t, int32(stateAccusationTransfer), gs.StateID, + "expected accusation-transfer state") - // verify points adjusted + // verify points adjusted (the penalty applies at affirm time) playersAfter, err := queries.GamePlayerPoints(ctx, gameID) require.NoError(t, err) for _, p := range playersAfter { @@ -817,6 +833,22 @@ func TestGame(t *testing.T) { } }) + t.Run("POST /{game_id}/action/accusation-transfer (skip)", func(t *testing.T) { + path := fmt.Sprintf("/%s/action/accusation-transfer?skip=1", gameID) + req := httptest.NewRequest(http.MethodPost, path, nil) + req.AddCookie(accuserCookie) // the accuser owes the choice + w := httptest.NewRecorder() + cache.Delete(gameID) + actionHandler(w, req) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + + // skipping resumes normal play + cache.Delete(gameID) + gs, err := queries.GameState(ctx, gameID) + require.NoError(t, err) + require.Equal(t, int32(stateTurn), gs.StateID, "expected turn state") + }) + // test absolve flow t.Run("POST /{game_id}/action/accuse (for absolve)", func(t *testing.T) { path := fmt.Sprintf( @@ -1009,6 +1041,130 @@ func TestGame(t *testing.T) { require.True(t, modifierGone, "used modifier card should be shredded") }) + // prompt-shred: after a succeeded prompt, the spinner may shred one of + // their own rule cards. seed a rule card on the turn player and the + // prompt-shred state, then shred it. + t.Run("POST /{game_id}/action/prompt-shred (shreds a card)", func(t *testing.T) { + var gcID int32 + require.NoError(t, dbPool.QueryRow(ctx, + `SELECT gc.id FROM game_cards gc JOIN cards c ON c.id = gc.card_id + WHERE gc.game_id = $1 AND c.type = 'rule' AND gc.shredded = false LIMIT 1`, + gameID).Scan(&gcID)) + _, err = dbPool.Exec(ctx, + `UPDATE game_cards SET player_id = $1, shredded = false, slot = NULL + WHERE id = $2 AND game_id = $3`, turnPlayerID, gcID, gameID) + require.NoError(t, err) + require.NoError(t, queries.GameUpdate(ctx, sqlc.GameUpdateParams{ + ID: gameID, + StateID: statePromptShred, + InitiativeCurrent: pgtype.Int4{Int32: 1, Valid: true}, + })) + cache.Delete(gameID) + + path := fmt.Sprintf("/%s/action/prompt-shred?game_card_id=%d", gameID, gcID) + req := httptest.NewRequest(http.MethodPost, path, nil) + req.AddCookie(turnCookie) + w := httptest.NewRecorder() + cache.Delete(gameID) + actionHandler(w, req) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + + // the chosen card is shredded (gone from the view) and play resumes + cache.Delete(gameID) + cards, err := queries.GameCardsPlayerView(ctx, gameID) + require.NoError(t, err) + for _, c := range cards { + require.NotEqual(t, gcID, c.ID, "shredded card should be gone") + } + gs, err := queries.GameState(ctx, gameID) + require.NoError(t, err) + require.Equal(t, int32(stateTurn), gs.StateID, "expected turn state") + }) + + // host advance is the escape hatch out of prompt-shred when the spinner + // never acts: it skips for them and advances. + t.Run("POST /{game_id}/action/advance (host skips prompt-shred)", func(t *testing.T) { + require.NoError(t, queries.GameUpdate(ctx, sqlc.GameUpdateParams{ + ID: gameID, + StateID: statePromptShred, + InitiativeCurrent: pgtype.Int4{Int32: 1, Valid: true}, + })) + cache.Delete(gameID) + path := fmt.Sprintf("/%s/action/advance", gameID) + req := httptest.NewRequest(http.MethodPost, path, nil) + req.AddCookie(cookieByInitiative[0]) // host + w := httptest.NewRecorder() + cache.Delete(gameID) + actionHandler(w, req) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + cache.Delete(gameID) + gs, err := queries.GameState(ctx, gameID) + require.NoError(t, err) + require.Equal(t, int32(stateTurn), gs.StateID, "host skip should advance") + }) + + // accusation-transfer: after an upheld accusation, the accuser gives one of + // their own rule cards to the accused. seed an affirmed, transfer-pending + // infraction and a rule card on the accuser, then give it. + t.Run("POST /{game_id}/action/accusation-transfer (gives a card)", func(t *testing.T) { + var anyGCID int32 + require.NoError(t, dbPool.QueryRow(ctx, + `SELECT id FROM game_cards WHERE game_id = $1 LIMIT 1`, + gameID).Scan(&anyGCID)) + var infID int32 + require.NoError(t, dbPool.QueryRow(ctx, + `INSERT INTO infractions + (game_id, game_card_id, accused, accuser, active, affirmed, transfer_pending) + VALUES ($1, $2, $3, $4, false, true, true) RETURNING id`, + gameID, anyGCID, targetPlayerID, turnPlayerID).Scan(&infID)) + + var giveGCID int32 + require.NoError(t, dbPool.QueryRow(ctx, + `SELECT gc.id FROM game_cards gc JOIN cards c ON c.id = gc.card_id + WHERE gc.game_id = $1 AND c.type = 'rule' AND gc.shredded = false LIMIT 1`, + gameID).Scan(&giveGCID)) + _, err = dbPool.Exec(ctx, + `UPDATE game_cards SET player_id = $1, shredded = false, slot = NULL + WHERE id = $2 AND game_id = $3`, turnPlayerID, giveGCID, gameID) + require.NoError(t, err) + require.NoError(t, queries.GameUpdate(ctx, sqlc.GameUpdateParams{ + ID: gameID, + StateID: stateAccusationTransfer, + InitiativeCurrent: pgtype.Int4{Int32: 1, Valid: true}, + })) + cache.Delete(gameID) + + path := fmt.Sprintf("/%s/action/accusation-transfer?game_card_id=%d", gameID, giveGCID) + req := httptest.NewRequest(http.MethodPost, path, nil) + req.AddCookie(turnCookie) // the accuser + w := httptest.NewRecorder() + cache.Delete(gameID) + actionHandler(w, req) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + + // the card now belongs to the accused and the transfer is no longer owed + cache.Delete(gameID) + cards, err := queries.GameCardsPlayerView(ctx, gameID) + require.NoError(t, err) + var moved bool + for _, c := range cards { + if c.ID == giveGCID { + require.Equal(t, targetPlayerID, c.PlayerID.Int32, + "given card should move to the accused") + moved = true + } + } + require.True(t, moved, "given card should appear under the accused") + var stillPending bool + require.NoError(t, dbPool.QueryRow(ctx, + `SELECT transfer_pending FROM infractions WHERE id = $1`, infID).Scan(&stillPending)) + require.False(t, stillPending, "transfer should be resolved") + gs, err := queries.GameState(ctx, gameID) + require.NoError(t, err) + require.NotEqual(t, int32(stateAccusationTransfer), gs.StateID, + "play should resume after the transfer") + }) + 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/state.go b/state.go index fdf65ea..872d821 100644 --- a/state.go +++ b/state.go @@ -181,6 +181,19 @@ func (s state) Standings() []sqlc.GamePlayerPointsRow { return ranked } +// CallerRuleCount returns how many rule cards the caller currently holds. +// The prompt-shred chooser uses it to show the award (1 point plus 1 per +// rule). Value receiver so templates can call it on the by-value state data. +func (s state) CallerRuleCount() int { + var count int + for _, c := range s.CardsPlayers { + if c.PlayerID.Int32 == int32(s.CallerID) && c.Type == "rule" { + count++ + } + } + return count +} + // Winners returns the player(s) with the most points, including ties. func (s state) Winners() []sqlc.GamePlayerPointsRow { ranked := s.Standings() diff --git a/static/css/style.css b/static/css/style.css index f65fb4d..fd4938f 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -152,6 +152,10 @@ a { gap: .75em; } +.actions-centered { + justify-content: center; +} + /* buttons (shared base) */ .button, .button-teal, diff --git a/static/html/tmpl.game.html b/static/html/tmpl.game.html index b325ee7..8153b97 100644 --- a/static/html/tmpl.game.html +++ b/static/html/tmpl.game.html @@ -15,6 +15,8 @@ + +
@@ -79,6 +81,13 @@ hx-on::after-request="handlePrompt(event)">
+
+
+

prompt challenge!

@@ -123,6 +132,22 @@

Verdict

hx-swap="innerHTML">
+ +
+
+ + +
+

give a card

+

+
+ +
+
+

diff --git a/static/html/tmpl.prompt_shred.html b/static/html/tmpl.prompt_shred.html new file mode 100644 index 0000000..c2d2eff --- /dev/null +++ b/static/html/tmpl.prompt_shred.html @@ -0,0 +1,30 @@ +{{ if eq .Game.StateName "prompt-shred" }} + {{ $isTurn := false }} + {{ range .Players }} + {{ if and (eq $.CallerID .PlayerID) (eq .Initiative.Int32 $.Game.InitiativeCurrent.Int32) }} + {{ $isTurn = true }} + {{ end }} + {{ end }} + {{ if $isTurn }} +
+

prompt complete!

+

You earned {{ add .CallerRuleCount 1 }} points (1 + {{ .CallerRuleCount }} cards).

+

Shred one of your rules, or keep them all.

+
+ {{ range .CardsPlayers }} + {{ if and (eq .PlayerID.Int32 $.CallerID) (eq .Type "rule") }} + + {{ end }} + {{ end }} +
+ +
+ {{ end }} +{{ end }} diff --git a/static/html/tmpl.table.html b/static/html/tmpl.table.html index 5fa99fc..dcb87e1 100644 --- a/static/html/tmpl.table.html +++ b/static/html/tmpl.table.html @@ -39,7 +39,7 @@ {{ end }}
{{- else -}} -
+
{{ if not $isHost }} {{ if eq $.Game.StateName "ready" }} diff --git a/static/js/prompt.js b/static/js/prompt.js index 24c715f..844e9d4 100644 --- a/static/js/prompt.js +++ b/static/js/prompt.js @@ -82,6 +82,20 @@ if (!ev) return; var id = ev.getAttribute("data-event-id"); if (id === lastOutcomeId) return; // already shown this result + + // a succeeded prompt that owes me a shred is handled by the shred chooser, + // which carries the success message itself — don't also pop this generic + // outcome, or the two modals stack. the chooser is game-state driven, so + // it's the one that survives a refresh. + var bar = document.querySelector(".table-bar"); + if (bar && bar.dataset.promptShredPending === "true") { + lastOutcomeId = id; + spinnerActive = false; + clearSpinnerTimer(); + if (dialog.open) dialog.close(); + return; + } + lastOutcomeId = id; spinnerActive = false; clearSpinnerTimer(); diff --git a/static/js/prompt_shred.js b/static/js/prompt_shred.js new file mode 100644 index 0000000..bf68075 --- /dev/null +++ b/static/js/prompt_shred.js @@ -0,0 +1,73 @@ +(function () { + // the spinner's bonus chooser after a succeeded prompt: shred one of their + // own rule cards, or skip. modeled on the modifier chooser so a refresh + // re-surfaces it and the turn can't get stuck. + function getDialog() { + return document.getElementById("prompt-shred-dialog"); + } + + var inFlight = false; + + // post a shred-or-skip choice. on success the dialog closes and the table + // refreshes; any failure is surfaced and the dialog dismissed. + function attempt(url) { + if (inFlight) return; + inFlight = true; + fetch(url, { method: "POST" }).then(function (res) { + inFlight = false; + var d = getDialog(); + if (res.ok) { + if (d) d.close(); + document.body.dispatchEvent(new Event("refreshTable")); + return; + } + if (d) d.close(); + document.body.dispatchEvent(new Event("refreshTable")); + res.text().then(function (msg) { + alert(msg.trim() || "Could not complete that action."); + }); + }).catch(function () { + inFlight = false; + alert("Network error. Please try again."); + }); + } + + document.body.addEventListener("htmx:afterSettle", function (e) { + if (!e.detail || !e.detail.elt) return; + // every table poll is a chance to (re)open the chooser for the turn + // player while the game waits in the prompt-shred state. + if (e.detail.elt.id === "table") { + var bar = document.querySelector(".table-bar"); + var dlg = getDialog(); + if (bar && bar.dataset.promptShredPending === "true" && dlg && !dlg.open) { + document.body.dispatchEvent(new Event("loadPromptShred")); + } + return; + } + // open once the fragment lands and actually rendered the chooser (it's + // empty for anyone who isn't the spinner). + if (e.detail.elt.id !== "prompt-shred-content") return; + var dialog = getDialog(); + var data = document.getElementById("prompt-shred-data"); + if (!dialog || !data) return; + // the chooser carries the success message, so retire the generic prompt + // outcome popup if it's still up — one modal, not two stacked. + var outcome = document.getElementById("newprompt-dialog"); + if (outcome && outcome.open) outcome.close(); + if (!dialog.open) dialog.showModal(); + }); + + document.body.addEventListener("click", function (e) { + var card = e.target.closest(".prompt-shred-card-btn"); + if (card) { + e.preventDefault(); + attempt(card.dataset.action + "?game_card_id=" + card.dataset.gameCardId); + return; + } + var skip = e.target.closest(".prompt-shred-skip-btn"); + if (skip) { + e.preventDefault(); + attempt(skip.dataset.action + "?skip=1"); + } + }); +})(); diff --git a/static/js/transfer.js b/static/js/transfer.js new file mode 100644 index 0000000..82119ff --- /dev/null +++ b/static/js/transfer.js @@ -0,0 +1,86 @@ +(function () { + // the accuser's chooser after their accusation is upheld: give one of their + // own rule cards to the accused, or skip. driven by a poll so it reaches the + // accuser's client (a different device from the host who decided), modeled on + // the infraction poll in accuse.js. + var lastResolvedId = null; // infraction we've already acted on; don't reopen + var shownId = null; // infraction currently shown in the dialog + var inFlight = false; + + function getDialog() { + return document.getElementById("transfer-dialog"); + } + + function post(url) { + if (inFlight) return; + inFlight = true; + var dialog = getDialog(); + fetch(url, { method: "POST" }).then(function (res) { + inFlight = false; + if (res.ok) { + lastResolvedId = shownId; + shownId = null; + if (dialog) dialog.close(); + document.body.dispatchEvent(new Event("refreshTable")); + return; + } + if (dialog) dialog.close(); + shownId = null; + document.body.dispatchEvent(new Event("refreshTable")); + res.text().then(function (msg) { + alert(msg.trim() || "Could not complete that action."); + }); + }).catch(function () { + inFlight = false; + alert("Network error. Please try again."); + }); + } + + // open the chooser when the poll finds a transfer owed to me. 200 = my turn + // to give a card; 204 = nothing (close any stale dialog). + window.handleTransfer = function (e) { + var dialog = getDialog(); + if (!dialog) return; + if (e.detail.xhr.status !== 200) { + if (dialog.open && shownId !== null) { + dialog.close(); + shownId = null; + } + return; + } + var data; + try { + data = JSON.parse(e.detail.xhr.responseText); + } catch (err) { + return; + } + if (String(data.infraction_id) === String(lastResolvedId)) return; + if (String(data.infraction_id) === String(shownId)) return; // already up + + shownId = data.infraction_id; + var gameId = dialog.dataset.gameId; + var prompt = document.getElementById("transfer-prompt"); + if (prompt) prompt.textContent = "Give one of your rules to " + data.accused + ", or keep them all."; + + var list = document.getElementById("transfer-cards"); + if (list) { + list.replaceChildren(); + (data.cards || []).forEach(function (c) { + var btn = document.createElement("button"); + btn.className = "index-card index-card-accuse"; + btn.textContent = c.content; + btn.addEventListener("click", function () { + post("/" + gameId + "/action/accusation-transfer?game_card_id=" + c.id); + }); + list.appendChild(btn); + }); + } + var skip = document.getElementById("transfer-skip-btn"); + if (skip) { + skip.onclick = function () { + post("/" + gameId + "/action/accusation-transfer?skip=1"); + }; + } + if (!dialog.open) dialog.showModal(); + }; +})();