package web import ( "encoding/json" "time" "pete/internal/games/uno" "pete/internal/storage" ) // UNO as a shared table: the seam the runtime drives it through, and the two // facts about a hand only the engine can tell the runtime — when the clock must // next act, and what a finished hand owes the audit trail. // // Everything about *playing* UNO is still in the engine. This file is the // translation layer between a uno.State and the game-agnostic table runtime. // unoTable is the tableGame for UNO. It holds no state — the table's state is the // blob in game_tables — so a single value serves every felt in the room. type unoTable struct{} func (unoTable) name() string { return gameUno } // timeout plays a passive move for the human whose clock ran out and marks them // away, so the runtime auto-acts them on sight after this rather than waiting a // full clock every orbit. The move is the gentlest one that still advances the // turn: give in to a stack, pass a drawn card (or play it where the rules force // it), otherwise play a legal card if there is one, or draw. // // If, on decode, the seat to act is not a waiting human, the scan raced a real // move that had not yet bumped the version: errNotDue, and the clock steps aside. func (unoTable) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) { var g uno.State if err := json.Unmarshal(state, &g); err != nil { return step{}, nil, err } if !unoPlaying(g) { return step{}, seats, errNotDue } seat := g.Turn if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot { return step{}, seats, errNotDue } move := unoIdleMove(g, seat) next, evs, err := uno.ApplyMove(g, seat, move) if err != nil { return step{}, nil, err } changed := markAway(seats, seat) st, err := unoStep(next, evs, seats) return st, changed, err } // unoIdleMove is the move the clock makes for a walked-away seat: the most passive // legal one that still hands the turn on. func unoIdleMove(g uno.State, seat int) uno.Move { switch g.Phase { case uno.PhaseStack: return uno.Move{Kind: uno.MoveTake} case uno.PhaseDrawn: if g.Tier.NoMercy { // No Mercy makes you play the card you drew; it is the last in the hand. return uno.Move{Kind: uno.MovePlay, Index: len(g.Hands[seat]) - 1, Color: uno.Red} } return uno.Move{Kind: uno.MovePass} default: if p := g.Playable(seat); len(p) > 0 { return uno.Move{Kind: uno.MovePlay, Index: p[0], Color: uno.Red} } return uno.Move{Kind: uno.MoveDraw} } } // stacks reports the chips in front of each seat, index-aligned with the table's // seat rows, so the abandoned-table reaper can cash out a walked-away human. func (unoTable) stacks(state []byte) ([]int64, error) { var g uno.State if err := json.Unmarshal(state, &g); err != nil { return nil, err } out := make([]int64, len(g.Seats)) for i := range g.Seats { out[i] = g.Seats[i].Stack } return out, nil } // unoStep packages a played-out state for the runtime: the blob to persist, the // deadline the clock must honour next, and the audit of any hand that just ended. func unoStep(next uno.State, evs []uno.Event, seats []storage.Seat) (step, error) { blob, err := json.Marshal(next) if err != nil { return step{}, err } st := step{ State: blob, Phase: string(next.Phase), HandNo: int64(next.HandNo), Deadline: unoDeadline(next, seats), Audit: unoAudit(next, evs, seats), } return st, nil } // unoPlaying reports whether a hand is in progress — the only phase a turn clock // has anything to do in. func unoPlaying(g uno.State) bool { switch g.Phase { case uno.PhasePlay, uno.PhaseDrawn, uno.PhaseStack: return true } return false } // unoDeadline is when the clock must next act, or 0 for never. A clock is only set // on a *present* human whose turn it is: a bot resolves inside the move, an away // human is auto-acted on sight, and between hands there is no per-seat clock — // dealing the next hand is a seated player's call, and an abandoned table is the // reaper's job. func unoDeadline(g uno.State, seats []storage.Seat) int64 { if !unoPlaying(g) { return 0 } seat := g.Turn if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot { return 0 } if seatAway(seats, seat) { return 0 } return time.Now().Unix() + turnSeconds } // unoAudit is the per-hand record of a finished hand — one row per human who was // in it. Empty until a hand actually ends, which is exactly when a settle beat is // emitted. // // The rake rides on the winner's row alone, and every other seat carries zero, so // game_hands.rake (which HouseTake sums) records a pot's rake once and once only. // A seat's ante is its bet; a refunded tie pays each seat its ante straight back. func unoAudit(g uno.State, evs []uno.Event, seats []storage.Seat) []storage.Hand { if !unoHandEnded(evs) { return nil } var audit []storage.Hand for i := range g.Seats { p := g.Seats[i] if p.Bot || i >= len(seats) || seats[i].MatrixUser == "" { continue } if p.Ante == 0 { continue // sat this hand out — nothing to record } outcome, payout, rake := "lost", int64(0), int64(0) switch { case i == g.Winner: outcome, payout, rake = "won", p.Won, g.Rake case g.Outcome == uno.OutcomeTie: outcome, payout = "push", p.Ante // the ante came straight back } audit = append(audit, storage.Hand{ MatrixUser: seats[i].MatrixUser, Game: gameUno, Bet: p.Ante, Payout: payout, Rake: rake, Outcome: outcome, Seed1: g.Seed1, Seed2: g.Seed2, }) } return audit } // unoHandEnded reports whether a hand finished in this batch of events. A settle // beat is emitted exactly once, when a hand ends, so it is the clean signal that // the seats' Ante/Won are this hand's final numbers. func unoHandEnded(evs []uno.Event) bool { for _, e := range evs { if e.Kind == uno.EvSettle { return true } } return false }