Compare commits

...
10 Commits
Author SHA1 Message Date
Greyson Parrelli 84900415b2 Fix false draw case. 2026-08-10 16:24:32 -04:00
Greyson Parrelli 6ae4d41976 Put the bug report behind an always-available button.
The debug report was only reachable in DEBUG mode, which is the one mode
a player hitting a real bug won't be running. Move it to a "🐛 Report a
bug" item in the game menu: a note field, a download, and a copy-as-text,
on any server.

That knowingly shows the reporter their opponents' hands and the shop
deck order. Credentials are still required, so a report only ever reaches
someone seated at that table. The buy-any-card panel stays DEBUG-only —
it changes the game; a report only reads it.
2026-08-10 13:42:31 -04:00
Greyson Parrelli 8ccde03023 Add replayable debug reports.
A report dumps everything needed to understand a game that went wrong:
the full state (decks card by card, shop row and tier deck order,
discards, pending choices, banked Mana/Trumpets/apples), the round's
battles, and the whole event log. Available as JSON, which replays, or
text, which reads — and the text ends with a paste-ready repro test.

Battles now record the randomness they consume alongside what they
started from, so a result can be replayed long after the round cleared
those banks: same lineups, same dice, same events, down to the log text.
Results predating the recording say so rather than quietly re-rolling.

Three ways in: the 🐛 panel's download/copy buttons, GET
/api/debug/report (DEBUG-only, since a report holds both players' hands
and the shop deck order), and `mise run report`, which reads the
database directly so a live game can be dumped without DEBUG.
2026-08-10 10:06:06 -04:00
Greyson Parrelli a4f5f6910d Add support for up to 6 players. 2026-07-28 07:36:09 -04:00
Greyson Parrelli e542118175 Stop the hover magnifier sticking open after a drag.
`noMagnify` only suppressed rendering — each card went on tracking hover
underneath it. During a drag the cursor outruns the lifted card (its
transform lands a frame later), so the rows it passes take mouseenter,
and React re-inserts those rows as the list reorders, so the matching
mouseleave often never arrives. Every card left holding stale hover then
popped its preview the moment the drag state cleared, at stale
coordinates, and stayed there until that exact card was hovered and left
again.

noMagnify now means the magnifier is off, not hidden: the handlers come
off and any tracked hover is dropped. Nothing can be left half-armed
regardless of which boundary events the browser does or doesn't deliver.

The drag also cleared its state 180ms after release — its own glide — while
the neighbours it passed keep gliding for anim.ts's MOVE_MS of 240ms. A
row sliding out from under a stationary cursor in that 60ms tail could
re-arm the same stale hover, so the settle window now covers the whole
tray coming to rest.
2026-07-27 16:39:46 -04:00
Greyson Parrelli 8383f0f718 Drag arrange cards by the card, not a grip handle.
Reordering the battle line meant finding a small ⠿ handle beside each
card. Now the card itself is the drag surface, with the gesture split by
input type so it can't collide with the two things a press already means:

  * A mouse lifts the card once the cursor travels 5px. Shorter presses
    stay clicks.
  * A finger has to hold still for 220ms first. Arrange is the one screen
    tall enough to scroll, and a finger swiping a card almost always
    means scrolling — so the card deliberately does NOT set
    `touch-action: none`. The hold is what distinguishes a drag, and once
    it lands the scroll is cancelled by preventing `touchmove`, which
    works precisely because a still finger hasn't started one.

That also fixes the phone's tap-to-magnify fighting the drag: only a
press that never became a drag counts as a tap, so hauling a card up the
list no longer pops the expanded card view open under your finger. A
picked-up card swells and casts a shadow, which on touch is the only
confirmation the hold registered.

The up/down arrows stay as the precise alternative and grow to 46px
(40px on phones) now that they aren't sharing space with the handle.
2026-07-27 16:25:15 -04:00
Greyson Parrelli a2024229ff Hold the battle arena at a fixed height.
The play area grew and shrank as cards entered and left play. Two slots
were sized independently of the cards that fill them: the pet zone
reserved 150px for a 162px battle card, and the empty-deck placeholder
kept its desktop 84x116 on phones where the face-down card is 56x78. So
each faint/reveal moved the arena 12px per side on desktop, and a deck
running dry moved it 38px per side on a phone.

Every arena slot now derives from one set of --unit-*/--deck-* metrics on
.battle, and holds that size whether or not a card occupies it. Measured
across a battle's states (no pets, both revealed, one fainted, fans of
apples, decks dry), the arena height spread goes from 24px desktop /
76px phone to 0.

Also hide the arena's horizontal scrollbar: a wide apple fan made it
appear and vanish mid-battle, and with a content-driven height that alone
jumped the arena by the scrollbar's thickness. Touch, trackpad and
shift+wheel still scroll it.
2026-07-27 16:24:52 -04:00
Greyson Parrelli b5a143d059 Better desktop scaling. 2026-07-27 16:06:23 -04:00
Greyson Parrelli 4fb5c5929f Keep deck storted sensibly during shop phase. 2026-07-27 09:29:07 -04:00
Greyson Parrelli 1070999b95 Fix some card UX. 2026-07-27 00:03:23 -04:00
49 changed files with 4337 additions and 885 deletions
+22
View File
@@ -6,6 +6,28 @@ Go backend (pure rules engine in `internal/game`, WebSocket rooms in
- `mise run test` — Go tests · `mise run check` — go vet + frontend tsc - `mise run test` — Go tests · `mise run check` — go vet + frontend tsc
## Seats, sides, and packs
Three distinctions are easy to conflate and worth keeping straight:
- **Seat vs. side.** A game seats 2, 4, or 6 players, and each round pairs
them off into simultaneous battles (`schedule.go`). Inside a
`BattleResult` everything is indexed by *side* — 0 or 1 within that one
battle — including `BattleEvent.Seat`/`Target`, `Lineups`, `Survivors`,
and `ManaAfter`. `Seats` maps side → seat, and `Side(seat)` maps back.
`WinnerSeat` is the deliberate exception: it's a real seat, because it's
the only field that means anything outside the battle. When adding a
per-side field to a battle, index it by side and say so.
- **One battle vs. the round.** `resolveBattles` loops the round's pairings;
`runBattle(first, second)` resolves one of them and mutates no persistent
state. Anything that should happen once per round rather than once per
battle (clearing per-round banks, say) belongs in `resolveBattles`, not
`finalizeBattle`.
- **Packs are plural.** `Game.Packs` is a list whose tier decks shuffle
together. Anything reading printed card data must go through
`TierContentsForPacks` / `CatalogForPacks` / `g.packList()`, never a
single pack — a game can hold Trumpets, Mana, and Ailments at once.
## Keep the AI in sync with game behavior ## Keep the AI in sync with game behavior
**Whenever game behavior changes — rules, cards, effects, phases, log **Whenever game behavior changes — rules, cards, effects, phases, log
+99 -18
View File
@@ -1,8 +1,8 @@
# Super Auto Pets: The Board Game — Online # Super Auto Pets: The Board Game — Online
A web app for playing the Super Auto Pets board game remotely. 1v1 for now; A web app for playing the Super Auto Pets board game remotely, at 2, 4, or 6
the engine is built to grow to more players. Play a friend by room code, or players. Play friends by room code, fill any empty seat with a computer
play solo against a computer opponent (easy / medium / hard). opponent (easy / medium / hard), or play solo against a table of them.
## Stack ## Stack
@@ -39,10 +39,52 @@ During development open the Vite URL (http://localhost:5173); it proxies
Set via environment or a `.env` file (see `.env.example`): Set via environment or a `.env` file (see `.env.example`):
| Variable | Default | Purpose | | Variable | Default | Purpose |
| ------------ | ---------- | ------------------------------------ | | ------------ | ---------- | ------------------------------------------- |
| `DATA_DIR` | `data` | Directory holding the SQLite DB | | `DATA_DIR` | `data` | Directory holding the SQLite DB |
| `PORT` | `8080` | HTTP port | | `PORT` | `8080` | HTTP port |
| `STATIC_DIR` | `web/dist` | Built frontend to serve | | `STATIC_DIR` | `web/dist` | Built frontend to serve |
| `DEBUG` | off | Unlocks the in-game buy-any-card panel |
## Debugging a game that went wrong
Any game can be dumped as a **debug report**: the full state (every deck card by
card, the shop row and the order of the remaining tier decks, discards, pending
choices, each player's banked Mana/Trumpets/apples), the round's battles with
their lineups and *the dice they rolled*, and the entire event log.
**In game**, the `⋯` menu has **🐛 Report a bug**: a note field, a
**Download report** button, and **Copy as text**. It works on any server, DEBUG
or not, so a bug hit in a real game can actually be reported. That does hand the
player their opponents' hands and the shop deck order — an accepted trade, since
a report only ever goes to someone seated at that table (`GET
/api/debug/report`, same credentials as the WebSocket).
**From the command line**, against the server's database:
```sh
mise run report # list recent games
mise run report -- QWERT # the report, as text, for reading
mise run report -- -json -out internal/game/testdata/bug.json QWERT
```
The JSON form is the useful one, because it replays. Drop it in
`internal/game/testdata/` and the battle re-runs exactly — same lineups, same
dice, same events, right down to the log text:
```go
rep, err := LoadDebugReportFile("testdata/bug.json")
res, err := rep.ReplayBattle(0) // 0 = the round's first pairing
if res.WinnerSeat != 1 { // assert what *should* have happened
t.Fatalf("expected seat 1 to win, got %d", res.WinnerSeat)
}
```
That works because every battle records the randomness it consumed
(`BattleResult.Draws`) alongside what it started from (`Lineups`, `Inputs`), so
a result is replayable long after the round cleared those banks. `rep.Game()`
hands back the whole game if the bug wasn't in the battle — carry on from the
shop, replay a different pairing, or read a deck card by card. The text form of
the report ends with a paste-ready version of the test above.
## Rules implemented ## Rules implemented
@@ -74,17 +116,49 @@ Six rounds, each with its own shop tier deck. Per round:
(Peacock, Camel, Gorilla). Shields (Turtle/Gorilla/Melon) block whole (Peacock, Camel, Gorilla). Shields (Turtle/Gorilla/Melon) block whole
hits, Garlic shaves 1 per attack, and Scorpion KOs anything its clash hits, Garlic shaves 1 per attack, and Scorpion KOs anything its clash
attack manages to hurt. Hippo heals when enemies faint; Rhino rocks each attack manages to hurt. Hippo heals when enemies faint; Rhino rocks each
enemy pet as it's played. A clash that changes nothing ends the battle enemy pet as it's played. A clash that changes nothing is a stalemate:
as a stalemate draw. Last player able to field a pet wins: 1 trophy for both pets faint so the ones behind them can settle it. Last player able
rounds 15, 2 for round 6. Draws award nothing. to field a pet wins: 1 trophy for rounds 15, 2 for round 6. A battle
that runs both sides out is a draw, and draws award nothing.
Apples and bees are **temporary**: they leave your deck after the battle. Apples and bees are **temporary**: they leave your deck after the battle.
Most trophies after round 6 wins. Most trophies after round 6 wins.
## 4 & 6 player mode
A game seats an even number of players — 2, 4, or 6 — so everyone has an
opponent every round. A lobby with an odd number of humans fills the gap with
a bot; any seat can be a bot, so a table of one human and five computers is
just as valid as six humans.
Above two players the shop is shared but the battles are not: each round
splits the table into pairs, and every pair fights its own battle
simultaneously. The pairings come from the fixed table printed in the rulebook
(`internal/game/schedule.go`) — a round-robin that runs everyone past everyone
before replaying earlier rounds to fill out the six. All of it is public: you
can replay any table's battle, not just your own, and peek at any player's
lineup afterwards.
Two rules change with the bigger table:
- **First shopper** — at two players the priority token does double duty
(shops first, acts first in battle) and passes from a winner to the loser.
With more players it's a separate token that starts at seat A and walks one
seat along each round, while every battle flips its own first player.
- **Packs** — the rulebook asks for one pack per pair (2 packs for 4 players,
3 for 6). The host picks them in the lobby and their tier decks shuffle
together: all the tier 1 cards into one tier 1 deck, and so on. Two players
may combine packs too, for a deeper shop.
Ties on trophies are settled by counting back from the last round: whoever won
round 6 takes it, else round 5, and so on. Players with identical records
share the victory.
## Packs ## Packs
Three card packs are playable, chosen by the host in the lobby. Each pack is Three card packs are playable, and the host combines one or more of them in
six tiers, one per round, and every pet ships as two copies. the lobby. Each pack is six tiers, one per round, and every pet ships as two
copies.
### Turtle pack ### Turtle pack
@@ -153,6 +227,7 @@ The Unicorn pack adds two mechanics the others don't have:
``` ```
cmd/server/ entrypoint cmd/server/ entrypoint
cmd/report/ debug report dumper (reads the DB directly)
internal/game/ rules engine (pure, fully tested) internal/game/ rules engine (pure, fully tested)
internal/ai/ computer opponent (decides from a player View only) internal/ai/ computer opponent (decides from a player View only)
internal/server/ HTTP + WebSocket rooms; drives bot turns internal/server/ HTTP + WebSocket rooms; drives bot turns
@@ -164,12 +239,18 @@ web/ React frontend
## The computer opponent ## The computer opponent
Any seat can be a bot (`Player.IsBot`); humans and bots are interchangeable Any seat can be a bot (`Player.IsBot`); humans and bots are interchangeable
to the engine, which is what will let future >2-player games mix them to the engine, so a table can mix them freely. The AI in `internal/ai` never
freely. The AI in `internal/ai` never touches the `Game` — it decides from a touches the `Game` — it decides from a `game.View`, the same per-player state
`game.View`, the same per-player state a human client is sent, plus a a human client is sent, plus a persisted memory of public observations
persisted memory of public observations (battle lineups, the event log, shop (battle lineups, the event log, shop row changes). It cannot see your deck
row changes). It cannot see your deck order, hidden trade picks, or the order, hidden trade picks, or the shuffled shop decks. It scores candidate
shuffled shop decks. It scores candidate moves by Monte-Carlo battle moves by Monte-Carlo battle rollouts (`game.SimulateBattle`) against sampled
rollouts (`game.SimulateBattle`) against sampled guesses of your deck and guesses of your deck and ordering, blended with a long-term deck-value
ordering, blended with a long-term deck-value heuristic; difficulty tunes a heuristic; difficulty tunes a softmax over the scored moves plus the rollout
softmax over the scored moves plus the rollout budget. budget.
At a bigger table it keeps a model of *every* seat, not just one rival — it
will face each of them eventually, and every battle is fought in the open —
but plans each round against the specific opponent the schedule pairs it
with. Its card counting spans the whole table's known cards and the combined
contents of the packs in play.
+115
View File
@@ -0,0 +1,115 @@
// Command report prints a debug report for a saved game: the full state, every
// deck card by card, the round's battles with the dice they rolled, and the
// whole event log — enough to replay the situation in a test (see
// game.DebugReport).
//
// It reads the server's database directly, so it works against a live game
// without the server's DEBUG flag, and it works after the fact — the state is
// persisted on every action.
//
// go run ./cmd/report # list recent games
// go run ./cmd/report QWERT # the report, as text
// go run ./cmd/report -json QWERT # the report, as JSON
// go run ./cmd/report -json -out internal/game/testdata/bug.json QWERT
package main
import (
"flag"
"fmt"
"os"
"strings"
"time"
"github.com/greyson/super-auto-pets-board-game/internal/env"
"github.com/greyson/super-auto-pets-board-game/internal/store"
)
func main() {
asJSON := flag.Bool("json", false, "emit the JSON report (replayable) instead of the text one (readable)")
out := flag.String("out", "", "write to this file instead of stdout")
note := flag.String("note", "", "what looked wrong, recorded in the report")
dataDir := flag.String("data", "", "data directory holding games.db (default: $DATA_DIR or ./data)")
limit := flag.Int("limit", 20, "how many games to list")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "usage: report [flags] [game-code-or-id]\n\n"+
"With no game, lists the most recently played ones.\n\nFlags:\n")
flag.PrintDefaults()
}
flag.Parse()
env.Load(".env")
dir := *dataDir
if dir == "" {
dir = env.Get("DATA_DIR", "data")
}
st, err := store.Open(dir)
if err != nil {
fail("open %s: %v", dir, err)
}
defer st.Close()
if flag.NArg() == 0 {
list(st, *limit)
return
}
g, err := st.LoadAny(flag.Arg(0))
if err != nil {
fail("load %s: %v", flag.Arg(0), err)
}
rep, err := g.DebugReport()
if err != nil {
fail("capture %s: %v", g.Code, err)
}
rep.CapturedAt = time.Now().UTC().Format(time.RFC3339)
rep.Note = *note
var body []byte
if *asJSON {
if body, err = rep.JSON(); err != nil {
fail("render: %v", err)
}
} else {
body = []byte(rep.Text())
}
if *out == "" {
os.Stdout.Write(body)
return
}
if err := os.WriteFile(*out, body, 0o644); err != nil {
fail("write %s: %v", *out, err)
}
fmt.Fprintf(os.Stderr, "wrote %s (%d bytes)\n", *out, len(body))
}
// list prints the games on hand, so you can find the one you mean without
// knowing its code.
func list(st *store.Store, limit int) {
games, err := st.Recent(limit)
if err != nil {
fail("list games: %v", err)
}
if len(games) == 0 {
fmt.Println("no games saved yet")
return
}
fmt.Printf("%-6s %-8s %-5s %-7s %s\n", "CODE", "PHASE", "ROUND", "PLAYERS", "SEATS")
for _, g := range games {
var seats []string
for _, p := range g.Players {
name := p.Name
if p.IsBot {
name += " (bot)"
}
seats = append(seats, name)
}
fmt.Printf("%-6s %-8s %-5d %-7d %s\n",
g.Code, g.Phase, g.Round, len(g.Players), strings.Join(seats, ", "))
}
fmt.Printf("\nrun `report <code>` for any of these\n")
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, "report: "+format+"\n", args...)
os.Exit(1)
}
+9 -2
View File
@@ -247,11 +247,18 @@ func immediateWeight(v *game.View) float64 {
if v.MaxRounds > 1 { if v.MaxRounds > 1 {
w += 0.60 * float64(v.Round-1) / float64(v.MaxRounds-1) w += 0.60 * float64(v.Round-1) / float64(v.MaxRounds-1)
} }
me := v.Players[v.YouSeat] // How far behind the field the bot is. The yardstick is whoever is leading,
// not the sum of everyone — at a six-player table the title is a race
// against the front-runner, and summing would swamp the round term.
me := v.PlayerView(v.YouSeat)
best := 0
for _, p := range v.Players { for _, p := range v.Players {
if p.Seat != v.YouSeat { if p.Seat != v.YouSeat {
w += 0.08 * float64(p.Trophies-me.Trophies) best = max(best, p.Trophies)
} }
} }
if me != nil {
w += 0.08 * float64(best-me.Trophies)
}
return min(max(w, 0.25), 1) return min(max(w, 0.25), 1)
} }
+67 -18
View File
@@ -32,24 +32,36 @@ func forcePlayable(id string) func() {
func playBotGamePack(t *testing.T, pack string, levelA, levelB float64) *game.Game { func playBotGamePack(t *testing.T, pack string, levelA, levelB float64) *game.Game {
t.Helper() t.Helper()
return playBotTable(t, []string{pack}, levelA, levelB)
}
// playBotTable drives a full game with a bot in every seat — one per level
// given, so it covers tables of two, four, or six — the same way the server
// would: observe on every state change, then act when input is owed. It fails
// the test if a bot ever produces an illegal action or the game stops making
// progress.
func playBotTable(t *testing.T, packs []string, levels ...float64) *game.Game {
t.Helper()
for _, pack := range packs {
defer forcePlayable(pack)() defer forcePlayable(pack)()
}
g := game.New() g := game.New()
pa, err := g.AddBot("Bot A", levelA) bots := map[string]*Bot{}
mems := map[string]*Memory{}
for i, level := range levels {
p, err := g.AddBot(fmt.Sprintf("Bot %c", 'A'+i), level)
if err != nil { if err != nil {
t.Fatalf("AddBot A: %v", err) t.Fatalf("AddBot %d: %v", i, err)
} }
pb, err := g.AddBot("Bot B", levelB) bots[p.ID] = New(level)
if err != nil { mems[p.ID] = &Memory{}
t.Fatalf("AddBot B: %v", err)
} }
if err := g.SetPack(pack); err != nil { if err := g.SetPacks(packs); err != nil {
t.Fatalf("SetPack %s: %v", pack, err) t.Fatalf("SetPacks %v: %v", packs, err)
} }
if err := g.StartGame(); err != nil { if err := g.StartGame(); err != nil {
t.Fatalf("StartGame: %v", err) t.Fatalf("StartGame: %v", err)
} }
bots := map[string]*Bot{pa.ID: New(levelA), pb.ID: New(levelB)}
mems := map[string]*Memory{pa.ID: {}, pb.ID: {}}
observe := func() { observe := func() {
for _, p := range g.Players { for _, p := range g.Players {
@@ -128,6 +140,43 @@ func TestBotsFinishGames(t *testing.T) {
} }
} }
// TestBotsFinishMultiplayerGames plays complete four- and six-bot games on
// combined packs — the multiplayer setup the rulebook calls for. Beyond the
// usual "no illegal action" safety net, it exercises the bot against a rotating
// opponent (a different rival every round), several battles resolving in one
// round, and a card pool spanning more than one pack.
func TestBotsFinishMultiplayerGames(t *testing.T) {
tables := []struct {
name string
packs []string
levels []float64
}{
{"4p", []string{"turtle", "golden"}, []float64{1, 0.6, 0.25, 0.6}},
{"6p", []string{"turtle", "golden", "unicorn"}, []float64{1, 0.6, 0.25, 0, 1, 0.6}},
}
for _, tc := range tables {
t.Run(tc.name, func(t *testing.T) {
g := playBotTable(t, tc.packs, tc.levels...)
if g.Round != game.MaxRounds {
t.Errorf("game ended on round %d, want %d", g.Round, game.MaxRounds)
}
// Every seat should have fought all six rounds, so the trophies in
// play must add up to the six battles per seat-pair.
total := 0
for _, p := range g.Players {
total += p.Trophies
}
maxPossible := len(tc.levels) / 2 * (game.MaxRounds + 1) // round 6 pays double
if total > maxPossible {
t.Errorf("%d trophies awarded, only %d were available", total, maxPossible)
}
if len(g.WinnerSeats) == 0 {
t.Error("a finished game should name at least one winner")
}
})
}
}
// TestBotsFinishGoldenGame plays complete games on the Golden pack (tiers 1-3 // TestBotsFinishGoldenGame plays complete games on the Golden pack (tiers 1-3
// printed; 4-6 empty). It exercises the Trumpet/Golden Retriever/Cone Snail // printed; 4-6 empty). It exercises the Trumpet/Golden Retriever/Cone Snail
// battle mechanics via SimulateBattle rollouts and the new shop effects, and // battle mechanics via SimulateBattle rollouts and the new shop effects, and
@@ -233,11 +282,11 @@ func assertModelMatches(t *testing.T, mem *Memory, opp *game.Player) {
want[c.Name]++ want[c.Name]++
} }
got := map[string]int{} got := map[string]int{}
for _, c := range mem.Opp.Known { for _, c := range mem.Opp(1).Known {
got[c.Name]++ got[c.Name]++
} }
if len(mem.Opp.Hidden) != 0 { if len(mem.Opp(1).Hidden) != 0 {
t.Errorf("model has %d hidden cards, want 0 (everything was public)", len(mem.Opp.Hidden)) t.Errorf("model has %d hidden cards, want 0 (everything was public)", len(mem.Opp(1).Hidden))
} }
for name, n := range want { for name, n := range want {
if got[name] != n { if got[name] != n {
@@ -306,8 +355,9 @@ func TestDecideShopNeverSellsLastPet(t *testing.T) {
Round: game.MaxRounds, // alpha == 1: score is win-now only Round: game.MaxRounds, // alpha == 1: score is win-now only
MaxRounds: game.MaxRounds, MaxRounds: game.MaxRounds,
MaxPets: game.MaxPets, MaxPets: game.MaxPets,
Pack: game.DefaultPack, Packs: []string{game.DefaultPack},
YouSeat: 0, YouSeat: 0,
YourOpponent: 1,
Turn: 0, Turn: 0,
PrioritySeat: 0, PrioritySeat: 0,
DeckCounts: make([]int, game.MaxRounds+1), DeckCounts: make([]int, game.MaxRounds+1),
@@ -319,9 +369,8 @@ func TestDecideShopNeverSellsLastPet(t *testing.T) {
} }
// Model a crushing opponent so every simulated battle is a loss. // Model a crushing opponent so every simulated battle is a loss.
mem := &Memory{} mem := &Memory{}
mem.Opp.Seat = 1
for i := range 5 { for i := range 5 {
mem.Opp.Known = append(mem.Opp.Known, mem.opp(1).Known = append(mem.opp(1).Known,
game.Card{ID: fmt.Sprintf("o%d", i), Kind: game.KindPet, Name: "Wall", Tier: 1, Power: 50}) game.Card{ID: fmt.Sprintf("o%d", i), Kind: game.KindPet, Name: "Wall", Tier: 1, Power: 50})
} }
@@ -360,8 +409,9 @@ func TestDecideShopKeepsHealthyBoard(t *testing.T) {
Round: 3, // mid-game: future value still carries real weight Round: 3, // mid-game: future value still carries real weight
MaxRounds: game.MaxRounds, MaxRounds: game.MaxRounds,
MaxPets: game.MaxPets, MaxPets: game.MaxPets,
Pack: game.DefaultPack, Packs: []string{game.DefaultPack},
YouSeat: 0, YouSeat: 0,
YourOpponent: 1,
Turn: 0, Turn: 0,
PrioritySeat: 0, PrioritySeat: 0,
DeckCounts: make([]int, game.MaxRounds+1), DeckCounts: make([]int, game.MaxRounds+1),
@@ -379,9 +429,8 @@ func TestDecideShopKeepsHealthyBoard(t *testing.T) {
// A comparable opponent, so battles are genuinely competitive — selling is // A comparable opponent, so battles are genuinely competitive — selling is
// a real temptation, not a hopeless-position tie-break. // a real temptation, not a hopeless-position tie-break.
mem := &Memory{} mem := &Memory{}
mem.Opp.Seat = 1
for i, n := range []string{"Dog", "Sheep", "Ant", "Cricket"} { for i, n := range []string{"Dog", "Sheep", "Ant", "Cricket"} {
mem.Opp.Known = append(mem.Opp.Known, pet(fmt.Sprintf("o%d", i), n, 3, 3, game.SuitRed)) mem.opp(1).Known = append(mem.opp(1).Known, pet(fmt.Sprintf("o%d", i), n, 3, 3, game.SuitRed))
} }
bot := New(1.0) // a capable bot should almost never make this trade bot := New(1.0) // a capable bot should almost never make this trade
+59
View File
@@ -0,0 +1,59 @@
package ai
import (
"encoding/json"
"testing"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// A debug report is only worth taking if replaying it reproduces the battle it
// captured, and the situations worth reporting are the messy ones — six seats,
// three packs shuffled together, Mana and Trumpets banked, Komodo shuffling
// apples into a deck mid-fight. This test lives in the ai package because that
// is where full games get played: it hands the bots a table, then replays every
// battle of the last round out of the report and demands the identical result.
//
// If it ever fails, the engine has grown a source of randomness (or a piece of
// battle input) that the result doesn't record, and reports of that battle are
// no longer reproducible.
func TestDebugReportReplaysRealGames(t *testing.T) {
for _, tc := range []struct {
name string
packs []string
levels []float64
}{
{"two seats, one pack", []string{"turtle"}, []float64{0.9, 0.9}},
{"six seats, three packs", []string{"turtle", "golden", "unicorn"},
[]float64{0.9, 0.9, 0.9, 0.9, 0.9, 0.9}},
} {
t.Run(tc.name, func(t *testing.T) {
g := playBotTable(t, tc.packs, tc.levels...)
rep, err := g.DebugReport()
if err != nil {
t.Fatal(err)
}
if len(g.Battles) == 0 {
t.Fatal("a finished game should have battles to replay")
}
for i, recorded := range g.Battles {
replayed, err := rep.ReplayBattle(i)
if err != nil {
t.Fatalf("battle %d: %v", i, err)
}
if got, want := marshal(t, replayed), marshal(t, recorded); got != want {
t.Fatalf("battle %d replayed differently:\nrecorded %s\nreplayed %s", i, want, got)
}
}
})
}
}
func marshal(t *testing.T, res *game.BattleResult) string {
t.Helper()
b, err := json.Marshal(res)
if err != nil {
t.Fatal(err)
}
return string(b)
}
+22 -9
View File
@@ -36,9 +36,11 @@ func winScore(res *game.BattleResult, mySeat int) float64 {
default: default:
base = 0 base = 0
} }
// Survivors is indexed by battle side, not by seat.
mySide := res.Side(mySeat)
margin := 0 margin := 0
for seat, s := range res.Survivors { for side, s := range res.Survivors {
if seat == mySeat { if side == mySide {
margin += s margin += s
} else { } else {
margin -= s margin -= s
@@ -58,19 +60,30 @@ func (cx *ctx) winProb(myDeck []game.Card, oppDecks [][]game.Card, simsPer int)
total, n := 0.0, 0 total, n := 0.0, 0
for _, opp := range oppDecks { for _, opp := range oppDecks {
for range simsPer { for range simsPer {
var res *game.BattleResult // The bot's own deck always takes scratch seat 0, so a rollout reads
if cx.me.Seat == 0 { // the same however the real table happens to be seated.
res = game.SimulateBattle(cx.v.Round, cx.v.PrioritySeat, myDeck, opp, nil) res := game.SimulateBattle(cx.v.Round, cx.simFirstSeat(), myDeck, opp, nil)
} else { total += winScore(res, 0)
res = game.SimulateBattle(cx.v.Round, cx.v.PrioritySeat, opp, myDeck, nil)
}
total += winScore(res, cx.me.Seat)
n++ n++
} }
} }
return total / float64(n) return total / float64(n)
} }
// simFirstSeat picks who acts first in a rollout, with the bot at seat 0. Two
// players pass a priority token the bot can see, so it plans against the real
// one; bigger tables flip a coin for each battle, which the bot can't know in
// advance — it rolls too, and averages over both possibilities.
func (cx *ctx) simFirstSeat() int {
if len(cx.v.Players) == 2 {
if cx.v.PrioritySeat == cx.me.Seat {
return 0
}
return 1
}
return rand.IntN(2)
}
// keepValue ranks a single card's worth to the bot's future: what it loses // keepValue ranks a single card's worth to the bot's future: what it loses
// by selling or trading it away. Temporary cards (apples) are nearly free to // by selling or trading it away. Temporary cards (apples) are nearly free to
// lose — they vanish after the next battle anyway. // lose — they vanish after the next battle anyway.
+4 -1
View File
@@ -11,8 +11,11 @@ import (
// to spare, a loss that took most of the enemy down), but no margin, however // to spare, a loss that took most of the enemy down), but no margin, however
// lopsided, may ever raise a loss above a draw or a draw above a win. // lopsided, may ever raise a loss above a draw or a draw above a win.
func TestWinScoreMarginBreaksTiesNotVerdicts(t *testing.T) { func TestWinScoreMarginBreaksTiesNotVerdicts(t *testing.T) {
// Seat 0 fights seat 1, and is side 0 of the battle — Survivors and the
// other per-side slices are indexed by side, so the mapping has to be there
// for winScore to read the margin from the right end.
mk := func(winner, mine, theirs int) *game.BattleResult { mk := func(winner, mine, theirs int) *game.BattleResult {
return &game.BattleResult{WinnerSeat: winner, Survivors: []int{mine, theirs}} return &game.BattleResult{WinnerSeat: winner, Seats: []int{0, 1}, Survivors: []int{mine, theirs}}
} }
decisiveWin := winScore(mk(0, 5, 0), 0) decisiveWin := winScore(mk(0, 5, 0), 0)
+111 -57
View File
@@ -17,7 +17,11 @@ type Memory struct {
LastSeq int `json:"lastSeq"` // last event-log entry processed LastSeq int `json:"lastSeq"` // last event-log entry processed
LastBattleRound int `json:"lastBattleRound"` // last battle lineup ingested LastBattleRound int `json:"lastBattleRound"` // last battle lineup ingested
PrevShopRow []game.Card `json:"prevShopRow"` // shop row at the previous observation PrevShopRow []game.Card `json:"prevShopRow"` // shop row at the previous observation
Opp OppModel `json:"opp"` // Opps models every other seat at the table, keyed by seat. A bot fights a
// different opponent each round (see the rulebook's pairings), and every
// battle is played in the open, so it tracks the whole field rather than
// one rival.
Opps map[int]*OppModel `json:"opps,omitempty"`
} }
// OppModel is the bot's belief about one opponent's deck. Known holds cards // OppModel is the bot's belief about one opponent's deck. Known holds cards
@@ -30,6 +34,28 @@ type OppModel struct {
Hidden []HiddenCard `json:"hidden,omitempty"` Hidden []HiddenCard `json:"hidden,omitempty"`
} }
// opp returns the model for a seat, creating it on first sight.
func (m *Memory) opp(seat int) *OppModel {
if m.Opps == nil {
m.Opps = map[int]*OppModel{}
}
o, ok := m.Opps[seat]
if !ok {
o = &OppModel{Seat: seat}
m.Opps[seat] = o
}
return o
}
// Opp returns the bot's model of one seat. A seat it has never seen comes back
// empty rather than nil, so callers can read it unconditionally.
func (m *Memory) Opp(seat int) *OppModel {
if o, ok := m.Opps[seat]; ok {
return o
}
return &OppModel{Seat: seat}
}
// HiddenCard is a card the opponent holds that the bot has not seen. Name is // HiddenCard is a card the opponent holds that the bot has not seen. Name is
// set when the card was later named publicly (e.g. a trade pick revealed by // set when the card was later named publicly (e.g. a trade pick revealed by
// its buy ability) — the suit still isn't known, but the stats are. // its buy ability) — the suit still isn't known, but the stats are.
@@ -44,6 +70,14 @@ func LoadMemory(raw json.RawMessage) *Memory {
m := &Memory{} m := &Memory{}
if len(raw) > 0 { if len(raw) > 0 {
_ = json.Unmarshal(raw, m) _ = json.Unmarshal(raw, m)
// Notebooks written before the bot tracked a whole field held a single
// "opp"; file it under its seat.
var legacy struct {
Opp *OppModel `json:"opp"`
}
if json.Unmarshal(raw, &legacy) == nil && legacy.Opp != nil && len(m.Opps) == 0 {
m.Opps = map[int]*OppModel{legacy.Opp.Seat: legacy.Opp}
}
} }
return m return m
} }
@@ -64,112 +98,132 @@ func (m *Memory) Marshal() json.RawMessage {
// 1. new event-log entries, whose structured tags describe opponent shop // 1. new event-log entries, whose structured tags describe opponent shop
// actions (buys name the card, sells name what left, trades list the // actions (buys name the card, sells name what left, trades list the
// discarded trio, spawn entries count apples gained); // discarded trio, spawn entries count apples gained);
// 2. the latest battle's lineups, which reveal both decks in full and reset // 2. the round's battle lineups, which reveal every deck in full and reset
// the model to ground truth every round (so any drift lasts one round); // the models to ground truth every round (so any drift lasts one round);
// 3. the opponent's public deck size, as a reconciliation safety net. // 3. each opponent's public deck size, as a reconciliation safety net.
//
// Every source is table-wide: the bot follows all its rivals, not only the one
// it happens to be paired against, because it will face each of them later.
func Observe(v *game.View, m *Memory) { func Observe(v *game.View, m *Memory) {
if v.YouSeat < 0 { if v.YouSeat < 0 {
return return
} }
oppSeat := -1 isOpponent := func(seat int) bool {
for _, p := range v.Players { return seat >= 0 && seat != v.YouSeat && v.PlayerView(seat) != nil
if p.Seat != v.YouSeat {
oppSeat = p.Seat
break
} }
}
if oppSeat < 0 {
return
}
m.Opp.Seat = oppSeat
for _, e := range v.Log { for _, e := range v.Log {
if e.Seq <= m.LastSeq { if e.Seq <= m.LastSeq {
continue continue
} }
m.LastSeq = e.Seq m.LastSeq = e.Seq
if e.Seat != oppSeat { if !isOpponent(e.Seat) {
continue continue
} }
opp := m.opp(e.Seat)
switch { switch {
case e.Kind == game.LogBuy: case e.Kind == game.LogBuy:
if c, ok := cardByID(m.PrevShopRow, e.Source); ok { if c, ok := cardByID(m.PrevShopRow, e.Source); ok {
// An Avocado buy is set aside, not kept in the deck (Golden // An Avocado buy is set aside, not kept in the deck (Golden
// pack): don't add it to the deck model. // pack): don't add it to the deck model.
if c.Food != game.FoodAvocado { if c.Food != game.FoodAvocado {
m.Opp.Known = append(m.Opp.Known, c) opp.Known = append(opp.Known, c)
} }
} else if c, ok := templateByName(v.Pack, e.CardName); ok { } else if c, ok := templateByName(v.Packs, e.CardName); ok {
if c.Food != game.FoodAvocado { if c.Food != game.FoodAvocado {
m.Opp.Known = append(m.Opp.Known, c) opp.Known = append(opp.Known, c)
} }
} }
case e.Kind == game.LogSell: case e.Kind == game.LogSell:
m.removeOppCard(e.Source, e.CardName) opp.remove(e.Source, e.CardName)
m.Opp.Known = append(m.Opp.Known, memApple(len(m.Opp.Known))) opp.Known = append(opp.Known, memApple(len(opp.Known)))
case e.Kind == game.LogTrade: case e.Kind == game.LogTrade:
for _, id := range e.Cards { for _, id := range e.Cards {
m.removeOppCard(id, "") opp.remove(id, "")
} }
case e.Kind == game.LogTradePick: case e.Kind == game.LogTradePick:
m.Opp.Hidden = append(m.Opp.Hidden, opp.Hidden = append(opp.Hidden,
HiddenCard{Tier: min(e.Round+1, game.MaxRounds), Name: e.CardName}) HiddenCard{Tier: min(e.Round+1, game.MaxRounds), Name: e.CardName})
case e.Spawn == "apple" && e.Kind == "": case e.Spawn == "apple" && e.Kind == "":
n := max(e.Count, 1) n := max(e.Count, 1)
for range n { for range n {
m.Opp.Known = append(m.Opp.Known, memApple(len(m.Opp.Known))) opp.Known = append(opp.Known, memApple(len(opp.Known)))
} }
} }
} }
// Battle lineups are ground truth: rebuild the model from the opponent's // Battle lineups are ground truth: rebuild each opponent's model from their
// revealed deck, minus temporary cards (they expire with the battle). // revealed deck, minus temporary cards (they expire with the battle). Every
if v.Battle != nil && v.Battle.Round > m.LastBattleRound && oppSeat < len(v.Battle.Lineups) { // table's battle is public, so one round refreshes the whole field.
m.LastBattleRound = v.Battle.Round for _, b := range v.Battles {
m.Opp.Known = m.Opp.Known[:0] if b == nil || b.Round <= m.LastBattleRound {
m.Opp.Hidden = nil continue
for _, c := range v.Battle.Lineups[oppSeat] { }
for side, seat := range b.Seats {
if !isOpponent(seat) || side >= len(b.Lineups) {
continue
}
opp := m.opp(seat)
opp.Known = opp.Known[:0]
opp.Hidden = nil
for _, c := range b.Lineups[side] {
if !c.Temporary { if !c.Temporary {
m.Opp.Known = append(m.Opp.Known, c) opp.Known = append(opp.Known, c)
} }
} }
} }
}
for _, b := range v.Battles {
if b != nil {
m.LastBattleRound = max(m.LastBattleRound, b.Round)
}
}
// Reconcile with the public deck size. Skipped during the battle phase, // Reconcile with the public deck sizes. Skipped during the battle phase,
// where the live deck still holds temporaries the model excludes. // where the live decks still hold temporaries the models exclude.
if v.Phase == game.PhaseShop || v.Phase == game.PhaseArrange { if v.Phase == game.PhaseShop || v.Phase == game.PhaseArrange {
size := v.Players[slices.IndexFunc(v.Players, func(p game.PlayerView) bool { return p.Seat == oppSeat })].DeckSize for _, p := range v.Players {
for len(m.Opp.Known)+len(m.Opp.Hidden) < size { if !isOpponent(p.Seat) {
m.Opp.Hidden = append(m.Opp.Hidden, HiddenCard{Tier: v.Round}) continue
}
for len(m.Opp.Known)+len(m.Opp.Hidden) > size {
if len(m.Opp.Hidden) > 0 {
m.Opp.Hidden = m.Opp.Hidden[:len(m.Opp.Hidden)-1]
} else {
m.Opp.Known = m.Opp.Known[:len(m.Opp.Known)-1]
} }
m.opp(p.Seat).reconcile(p.DeckSize, v.Round)
} }
} }
m.PrevShopRow = append(m.PrevShopRow[:0], v.ShopRow...) m.PrevShopRow = append(m.PrevShopRow[:0], v.ShopRow...)
} }
// removeOppCard drops one card from the model: by exact ID when we tracked // remove drops one card from the model: by exact ID when we tracked it, by
// it, by name as a fallback (model-minted apples have synthetic IDs), and // name as a fallback (model-minted apples have synthetic IDs), and failing
// failing both, one hidden card — something we didn't know they had left. // both, one hidden card — something we didn't know they had, now gone.
func (m *Memory) removeOppCard(id, name string) { func (o *OppModel) remove(id, name string) {
if i := slices.IndexFunc(m.Opp.Known, func(c game.Card) bool { return c.ID == id }); i >= 0 { if i := slices.IndexFunc(o.Known, func(c game.Card) bool { return c.ID == id }); i >= 0 {
m.Opp.Known = slices.Delete(m.Opp.Known, i, i+1) o.Known = slices.Delete(o.Known, i, i+1)
return return
} }
if name != "" { if name != "" {
if i := slices.IndexFunc(m.Opp.Known, func(c game.Card) bool { return c.Name == name }); i >= 0 { if i := slices.IndexFunc(o.Known, func(c game.Card) bool { return c.Name == name }); i >= 0 {
m.Opp.Known = slices.Delete(m.Opp.Known, i, i+1) o.Known = slices.Delete(o.Known, i, i+1)
return return
} }
} }
if len(m.Opp.Hidden) > 0 { if len(o.Hidden) > 0 {
m.Opp.Hidden = m.Opp.Hidden[:len(m.Opp.Hidden)-1] o.Hidden = o.Hidden[:len(o.Hidden)-1]
}
}
// reconcile forces the model to hold exactly size cards, the count everyone can
// see, padding with unknowns of the current tier or dropping the excess.
func (o *OppModel) reconcile(size, round int) {
for len(o.Known)+len(o.Hidden) < size {
o.Hidden = append(o.Hidden, HiddenCard{Tier: round})
}
for len(o.Known)+len(o.Hidden) > size {
if len(o.Hidden) > 0 {
o.Hidden = o.Hidden[:len(o.Hidden)-1]
} else {
o.Known = o.Known[:len(o.Known)-1]
}
} }
} }
@@ -185,15 +239,15 @@ func cardByID(cards []game.Card, id string) (game.Card, bool) {
return game.Card{}, false return game.Card{}, false
} }
// templateByName mints a reference copy of a named card from the pack's // templateByName mints a reference copy of a named card from the printed tier
// printed tier contents. The suit is whatever the first printed copy has — // contents of the packs in play. The suit is whatever the first printed copy
// callers only rely on stats and effects. // has — callers only rely on stats and effects.
func templateByName(pack, name string) (game.Card, bool) { func templateByName(packs []string, name string) (game.Card, bool) {
if name == "" { if name == "" {
return game.Card{}, false return game.Card{}, false
} }
for tier := 1; tier <= game.MaxRounds; tier++ { for tier := 1; tier <= game.MaxRounds; tier++ {
for _, c := range game.TierContentsForPack(pack, tier) { for _, c := range game.TierContentsForPacks(packs, tier) {
if c.Name == name { if c.Name == name {
return c, true return c, true
} }
+20 -9
View File
@@ -19,27 +19,35 @@ type ctx struct {
} }
func newCtx(v *game.View, m *Memory) *ctx { func newCtx(v *game.View, m *Memory) *ctx {
cx := &ctx{v: v, m: m, me: &v.Players[v.YouSeat], oppSeat: m.Opp.Seat, pools: map[int][]game.Card{}} cx := &ctx{v: v, m: m, me: v.PlayerView(v.YouSeat), oppSeat: v.YourOpponent, pools: map[int][]game.Card{}}
// The round's pairing says exactly who the bot is preparing for, so it
// plans against that one rival even at a six-player table. Falling back to
// any other seat keeps a malformed view from wedging the bot.
if cx.oppSeat == cx.me.Seat || cx.oppSeat < 0 { if cx.oppSeat == cx.me.Seat || cx.oppSeat < 0 {
// Memory hasn't observed yet (shouldn't happen in practice).
for _, p := range v.Players { for _, p := range v.Players {
if p.Seat != v.YouSeat { if p.Seat != v.YouSeat {
cx.oppSeat = p.Seat cx.oppSeat = p.Seat
break
} }
} }
} }
return cx return cx
} }
// opp is the model of the opponent this round's battle is against.
func (cx *ctx) opp() *OppModel { return cx.m.Opp(cx.oppSeat) }
func (cx *ctx) nextSimID() string { func (cx *ctx) nextSimID() string {
cx.simID++ cx.simID++
return fmt.Sprintf("sim-%d", cx.simID) return fmt.Sprintf("sim-%d", cx.simID)
} }
// unseenPool lists the printed cards of a tier that the bot cannot account // unseenPool lists the printed cards of a tier that the bot cannot account
// for anywhere it can see — its own deck, the opponent model, the shop row. // for anywhere it can see — its own deck, every opponent model, the shop row.
// Hidden opponent cards are drawn from this pool, so the bot's guesses // Hidden opponent cards are drawn from this pool, so the bot's guesses
// respect card counting without peeking at the real decks. // respect card counting without peeking at the real decks. It counts against
// the whole table's known cards, and against the combined contents of every
// pack in play, which is what a human counting cards would be working from.
func (cx *ctx) unseenPool(tier int) []game.Card { func (cx *ctx) unseenPool(tier int) []game.Card {
if pool, ok := cx.pools[tier]; ok { if pool, ok := cx.pools[tier]; ok {
return pool return pool
@@ -53,14 +61,16 @@ func (cx *ctx) unseenPool(tier int) []game.Card {
for _, c := range cx.me.Deck { for _, c := range cx.me.Deck {
note(c) note(c)
} }
for _, c := range cx.m.Opp.Known { for _, opp := range cx.m.Opps {
for _, c := range opp.Known {
note(c) note(c)
} }
}
for _, c := range cx.v.ShopRow { for _, c := range cx.v.ShopRow {
note(c) note(c)
} }
var pool []game.Card var pool []game.Card
for _, c := range game.TierContentsForPack(cx.v.Pack, tier) { for _, c := range game.TierContentsForPacks(cx.v.Packs, tier) {
if seen[c.Name] > 0 { if seen[c.Name] > 0 {
seen[c.Name]-- seen[c.Name]--
continue continue
@@ -75,10 +85,11 @@ func (cx *ctx) unseenPool(tier int) []game.Card {
// known cards as-is, hidden cards drawn from the unseen pool of their tier // known cards as-is, hidden cards drawn from the unseen pool of their tier
// (or their named template, when a pick was later revealed). // (or their named template, when a pick was later revealed).
func (cx *ctx) sampleOppDeck() []game.Card { func (cx *ctx) sampleOppDeck() []game.Card {
deck := append([]game.Card(nil), cx.m.Opp.Known...) opp := cx.opp()
for _, h := range cx.m.Opp.Hidden { deck := append([]game.Card(nil), opp.Known...)
for _, h := range opp.Hidden {
var c game.Card var c game.Card
if t, ok := templateByName(cx.v.Pack, h.Name); ok { if t, ok := templateByName(cx.v.Packs, h.Name); ok {
c = t c = t
} else if pool := cx.unseenPool(h.Tier); len(pool) > 0 { } else if pool := cx.unseenPool(h.Tier); len(pool) > 0 {
c = pool[rand.IntN(len(pool))] c = pool[rand.IntN(len(pool))]
+3 -4
View File
@@ -88,7 +88,7 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g
// with a sampled card of the current tier. // with a sampled card of the current tier.
pool := cx.unseenPool(cx.v.Round) pool := cx.unseenPool(cx.v.Round)
if len(pool) == 0 { if len(pool) == 0 {
pool = game.TierContentsForPack(cx.v.Pack, cx.v.Round) pool = game.TierContentsForPacks(cx.v.Packs, cx.v.Round)
} }
if len(pool) > 0 { if len(pool) > 0 {
rc := pool[rand.IntN(len(pool))] rc := pool[rand.IntN(len(pool))]
@@ -126,7 +126,7 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g
deck = slices.Delete(deck, worst, worst+1) deck = slices.Delete(deck, worst, worst+1)
pool := cx.unseenPool(nextTier) pool := cx.unseenPool(nextTier)
if len(pool) == 0 { if len(pool) == 0 {
pool = game.TierContentsForPack(cx.v.Pack, nextTier) pool = game.TierContentsForPacks(cx.v.Packs, nextTier)
} }
if len(pool) > 0 { if len(pool) > 0 {
rc := pool[rand.IntN(len(pool))] rc := pool[rand.IntN(len(pool))]
@@ -370,7 +370,7 @@ func (b *Bot) decideShop(v *game.View, mem *Memory) *Action {
} }
pool := cx.unseenPool(v.Round + 1) pool := cx.unseenPool(v.Round + 1)
if len(pool) == 0 { if len(pool) == 0 {
pool = game.TierContentsForPack(v.Pack, v.Round+1) pool = game.TierContentsForPacks(v.Packs, v.Round+1)
} }
var decks [][]game.Card var decks [][]game.Card
for range 3 { for range 3 {
@@ -409,4 +409,3 @@ func (b *Bot) decideTradeChoose(v *game.View, mem *Memory) *Action {
b.score(cx, cands) b.score(cx, cands)
return b.pick(cands).act return b.pick(cands).act
} }
+2 -2
View File
@@ -15,8 +15,8 @@ func playHeadToHead(t *testing.T, pack string, level0, level1 float64) int {
g := game.New() g := game.New()
pa, _ := g.AddBot("Bot A", level0) pa, _ := g.AddBot("Bot A", level0)
pb, _ := g.AddBot("Bot B", level1) pb, _ := g.AddBot("Bot B", level1)
if err := g.SetPack(pack); err != nil { if err := g.SetPacks([]string{pack}); err != nil {
t.Fatalf("SetPack: %v", err) t.Fatalf("SetPacks: %v", err)
} }
if err := g.StartGame(); err != nil { if err := g.StartGame(); err != nil {
t.Fatalf("StartGame: %v", err) t.Fatalf("StartGame: %v", err)
+206 -92
View File
@@ -2,6 +2,7 @@ package game
import ( import (
"fmt" "fmt"
"slices"
) )
// BattleUnit is a pet in play with its attached foods applied. Power // BattleUnit is a pet in play with its attached foods applied. Power
@@ -179,27 +180,82 @@ type BattleEvent struct {
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
} }
// BattleResult is the full, public record of one round's battle. // BattleResult is the full, public record of one battle.
//
// A round runs one battle per pairing (see schedule.go), so a six-player round
// produces three of these. Everything inside a result is indexed by *side* —
// 0 or 1 within this battle — not by the player's seat at the table: Seats maps
// the two apart, and BattleEvent.Seat/Target are side indices too. WinnerSeat
// is the exception, and is a real seat, because it's the one field that means
// something outside the battle.
type BattleResult struct { type BattleResult struct {
Round int `json:"round"` Round int `json:"round"`
StackSizes []int `json:"stackSizes"` // starting deck size per seat // Seats are the two players fighting, in first-player order: Seats[0] holds
// Lineups is each seat's arranged deck at battle start (top of deck // priority and acts first when two effects would land simultaneously.
Seats []int `json:"seats"`
StackSizes []int `json:"stackSizes"` // starting deck size per side
// Lineups is each side's arranged deck at battle start (top of deck
// first). Public so players can review the whole matchup — including the // first). Public so players can review the whole matchup — including the
// opponent's cards — during and after the fight. // opponent's cards — during and after the fight.
Lineups [][]Card `json:"lineups,omitempty"` Lineups [][]Card `json:"lineups,omitempty"`
Events []BattleEvent `json:"events"` Events []BattleEvent `json:"events"`
WinnerSeat int `json:"winnerSeat"` // -1 = draw WinnerSeat int `json:"winnerSeat"` // a seat at the table; -1 = draw
Trophies int `json:"trophies"` // awarded to the winner Trophies int `json:"trophies"` // awarded to the winner
// Survivors is each seat's remaining force at battle end: pets still in // Survivors is each side's remaining force at battle end: pets still in
// play plus any never reached in the stack. The loser is 0. It measures how // play plus any never reached in the stack. The loser is 0. It measures how
// decisive the result was — the margin the AI uses to prefer a lineup that // decisive the result was — the margin the AI uses to prefer a lineup that
// fights harder, even in a battle it can't win. // fights harder, even in a battle it can't win.
Survivors []int `json:"survivors,omitempty"` Survivors []int `json:"survivors,omitempty"`
// ManaAfter (Unicorn pack) is each seat's persistent Mana pool once the // ManaAfter (Unicorn pack) is each side's persistent Mana pool once the
// battle ends; finalizeBattle writes it back to the players. NextRoundApples // battle ends; finalizeBattle writes it back to the players. NextRoundApples
// is apples each seat banked for next round's hand (Skeleton Dog). // is apples each side banked for next round's hand (Skeleton Dog).
ManaAfter []int `json:"manaAfter,omitempty"` ManaAfter []int `json:"manaAfter,omitempty"`
NextRoundApples []int `json:"nextRoundApples,omitempty"` NextRoundApples []int `json:"nextRoundApples,omitempty"`
// Draws is every random draw this battle made, in order: rock die faces,
// Komodo's apple shuffle, random target picks. It's a recording, not an
// input — feed it back through ReplayResult and the identical battle plays
// out, which is how a debug report reproduces a fight that went wrong.
// Public like the rest of the result: the dice are rolled in the open.
Draws []int `json:"draws,omitempty"`
// Inputs is the rest of what the battle started from, per side, and
// StartCardID is the card-id counter it began minting apples at. Recorded
// because the round clears those banks the moment the battles end, so
// without them a result can't be replayed after the fact. Nothing here is
// private: the battle already announces each of them in its own events.
Inputs []BattleInputs `json:"inputs,omitempty"`
StartCardID int `json:"startCardId,omitempty"`
}
// Replayable reports whether this result carries the recording a faithful
// replay needs. Results saved before the engine recorded battles don't: their
// lineups are still on file, so the fight can be re-run, but the dice will fall
// where they may and the outcome may differ from what the player saw.
func (r *BattleResult) Replayable() bool { return r.StartCardID > 0 }
// BattleInputs is the persistent player state one side brought into a battle —
// everything runBattle reads off the Player besides the arranged lineup.
type BattleInputs struct {
Mana int `json:"mana,omitempty"` // Player.Mana (Unicorn)
Trumpets int `json:"trumpets,omitempty"` // Player.PendingTrumpets (Golden)
ApplesInPlay int `json:"applesInPlay,omitempty"` // Player.PendingApplesInPlay (Golden)
}
// Side returns the battle-side index (0 or 1) for a seat at the table, or -1
// if that player wasn't in this battle. Use it to read any of the per-side
// slices above from a seat.
func (r *BattleResult) Side(seat int) int {
return slices.Index(r.Seats, seat)
}
// Has reports whether a seat fought in this battle.
func (r *BattleResult) Has(seat int) bool { return r.Side(seat) >= 0 }
// SeatOf returns the seat holding a side of this battle, or -1.
func (r *BattleResult) SeatOf(side int) int {
if side < 0 || side >= len(r.Seats) {
return -1
}
return r.Seats[side]
} }
// setAsideRocks is a fainted pet's pending rock payout. // setAsideRocks is a fainted pet's pending rock payout.
@@ -324,10 +380,10 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit, enemy *battleSide) int
return n return n
} }
// resolveBattle simulates the battle from the players' arranged decks, // resolveBattles fights every pairing of the current round, records the event
// records the event log, awards trophies, and moves to PhaseBattle. // logs, and awards trophies.
// //
// The battle is a stack machine: each side reveals cards off the top of // Each battle is a stack machine: each side reveals cards off the top of
// their deck until a pet is in play (foods along the way attach to it; only // their deck until a pet is in play (foods along the way attach to it; only
// the last-applied perk counts). If anyone can no longer field a pet the // the last-applied perk counts). If anyone can no longer field a pet the
// battle ends. Otherwise play effects resolve (rocks, strips, steals, // battle ends. Otherwise play effects resolve (rocks, strips, steals,
@@ -336,86 +392,135 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit, enemy *battleSide) int
// pet with Damage >= Power faints, firing Faint effects. Survivors that // pet with Damage >= Power faints, firing Faint effects. Survivors that
// took damage fire Hurt effects. Shields (Turtle, Gorilla, Melon) block // took damage fire Hurt effects. Shields (Turtle, Gorilla, Melon) block
// entire hits; Garlic shaves 1 from each; Scorpion KOs whatever its clash // entire hits; Garlic shaves 1 from each; Scorpion KOs whatever its clash
// attack manages to hurt. A clash that changes nothing ends the battle as a // attack manages to hurt. A clash that changes nothing is a stalemate: that
// stalemate. // pair both faint so the pets behind them can settle the battle.
// //
// resolveBattle is the orchestrator: it runs the (deterministic) simulation and // resolveBattles is the orchestrator: it runs each (deterministic) simulation
// publishes the completed result. // and publishes the completed results.
func (g *Game) resolveBattle() { func (g *Game) resolveBattles() {
res := g.runBattle() g.Battles = nil
g.Battle = res for _, m := range g.Pairings() {
first, second := g.firstPlayer(m)
res := g.runBattle(first, second)
g.Battles = append(g.Battles, res)
g.finalizeBattle(res) g.finalizeBattle(res)
}
// finalizeBattle applies the persistent effects of a completed battle: trophies,
// the priority token hand-off, the result log line, and clearing the per-round
// apples-in-play bank. Kept separate from runBattle, which mutates no persistent
// player state.
func (g *Game) finalizeBattle(res *BattleResult) {
n := len(g.Players)
winner := res.WinnerSeat
if winner >= 0 {
g.Players[winner].Trophies += res.Trophies
// Priority token: the winner hands it to the other player; a loser who
// held it keeps it; a draw leaves it put. (Two-player rule.)
if winner == g.PrioritySeat {
g.PrioritySeat = (winner + 1) % n
}
}
if winner < 0 {
g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult,
Text: fmt.Sprintf("Round %d battle ends in a draw.", g.Round)})
} else {
g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult,
Text: fmt.Sprintf("%s wins the round %d battle (+%d🏆).", g.Players[winner].Name, g.Round, res.Trophies)})
} }
// Per-round bookkeeping that isn't tied to one battle: the temporary
// resources every player banked for the fight are spent now, win or lose.
for _, p := range g.Players { for _, p := range g.Players {
p.PendingApplesInPlay = 0 p.PendingApplesInPlay = 0
p.PendingTrumpets = 0 p.PendingTrumpets = 0
}
}
// firstPlayer decides which half of a pairing acts first — the side that wins
// simultaneity races during the battle. Two players settle it with the
// priority token they pass between them; a bigger table flips for it, as the
// rulebook's "determine the First Player for each battle by flipping a gold
// token" asks.
func (g *Game) firstPlayer(m Matchup) (first, second int) {
if len(g.Players) == 2 {
if m[1] == g.PrioritySeat {
return m[1], m[0]
}
return m[0], m[1]
}
if randInt(2) == 1 {
return m[1], m[0]
}
return m[0], m[1]
}
// finalizeBattle applies the persistent effects of one completed battle:
// trophies, the round-win record, the priority token hand-off, and the result
// log line. Kept separate from runBattle, which mutates no persistent player
// state.
func (g *Game) finalizeBattle(res *BattleResult) {
winner := res.WinnerSeat
if winner >= 0 {
g.Players[winner].Trophies += res.Trophies
g.Players[winner].RoundWins = append(g.Players[winner].RoundWins, res.Round)
// Priority token (two-player rule): the winner hands it to the other
// player; a loser who held it keeps it; a draw leaves it put. At bigger
// tables the token instead walks the table each round (startShopRound).
if len(g.Players) == 2 && winner == g.PrioritySeat {
g.PrioritySeat = (winner + 1) % len(g.Players)
}
}
// The result line names the table it came from, since several resolve at once.
loser := res.SeatOf(0)
if loser == winner {
loser = res.SeatOf(1)
}
if winner < 0 {
g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult,
Text: fmt.Sprintf("%s vs %s ends in a draw.", g.seatName(res.SeatOf(0)), g.seatName(res.SeatOf(1)))})
} else {
g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult,
Text: fmt.Sprintf("%s beats %s (+%d🏆).", g.seatName(winner), g.seatName(loser), res.Trophies)})
}
for _, seat := range res.Seats {
p := g.Players[seat]
side := res.Side(seat)
// Unicorn pack: persist the Mana pool as it stood at battle's end, and // Unicorn pack: persist the Mana pool as it stood at battle's end, and
// bank any apples destined for next round's hand (Skeleton Dog). // bank any apples destined for next round's hand (Skeleton Dog).
if res.ManaAfter != nil && p.Seat < len(res.ManaAfter) { if side < len(res.ManaAfter) {
p.Mana = res.ManaAfter[p.Seat] p.Mana = res.ManaAfter[side]
} }
if res.NextRoundApples != nil && p.Seat < len(res.NextRoundApples) { if side < len(res.NextRoundApples) {
p.NextRoundApples += res.NextRoundApples[p.Seat] p.NextRoundApples += res.NextRoundApples[side]
} }
} }
} }
// runBattle plays the simulation to completion, returning the result. It // runBattle plays one pairing's simulation to completion, returning the
// mutates no persistent player state — that is finalizeBattle's job. // result. It mutates no persistent player state — that is finalizeBattle's job.
func (g *Game) runBattle() *BattleResult { //
n := len(g.Players) // first and second are the seats fighting, first having priority. Everything
res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n), Lineups: make([][]Card, n)} // below works in *side* indices — 0 is first, 1 is second — so the resolver
// only ever deals with two combatants no matter how big the table is; res.Seats
// maps back out. Read `seat` in this function as "side" throughout.
func (g *Game) runBattle(first, second int) *BattleResult {
const n = 2 // sides in a battle, not players at the table
seats := []int{first, second}
res := &BattleResult{Round: g.Round, WinnerSeat: -1, Seats: seats,
StackSizes: make([]int, n), Lineups: make([][]Card, n)}
// Every die this battle rolls lands on the tape and ships with the result,
// so a debug report can replay the fight exactly (see debug.go). The tape
// belongs to one battle: start it empty and hand it over on the way out.
g.drawTape = nil
defer func() { res.Draws, g.drawTape, g.drawReplay = g.drawTape, nil, nil }()
res.StartCardID = g.NextCardID
res.Inputs = make([]BattleInputs, n)
sides := make([]*battleSide, n) sides := make([]*battleSide, n)
emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) } emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) }
// pname is the owning player's display name for a seat, for log text. // pname is the owning player's display name for a side, for log text.
pname := func(seat int) string { return g.Players[seat].Name } pname := func(side int) string { return g.Players[seats[side]].Name }
for _, p := range g.Players { for side, seat := range seats {
p := g.Players[seat]
s := &battleSide{stack: append([]Card(nil), p.Deck...), faintedHats: map[Suit]bool{}} s := &battleSide{stack: append([]Card(nil), p.Deck...), faintedHats: map[Suit]bool{}}
// Unicorn pack: the persistent Mana pool comes into battle (read-only // Unicorn pack: the persistent Mana pool comes into battle (read-only
// here; written back by finalizeBattle so re-runs stay deterministic). // here; written back by finalizeBattle so re-runs stay deterministic).
s.mana = p.Mana s.mana = p.Mana
sides[p.Seat] = s sides[side] = s
res.StackSizes[p.Seat] = len(p.Deck) res.StackSizes[side] = len(p.Deck)
res.Lineups[p.Seat] = append([]Card(nil), p.Deck...) res.Lineups[side] = append([]Card(nil), p.Deck...)
// Everything else runBattle reads off the Player, banked for the replay.
res.Inputs[side] = BattleInputs{
Mana: p.Mana,
Trumpets: p.PendingTrumpets,
ApplesInPlay: p.PendingApplesInPlay,
} }
// enemyOf returns the opposing side (two-player; generalizes later). }
// enemyOf returns the opposing side.
enemyOf := func(seat int) *battleSide { return sides[(seat+1)%n] } enemyOf := func(seat int) *battleSide { return sides[(seat+1)%n] }
// seatOrder resolves the priority-token holder first, then everyone else. // seatOrder resolves the first player before the second. Reveals, queued
// Reveals, queued play effects, and cross-side triggers all follow it, so // play effects, and cross-side triggers all follow it, so when two pets
// when two pets would act simultaneously (e.g. both throwing rocks) the // would act simultaneously (e.g. both throwing rocks) the first player acts
// holder acts first — its rocks can faint the enemy pet before that pet's // first — its rocks can faint the enemy pet before that pet's own queued
// own queued rocks resolve. // rocks resolve. Side 0 is the first player by construction.
seatOrder := make([]int, 0, n) seatOrder := []int{0, 1}
seatOrder = append(seatOrder, g.PrioritySeat)
for seat := range sides {
if seat != g.PrioritySeat {
seatOrder = append(seatOrder, seat)
}
}
// startApple seeds one in-play apple onto a seat's first pet. // startApple seeds one in-play apple onto a seat's first pet.
startApple := func(seat int) { startApple := func(seat int) {
apple := g.newApple() apple := g.newApple()
@@ -425,27 +530,28 @@ func (g *Game) runBattle() *BattleResult {
} }
// Battle-prep effects that start apples in play (Monkey): they attach // Battle-prep effects that start apples in play (Monkey): they attach
// to the owner's first pet. // to the owner's first pet.
for _, p := range g.Players { for side, seat := range seats {
p := g.Players[seat]
for _, c := range p.Deck { for _, c := range p.Deck {
for _, e := range c.Effects { for _, e := range c.Effects {
if e.Trigger == TriggerBattlePrep && e.Action == ActionApplesInPlay { if e.Trigger == TriggerBattlePrep && e.Action == ActionApplesInPlay {
for range e.count() { for range e.count() {
startApple(p.Seat) startApple(side)
} }
} }
} }
} }
// Golden pack: apples-in-play banked by a sold Hercules Beetle this // Golden pack: apples-in-play banked by a sold Hercules Beetle this
// round (read-only here; finalizeBattle clears it once the battle ends, // round (read-only here; resolveBattles clears it once the round's
// so re-runs bank the same amount). // battles end, so re-runs bank the same amount).
for range p.PendingApplesInPlay { for range p.PendingApplesInPlay {
startApple(p.Seat) startApple(side)
} }
// Bird of Paradise: start the battle with Trumpets in the pool. // Bird of Paradise: start the battle with Trumpets in the pool.
if p.PendingTrumpets > 0 { if p.PendingTrumpets > 0 {
sides[p.Seat].trumpets += p.PendingTrumpets sides[side].trumpets += p.PendingTrumpets
emit(BattleEvent{Type: "trumpet", Seat: p.Seat, Count: p.PendingTrumpets, emit(BattleEvent{Type: "trumpet", Seat: side, Count: p.PendingTrumpets,
Text: fmt.Sprintf("%s starts with %d Trumpet%s.", pname(p.Seat), p.PendingTrumpets, plural(p.PendingTrumpets))}) Text: fmt.Sprintf("%s starts with %d Trumpet%s.", pname(side), p.PendingTrumpets, plural(p.PendingTrumpets))})
} }
} }
@@ -1686,8 +1792,7 @@ func (g *Game) runBattle() *BattleResult {
continue // refill before any clash continue // refill before any clash
} }
// Clash. Two-player for now; >2-player battle pairings come later // Clash: the two sides' pets trade blows. A is the first player's side.
// (the surrounding state is already per-seat).
ua, ub := sides[0].unit, sides[1].unit ua, ub := sides[0].unit, sides[1].unit
// Unicorn pack: Spooked lowers a pet's clash attack (Exposed is applied // Unicorn pack: Spooked lowers a pet's clash attack (Exposed is applied
// to the defender inside hitUnit); a Manticore boosts enemy ailments. // to the defender inside hitUnit); a Manticore boosts enemy ailments.
@@ -1702,12 +1807,18 @@ func (g *Game) runBattle() *BattleResult {
if dealtB > 0 && ua.hasKnockout() { if dealtB > 0 && ua.hasKnockout() {
ub.Damage = max(ub.Damage, ub.Power()) ub.Damage = max(ub.Damage, ub.Power())
} }
// Stalemate: the clash changed nothing at all, so repeating it never
// will either — this pair is stuck no matter how many exchanges it
// gets. Resolve it like a deadlock (below) rather than ending the
// battle: the pets *behind* these two can still settle it.
stuck := ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 &&
blockA == nil && blockB == nil && prevA == nil && prevB == nil
// Deadlock guard: if the pair keeps surviving, count the exchanges and // Deadlock guard: if the pair keeps surviving, count the exchanges and
// force a mutual knockout once they hit the limit. // force a mutual knockout once they hit the limit.
deadlocked := false deadlocked := false
if ua.Alive() && ub.Alive() { if ua.Alive() && ub.Alive() {
clashStreak++ clashStreak++
if clashStreak >= deadlockLimit { if stuck || clashStreak >= deadlockLimit {
deadlocked = true deadlocked = true
ua.Damage = max(ua.Damage, ua.Power()) ua.Damage = max(ua.Damage, ua.Power())
ub.Damage = max(ub.Damage, ub.Power()) ub.Damage = max(ub.Damage, ub.Power())
@@ -1718,6 +1829,9 @@ func (g *Game) runBattle() *BattleResult {
clashTxt := fmt.Sprintf("%s's %s and %s's %s trade blows.", clashTxt := fmt.Sprintf("%s's %s and %s's %s trade blows.",
pname(0), ua.Card.Name, pname(1), ub.Card.Name) pname(0), ua.Card.Name, pname(1), ub.Card.Name)
switch da, db := !ua.Alive(), !ub.Alive(); { switch da, db := !ua.Alive(), !ub.Alive(); {
case stuck:
clashTxt = fmt.Sprintf("%s's %s and %s's %s can't hurt each other — both faint.",
pname(0), ua.Card.Name, pname(1), ub.Card.Name)
case deadlocked: case deadlocked:
clashTxt = fmt.Sprintf("%s's %s and %s's %s are deadlocked after %d clashes — both faint.", clashTxt = fmt.Sprintf("%s's %s and %s's %s are deadlocked after %d clashes — both faint.",
pname(0), ua.Card.Name, pname(1), ub.Card.Name, deadlockLimit) pname(0), ua.Card.Name, pname(1), ub.Card.Name, deadlockLimit)
@@ -1746,10 +1860,6 @@ func (g *Game) runBattle() *BattleResult {
if prevB != nil { if prevB != nil {
emitPrevent(1, ub.Card.Name, prevB) emitPrevent(1, ub.Card.Name, prevB)
} }
if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 &&
blockA == nil && blockB == nil && prevA == nil && prevB == nil {
break // stalemate: nothing can ever change
}
dealt := []int{dealtA, dealtB} dealt := []int{dealtA, dealtB}
for seat, u := range []*BattleUnit{ua, ub} { for seat, u := range []*BattleUnit{ua, ub} {
if !u.Alive() { if !u.Alive() {
@@ -1768,23 +1878,27 @@ func (g *Game) runBattle() *BattleResult {
} }
} }
// A single side that can still field a pet wins; anything else (everyone // A single side that can still field a pet wins; anything else (both out,
// out, or a stalemate with pets on both sides) is a draw. We test canField, // or the iteration cap tripping with pets on both sides) is a draw. We test
// not unit, because the loop can break the instant one side runs out while // canField, not unit, because the loop can break the instant one side runs
// the other's current pet has just fainted — that side still has pets left // out while the other's current pet has just fainted — that side still has
// in its stack (it simply wasn't refilled) and is the rightful winner. // pets left in its stack (it simply wasn't refilled) and is the rightful
// winner.
winner := -1 winner := -1
for seat, s := range sides { for side, s := range sides {
if s.canField() { if s.canField() {
if winner >= 0 { if winner >= 0 {
winner = -1 // stalemate / >2-player safety winner = -1 // both still standing: a draw
break break
} }
winner = seat winner = side
} }
} }
res.WinnerSeat = winner // The winner leaves this function as a seat at the table, the one piece of
// the result that means anything outside the battle.
if winner >= 0 { if winner >= 0 {
res.WinnerSeat = seats[winner]
// The last round is worth double.
res.Trophies = 1 res.Trophies = 1
if g.Round == MaxRounds { if g.Round == MaxRounds {
res.Trophies = 2 res.Trophies = 2
+7 -7
View File
@@ -96,7 +96,7 @@ func forceBattle(t *testing.T, g *Game, d1, d2 []Card) *BattleResult {
if g.Phase != PhaseBattle { if g.Phase != PhaseBattle {
t.Fatalf("expected battle phase, got %s", g.Phase) t.Fatalf("expected battle phase, got %s", g.Phase)
} }
return g.Battle return g.Battles[0]
} }
func eventsOfType(res *BattleResult, typ string) []BattleEvent { func eventsOfType(res *BattleResult, typ string) []BattleEvent {
@@ -712,8 +712,8 @@ func TestPriorityTokenTransfer(t *testing.T) {
g, _, _ := testGame(t) g, _, _ := testGame(t)
g.PrioritySeat = 0 g.PrioritySeat = 0
forceBattle(t, g, []Card{g.pet("Champ", 9)}, []Card{g.pet("Chump", 1)}) forceBattle(t, g, []Card{g.pet("Champ", 9)}, []Card{g.pet("Chump", 1)})
if g.Battle.WinnerSeat != 0 { if g.Battles[0].WinnerSeat != 0 {
t.Fatalf("seat 0 should win, got %d", g.Battle.WinnerSeat) t.Fatalf("seat 0 should win, got %d", g.Battles[0].WinnerSeat)
} }
if g.PrioritySeat != 1 { if g.PrioritySeat != 1 {
t.Fatalf("winner should hand the token to the loser, priority=%d", g.PrioritySeat) t.Fatalf("winner should hand the token to the loser, priority=%d", g.PrioritySeat)
@@ -723,8 +723,8 @@ func TestPriorityTokenTransfer(t *testing.T) {
g, _, _ = testGame(t) g, _, _ = testGame(t)
g.PrioritySeat = 1 g.PrioritySeat = 1
forceBattle(t, g, []Card{g.pet("Champ", 9)}, []Card{g.pet("Chump", 1)}) forceBattle(t, g, []Card{g.pet("Champ", 9)}, []Card{g.pet("Chump", 1)})
if g.Battle.WinnerSeat != 0 { if g.Battles[0].WinnerSeat != 0 {
t.Fatalf("seat 0 should win, got %d", g.Battle.WinnerSeat) t.Fatalf("seat 0 should win, got %d", g.Battles[0].WinnerSeat)
} }
if g.PrioritySeat != 1 { if g.PrioritySeat != 1 {
t.Fatalf("a losing token holder should keep it, priority=%d", g.PrioritySeat) t.Fatalf("a losing token holder should keep it, priority=%d", g.PrioritySeat)
@@ -734,8 +734,8 @@ func TestPriorityTokenTransfer(t *testing.T) {
g, _, _ = testGame(t) g, _, _ = testGame(t)
g.PrioritySeat = 0 g.PrioritySeat = 0
forceBattle(t, g, []Card{g.pet("A", 3)}, []Card{g.pet("B", 3)}) forceBattle(t, g, []Card{g.pet("A", 3)}, []Card{g.pet("B", 3)})
if g.Battle.WinnerSeat != -1 { if g.Battles[0].WinnerSeat != -1 {
t.Fatalf("mutual KO should draw, got %d", g.Battle.WinnerSeat) t.Fatalf("mutual KO should draw, got %d", g.Battles[0].WinnerSeat)
} }
if g.PrioritySeat != 0 { if g.PrioritySeat != 0 {
t.Fatalf("a draw should leave the token put, priority=%d", g.PrioritySeat) t.Fatalf("a draw should leave the token put, priority=%d", g.PrioritySeat)
+35 -14
View File
@@ -1291,12 +1291,17 @@ func packTiers(pack string) (*[MaxRounds][]petTemplate, *[MaxRounds][]foodTempla
} }
} }
// buildShopDecks creates all six tier decks (unshuffled) for the game's pack. // buildShopDecks creates all six tier decks (unshuffled) from the game's
// selected packs. Combining packs is the rulebook's answer to seating more
// than two players: every pack's tier 1 cards shuffle together into one tier 1
// deck, its tier 2 cards into one tier 2 deck, and so on. A game on a single
// pack is just the one-element case.
func (g *Game) buildShopDecks() { func (g *Game) buildShopDecks() {
pets, foods := packTiers(g.Pack)
g.ShopDecks = make([][]Card, MaxRounds) g.ShopDecks = make([][]Card, MaxRounds)
for _, pack := range g.packList() {
pets, foods := packTiers(pack)
for tierIdx := range pets { for tierIdx := range pets {
var deck []Card deck := g.ShopDecks[tierIdx]
for _, t := range pets[tierIdx] { for _, t := range pets[tierIdx] {
for _, suit := range t.Suits { for _, suit := range t.Suits {
deck = append(deck, Card{ deck = append(deck, Card{
@@ -1327,18 +1332,31 @@ func (g *Game) buildShopDecks() {
} }
g.ShopDecks[tierIdx] = deck g.ShopDecks[tierIdx] = deck
} }
}
}
// packList is the game's pack selection, defaulting to the base pack so a
// zero-value Game (scratch simulations, tests) still builds real decks.
func (g *Game) packList() []string {
if len(g.Packs) == 0 {
return []string{DefaultPack}
}
return g.Packs
} }
// Catalog returns the default pack's representative cards. // Catalog returns the default pack's representative cards.
func Catalog() []Card { return CatalogForPack(DefaultPack) } func Catalog() []Card { return CatalogForPacks([]string{DefaultPack}) }
// CatalogForPack returns one representative card for every pet and food in a // CatalogForPacks returns one representative card for every pet and food in
// pack, tier by tier, for the debug "buy any card" panel. IDs are name-based // the given packs, tier by tier, for the debug "buy any card" panel. IDs are
// placeholders (not real instances); pets use their first printed suit. // name-based placeholders (not real instances); pets use their first printed
func CatalogForPack(pack string) []Card { // suit. Cards are grouped by tier across all packs, matching how the shop
pets, foods := packTiers(pack) // decks combine.
func CatalogForPacks(packs []string) []Card {
var cards []Card var cards []Card
for tierIdx := range pets { for tierIdx := range MaxRounds {
for _, pack := range packs {
pets, foods := packTiers(pack)
for _, t := range pets[tierIdx] { for _, t := range pets[tierIdx] {
suit := SuitRed suit := SuitRed
if len(t.Suits) > 0 { if len(t.Suits) > 0 {
@@ -1356,14 +1374,16 @@ func CatalogForPack(pack string) []Card {
}) })
} }
} }
}
return cards return cards
} }
// cardByName mints a fresh instance of the named pet or food from the current // cardByName mints a fresh instance of the named pet or food from the
// pack's templates (pets take their first printed suit). Returns false if // templates of any pack in play (pets take their first printed suit). Returns
// unknown. // false if unknown.
func (g *Game) cardByName(name string) (Card, bool) { func (g *Game) cardByName(name string) (Card, bool) {
pets, foods := packTiers(g.Pack) for _, pack := range g.packList() {
pets, foods := packTiers(pack)
for tierIdx := range pets { for tierIdx := range pets {
for _, t := range pets[tierIdx] { for _, t := range pets[tierIdx] {
if t.Name == name { if t.Name == name {
@@ -1386,6 +1406,7 @@ func (g *Game) cardByName(name string) (Card, bool) {
} }
} }
} }
}
return Card{}, false return Card{}, false
} }
+521
View File
@@ -0,0 +1,521 @@
package game
import (
"encoding/json"
"fmt"
"os"
"strings"
)
// A debug report is a snapshot of a game that went wrong, complete enough to
// reproduce it: the full authoritative state (every deck card by card, the shop
// row and remaining decks, discards, pending choices, per-player banks), every
// battle fought that round with its lineups and its recording of every die, and
// the entire event log.
//
// It exists in two forms. The JSON form is exact — feed it back through
// ParseDebugReport and you have the same Game the server had, ready to replay.
// The text form is for reading: the same content laid out for a human trying to
// work out which effect misfired. Report.Text() ends with a paste-ready test.
//
// Both forms contain hidden information — opponents' decks, the order of the
// shop decks — and that is an accepted trade: the in-game "Report a bug" button
// hands a player their own report on any server, DEBUG or not, because a bug
// nobody can reproduce costs more than the little a cheat would gain.
// `go run ./cmd/report` reads the same report out of the database.
// DebugReportVersion is the report format's version, so an old report found on
// disk can be recognized for what it is.
const DebugReportVersion = 1
// DebugReport is a captured game, ready to read or replay.
type DebugReport struct {
Version int `json:"version"`
// CapturedAt is an RFC3339 timestamp stamped by whoever took the report;
// the engine is clockless and leaves it empty. Note is free text — what
// looked wrong, in the reporter's words.
CapturedAt string `json:"capturedAt,omitempty"`
Note string `json:"note,omitempty"`
// Summary is the at-a-glance header, so a pile of reports can be triaged
// (and filenames built) without unmarshalling the state.
Summary DebugSummary `json:"summary"`
// State is the complete Game, byte for byte as the server persists it.
// Everything the report can tell you is derived from this.
State json.RawMessage `json:"state"`
}
// DebugSummary identifies a report at a glance.
type DebugSummary struct {
GameID string `json:"gameId"`
Code string `json:"code"`
Packs []string `json:"packs"`
Phase Phase `json:"phase"`
Round int `json:"round"`
Seats []string `json:"seats"` // display names, by seat
Battles int `json:"battles"` // battles recorded in this round
LogEntries int `json:"logEntries"` // size of the event log
WinnerSeat int `json:"winnerSeat"` // at gameover; -1 otherwise
Bots []int `json:"bots,omitempty"` // seats played by the computer
}
// DebugReport captures the game as it stands. The caller owns the game lock;
// the report is a copy and shares nothing with it afterwards.
func (g *Game) DebugReport() (*DebugReport, error) {
state, err := json.Marshal(g)
if err != nil {
return nil, fmt.Errorf("capture game state: %w", err)
}
sum := DebugSummary{
GameID: g.ID,
Code: g.Code,
Packs: g.packList(),
Phase: g.Phase,
Round: g.Round,
Battles: len(g.Battles),
LogEntries: len(g.Log),
WinnerSeat: g.WinnerSeat,
}
for _, p := range g.Players {
sum.Seats = append(sum.Seats, p.Name)
if p.IsBot {
sum.Bots = append(sum.Bots, p.Seat)
}
}
return &DebugReport{Version: DebugReportVersion, Summary: sum, State: state}, nil
}
// ParseDebugReport reads a report. It also accepts a bare game state — the raw
// JSON blob out of the store, or the `state` field pulled from a report — so
// anything game-shaped you can lay hands on can be replayed.
func ParseDebugReport(data []byte) (*DebugReport, error) {
var r DebugReport
if err := json.Unmarshal(data, &r); err != nil {
return nil, fmt.Errorf("parse debug report: %w", err)
}
if len(r.State) == 0 {
// Not a report — assume it's a game state and wrap it in one.
var g Game
if err := json.Unmarshal(data, &g); err != nil {
return nil, fmt.Errorf("parse debug report: not a report or a game state: %w", err)
}
if g.ID == "" && g.Code == "" && len(g.Players) == 0 {
return nil, fmt.Errorf("parse debug report: no game state found")
}
return g.DebugReport()
}
if _, err := r.Game(); err != nil {
return nil, err
}
return &r, nil
}
// LoadDebugReportFile reads a report (or a bare game state) off disk. This is
// the entry point for a test built around a report: keep the JSON in testdata
// and load it here.
func LoadDebugReportFile(path string) (*DebugReport, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return ParseDebugReport(data)
}
// Game rebuilds the captured game. Each call returns a fresh copy, so callers
// are free to poke at it — replay a battle, take another action, run the state
// forward — without disturbing the report or each other.
func (r *DebugReport) Game() (*Game, error) {
var g Game
if err := json.Unmarshal(r.State, &g); err != nil {
return nil, fmt.Errorf("restore game state: %w", err)
}
return &g, nil
}
// JSON renders the report as indented JSON: what you save to a file, attach to
// a bug, or drop into testdata.
func (r *DebugReport) JSON() ([]byte, error) {
return json.MarshalIndent(r, "", " ")
}
// Filename is a descriptive, filesystem-safe name for this report.
func (r *DebugReport) Filename() string {
code := r.Summary.Code
if code == "" {
code = "game"
}
return fmt.Sprintf("sap-debug-%s-r%d-%s.json", strings.ToLower(code), r.Summary.Round, r.Summary.Phase)
}
// ReplayBattle re-runs one of the round's recorded battles — index 0 is the
// first pairing, matching Game.Battles — against a fresh copy of the captured
// game. The recording supplies the dice, so the fight plays out exactly as it
// did for the player, right down to the event text. Compare the result against
// the recorded one to see whether the engine still does what it did, or step
// through it to find where it went wrong.
func (r *DebugReport) ReplayBattle(i int) (*BattleResult, error) {
g, err := r.Game()
if err != nil {
return nil, err
}
if i < 0 || i >= len(g.Battles) {
return nil, fmt.Errorf("no battle %d in this report (it has %d)", i, len(g.Battles))
}
rec := g.Battles[i]
if len(rec.Seats) != 2 || len(rec.Lineups) != 2 {
return nil, fmt.Errorf("battle %d has no recorded lineups to replay", i)
}
for _, seat := range rec.Seats {
if seat < 0 || seat >= len(g.Players) {
return nil, fmt.Errorf("battle %d names seat %d, which isn't at this table", i, seat)
}
}
// Refuse rather than hand back a battle that quietly differs from the one
// the player saw: a game saved before the engine recorded its dice can only
// be re-rolled, not replayed.
if !rec.Replayable() {
return nil, fmt.Errorf("battle %d predates dice recording, so it can't be replayed faithfully — "+
"its lineups are in the report, and ReplayResult will fight it again with fresh dice", i)
}
// Rewind the two fighters to how they went in: the round has since cleared
// their banks and written their Mana back, and the battle itself spent card
// ids minting apples.
applyBattleInputs(g, rec)
g.Round = rec.Round
g.NextCardID = rec.StartCardID
g.drawReplay = append([]int(nil), rec.Draws...)
return g.runBattle(rec.Seats[0], rec.Seats[1]), nil
}
// Text renders the report for reading.
func (r *DebugReport) Text() string {
var b strings.Builder
g, err := r.Game()
if err != nil {
fmt.Fprintf(&b, "debug report v%d — UNREADABLE STATE: %v\n", r.Version, err)
return b.String()
}
fmt.Fprintf(&b, "Super Auto Pets debug report (v%d)\n", r.Version)
if r.CapturedAt != "" {
fmt.Fprintf(&b, "captured %s\n", r.CapturedAt)
}
if r.Note != "" {
fmt.Fprintf(&b, "note: %s\n", r.Note)
}
packs := strings.Join(g.packList(), " + ")
fmt.Fprintf(&b, "\ngame %s · code %s · packs %s\n", g.ID, g.Code, packs)
fmt.Fprintf(&b, "round %d/%d · phase %s · %d players\n", g.Round, MaxRounds, g.Phase, len(g.Players))
fmt.Fprintf(&b, "priority seat %d · shop turn seat %d · next card id %d\n",
g.PrioritySeat, g.Turn, g.NextCardID)
if g.Phase == PhaseGameOver {
fmt.Fprintf(&b, "winner: %s\n", seatList(g, g.WinnerSeats))
}
if pairs := g.Pairings(); len(pairs) > 0 {
var parts []string
for _, m := range pairs {
parts = append(parts, fmt.Sprintf("%d v %d", m[0], m[1]))
}
fmt.Fprintf(&b, "this round's pairings: %s\n", strings.Join(parts, ", "))
}
b.WriteString("\n=== Players ===\n")
for _, p := range g.Players {
writePlayer(&b, p)
}
b.WriteString("\n=== Shop ===\n")
writeShop(&b, g)
if len(g.Battles) > 0 {
fmt.Fprintf(&b, "\n=== Battles (round %d) ===\n", g.Battles[0].Round)
for i, res := range g.Battles {
writeBattle(&b, g, i, res)
}
}
fmt.Fprintf(&b, "\n=== Event log (%d entries) ===\n", len(g.Log))
for _, e := range g.Log {
seat := " -"
if e.Seat >= 0 {
seat = fmt.Sprintf("s%d", e.Seat)
}
fmt.Fprintf(&b, " #%-4d r%d %-8s %-3s %s %s", e.Seq, e.Round, e.Phase, seat, e.Icon, e.Text)
if e.Kind != "" {
fmt.Fprintf(&b, " [kind=%s]", e.Kind)
}
b.WriteString("\n")
}
b.WriteString("\n=== Reproducing this ===\n")
b.WriteString(r.GoTest())
return b.String()
}
// GoTest is a paste-ready test that reproduces the report's battle. Save the
// report's JSON next to it and the test replays the exact fight — same
// lineups, same dice — so you can assert on what should have happened and then
// step into the resolver to find out why it didn't.
func (r *DebugReport) GoTest() string {
var b strings.Builder
name := r.Filename()
fmt.Fprintf(&b, "Save the JSON report as internal/game/testdata/%s, then in\n"+
"internal/game (package game):\n\n", name)
fmt.Fprintf(&b, "func TestReproFrom%s(t *testing.T) {\n", identifier(r.Summary.Code))
fmt.Fprintf(&b, "\trep, err := LoadDebugReportFile(\"testdata/%s\")\n", name)
b.WriteString("\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n")
b.WriteString("\tres, err := rep.ReplayBattle(0) // 0 = the round's first pairing\n")
b.WriteString("\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n")
b.WriteString("\tfor i, ev := range res.Events {\n\t\tt.Logf(\"%3d %-8s %s\", i, ev.Type, ev.Text)\n\t}\n")
// The assertion is seeded with what actually happened, since that's the
// thing under suspicion: flip it to what should have happened and the test
// becomes the bug report.
winner := -1
if g, err := r.Game(); err == nil && len(g.Battles) > 0 {
winner = g.Battles[0].WinnerSeat
}
outcome := fmt.Sprintf("seat %d to win", winner)
if winner < 0 {
outcome = "a draw"
}
fmt.Fprintf(&b, "\tif res.WinnerSeat != %d { // what happened; assert what should have\n", winner)
fmt.Fprintf(&b, "\t\tt.Fatalf(\"expected %s, got winner seat %%d\", res.WinnerSeat)\n", outcome)
b.WriteString("\t}\n}\n\n")
b.WriteString("The whole game is there too, not just the battle: rep.Game() hands back\n" +
"the exact state the server held, so you can carry on from the shop, replay\n" +
"a different pairing, or read a deck card by card.\n")
return b.String()
}
// writePlayer renders one seat: its banks, then its deck in order.
func writePlayer(b *strings.Builder, p *Player) {
who := "human"
if p.IsBot {
who = fmt.Sprintf("bot %.2f", p.BotLevel)
}
conn := "connected"
if !p.Connected {
conn = "DISCONNECTED"
}
ready := ""
if p.Ready {
ready = " · ready/passed"
}
wins := ""
if len(p.RoundWins) > 0 {
wins = fmt.Sprintf(" (won rounds %v)", p.RoundWins)
}
fmt.Fprintf(b, "\nSeat %d %s (%s, %s)%s\n", p.Seat, p.Name, who, conn, ready)
fmt.Fprintf(b, " %d coins · %d trophies%s · %d pets\n", p.Coins, p.Trophies, wins, p.PetCount())
var banks []string
for _, bank := range []struct {
label string
n int
}{
{"mana", p.Mana}, {"avocados", p.Avocados}, {"trumpets banked", p.PendingTrumpets},
{"apples in play next battle", p.PendingApplesInPlay}, {"apples next round", p.NextRoundApples},
{"buys this round", p.BuysThisRound},
} {
if bank.n != 0 {
banks = append(banks, fmt.Sprintf("%s %d", bank.label, bank.n))
}
}
if p.FirstBuyFree {
banks = append(banks, "first buy free")
}
if p.TripledThisRound {
banks = append(banks, "tripled this round")
}
if len(banks) > 0 {
fmt.Fprintf(b, " %s\n", strings.Join(banks, " · "))
}
if p.ShopPeek != nil {
fmt.Fprintf(b, " peeked at the shop deck: %s\n", cardLine(*p.ShopPeek))
}
fmt.Fprintf(b, " deck, in order (%d):\n", len(p.Deck))
if len(p.Deck) == 0 {
b.WriteString(" (empty)\n")
}
for i, c := range p.Deck {
fmt.Fprintf(b, " %d. %s\n", i+1, cardLine(c))
}
}
// writeShop renders the shop row, what's left in each tier deck, the discards,
// and any choice a player is mid-way through.
func writeShop(b *strings.Builder, g *Game) {
if len(g.ShopRow) == 0 {
b.WriteString(" row: (none — the shop is closed)\n")
}
for i, c := range g.ShopRow {
if c.ID == "" {
fmt.Fprintf(b, " row %d: (bought)\n", i)
continue
}
fmt.Fprintf(b, " row %d: %s\n", i, cardLine(c))
}
for tier, deck := range g.ShopDecks {
fmt.Fprintf(b, " tier %d deck: %d left", tier+1, len(deck))
// The order is the whole point of dumping the deck: it decides what the
// next buy, peek or trade-in turns up.
if len(deck) > 0 {
fmt.Fprintf(b, " — next: %s", cardNames(deck[:min(len(deck), 6)]))
}
b.WriteString("\n")
}
for tier := 1; tier <= MaxRounds; tier++ {
if pile := g.Discards[tier]; len(pile) > 0 {
fmt.Fprintf(b, " tier %d discards: %s\n", tier, cardNames(pile))
}
}
if t := g.Pending; t != nil {
fmt.Fprintf(b, " PENDING trade-in by %s from tier %d: %s | %s\n",
playerName(g, t.PlayerID), t.Tier, cardLine(t.Options[0]), cardLine(t.Options[1]))
}
if rv := g.PendingReveal; rv != nil {
fmt.Fprintf(b, " PENDING reveal by %s (source %s, %d apples), options %v\n",
playerName(g, rv.PlayerID), rv.Source, rv.Apples, rv.Options)
}
if s := g.PendingSacrifice; s != nil {
fmt.Fprintf(b, " PENDING sacrifice by %s (source %s, tier %d), options %v\n",
playerName(g, s.PlayerID), s.Source, s.Tier, s.Options)
}
}
// writeBattle renders one battle: who fought, what they fielded, the dice it
// rolled, every event in order, and how it ended.
func writeBattle(b *strings.Builder, g *Game, i int, res *BattleResult) {
fmt.Fprintf(b, "\n-- battle %d: ", i)
if len(res.Seats) == 2 {
fmt.Fprintf(b, "seat %d (%s, first) v seat %d (%s) --\n",
res.Seats[0], seatName(g, res.Seats[0]), res.Seats[1], seatName(g, res.Seats[1]))
} else {
fmt.Fprintf(b, "seats %v --\n", res.Seats)
}
outcome := "a draw"
if res.WinnerSeat >= 0 {
outcome = fmt.Sprintf("seat %d (%s) won %d trophy(s)",
res.WinnerSeat, seatName(g, res.WinnerSeat), res.Trophies)
}
fmt.Fprintf(b, " outcome: %s · survivors by side %v\n", outcome, res.Survivors)
for side, lineup := range res.Lineups {
fmt.Fprintf(b, " side %d (seat %d) fielded, top of deck first:\n", side, res.SeatOf(side))
for j, c := range lineup {
fmt.Fprintf(b, " %d. %s\n", j+1, cardLine(c))
}
if side < len(res.Inputs) {
in := res.Inputs[side]
fmt.Fprintf(b, " started with: %d mana, %d trumpets, %d apples in play\n",
in.Mana, in.Trumpets, in.ApplesInPlay)
}
}
if res.Replayable() {
fmt.Fprintf(b, " dice tape (%d draws): %v\n", len(res.Draws), res.Draws)
} else {
b.WriteString(" dice tape: NOT RECORDED — this battle was played before the engine\n" +
" recorded its dice, so it can only be re-fought, not replayed\n")
}
fmt.Fprintf(b, " events (%d):\n", len(res.Events))
for j, ev := range res.Events {
fmt.Fprintf(b, " %3d %-9s side %d", j, ev.Type, ev.Seat)
if ev.Card != nil {
fmt.Fprintf(b, " %s", ev.Card.Name)
}
if len(ev.Dice) > 0 {
fmt.Fprintf(b, " dice=%v(%d)", ev.Dice, ev.Roll)
}
if len(ev.Damage) > 0 {
fmt.Fprintf(b, " dmg=%v died=%v", ev.Damage, ev.Died)
}
if ev.Count != 0 {
fmt.Fprintf(b, " count=%d", ev.Count)
}
if ev.Text != "" {
fmt.Fprintf(b, " · %s", ev.Text)
}
b.WriteString("\n")
}
}
// cardLine describes one card in full: what it is, what it does, and the id the
// log and battle events refer to it by.
func cardLine(c Card) string {
var b strings.Builder
b.WriteString(c.Name)
switch {
case c.IsPet():
fmt.Fprintf(&b, " (pet, power %d", c.Power)
if c.Suit != "" {
fmt.Fprintf(&b, ", %s", c.Suit)
}
case c.IsAilment():
fmt.Fprintf(&b, " (ailment %s", c.Ailment)
default:
b.WriteString(" (food")
if c.Food != "" {
fmt.Fprintf(&b, " %s", c.Food)
}
if c.Perk {
b.WriteString(", perk")
}
}
if c.Tier > 0 {
fmt.Fprintf(&b, ", tier %d", c.Tier)
}
if c.Temporary {
b.WriteString(", temporary")
}
fmt.Fprintf(&b, ", id %s)", c.ID)
if c.EffectText != "" {
fmt.Fprintf(&b, " — %s", c.EffectText)
}
return b.String()
}
// cardNames lists cards by name alone, for places where the detail would drown
// the point (deck order, discard piles).
func cardNames(cards []Card) string {
names := make([]string, len(cards))
for i, c := range cards {
names[i] = c.Name
}
return strings.Join(names, ", ")
}
func seatName(g *Game, seat int) string {
if seat < 0 || seat >= len(g.Players) {
return "?"
}
return g.Players[seat].Name
}
func seatList(g *Game, seats []int) string {
if len(seats) == 0 {
return "(none)"
}
names := make([]string, len(seats))
for i, s := range seats {
names[i] = fmt.Sprintf("seat %d (%s)", s, seatName(g, s))
}
return strings.Join(names, ", ")
}
// identifier makes a string safe to paste into a Go function name.
func identifier(s string) string {
var b strings.Builder
for _, r := range strings.ToUpper(s) {
if r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
if b.Len() == 0 || b.String()[0] >= '0' && b.String()[0] <= '9' {
return "Game" + b.String()
}
return b.String()
}
func playerName(g *Game, playerID string) string {
if p := g.PlayerByID(playerID); p != nil {
return fmt.Sprintf("seat %d (%s)", p.Seat, p.Name)
}
return playerID
}
+297
View File
@@ -0,0 +1,297 @@
package game
import (
"encoding/json"
"os"
"strings"
"testing"
)
// eventsJSON renders a result's events for comparison. Two battles that played
// out identically serialize identically, which is exactly the property a
// replayed report has to have.
func eventsJSON(t *testing.T, res *BattleResult) string {
t.Helper()
b, err := json.Marshal(res.Events)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// A report survives a trip through JSON and rebuilds the same game: same decks,
// same shop, same log.
func TestDebugReportRoundTrip(t *testing.T) {
g, p1, p2 := testGame(t)
forceBattle(t, g,
[]Card{g.realPet(t, "Mosquito"), g.pet("Tank", 4)},
[]Card{g.realPet(t, "Dolphin"), g.pet("Wall", 3)},
)
rep, err := g.DebugReport()
if err != nil {
t.Fatal(err)
}
blob, err := rep.JSON()
if err != nil {
t.Fatal(err)
}
back, err := ParseDebugReport(blob)
if err != nil {
t.Fatal(err)
}
if back.Summary.Code != g.Code || back.Summary.Round != g.Round {
t.Fatalf("summary lost the game's identity: %+v", back.Summary)
}
if got, want := len(back.Summary.Seats), len(g.Players); got != want {
t.Fatalf("summary lists %d seats, the table has %d", got, want)
}
if back.Summary.LogEntries != len(g.Log) || back.Summary.LogEntries == 0 {
t.Fatalf("summary says %d log entries, the game has %d", back.Summary.LogEntries, len(g.Log))
}
restored, err := back.Game()
if err != nil {
t.Fatal(err)
}
for _, want := range []*Player{p1, p2} {
got := restored.PlayerByID(want.ID)
if got == nil {
t.Fatalf("seat %d didn't survive the round trip", want.Seat)
}
if len(got.Deck) != len(want.Deck) {
t.Fatalf("seat %d came back with %d cards, had %d", want.Seat, len(got.Deck), len(want.Deck))
}
for i := range want.Deck {
if got.Deck[i].ID != want.Deck[i].ID || got.Deck[i].Name != want.Deck[i].Name {
t.Fatalf("seat %d card %d came back as %+v, was %+v", want.Seat, i, got.Deck[i], want.Deck[i])
}
}
}
if len(restored.Battles) != len(g.Battles) {
t.Fatalf("restored %d battles, the round had %d", len(restored.Battles), len(g.Battles))
}
// Each Game() is a fresh copy: mutating one must not disturb the report.
restored.Players[0].Deck = nil
again, err := back.Game()
if err != nil {
t.Fatal(err)
}
if len(again.Players[0].Deck) == 0 {
t.Fatal("Game() handed out a shared copy — poking one restore emptied the next")
}
}
// Replaying a report's battle reproduces it exactly — the same dice, so the
// same events, in the same order, with the same text. This is the property the
// whole report rests on: whatever the player saw, we see again.
func TestDebugReportReplaysBattleExactly(t *testing.T) {
// No RollDie override: the rocks below roll for real, and only the recorded
// tape can make the replay land on the same faces.
g, _, _ := testGame(t)
original := forceBattle(t, g,
[]Card{g.realPet(t, "Dolphin"), g.realPet(t, "Mosquito"), g.pet("Tank", 4)},
[]Card{g.realPet(t, "Mosquito"), g.pet("Wall", 5), g.realPet(t, "Dolphin")},
)
if len(original.Draws) == 0 {
t.Fatal("a battle full of rocks recorded no dice at all")
}
rep, err := g.DebugReport()
if err != nil {
t.Fatal(err)
}
replay, err := rep.ReplayBattle(0)
if err != nil {
t.Fatal(err)
}
if got, want := eventsJSON(t, replay), eventsJSON(t, original); got != want {
t.Fatalf("the replay diverged from the recording:\n got %s\nwant %s", got, want)
}
if replay.WinnerSeat != original.WinnerSeat {
t.Fatalf("replay winner seat %d, recorded %d", replay.WinnerSeat, original.WinnerSeat)
}
if len(replay.Draws) != len(original.Draws) {
t.Fatalf("replay rolled %d dice, the recording has %d", len(replay.Draws), len(original.Draws))
}
for i, d := range original.Draws {
if replay.Draws[i] != d {
t.Fatalf("draw %d replayed as %d, was %d", i, replay.Draws[i], d)
}
}
// ReplayResult works off the result alone, without the surrounding game —
// the shape of the fight is identical, only the player names differ.
loose := ReplayResult(original)
if loose == nil {
t.Fatal("ReplayResult refused a well-formed result")
}
if loose.WinnerSeat != original.WinnerSeat || len(loose.Events) != len(original.Events) {
t.Fatalf("ReplayResult diverged: winner %d (want %d), %d events (want %d)",
loose.WinnerSeat, original.WinnerSeat, len(loose.Events), len(original.Events))
}
}
// The workflow the report's own instructions describe: save the JSON, load it
// back from disk in a test, replay the battle.
func TestDebugReportFromFile(t *testing.T) {
g, _, _ := testGame(t)
original := forceBattle(t, g,
[]Card{g.realPet(t, "Dolphin"), g.pet("Tank", 4)},
[]Card{g.pet("Wall", 3), g.realPet(t, "Mosquito")},
)
rep, err := g.DebugReport()
if err != nil {
t.Fatal(err)
}
blob, err := rep.JSON()
if err != nil {
t.Fatal(err)
}
path := t.TempDir() + "/" + rep.Filename()
if err := os.WriteFile(path, blob, 0o644); err != nil {
t.Fatal(err)
}
loaded, err := LoadDebugReportFile(path)
if err != nil {
t.Fatal(err)
}
replay, err := loaded.ReplayBattle(0)
if err != nil {
t.Fatal(err)
}
if got, want := eventsJSON(t, replay), eventsJSON(t, original); got != want {
t.Fatalf("a report off disk replayed differently:\n got %s\nwant %s", got, want)
}
if _, err := loaded.ReplayBattle(7); err == nil {
t.Fatal("replaying a battle that isn't in the report should fail, not panic")
}
if _, err := LoadDebugReportFile(path + ".nope"); err == nil {
t.Fatal("loading a missing file should fail")
}
}
// A battle's banked resources are cleared the moment the round ends, so a
// result has to carry them itself or a later replay fights a different battle.
func TestDebugReportReplayRestoresBankedResources(t *testing.T) {
g, p1, _ := testGame(t)
p1.PendingTrumpets = 2
p1.Mana = 3
p1.PendingApplesInPlay = 1
original := forceBattle(t, g,
[]Card{g.pet("Tank", 4)},
[]Card{g.pet("Wall", 3)},
)
if p1.PendingTrumpets != 0 || p1.PendingApplesInPlay != 0 {
// resolveBattles spends the banks; that's what makes recording them
// on the result necessary in the first place.
t.Log("banks cleared by the round, as expected")
}
if in := original.Inputs[0]; in.Trumpets != 2 || in.Mana != 3 || in.ApplesInPlay != 1 {
t.Fatalf("the result didn't record what side 0 brought in: %+v", in)
}
rep, err := g.DebugReport()
if err != nil {
t.Fatal(err)
}
replay, err := rep.ReplayBattle(0)
if err != nil {
t.Fatal(err)
}
if got, want := eventsJSON(t, replay), eventsJSON(t, original); got != want {
t.Fatalf("the replay fought a different battle than the recording:\n got %s\nwant %s", got, want)
}
}
// Games saved before the engine recorded its dice are still in the database,
// and their battles can't be reproduced. Saying so beats handing back a battle
// that quietly differs from the one the player saw.
func TestDebugReportRefusesUnrecordedBattle(t *testing.T) {
g, _, _ := testGame(t)
forceBattle(t, g,
[]Card{g.realPet(t, "Dolphin")},
[]Card{g.pet("Wall", 3)},
)
// Strip the recording, the way a result written by an older build looks.
g.Battles[0].Draws = nil
g.Battles[0].StartCardID = 0
if g.Battles[0].Replayable() {
t.Fatal("a result with no recording claims to be replayable")
}
rep, err := g.DebugReport()
if err != nil {
t.Fatal(err)
}
_, err = rep.ReplayBattle(0)
if err == nil {
t.Fatal("replaying an unrecorded battle should fail loudly, not roll fresh dice")
}
if !strings.Contains(err.Error(), "predates") {
t.Fatalf("the error should explain why it can't be replayed, got %q", err)
}
if !strings.Contains(rep.Text(), "NOT RECORDED") {
t.Fatal("the text report should flag a battle it can't replay")
}
// Re-fighting it is still on offer, with fresh dice and no promises.
if ReplayResult(g.Battles[0]) == nil {
t.Fatal("ReplayResult should still re-fight an unrecorded battle")
}
}
// The raw state blob out of the database is accepted as a report too, so
// anything game-shaped can be replayed.
func TestParseDebugReportAcceptsBareState(t *testing.T) {
g, _, _ := testGame(t)
blob, err := json.Marshal(g)
if err != nil {
t.Fatal(err)
}
rep, err := ParseDebugReport(blob)
if err != nil {
t.Fatal(err)
}
if rep.Summary.Code != g.Code {
t.Fatalf("bare state parsed as game %q, want %q", rep.Summary.Code, g.Code)
}
if _, err := ParseDebugReport([]byte(`{"nothing":"here"}`)); err == nil {
t.Fatal("a JSON object with no game in it should not parse as a report")
}
if _, err := ParseDebugReport([]byte(`not json`)); err == nil {
t.Fatal("garbage should not parse as a report")
}
}
// The text rendering is the artifact a human actually reads, so it has to name
// the cards in play, the log, and the battle — not just summarize.
func TestDebugReportTextCoversTheGame(t *testing.T) {
g, p1, _ := testGame(t)
forceBattle(t, g,
[]Card{g.realPet(t, "Mosquito")},
[]Card{g.pet("Wall", 9)},
)
rep, err := g.DebugReport()
if err != nil {
t.Fatal(err)
}
rep.Note = "the mosquito's rock vanished"
text := rep.Text()
for _, want := range []string{
g.Code, // which game
p1.Name, // who was playing
"Mosquito", // what was on the table
"Play: throw 1 Rock", // and what it was supposed to do
"the mosquito's rock vanished", // the reporter's note
"=== Event log", // the log
"dice tape", // the recording that makes it replayable
"ReplayBattle(0)", // the paste-ready repro
} {
if !strings.Contains(text, want) {
t.Fatalf("the report never mentions %q:\n%s", want, text)
}
}
}
+225 -47
View File
@@ -1,6 +1,7 @@
package game package game
import ( import (
"cmp"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@@ -11,8 +12,8 @@ import (
"strings" "strings"
) )
// Tunable rules. The engine supports any player count >= 2; MinPlayers / // Tunable rules. A game seats an even number of players (2, 4, or 6) so
// MaxPlayers gate when a lobby can start (2 for now, more later). // everyone has an opponent in every round's pairings; see schedule.go.
const ( const (
MaxRounds = 6 MaxRounds = 6
CoinsPerRound = 3 CoinsPerRound = 3
@@ -20,7 +21,7 @@ const (
MaxPets = 5 MaxPets = 5
TradeInCount = 3 TradeInCount = 3
MinPlayers = 2 MinPlayers = 2
MaxPlayers = 2 MaxPlayers = 6
) )
// Phase is the game's top-level state. // Phase is the game's top-level state.
@@ -44,6 +45,9 @@ type Player struct {
Coins int `json:"coins"` Coins int `json:"coins"`
Deck []Card `json:"deck"` Deck []Card `json:"deck"`
Trophies int `json:"trophies"` Trophies int `json:"trophies"`
// RoundWins lists the rounds whose battle this player won, in order. The
// end-of-game tie-break counts back through it from the final round.
RoundWins []int `json:"roundWins,omitempty"`
Ready bool `json:"ready"` // shop passed / arrange submitted / battle acknowledged Ready bool `json:"ready"` // shop passed / arrange submitted / battle acknowledged
Connected bool `json:"connected"` Connected bool `json:"connected"`
// TripledThisRound records whether the player used the Triple (trade-in) // TripledThisRound records whether the player used the Triple (trade-in)
@@ -139,9 +143,10 @@ type PendingSacrifice struct {
type Game struct { type Game struct {
ID string `json:"id"` ID string `json:"id"`
Code string `json:"code"` Code string `json:"code"`
// Pack is the selected card pack (see packs.go). Chosen in the lobby by // Packs are the selected card packs (see packs.go). Chosen in the lobby by
// the host; determines which cards fill the shop decks. // the host; their tier decks shuffle together to fill the shop. Seating
Pack string `json:"pack"` // more than two players requires more than one pack (see PacksNeeded).
Packs []string `json:"packs"`
Phase Phase `json:"phase"` Phase Phase `json:"phase"`
Round int `json:"round"` // 1-based Round int `json:"round"` // 1-based
Players []*Player `json:"players"` Players []*Player `json:"players"`
@@ -152,10 +157,12 @@ type Game struct {
// tier. Chimera and Abomination draw from it. Temporary cards never enter. // tier. Chimera and Abomination draw from it. Temporary cards never enter.
Discards map[int][]Card `json:"discards,omitempty"` Discards map[int][]Card `json:"discards,omitempty"`
Turn int `json:"turn"` // seat with the current shop turn Turn int `json:"turn"` // seat with the current shop turn
// PrioritySeat holds the priority token: that seat shops first each round // PrioritySeat holds the first-shopper token: that seat shops first this
// and wins simultaneity races in battle. Assigned randomly at game start; // round. How it moves depends on the table size. With two players it is
// a battle winner hands it to the loser, a loser keeps it, a draw leaves // also the battle's priority token — assigned randomly at game start, then
// it put. // handed by a winner to the loser (a loser who holds it keeps it, a draw
// leaves it put). With more players it starts at seat A and passes one seat
// along every round, and each battle flips separately for its first player.
PrioritySeat int `json:"prioritySeat"` PrioritySeat int `json:"prioritySeat"`
Pending *PendingTrade `json:"pending,omitempty"` Pending *PendingTrade `json:"pending,omitempty"`
// PendingReveal is an in-progress Cockatoo reveal (Golden pack); it blocks // PendingReveal is an in-progress Cockatoo reveal (Golden pack); it blocks
@@ -164,9 +171,15 @@ type Game struct {
// PendingSacrifice is an in-progress Water of Youth choice (Unicorn pack); // PendingSacrifice is an in-progress Water of Youth choice (Unicorn pack);
// it blocks other shop actions on that seat until resolved, like Pending. // it blocks other shop actions on that seat until resolved, like Pending.
PendingSacrifice *PendingSacrifice `json:"pendingSacrifice,omitempty"` PendingSacrifice *PendingSacrifice `json:"pendingSacrifice,omitempty"`
Battle *BattleResult `json:"battle,omitempty"` // most recent battle // Battles holds the most recent round's battles — one per pairing (see
// schedule.go), so two players produce one and six produce three. They are
// all public: everyone can replay every table.
Battles []*BattleResult `json:"battles,omitempty"`
NextCardID int `json:"nextCardId"` NextCardID int `json:"nextCardId"`
WinnerSeat int `json:"winnerSeat"` // set at gameover; -1 = tie // WinnerSeat is the outright winner at gameover, or -1 when the title is
// shared. WinnerSeats always lists every player holding it (see finish).
WinnerSeat int `json:"winnerSeat"`
WinnerSeats []int `json:"winnerSeats,omitempty"`
// Log is the running, human-readable event log shown across every phase. // Log is the running, human-readable event log shown across every phase.
Log []LogEntry `json:"log,omitempty"` Log []LogEntry `json:"log,omitempty"`
LogSeq int `json:"logSeq"` // last assigned entry sequence number LogSeq int `json:"logSeq"` // last assigned entry sequence number
@@ -174,20 +187,41 @@ type Game struct {
// RollDie overrides the rock die (faces 0,0,1,1,2,2) for tests. Nil // RollDie overrides the rock die (faces 0,0,1,1,2,2) for tests. Nil
// (including after loading from storage) means a fair random roll. // (including after loading from storage) means a fair random roll.
RollDie func() int `json:"-"` RollDie func() int `json:"-"`
// drawTape records every battleDraw the battle currently resolving makes,
// and drawReplay feeds a recording back in. Together they make a finished
// battle exactly reproducible from a debug report (see debug.go). Both are
// scratch state for one battle and are never serialized.
drawTape []int
drawReplay []int
} }
// battleDraw returns a random value in [0, n) for a battle's randomness — rock // battleDraw returns a random value in [0, n) for a battle's randomness — rock
// dice and Komodo's apple shuffle alike. The RollDie test override applies to // dice and Komodo's apple shuffle alike. The RollDie test override applies to
// rock dice (n == 3). // rock dice (n == 3). Every value handed out is appended to the running draw
// tape, which runBattle files with the result so the battle can be replayed.
func (g *Game) battleDraw(n int) int { func (g *Game) battleDraw(n int) int {
switch { if n <= 0 {
case n <= 0:
return 0 return 0
case n == 3 && g.RollDie != nil:
return g.RollDie() // test override applies to rock dice
default:
return randInt(n)
} }
var v int
switch {
case len(g.drawReplay) > 0:
// Replaying a recording: take the next value off the tape. It's folded
// back into range in case the replay diverged onto a differently-sized
// draw, so a stale tape can never panic or roll an illegal face.
v, g.drawReplay = g.drawReplay[0], g.drawReplay[1:]
if v < 0 {
v = -v
}
v %= n
case n == 3 && g.RollDie != nil:
v = g.RollDie() // test override applies to rock dice
default:
v = randInt(n)
}
g.drawTape = append(g.drawTape, v)
return v
} }
// rollRockDie rolls one rock die: 0, 1, or 2 with equal probability. // rollRockDie rolls one rock die: 0, 1, or 2 with equal probability.
@@ -252,7 +286,7 @@ func New() *Game {
g := &Game{ g := &Game{
ID: randomID(16), ID: randomID(16),
Code: randomCode(), Code: randomCode(),
Pack: DefaultPack, Packs: []string{DefaultPack},
Phase: PhaseLobby, Phase: PhaseLobby,
WinnerSeat: -1, WinnerSeat: -1,
} }
@@ -260,6 +294,33 @@ func New() *Game {
return g return g
} }
// gameJSON aliases Game so UnmarshalJSON can decode into it without recursing.
type gameJSON Game
// UnmarshalJSON decodes a persisted game, migrating states written before the
// game supported more than one pack (a single "pack" string) and before a
// round could hold more than one battle (a single "battle" object).
func (g *Game) UnmarshalJSON(data []byte) error {
aux := struct {
*gameJSON
LegacyPack string `json:"pack"`
LegacyBattle *BattleResult `json:"battle"`
}{gameJSON: (*gameJSON)(g)}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
if len(g.Packs) == 0 {
g.Packs = []string{cmp.Or(aux.LegacyPack, DefaultPack)}
}
if len(g.Battles) == 0 && aux.LegacyBattle != nil {
if len(aux.LegacyBattle.Seats) == 0 {
aux.LegacyBattle.Seats = []int{0, 1} // pre-pairing battles were always A vs B
}
g.Battles = []*BattleResult{aux.LegacyBattle}
}
return nil
}
// buildDecks (re)creates and shuffles the shop decks for the current pack. // buildDecks (re)creates and shuffles the shop decks for the current pack.
// Called on creation and whenever the pack changes, so ShopDecks always match // Called on creation and whenever the pack changes, so ShopDecks always match
// g.Pack and are ready the moment the game starts. // g.Pack and are ready the moment the game starts.
@@ -306,20 +367,19 @@ func (g *Game) AddBot(name string, level float64) (*Player, error) {
return p, nil return p, nil
} }
// SetPack changes the game's card pack during the lobby and rebuilds the shop // SetPacks changes the game's card packs during the lobby and rebuilds the
// decks to match. Only playable packs may be selected. // shop decks to match. Only distinct, playable packs may be selected; how many
func (g *Game) SetPack(packID string) error { // are *required* depends on the final player count and is checked at start
// (see StartGame), so the host can pick packs and seats in either order.
func (g *Game) SetPacks(packIDs []string) error {
if g.Phase != PhaseLobby { if g.Phase != PhaseLobby {
return fmt.Errorf("%w: game already started", ErrWrongPhase) return fmt.Errorf("%w: game already started", ErrWrongPhase)
} }
pack, ok := packByID(packID) packs, err := validatePacks(packIDs)
if !ok { if err != nil {
return fmt.Errorf("%w: unknown pack", ErrInvalidAction) return err
} }
if !pack.Playable { g.Packs = packs
return fmt.Errorf("%w: that pack isn't available yet", ErrInvalidAction)
}
g.Pack = pack.ID
g.buildDecks() g.buildDecks()
return nil return nil
} }
@@ -344,23 +404,46 @@ func (g *Game) RemovePlayer(targetID string) error {
return nil return nil
} }
// StartGame begins the match from the lobby once enough players are seated. // StartGame begins the match from the lobby once the seats and packs line up.
// The shop decks are already built for g.Pack (see buildDecks); this just // The shop decks are already built for g.Packs (see buildDecks); this just
// validates and makes the transition. // validates and makes the transition.
func (g *Game) StartGame() error { func (g *Game) StartGame() error {
if g.Phase != PhaseLobby { if g.Phase != PhaseLobby {
return fmt.Errorf("%w: game already started", ErrWrongPhase) return fmt.Errorf("%w: game already started", ErrWrongPhase)
} }
if len(g.Players) < MinPlayers { n := len(g.Players)
if n < MinPlayers {
return fmt.Errorf("%w: need at least %d players to start", ErrInvalidAction, MinPlayers) return fmt.Errorf("%w: need at least %d players to start", ErrInvalidAction, MinPlayers)
} }
if pack, ok := packByID(g.Pack); !ok || !pack.Playable { // Every round pairs players off, so the table has to be even. A lobby with
return fmt.Errorf("%w: that pack isn't available yet", ErrInvalidAction) // an odd number of people fills the empty seat with a bot.
if !ValidPlayerCount(n) {
return fmt.Errorf("%w: %d players can't pair off — play with %s (add or remove a seat)",
ErrInvalidAction, n, joinCounts(PlayerCounts))
}
if _, err := validatePacks(g.Packs); err != nil {
return err
}
if need := PacksNeeded(n); len(g.Packs) < need {
return fmt.Errorf("%w: %d players needs at least %d packs shuffled together (%d selected)",
ErrInvalidAction, n, need, len(g.Packs))
} }
g.start() g.start()
return nil return nil
} }
// joinCounts renders the legal player counts as "2, 4, or 6".
func joinCounts(counts []int) string {
parts := make([]string, len(counts))
for i, c := range counts {
parts[i] = fmt.Sprint(c)
}
if len(parts) < 2 {
return strings.Join(parts, "")
}
return strings.Join(parts[:len(parts)-1], ", ") + ", or " + parts[len(parts)-1]
}
// PlayerByID returns the player, or nil. // PlayerByID returns the player, or nil.
func (g *Game) PlayerByID(id string) *Player { func (g *Game) PlayerByID(id string) *Player {
for _, p := range g.Players { for _, p := range g.Players {
@@ -373,8 +456,16 @@ func (g *Game) PlayerByID(id string) *Player {
func (g *Game) start() { func (g *Game) start() {
g.Round = 1 g.Round = 1
// The priority token starts with a random seat. // Two players share one token for both jobs, and it starts on a random
// seat. With more players the first-shopper token is a separate thing that
// simply starts at seat A and walks the table (see startShopRound), while
// each battle flips for its own first player.
if len(g.Players) == 2 {
g.PrioritySeat = randInt(len(g.Players)) g.PrioritySeat = randInt(len(g.Players))
} else {
g.PrioritySeat = 0
}
g.logf(-1, "🎴", "Game on — %d players, %s.", len(g.Players), PackNames(g.Packs))
g.startShopRound() g.startShopRound()
} }
@@ -385,6 +476,12 @@ func (g *Game) startShopRound() {
g.Pending = nil g.Pending = nil
g.PendingReveal = nil g.PendingReveal = nil
g.PendingSacrifice = nil g.PendingSacrifice = nil
// With more than two players the first-shopper token starts on seat A and
// passes one seat along at the end of every round. (At two players it
// instead follows the battle results — see finalizeBattle.)
if len(g.Players) > 2 {
g.PrioritySeat = (g.Round - 1) % len(g.Players)
}
for _, p := range g.Players { for _, p := range g.Players {
p.Coins = CoinsPerRound p.Coins = CoinsPerRound
p.Ready = false p.Ready = false
@@ -410,12 +507,36 @@ func (g *Game) startShopRound() {
for _, c := range slices.Clone(p.Deck) { for _, c := range slices.Clone(p.Deck) {
g.applyShopTrigger(p, c, TriggerShopStart) g.applyShopTrigger(p, c, TriggerShopStart)
} }
g.sortShopDeck(p)
} }
// The priority-token holder shops first. // The priority-token holder shops first.
g.Turn = g.PrioritySeat g.Turn = g.PrioritySeat
g.logf(-1, "🛒", "Round %d — shop opens (%s goes first).", g.Round, g.Players[g.PrioritySeat].Name) g.logf(-1, "🛒", "Round %d — shop opens (%s goes first).", g.Round, g.Players[g.PrioritySeat].Name)
} }
// sortShopDeck stably reorders a player's deck for the shop's default
// display: pets first, then perk foods, then everything else (apples and
// other loose foods). Deck order carries no battle meaning during the shop —
// the battle uses the permutation each player submits in the arrange phase
// (see SubmitOrder) — so this is purely a cosmetic default that keeps freshly
// bought pets grouped at the front. The stable sort preserves buy order
// within each category.
func (g *Game) sortShopDeck(p *Player) {
rank := func(c Card) int {
switch {
case c.IsPet():
return 0
case c.IsFood() && c.Perk:
return 1
default:
return 2
}
}
slices.SortStableFunc(p.Deck, func(a, b Card) int {
return rank(a) - rank(b)
})
}
// drawFromTier pops the top card of the given tier's deck (1-based tier). // drawFromTier pops the top card of the given tier's deck (1-based tier).
// Returns a zero Card if the deck is empty. // Returns a zero Card if the deck is empty.
func (g *Game) drawFromTier(tier int) Card { func (g *Game) drawFromTier(tier int) Card {
@@ -1011,6 +1132,10 @@ func (g *Game) Pass(playerID string) error {
// the shop, and it requires being at the pet limit, so no cleanup step is // the shop, and it requires being at the pet limit, so no cleanup step is
// needed here. // needed here.
func (g *Game) advanceShopTurn() { func (g *Game) advanceShopTurn() {
// The player who just acted may have changed their deck (bought, sold,
// traded, or triggered an apple-granting effect); keep it in the shop's
// default order for them before handing off.
g.sortShopDeck(g.Players[g.Turn])
for i := 1; i <= len(g.Players); i++ { for i := 1; i <= len(g.Players); i++ {
seat := (g.Turn + i) % len(g.Players) seat := (g.Turn + i) % len(g.Players)
if !g.Players[seat].Ready { if !g.Players[seat].Ready {
@@ -1075,13 +1200,32 @@ func (g *Game) SubmitOrder(playerID string, orderedIDs []string) error {
return nil return nil
} }
// startBattle enters the battle phase and resolves it. // startBattle enters the battle phase and resolves every pairing in it.
func (g *Game) startBattle() { func (g *Game) startBattle() {
for _, p := range g.Players { for _, p := range g.Players {
p.Ready = false p.Ready = false
} }
g.Phase = PhaseBattle g.Phase = PhaseBattle
g.resolveBattle() g.resolveBattles()
}
// seatName is a seat's display name, for log text.
func (g *Game) seatName(seat int) string {
if seat < 0 || seat >= len(g.Players) {
return "nobody"
}
return g.Players[seat].Name
}
// BattleFor returns the battle the given seat fought in the current round, or
// nil if there isn't one.
func (g *Game) BattleFor(seat int) *BattleResult {
for _, b := range g.Battles {
if b.Has(seat) {
return b
}
}
return nil
} }
// AcknowledgeBattle marks the player done reviewing the battle. When all // AcknowledgeBattle marks the player done reviewing the battle. When all
@@ -1111,21 +1255,55 @@ func (g *Game) AcknowledgeBattle(playerID string) error {
return nil return nil
} }
// finish ends the game and decides the title. Most trophies wins. Ties are
// broken by counting back through the rounds as the rulebook asks: "if only
// one of the tied players won round 6, they are the winner. If still tied,
// look to round 5, etc." Players still level after every round has been
// considered had identical records and share the victory.
func (g *Game) finish() { func (g *Game) finish() {
g.Phase = PhaseGameOver g.Phase = PhaseGameOver
best, bestSeat, tie := -1, -1, false best := -1
for _, p := range g.Players { for _, p := range g.Players {
switch { best = max(best, p.Trophies)
case p.Trophies > best: }
best, bestSeat, tie = p.Trophies, p.Seat, false var tied []*Player
case p.Trophies == best: for _, p := range g.Players {
tie = true if p.Trophies == best {
tied = append(tied, p)
} }
} }
if tie { for round := MaxRounds; round > 0 && len(tied) > 1; round-- {
var won []*Player
for _, p := range tied {
if slices.Contains(p.RoundWins, round) {
won = append(won, p)
}
}
// A round only separates them if it split the field: if every remaining
// contender won it (or none did), it says nothing and we count back further.
if len(won) > 0 && len(won) < len(tied) {
tied = won
}
}
g.WinnerSeats = make([]int, len(tied))
for i, p := range tied {
g.WinnerSeats[i] = p.Seat
}
slices.Sort(g.WinnerSeats)
// WinnerSeat names an outright winner only; a shared title reads as -1.
g.WinnerSeat = -1 g.WinnerSeat = -1
} else { if len(g.WinnerSeats) == 1 {
g.WinnerSeat = bestSeat g.WinnerSeat = g.WinnerSeats[0]
}
switch len(tied) {
case 1:
g.logf(tied[0].Seat, "👑", "%s wins the game with %d🏆!", tied[0].Name, best)
default:
names := make([]string, len(tied))
for i, p := range tied {
names[i] = p.Name
}
g.logf(-1, "🤝", "%s share the victory with %d🏆 each.", strings.Join(names, " and "), best)
} }
} }
+12 -4
View File
@@ -33,8 +33,16 @@ func TestLobbyManualStart(t *testing.T) {
if _, err := g.AddPlayer("Bob"); err != nil { if _, err := g.AddPlayer("Bob"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := g.AddPlayer("Carol"); err == nil { // An odd table can't pair off, so a third player has to be matched by a
t.Fatal("third player should be rejected while MaxPlayers=2") // fourth (or removed) before the host can start.
if _, err := g.AddPlayer("Carol"); err != nil {
t.Fatal(err)
}
if err := g.StartGame(); err == nil {
t.Fatal("start should be rejected with an odd number of players")
}
if err := g.RemovePlayer(g.Players[2].ID); err != nil {
t.Fatal(err)
} }
// The lobby stays open until the host explicitly starts. // The lobby stays open until the host explicitly starts.
if g.Phase != PhaseLobby { if g.Phase != PhaseLobby {
@@ -503,11 +511,11 @@ func TestFullGameFlow(t *testing.T) {
if g.Phase != PhaseBattle { if g.Phase != PhaseBattle {
t.Fatalf("expected battle after both arrange, got %s", g.Phase) t.Fatalf("expected battle after both arrange, got %s", g.Phase)
} }
if g.Battle.WinnerSeat != p1.Seat { if g.Battles[0].WinnerSeat != p1.Seat {
t.Fatalf("round %d: seat 0 should win", round) t.Fatalf("round %d: seat 0 should win", round)
} }
// The winner hands the token to the loser; a loser keeps it. // The winner hands the token to the loser; a loser keeps it.
if wantPriority == g.Battle.WinnerSeat { if wantPriority == g.Battles[0].WinnerSeat {
wantPriority = (wantPriority + 1) % len(g.Players) wantPriority = (wantPriority + 1) % len(g.Players)
} }
for _, p := range g.Players { for _, p := range g.Players {
+1 -1
View File
@@ -10,7 +10,7 @@ import "testing"
func goldenGame(t *testing.T) (*Game, *Player, *Player) { func goldenGame(t *testing.T) (*Game, *Player, *Player) {
t.Helper() t.Helper()
g := New() g := New()
g.Pack = "golden" g.Packs = []string{"golden"}
g.buildDecks() g.buildDecks()
p1, err := g.AddPlayer("Alice") p1, err := g.AddPlayer("Alice")
if err != nil { if err != nil {
+415
View File
@@ -0,0 +1,415 @@
package game
import (
"fmt"
"slices"
"testing"
)
// pairKey names an unordered pairing, so a schedule can be checked for repeats
// regardless of which seat the book printed first.
func pairKey(m Matchup) string {
a, b := min(m[0], m[1]), max(m[0], m[1])
return fmt.Sprintf("%d-%d", a, b)
}
// TestPairingTablesAreWellFormed checks the transcribed rulebook tables against
// the properties they must have: every round seats everyone exactly once, and
// the opening rounds run a true round-robin (three rounds cover all six pairs
// at four players; five rounds cover all fifteen at six) before the schedule
// starts replaying earlier rounds to fill out the six.
func TestPairingTablesAreWellFormed(t *testing.T) {
for _, players := range PlayerCounts {
for round := 1; round <= MaxRounds; round++ {
ms := Pairings(players, round)
if len(ms) != players/2 {
t.Fatalf("%dp round %d: got %d battles, want %d", players, round, len(ms), players/2)
}
seen := map[int]bool{}
for _, m := range ms {
for _, seat := range m {
if seat < 0 || seat >= players {
t.Fatalf("%dp round %d: seat %d out of range", players, round, seat)
}
if seen[seat] {
t.Fatalf("%dp round %d: seat %d fights twice", players, round, seat)
}
seen[seat] = true
}
if m[0] == m[1] {
t.Fatalf("%dp round %d: seat %d paired with itself", players, round, m[0])
}
}
}
// The round-robin prefix: enough rounds to pair everyone with everyone,
// with no pairing used twice along the way.
robin := players - 1
if players == 2 {
robin = 1
}
distinct := map[string]bool{}
for round := 1; round <= robin; round++ {
for _, m := range Pairings(players, round) {
key := pairKey(m)
if distinct[key] {
t.Errorf("%dp: pairing %s repeats inside the first %d rounds", players, key, robin)
}
distinct[key] = true
}
}
if want := players * (players - 1) / 2; players > 2 && len(distinct) != want {
t.Errorf("%dp: first %d rounds cover %d pairings, want all %d", players, robin, len(distinct), want)
}
}
}
// TestOpponentOfMatchesPairings checks the seat-to-opponent lookup agrees with
// the table it reads, in both directions, for every seat and round.
func TestOpponentOfMatchesPairings(t *testing.T) {
for _, players := range PlayerCounts {
for round := 1; round <= MaxRounds; round++ {
for seat := range players {
opp := OpponentOf(players, round, seat)
if opp < 0 {
t.Fatalf("%dp round %d: seat %d has no opponent", players, round, seat)
}
if back := OpponentOf(players, round, opp); back != seat {
t.Errorf("%dp round %d: seat %d fights %d, but %d fights %d",
players, round, seat, opp, opp, back)
}
}
}
}
if got := OpponentOf(3, 1, 0); got != -1 {
t.Errorf("an unplayable table should have no pairings, got opponent %d", got)
}
}
// TestCombinedPacksShuffleTogether checks the rulebook's multi-pack rule: the
// packs' tier decks merge into one deck per tier, so a combined game's tier 1
// holds exactly the tier 1 cards of every pack chosen.
func TestCombinedPacksShuffleTogether(t *testing.T) {
sizeOf := func(packs ...string) []int {
g := &Game{Packs: packs}
g.buildShopDecks()
sizes := make([]int, MaxRounds)
for i, d := range g.ShopDecks {
sizes[i] = len(d)
}
return sizes
}
turtle, golden := sizeOf("turtle"), sizeOf("golden")
both := sizeOf("turtle", "golden")
for tier := range MaxRounds {
if want := turtle[tier] + golden[tier]; both[tier] != want {
t.Errorf("tier %d of the combined decks holds %d cards, want %d+%d=%d",
tier+1, both[tier], turtle[tier], golden[tier], want)
}
}
// Both packs' cards really are in the same deck, and every card is a
// distinct instance — two packs means two of everything, not shared IDs.
g := &Game{Packs: []string{"turtle", "golden"}}
g.buildShopDecks()
names, ids := map[string]bool{}, map[string]bool{}
for _, deck := range g.ShopDecks {
for _, c := range deck {
names[c.Name] = true
if ids[c.ID] {
t.Fatalf("duplicate card id %q across the combined decks", c.ID)
}
ids[c.ID] = true
}
}
for _, want := range []string{"Ant", "Cricket", "Groundhog", "Bulldog"} {
if !names[want] {
t.Errorf("combined Turtle+Golden decks are missing %s", want)
}
}
}
// TestStartGameRequiresEvenTableAndEnoughPacks pins the lobby rules: play
// happens in pairs, so the table must be even, and the rulebook asks for one
// pack per pair.
func TestStartGameRequiresEvenTableAndEnoughPacks(t *testing.T) {
newLobby := func(t *testing.T, players int, packs ...string) *Game {
t.Helper()
g := New()
if err := g.SetPacks(packs); err != nil {
t.Fatal(err)
}
for i := range players {
if _, err := g.AddPlayer(fmt.Sprintf("P%d", i)); err != nil {
t.Fatal(err)
}
}
return g
}
if err := newLobby(t, 3, "turtle", "golden").StartGame(); err == nil {
t.Error("three players can't pair off and should not start")
}
if err := newLobby(t, 5, "turtle", "golden", "unicorn").StartGame(); err == nil {
t.Error("five players can't pair off and should not start")
}
if err := newLobby(t, 4, "turtle").StartGame(); err == nil {
t.Error("four players on a single pack should not start")
}
if err := newLobby(t, 6, "turtle", "golden").StartGame(); err == nil {
t.Error("six players on two packs should not start")
}
if err := newLobby(t, 4, "turtle", "golden").StartGame(); err != nil {
t.Errorf("four players on two packs should start: %v", err)
}
if err := newLobby(t, 6, "turtle", "golden", "unicorn").StartGame(); err != nil {
t.Errorf("six players on three packs should start: %v", err)
}
// Two players may still combine packs if they want a deeper shop.
if err := newLobby(t, 2, "turtle", "unicorn").StartGame(); err != nil {
t.Errorf("two players should be free to combine packs: %v", err)
}
g := New()
if err := g.SetPacks([]string{"turtle", "turtle"}); err == nil {
t.Error("the same pack twice should be rejected")
}
if err := g.SetPacks(nil); err == nil {
t.Error("an empty pack selection should be rejected")
}
}
// TestMaxPlayersCapacity checks the lobby fills to six seats and no further.
func TestMaxPlayersCapacity(t *testing.T) {
g := New()
for i := range MaxPlayers {
if _, err := g.AddPlayer(fmt.Sprintf("P%d", i)); err != nil {
t.Fatalf("seating player %d: %v", i, err)
}
}
if _, err := g.AddPlayer("one too many"); err == nil {
t.Errorf("a %dth player should be turned away", MaxPlayers+1)
}
}
// startMulti builds a running game with the given number of seats, enough
// packs to cover it, and every player holding one plain pet so battles resolve.
func startMulti(t *testing.T, players int) *Game {
t.Helper()
g := New()
packs := []string{"turtle", "golden", "unicorn"}[:PacksNeeded(players)]
if err := g.SetPacks(packs); err != nil {
t.Fatal(err)
}
for i := range players {
if _, err := g.AddPlayer(fmt.Sprintf("P%d", i)); err != nil {
t.Fatal(err)
}
}
if err := g.StartGame(); err != nil {
t.Fatal(err)
}
return g
}
// playRound walks a started game through one full round: everyone passes the
// shop, submits their deck as-is, and acknowledges the battles.
func playRound(t *testing.T, g *Game) {
t.Helper()
for g.Phase == PhaseShop {
p := g.Players[g.Turn]
if err := g.Pass(p.ID); err != nil {
t.Fatalf("round %d: %s could not pass: %v", g.Round, p.Name, err)
}
}
if g.Phase != PhaseArrange {
t.Fatalf("round %d: shop should hand off to arrange, got %s", g.Round, g.Phase)
}
for _, p := range g.Players {
ids := make([]string, len(p.Deck))
for i, c := range p.Deck {
ids[i] = c.ID
}
if err := g.SubmitOrder(p.ID, ids); err != nil {
t.Fatalf("round %d: %s could not submit: %v", g.Round, p.Name, err)
}
}
if g.Phase != PhaseBattle {
t.Fatalf("round %d: arrange should hand off to battle, got %s", g.Round, g.Phase)
}
for _, p := range g.Players {
if err := g.AcknowledgeBattle(p.ID); err != nil {
t.Fatalf("round %d: %s could not acknowledge: %v", g.Round, p.Name, err)
}
}
}
// TestMultiplayerRoundFightsEveryPairing plays 4- and 6-player games end to end
// and checks each round resolves exactly the scheduled battles, that every
// player is in exactly one of them, and that the trophies handed out match the
// results recorded.
func TestMultiplayerRoundFightsEveryPairing(t *testing.T) {
for _, players := range []int{4, 6} {
t.Run(fmt.Sprintf("%dp", players), func(t *testing.T) {
g := startMulti(t, players)
awarded := make([]int, players)
for round := 1; round <= MaxRounds; round++ {
if g.Round != round {
t.Fatalf("expected round %d, got %d", round, g.Round)
}
want := Pairings(players, round)
playRound(t, g)
if len(g.Battles) != len(want) {
t.Fatalf("round %d resolved %d battles, want %d", round, len(g.Battles), len(want))
}
fought := map[int]bool{}
for i, b := range g.Battles {
if len(b.Seats) != 2 {
t.Fatalf("round %d battle %d has %d seats", round, i, len(b.Seats))
}
if got, wantKey := pairKey(Matchup{b.Seats[0], b.Seats[1]}), pairKey(want[i]); got != wantKey {
t.Errorf("round %d battle %d paired %s, want %s", round, i, got, wantKey)
}
for _, seat := range b.Seats {
if fought[seat] {
t.Errorf("round %d: seat %d fought twice", round, seat)
}
fought[seat] = true
}
if b.WinnerSeat >= 0 {
if !b.Has(b.WinnerSeat) {
t.Errorf("round %d: winner seat %d wasn't in the battle", round, b.WinnerSeat)
}
awarded[b.WinnerSeat] += b.Trophies
}
}
if len(fought) != players {
t.Errorf("round %d: %d of %d players fought", round, len(fought), players)
}
}
if g.Phase != PhaseGameOver {
t.Fatalf("game should be over after %d rounds, got %s", MaxRounds, g.Phase)
}
for _, p := range g.Players {
if p.Trophies != awarded[p.Seat] {
t.Errorf("%s holds %d trophies, but won %d", p.Name, p.Trophies, awarded[p.Seat])
}
if len(p.RoundWins) != countWins(g, p.Seat) {
t.Errorf("%s recorded %d round wins, want %d", p.Name, len(p.RoundWins), countWins(g, p.Seat))
}
}
})
}
}
// countWins is an independent tally of a seat's round wins, read back off the
// event log rather than the player record it is checking.
func countWins(g *Game, seat int) int {
n := 0
for _, e := range g.Log {
if e.Kind == LogResult && e.Seat == seat {
n++
}
}
return n
}
// TestFirstShopperTokenWalksTheTable checks the multiplayer shop order: the
// token starts on seat A and passes one seat along at the end of every round,
// and the round's shopping starts with whoever holds it.
func TestFirstShopperTokenWalksTheTable(t *testing.T) {
g := startMulti(t, 4)
for round := 1; round <= MaxRounds; round++ {
want := (round - 1) % len(g.Players)
if g.PrioritySeat != want {
t.Errorf("round %d: first shopper is seat %d, want %d", round, g.PrioritySeat, want)
}
if g.Turn != want {
t.Errorf("round %d: shopping starts at seat %d, want %d", round, g.Turn, want)
}
playRound(t, g)
}
}
// TestTieBreakCountsBackFromTheLastRound pins the rulebook's tie-break: level
// on trophies, the title goes to whoever won the latest round that separates
// them; identical records share it.
func TestTieBreakCountsBackFromTheLastRound(t *testing.T) {
// finishWith runs finish() over a table whose trophies and round wins are
// set directly, which is the only state the tie-break reads.
finishWith := func(records ...[]int) *Game {
g := &Game{Phase: PhaseBattle, Round: MaxRounds, WinnerSeat: -1}
for i, wins := range records {
trophies := 0
for _, r := range wins {
trophies++
if r == MaxRounds {
trophies++ // the final round is worth double
}
}
g.Players = append(g.Players, &Player{
Name: string(rune('A' + i)), Seat: i, Trophies: trophies, RoundWins: wins,
})
}
g.finish()
return g
}
// Different trophy counts need no tie-break at all.
if g := finishWith([]int{1, 2}, []int{3}); g.WinnerSeat != 0 {
t.Errorf("most trophies should win outright, got seat %d", g.WinnerSeat)
}
// Level on trophies: seat 1 took the final round, so it takes the title.
g := finishWith([]int{1, 2, 3}, []int{1, 2, MaxRounds})
if g.WinnerSeat != 1 {
t.Errorf("the round-%d winner should break the tie, got seat %d", MaxRounds, g.WinnerSeat)
}
// Neither won the last round, so the countback keeps going: both won round
// 3, which separates nobody, and round 2 decides it.
g = finishWith([]int{2, 3}, []int{1, 3})
if g.WinnerSeat != 0 {
t.Errorf("countback should reach round 2 and pick seat 0, got seat %d", g.WinnerSeat)
}
// Identical records share the victory.
g = finishWith([]int{1, 3}, []int{1, 3}, []int{2})
if g.WinnerSeat != -1 {
t.Errorf("an unbreakable tie should have no outright winner, got seat %d", g.WinnerSeat)
}
if want := []int{0, 1}; !slices.Equal(g.WinnerSeats, want) {
t.Errorf("shared victory listed %v, want %v", g.WinnerSeats, want)
}
}
// TestViewShowsEveryTableButKeepsSecrets checks a player's view of a six-player
// round: all three battles are public and replayable, their own is singled out,
// and nobody else's hand leaks.
func TestViewShowsEveryTableButKeepsSecrets(t *testing.T) {
g := startMulti(t, 6)
playRound(t, g)
me := g.Players[2]
v := g.ViewFor(me.ID)
if len(v.Battles) != 3 {
t.Fatalf("view shows %d battles, want all 3", len(v.Battles))
}
if v.Battle == nil || !v.Battle.Has(me.Seat) {
t.Fatal("the view should single out the battle the viewer fought")
}
for _, b := range v.Battles {
if len(b.Lineups) != 2 {
t.Errorf("a battle result should carry both sides' lineups, got %d", len(b.Lineups))
}
}
for _, pv := range v.Players {
if pv.Seat != me.Seat && pv.Deck != nil {
t.Errorf("seat %d's hand leaked into seat %d's view", pv.Seat, me.Seat)
}
}
// The pairings are printed in the rulebook, so they're public.
if v.YourOpponent != OpponentOf(6, v.Round, me.Seat) {
t.Errorf("view names opponent %d, schedule says %d", v.YourOpponent, OpponentOf(6, v.Round, me.Seat))
}
}
+75
View File
@@ -1,5 +1,10 @@
package game package game
import (
"fmt"
"strings"
)
// Card packs are the selectable sets of pets and food a game is played with. // Card packs are the selectable sets of pets and food a game is played with.
// Turtle, Golden, and Unicorn all ship with full six-tier card data. // Turtle, Golden, and Unicorn all ship with full six-tier card data.
@@ -31,3 +36,73 @@ func packByID(id string) (PackInfo, bool) {
} }
return PackInfo{}, false return PackInfo{}, false
} }
// PacksNeeded is how many packs a game must combine to seat n players: the
// rulebook asks for at least 2 packs at 4 players and 3 at 6, i.e. one per
// pair. More packs than the minimum are always allowed — a deeper shop just
// means fewer repeated pets.
func PacksNeeded(players int) int {
if players < MinPlayers {
return 1
}
return players / 2
}
// sortPacks puts a pack selection into catalog order, so the same choice
// always reads the same way in views and logs.
func sortPacks(ids []string) []string {
out := make([]string, 0, len(ids))
for _, p := range Packs {
for _, id := range ids {
if id == p.ID {
out = append(out, p.ID)
break
}
}
}
return out
}
// validatePacks checks a pack selection: non-empty, known, playable, and free
// of duplicates. It returns the selection in catalog order.
func validatePacks(ids []string) ([]string, error) {
if len(ids) == 0 {
return nil, fmt.Errorf("%w: pick at least one pack", ErrInvalidAction)
}
seen := map[string]bool{}
for _, id := range ids {
pack, ok := packByID(id)
if !ok {
return nil, fmt.Errorf("%w: unknown pack %q", ErrInvalidAction, id)
}
if !pack.Playable {
return nil, fmt.Errorf("%w: the %s isn't available yet", ErrInvalidAction, pack.Name)
}
if seen[id] {
return nil, fmt.Errorf("%w: %s is selected twice", ErrInvalidAction, pack.Name)
}
seen[id] = true
}
return sortPacks(ids), nil
}
// PackNames renders a pack selection as a readable list ("Turtle Pack and
// Golden Pack") for log lines.
func PackNames(ids []string) string {
names := make([]string, 0, len(ids))
for _, id := range ids {
if p, ok := packByID(id); ok {
names = append(names, p.Name)
}
}
switch len(names) {
case 0:
return "no packs"
case 1:
return names[0]
case 2:
return names[0] + " and " + names[1]
default:
return strings.Join(names[:len(names)-1], ", ") + ", and " + names[len(names)-1]
}
}
+22
View File
@@ -24,3 +24,25 @@ func TestCrocodileLastPetVolleyFiresWhenOwnerOut(t *testing.T) {
t.Fatalf("both sides should be out (draw), got winner %d", res.WinnerSeat) t.Fatalf("both sides should be out (draw), got winner %d", res.WinnerSeat)
} }
} }
// Regression: a pair that can't hurt each other (two 0-attack pets, here
// Spooked down to nothing) must both faint so the pets behind them settle the
// battle. Previously the stalemate ended the whole battle in a draw, even with
// most of both decks still to come.
func TestStalematedPairFaintsAndBattleContinues(t *testing.T) {
g, p1, _ := unicornGame(t)
p1.Mana = 2
res := forceBattle(t, g,
// Nightcrawler (1 power) Spooks Barghest twice; Barghest (1 power)
// Spooks it once. Neither can deal damage, but Pengobble is next up.
[]Card{g.unicornPet(t, "Nightcrawler"), g.unicornPet(t, "Pengobble")},
[]Card{g.unicornPet(t, "Barghest"), g.pet("Chaff", 1)},
)
clashes := eventsOfType(res, "clash")
if len(clashes) != 1 || !clashes[0].Died[0] || !clashes[0].Died[1] {
t.Fatalf("the stuck pair should both faint in one clash: %+v", clashes)
}
if res.WinnerSeat != 0 {
t.Fatalf("Pengobble should go on to win for seat 0, got winner %d", res.WinnerSeat)
}
}
+87
View File
@@ -0,0 +1,87 @@
package game
// Battle pairings for multiplayer games ("4 & 6 Player Mode" in the rulebook).
//
// Every round, players split into pairs and each pair fights its own battle.
// The pairings are a fixed table printed in the book — seats are lettered
// A, B, C… in seat order, so seat 0 is A. Both tables are transcribed
// literally rather than generated: each is a round-robin that runs out of
// fresh pairings before six rounds are up (three rounds exhaust four players,
// five rounds exhaust six), and the book's choice of which earlier rounds to
// replay for the remainder is a decision, not something a generator would
// reproduce.
// Matchup is one battle: the two seats that fight it. The order is the printed
// order and carries no meaning on its own — the first player of each battle is
// decided separately (a coin flip; see Game.startBattles).
type Matchup [2]int
// pairingTables maps a player count to its per-round pairings, indexed by
// round-1. Two players is the degenerate case: one pairing, every round.
var pairingTables = map[int][MaxRounds][]Matchup{
2: {
{{0, 1}}, // R1 A/B
{{0, 1}}, // R2 A/B
{{0, 1}}, // R3 A/B
{{0, 1}}, // R4 A/B
{{0, 1}}, // R5 A/B
{{0, 1}}, // R6 A/B
},
4: {
{{0, 1}, {2, 3}}, // R1 A/B C/D
{{0, 2}, {1, 3}}, // R2 A/C B/D
{{0, 3}, {1, 2}}, // R3 A/D B/C
{{0, 1}, {2, 3}}, // R4 A/B C/D
{{0, 2}, {1, 3}}, // R5 A/C B/D
{{0, 3}, {1, 2}}, // R6 A/D B/C
},
6: {
{{0, 1}, {2, 3}, {4, 5}}, // R1 A/B C/D E/F
{{0, 2}, {1, 4}, {3, 5}}, // R2 A/C B/E D/F
{{0, 3}, {1, 5}, {2, 4}}, // R3 A/D B/F C/E
{{0, 4}, {1, 3}, {2, 5}}, // R4 A/E B/D C/F
{{0, 5}, {1, 2}, {3, 4}}, // R5 A/F B/C D/E
{{0, 1}, {2, 3}, {4, 5}}, // R6 A/B C/D E/F
},
}
// PlayerCounts lists the player counts a game can be played at, in order. The
// game needs an even number of players so everyone has an opponent every
// round; a lobby with an odd number of humans fills the gap with bots.
var PlayerCounts = []int{2, 4, 6}
// ValidPlayerCount reports whether n players can start a game.
func ValidPlayerCount(n int) bool {
_, ok := pairingTables[n]
return ok
}
// Pairings returns the battles for a round (1-based) at the given player
// count, or nil if either is out of range. The returned slice is shared table
// data — treat it as read-only.
func Pairings(players, round int) []Matchup {
table, ok := pairingTables[players]
if !ok || round < 1 || round > MaxRounds {
return nil
}
return table[round-1]
}
// OpponentOf returns the seat that `seat` fights in the given round, or -1 if
// it has no battle (which the fixed tables never produce for a valid count).
func OpponentOf(players, round, seat int) int {
for _, m := range Pairings(players, round) {
switch seat {
case m[0]:
return m[1]
case m[1]:
return m[0]
}
}
return -1
}
// Pairings returns this round's battle pairings for the game's player count.
func (g *Game) Pairings() []Matchup {
return Pairings(len(g.Players), g.Round)
}
+65 -15
View File
@@ -1,14 +1,16 @@
package game package game
// SimulateBattle resolves a hypothetical two-player battle between the given import "fmt"
// arranged decks (top of deck first) and returns the result. It runs on a
// scratch game, so it never touches real state — callers (notably the AI // SimulateBattle resolves a hypothetical battle between the given arranged
// player) can roll out as many what-if battles as they like. Dice rolls are // decks (top of deck first) and returns the result. deckA sits at seat 0 and
// random unless rollDie is non-nil. // deckB at seat 1; firstSeat (0 or 1) is the one holding priority. It runs on
func SimulateBattle(round, prioritySeat int, deckA, deckB []Card, rollDie func() int) *BattleResult { // a scratch game and awards nothing, so it never touches real state — callers
// (notably the AI player) can roll out as many what-if battles as they like.
// Dice rolls are random unless rollDie is non-nil.
func SimulateBattle(round, firstSeat int, deckA, deckB []Card, rollDie func() int) *BattleResult {
g := &Game{ g := &Game{
Round: round, Round: round,
PrioritySeat: prioritySeat,
RollDie: rollDie, RollDie: rollDie,
// Cards minted during the simulation (apples, bees) get IDs far away // Cards minted during the simulation (apples, bees) get IDs far away
// from real ones, purely to avoid confusion when reading results. // from real ones, purely to avoid confusion when reading results.
@@ -18,22 +20,70 @@ func SimulateBattle(round, prioritySeat int, deckA, deckB []Card, rollDie func()
{Name: "B", Seat: 1, Deck: append([]Card(nil), deckB...)}, {Name: "B", Seat: 1, Deck: append([]Card(nil), deckB...)},
}, },
} }
g.startBattle() if firstSeat == 1 {
return g.Battle return g.runBattle(1, 0)
}
return g.runBattle(0, 1)
}
// ReplayResult re-runs a recorded battle from the result it produced. A
// BattleResult carries everything the fight started from — both lineups, each
// side's banked Mana/Trumpets/apples, and the tape of every die it rolled — so
// replaying one reproduces it move for move. That is what makes a debug report
// reproducible: drop the report in a test, replay the battle, and step through
// the same fight the player saw.
//
// The two sides keep their real seat numbers, so WinnerSeat means what it did
// in the original. Player names are not part of a result, though, so they come
// back as "Seat N" and every event's Text reads accordingly — compare
// structure, not prose. DebugReport.ReplayBattle, which has the real game to
// replay against, reproduces the text too. Returns nil for a malformed result.
func ReplayResult(res *BattleResult) *BattleResult {
if res == nil || len(res.Seats) != 2 || len(res.Lineups) != 2 {
return nil
}
g := &Game{Round: res.Round, NextCardID: res.StartCardID,
drawReplay: append([]int(nil), res.Draws...)}
if g.NextCardID == 0 {
g.NextCardID = 1_000_000
}
// Seat the fighters where they really sat: at a bigger table the two sides
// of one battle are not seats 0 and 1, and WinnerSeat is a table seat.
for seat := range max(res.Seats[0], res.Seats[1]) + 1 {
g.Players = append(g.Players, &Player{Name: fmt.Sprintf("Seat %d", seat), Seat: seat})
}
applyBattleInputs(g, res)
return g.runBattle(res.Seats[0], res.Seats[1])
}
// applyBattleInputs stages a game's players for a replay of res: each side's
// lineup and the banked resources it fought with.
func applyBattleInputs(g *Game, res *BattleResult) {
for side, seat := range res.Seats {
if seat < 0 || seat >= len(g.Players) {
continue
}
p := g.Players[seat]
p.Deck = append([]Card(nil), res.Lineups[side]...)
if side < len(res.Inputs) {
in := res.Inputs[side]
p.Mana, p.PendingTrumpets, p.PendingApplesInPlay = in.Mana, in.Trumpets, in.ApplesInPlay
}
}
} }
// TierContents returns the full printed contents of a tier's shop deck for the // TierContents returns the full printed contents of a tier's shop deck for the
// default pack. Cards carry placeholder IDs; they are reference data, not live // default pack. Cards carry placeholder IDs; they are reference data, not live
// instances. // instances.
func TierContents(tier int) []Card { func TierContents(tier int) []Card {
return TierContentsForPack(DefaultPack, tier) return TierContentsForPacks([]string{DefaultPack}, tier)
} }
// TierContentsForPack returns a pack's printed tier contents — public // TierContentsForPacks returns the printed tier contents of a pack selection,
// information from the box. Used by the AI, which decides from a View that // combined the way the shop decks combine them — public information from the
// names its pack. // boxes. Used by the AI, which decides from a View that names its packs.
func TierContentsForPack(pack string, tier int) []Card { func TierContentsForPacks(packs []string, tier int) []Card {
scratch := &Game{Pack: pack} scratch := &Game{Packs: packs}
scratch.buildShopDecks() scratch.buildShopDecks()
if tier < 1 || tier > len(scratch.ShopDecks) { if tier < 1 || tier > len(scratch.ShopDecks) {
return nil return nil
+1 -1
View File
@@ -9,7 +9,7 @@ import "testing"
func unicornGame(t *testing.T) (*Game, *Player, *Player) { func unicornGame(t *testing.T) (*Game, *Player, *Player) {
t.Helper() t.Helper()
g := New() g := New()
g.Pack = "unicorn" g.Packs = []string{"unicorn"}
g.buildDecks() g.buildDecks()
p1, err := g.AddPlayer("Alice") p1, err := g.AddPlayer("Alice")
if err != nil { if err != nil {
+52 -12
View File
@@ -8,6 +8,9 @@ type PlayerView struct {
Seat int `json:"seat"` Seat int `json:"seat"`
Coins int `json:"coins"` Coins int `json:"coins"`
Trophies int `json:"trophies"` Trophies int `json:"trophies"`
// RoundWins are the rounds this player won a battle in. Public — every
// result is — and what the end-of-game countback tie-break runs on.
RoundWins []int `json:"roundWins,omitempty"`
Ready bool `json:"ready"` Ready bool `json:"ready"`
Connected bool `json:"connected"` Connected bool `json:"connected"`
IsBot bool `json:"isBot,omitempty"` IsBot bool `json:"isBot,omitempty"`
@@ -40,21 +43,31 @@ type View struct {
Round int `json:"round"` Round int `json:"round"`
MaxRounds int `json:"maxRounds"` MaxRounds int `json:"maxRounds"`
MaxPets int `json:"maxPets"` MaxPets int `json:"maxPets"`
// Pack is the selected card pack; Packs is the catalog of choices for the // Packs are the selected card packs, whose tiers shuffle together;
// lobby. HostSeat is the seat that controls the lobby (always 0 for now); // PackCatalog is the list of choices for the lobby and PacksNeeded is how
// MinPlayers is how many seats must be filled before the host can start. // many the current table size requires. HostSeat is the seat that controls
Pack string `json:"pack"` // the lobby (always 0 for now); PlayerCounts lists the table sizes a game
Packs []PackInfo `json:"packs"` // can start at.
Packs []string `json:"packs"`
PackCatalog []PackInfo `json:"packCatalog"`
PacksNeeded int `json:"packsNeeded"`
HostSeat int `json:"hostSeat"` HostSeat int `json:"hostSeat"`
MinPlayers int `json:"minPlayers"` MinPlayers int `json:"minPlayers"`
MaxPlayers int `json:"maxPlayers"` MaxPlayers int `json:"maxPlayers"`
PlayerCounts []int `json:"playerCounts"`
YouSeat int `json:"youSeat"` YouSeat int `json:"youSeat"`
Turn int `json:"turn"` Turn int `json:"turn"`
// PrioritySeat is the seat currently holding the priority token. // PrioritySeat is the seat holding the first-shopper token: it shops first
// this round (and, in a two-player game, also acts first in the battle).
PrioritySeat int `json:"prioritySeat"` PrioritySeat int `json:"prioritySeat"`
ShopRow []Card `json:"shopRow"` ShopRow []Card `json:"shopRow"`
DeckCounts []int `json:"deckCounts"` // remaining shop cards per tier DeckCounts []int `json:"deckCounts"` // remaining shop cards per tier
Players []PlayerView `json:"players"` Players []PlayerView `json:"players"`
// Matchups are this round's battle pairings — public, since the schedule is
// printed in the rulebook. YourOpponent is the seat you face this round, or
// -1 outside a running game.
Matchups []Matchup `json:"matchups,omitempty"`
YourOpponent int `json:"yourOpponent"`
// Pending is included for everyone so opponents see a trade is in // Pending is included for everyone so opponents see a trade is in
// progress, but the revealed options are only shown to the trader. // progress, but the revealed options are only shown to the trader.
Pending *PendingTrade `json:"pending,omitempty"` Pending *PendingTrade `json:"pending,omitempty"`
@@ -65,8 +78,14 @@ type View struct {
// PendingSacrifice (Unicorn pack: Water of Youth) mirrors PendingReveal: the // PendingSacrifice (Unicorn pack: Water of Youth) mirrors PendingReveal: the
// options (the buyer's own pets) are only sent to the buyer. // options (the buyer's own pets) are only sent to the buyer.
PendingSacrifice *PendingSacrifice `json:"pendingSacrifice,omitempty"` PendingSacrifice *PendingSacrifice `json:"pendingSacrifice,omitempty"`
// Battle is the viewer's own battle this round; Battles holds every table's,
// so a player can replay any of them. Both are public once resolved.
Battle *BattleResult `json:"battle,omitempty"` Battle *BattleResult `json:"battle,omitempty"`
Battles []*BattleResult `json:"battles,omitempty"`
// WinnerSeat is the outright winner at gameover (-1 when shared);
// WinnerSeats lists everyone holding the title.
WinnerSeat int `json:"winnerSeat"` WinnerSeat int `json:"winnerSeat"`
WinnerSeats []int `json:"winnerSeats,omitempty"`
// Log is the shared, public event log shown across every phase. // Log is the shared, public event log shown across every phase.
Log []LogEntry `json:"log,omitempty"` Log []LogEntry `json:"log,omitempty"`
// Debug is set by the server when its DEBUG flag is on, unlocking the // Debug is set by the server when its DEBUG flag is on, unlocking the
@@ -83,16 +102,21 @@ func (g *Game) ViewFor(playerID string) View {
Round: g.Round, Round: g.Round,
MaxRounds: MaxRounds, MaxRounds: MaxRounds,
MaxPets: MaxPets, MaxPets: MaxPets,
Pack: g.Pack, Packs: g.packList(),
Packs: Packs, PackCatalog: Packs,
PacksNeeded: PacksNeeded(len(g.Players)),
HostSeat: 0, HostSeat: 0,
MinPlayers: MinPlayers, MinPlayers: MinPlayers,
MaxPlayers: MaxPlayers, MaxPlayers: MaxPlayers,
PlayerCounts: PlayerCounts,
YouSeat: -1, YouSeat: -1,
YourOpponent: -1,
Turn: g.Turn, Turn: g.Turn,
PrioritySeat: g.PrioritySeat, PrioritySeat: g.PrioritySeat,
ShopRow: g.ShopRow, ShopRow: g.ShopRow,
WinnerSeat: g.WinnerSeat, WinnerSeat: g.WinnerSeat,
WinnerSeats: g.WinnerSeats,
Matchups: g.Pairings(),
Log: g.Log, Log: g.Log,
} }
for _, deck := range g.ShopDecks { for _, deck := range g.ShopDecks {
@@ -105,6 +129,7 @@ func (g *Game) ViewFor(playerID string) View {
Seat: p.Seat, Seat: p.Seat,
Coins: p.Coins, Coins: p.Coins,
Trophies: p.Trophies, Trophies: p.Trophies,
RoundWins: p.RoundWins,
Ready: p.Ready, Ready: p.Ready,
Connected: p.Connected, Connected: p.Connected,
IsBot: p.IsBot, IsBot: p.IsBot,
@@ -115,6 +140,7 @@ func (g *Game) ViewFor(playerID string) View {
} }
if p.ID == playerID { if p.ID == playerID {
v.YouSeat = p.Seat v.YouSeat = p.Seat
v.YourOpponent = OpponentOf(len(g.Players), g.Round, p.Seat)
pv.Deck = p.Deck pv.Deck = p.Deck
pv.FirstBuyFree = p.FirstBuyFree pv.FirstBuyFree = p.FirstBuyFree
pv.BuysThisRound = p.BuysThisRound pv.BuysThisRound = p.BuysThisRound
@@ -144,9 +170,23 @@ func (g *Game) ViewFor(playerID string) View {
} }
v.PendingSacrifice = &sac v.PendingSacrifice = &sac
} }
// Battle results (lineups, events) are public once resolved. Keep the // Battle results (lineups, events) are public once resolved — every table's,
// battle around during the following shop phase too, so late joiners / // not just your own, so players can watch how the rest of the field did.
// reconnects can still see the last result. // They stay around during the following shop phase too, so late joiners /
v.Battle = g.Battle // reconnects can still see the last round.
v.Battles = g.Battles
if v.YouSeat >= 0 {
v.Battle = g.BattleFor(v.YouSeat)
}
return v return v
} }
// PlayerView returns the view of one seat within a View, or nil.
func (v *View) PlayerView(seat int) *PlayerView {
for i := range v.Players {
if v.Players[i].Seat == seat {
return &v.Players[i]
}
}
return nil
}
+110
View File
@@ -96,3 +96,113 @@ func TestE2EBotGame(t *testing.T) {
} }
t.Logf("reached phase %s; bot played its shop turns", v.Phase) t.Logf("reached phase %s; bot played its shop turns", v.Phase)
} }
// TestE2EFourPlayerBotGame runs a four-seat game (one human, three bots) over
// the API and plays a full round. It's the multiplayer counterpart to
// TestE2EBotGame: the bot driver has to keep three seats moving through a
// shop that takes turns four ways, and the round has to resolve two battles at
// once rather than one.
func TestE2EFourPlayerBotGame(t *testing.T) {
st, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer st.Close()
srv := New(st, "", false)
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
resp, err := http.Post(ts.URL+"/api/games", "application/json",
bytes.NewBufferString(`{"name":"Human"}`))
if err != nil {
t.Fatal(err)
}
var join joinResponse
if err := json.NewDecoder(resp.Body).Decode(&join); err != nil {
t.Fatal(err)
}
resp.Body.Close()
wsBase := "ws" + strings.TrimPrefix(ts.URL, "http")
ws, _, err := websocket.Dial(ctx,
wsBase+"/api/ws?game="+join.GameID+"&player="+join.PlayerID+"&token="+join.Token, nil)
if err != nil {
t.Fatal(err)
}
defer ws.Close(websocket.StatusNormalClosure, "")
if p := readState(t, ctx, ws).Phase; p != game.PhaseLobby {
t.Fatalf("phase = %s, want lobby on create", p)
}
// Four seats need two packs shuffled together, per the rulebook.
send(t, ctx, ws, map[string]any{"type": "setPacks", "packs": []string{"turtle", "golden"}})
for _, level := range []string{"easy", "medium", "hard"} {
send(t, ctx, ws, map[string]any{"type": "addBot", "difficulty": level})
}
send(t, ctx, ws, map[string]any{"type": "start"})
var v *game.View
deadline := time.Now().Add(2 * time.Minute)
for time.Now().Before(deadline) {
rctx, rcancel := context.WithTimeout(ctx, 20*time.Second)
v = readState(t, rctx, ws)
rcancel()
if v.Phase == game.PhaseLobby {
continue
}
if len(v.Players) != 4 {
t.Fatalf("expected 4 seats, got %d", len(v.Players))
}
// Play the human side: pass as soon as it's our turn, then submit our
// deck as-is. The round can only progress if the three bots take their
// own turns around us.
switch v.Phase {
case game.PhaseShop:
if v.Turn == v.YouSeat && !v.Players[v.YouSeat].Ready && v.Pending == nil &&
v.PendingReveal == nil && v.PendingSacrifice == nil {
send(t, ctx, ws, map[string]any{"type": "pass"})
}
case game.PhaseArrange:
if !v.Players[v.YouSeat].Ready {
ids := []string{}
for _, c := range v.Players[v.YouSeat].Deck {
ids = append(ids, c.ID)
}
send(t, ctx, ws, map[string]any{"type": "arrange", "order": ids})
}
case game.PhaseBattle:
goto resolved
}
}
t.Fatalf("never reached the battle phase; stuck in %s", v.Phase)
resolved:
// Every bot name should be distinct, or the table is unreadable.
names := map[string]bool{}
for _, p := range v.Players {
if names[p.Name] {
t.Errorf("two seats are both called %q", p.Name)
}
names[p.Name] = true
}
// Four players pair into two simultaneous battles, together seating everyone.
if len(v.Battles) != 2 {
t.Fatalf("round resolved %d battles, want 2", len(v.Battles))
}
fought := map[int]bool{}
for _, b := range v.Battles {
for _, seat := range b.Seats {
fought[seat] = true
}
}
if len(fought) != 4 {
t.Errorf("%d of 4 seats fought this round", len(fought))
}
if v.Battle == nil || !v.Battle.Has(v.YouSeat) {
t.Error("the view should single out the battle the human fought")
}
t.Logf("four-player round resolved: %d battles, seats %v", len(v.Battles), fought)
}
+38 -13
View File
@@ -2,6 +2,7 @@ package server
import ( import (
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"math/rand/v2" "math/rand/v2"
"time" "time"
@@ -10,19 +11,23 @@ import (
"github.com/greyson/super-auto-pets-board-game/internal/game" "github.com/greyson/super-auto-pets-board-game/internal/game"
) )
// botDifficulty maps the API's difficulty names to a skill level and a // botDifficulty maps the API's difficulty names to a skill level and a pool of
// table name for the bot. The levels are calibrated against a competent // table names. The levels are calibrated against a competent player
// player (ai.competentLevel, ~0.60): easy loses ~95% of games to them, medium // (ai.competentLevel, ~0.60): easy loses ~95% of games to them, medium is an
// is an even match, and hard wins ~80% (see TestDiagWinRateCurve). Medium is // even match, and hard wins ~80% (see TestDiagWinRateCurve). Medium is pinned
// pinned to the competent level itself; hard is the strongest bot the engine // to the competent level itself; hard is the strongest bot the engine can
// can field. // field.
//
// A table can hold up to five bots, so each difficulty carries a list of names
// rather than one: the first unused name is taken, keeping every seat
// distinguishable while the name still hints at how hard it plays.
var botDifficulty = map[string]struct { var botDifficulty = map[string]struct {
level float64 level float64
name string names []string
}{ }{
"easy": {0.25, "Robo Rookie"}, "easy": {0.25, []string{"Robo Rookie", "Bitsy", "Clunk", "Pip", "Sprocket"}},
"medium": {0.60, "Robo Rival"}, "medium": {0.60, []string{"Robo Rival", "Gizmo", "Widget", "Rusty", "Cogsworth"}},
"hard": {1.00, "Robo Ace"}, "hard": {1.00, []string{"Robo Ace", "Vex", "Apex", "Onyx", "Zenith"}},
} }
// requireHost authorizes a lobby-management action: only the host (seat 0) // requireHost authorizes a lobby-management action: only the host (seat 0)
@@ -36,17 +41,37 @@ func requireHost(g *game.Game, playerID string) error {
} }
// addBot seats a computer opponent for a difficulty name, translating the // addBot seats a computer opponent for a difficulty name, translating the
// name to the engine's skill level. Capacity and phase are enforced by the // name to the engine's skill level and giving it a name nobody at the table is
// engine's AddPlayer. // already using. Capacity and phase are enforced by the engine's AddPlayer.
func addBot(g *game.Game, difficulty string) error { func addBot(g *game.Game, difficulty string) error {
bot, ok := botDifficulty[difficulty] bot, ok := botDifficulty[difficulty]
if !ok { if !ok {
return errors.New("unknown bot difficulty") return errors.New("unknown bot difficulty")
} }
_, err := g.AddBot(bot.name, bot.level) _, err := g.AddBot(botName(g, bot.names), bot.level)
return err return err
} }
// botName picks the first name in the pool that no seat has taken. If a host
// somehow exhausts the pool, it falls back to numbering the first name.
func botName(g *game.Game, pool []string) string {
taken := make(map[string]bool, len(g.Players))
for _, p := range g.Players {
taken[p.Name] = true
}
for _, name := range pool {
if !taken[name] {
return name
}
}
for n := 2; ; n++ {
name := fmt.Sprintf("%s %d", pool[0], n)
if !taken[name] {
return name
}
}
}
// commitLocked is the one path every game mutation goes through: bots update // commitLocked is the one path every game mutation goes through: bots update
// their memories from the new public state, the game is persisted, every // their memories from the new public state, the game is persisted, every
// client gets its view, and the next bot move (if any) is scheduled. Callers // client gets its view, and the next bot move (if any) is scheduled. Callers
+116
View File
@@ -0,0 +1,116 @@
package server
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/greyson/super-auto-pets-board-game/internal/game"
"github.com/greyson/super-auto-pets-board-game/internal/store"
)
// reportServer stands up an ordinary server — DEBUG off, the way it runs in
// production — holding one started two-player game, and returns it with the
// credentials for seat 0.
func reportServer(t *testing.T) (*httptest.Server, *game.Game, *game.Player) {
t.Helper()
st, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
g := game.New()
p1, _ := g.AddPlayer("Alice")
g.AddPlayer("Bob")
if err := g.StartGame(); err != nil {
t.Fatal(err)
}
if err := st.Save(g); err != nil {
t.Fatal(err)
}
srv := New(st, "", false)
srv.rooms[g.ID] = &room{game: g, conns: map[*client]struct{}{}}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts, g, p1
}
func get(t *testing.T, url string) (int, string) {
t.Helper()
res, err := http.Get(url)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
return res.StatusCode, string(body)
}
// A seated player can pull the report in both forms, and the JSON one restores
// to the game the server is actually holding.
func TestDebugReportEndpoint(t *testing.T) {
ts, g, p1 := reportServer(t)
url := ts.URL + "/api/debug/report?game=" + g.ID + "&player=" + p1.ID + "&token=" + p1.Token
status, body := get(t, url+"&note=shop+row+looked+wrong")
if status != http.StatusOK {
t.Fatalf("json report: status %d, body %s", status, body)
}
var rep game.DebugReport
if err := json.Unmarshal([]byte(body), &rep); err != nil {
t.Fatalf("the report isn't valid JSON: %v", err)
}
if rep.Note != "shop row looked wrong" {
t.Fatalf("the note didn't make it into the report: %q", rep.Note)
}
if rep.CapturedAt == "" {
t.Fatal("the server should stamp the report with a capture time")
}
restored, err := rep.Game()
if err != nil {
t.Fatal(err)
}
if restored.Code != g.Code || len(restored.Players) != len(g.Players) {
t.Fatalf("restored game %s with %d players, want %s with %d",
restored.Code, len(restored.Players), g.Code, len(g.Players))
}
status, text := get(t, url+"&format=text")
if status != http.StatusOK {
t.Fatalf("text report: status %d", status)
}
if !strings.Contains(text, g.Code) || !strings.Contains(text, "=== Players ===") {
t.Fatalf("the text report doesn't look like a report:\n%s", text)
}
}
// The in-game bug report button has to work on a normal server, so the endpoint
// is not DEBUG-gated — but it is still a seated player's own artifact, and never
// reachable on someone else's credentials.
func TestDebugReportNeedsCredentialsNotDebugMode(t *testing.T) {
ts, g, p1 := reportServer(t)
status, _ := get(t, ts.URL+"/api/debug/report?game="+g.ID+"&player="+p1.ID+"&token="+p1.Token)
if status != http.StatusOK {
t.Fatalf("a player should get their report without DEBUG, got %d", status)
}
status, _ = get(t, ts.URL+"/api/debug/report?game="+g.ID+"&player="+p1.ID+"&token=wrong")
if status != http.StatusForbidden {
t.Fatalf("a bad token should 403, got %d", status)
}
status, _ = get(t, ts.URL+"/api/debug/report?game="+g.ID+"&player=nobody&token="+p1.Token)
if status != http.StatusForbidden {
t.Fatalf("an unknown player should 403, got %d", status)
}
status, _ = get(t, ts.URL+"/api/debug/report?game=nosuchgame&player="+p1.ID+"&token="+p1.Token)
if status != http.StatusNotFound {
t.Fatalf("an unknown game should 404, got %d", status)
}
}
+71 -7
View File
@@ -13,6 +13,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"time"
"github.com/greyson/super-auto-pets-board-game/internal/game" "github.com/greyson/super-auto-pets-board-game/internal/game"
"github.com/greyson/super-auto-pets-board-game/internal/store" "github.com/greyson/super-auto-pets-board-game/internal/store"
@@ -47,18 +48,81 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/join", s.handleJoin) mux.HandleFunc("POST /api/join", s.handleJoin)
mux.HandleFunc("GET /api/ws", s.handleWS) mux.HandleFunc("GET /api/ws", s.handleWS)
mux.HandleFunc("GET /api/catalog", s.handleCatalog) mux.HandleFunc("GET /api/catalog", s.handleCatalog)
mux.HandleFunc("GET /api/debug/report", s.handleDebugReport)
mux.HandleFunc("/", s.handleStatic) mux.HandleFunc("/", s.handleStatic)
return mux return mux
} }
// handleCatalog returns every card in a pack (?pack=…, default Turtle), for the // handleDebugReport dumps a game's full state, battles and event log as a
// debug panel. Unknown packs fall back to the default. // debug report (see game.DebugReport) — everything needed to replay a situation
func (s *Server) handleCatalog(w http.ResponseWriter, req *http.Request) { // that went wrong in a test. Params match the WebSocket's: game, player, token.
pack := req.URL.Query().Get("pack") // `format=text` renders it for reading instead of as JSON, and `note=` records
if pack == "" { // what looked wrong.
pack = game.DefaultPack //
// This backs the in-game "Report a bug" button, so it is deliberately not
// DEBUG-gated: a reproducible bug report is worth more than the hidden
// information a report gives away. It does hand a seated player their
// opponents' hands and the order of the shop decks, which a determined one
// could read mid-game — the trade accepted here is that a player who wants to
// cheat gains little and a player who hits a bug can actually report it.
// Credentials are still required, so a report only ever goes to someone at that
// table.
func (s *Server) handleDebugReport(w http.ResponseWriter, req *http.Request) {
q := req.URL.Query()
r, err := s.getRoom(q.Get("game"))
if err != nil {
httpError(w, http.StatusNotFound, "game not found")
return
} }
writeJSON(w, game.CatalogForPack(pack)) r.mu.Lock()
p := r.game.PlayerByID(q.Get("player"))
if p == nil || p.Token != q.Get("token") {
r.mu.Unlock()
httpError(w, http.StatusForbidden, "bad player credentials")
return
}
rep, err := r.game.DebugReport()
r.mu.Unlock()
if err != nil {
httpError(w, http.StatusInternalServerError, "failed to capture the game: "+err.Error())
return
}
rep.CapturedAt = time.Now().UTC().Format(time.RFC3339)
rep.Note = q.Get("note")
if q.Get("format") == "text" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(rep.Text()))
return
}
blob, err := rep.JSON()
if err != nil {
httpError(w, http.StatusInternalServerError, "failed to render the report")
return
}
w.Header().Set("Content-Type", "application/json")
// Named so a browser download lands as a file you can drop into testdata.
w.Header().Set("Content-Disposition", `attachment; filename="`+rep.Filename()+`"`)
w.Write(blob)
}
// handleCatalog returns every card in the requested packs, for the debug panel
// and the event log's card previews. Packs come as a repeated or
// comma-separated ?pack= parameter and default to Turtle; unknown ids fall back
// to the default pack's cards.
func (s *Server) handleCatalog(w http.ResponseWriter, req *http.Request) {
var packs []string
for _, v := range req.URL.Query()["pack"] {
for _, id := range strings.Split(v, ",") {
if id = strings.TrimSpace(id); id != "" {
packs = append(packs, id)
}
}
}
if len(packs) == 0 {
packs = []string{game.DefaultPack}
}
writeJSON(w, game.CatalogForPacks(packs))
} }
// room is one live game plus its connections. // room is one live game plus its connections.
+3 -3
View File
@@ -28,7 +28,7 @@ type clientMessage struct {
Pick int `json:"pick"` // tradeChoose Pick int `json:"pick"` // tradeChoose
Order []string `json:"order"` // arrange Order []string `json:"order"` // arrange
Name string `json:"name"` // debugAdd Name string `json:"name"` // debugAdd
Pack string `json:"pack"` // setPack Packs []string `json:"packs"` // setPacks
Difficulty string `json:"difficulty"` // addBot Difficulty string `json:"difficulty"` // addBot
Target string `json:"target"` // removePlayer (player ID) Target string `json:"target"` // removePlayer (player ID)
Card string `json:"card"` // revealChoose (Cockatoo): pet card id Card string `json:"card"` // revealChoose (Cockatoo): pet card id
@@ -123,9 +123,9 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) {
g := r.game g := r.game
var err error var err error
switch msg.Type { switch msg.Type {
case "setPack": case "setPacks":
if err = requireHost(g, c.playerID); err == nil { if err = requireHost(g, c.playerID); err == nil {
err = g.SetPack(msg.Pack) err = g.SetPacks(msg.Packs)
} }
case "addBot": case "addBot":
if err = requireHost(g, c.playerID); err == nil { if err = requireHost(g, c.playerID); err == nil {
+39
View File
@@ -79,6 +79,45 @@ func (s *Store) LoadByCode(code string) (*game.Game, error) {
return s.loadWhere(`code = ?`, code) return s.loadWhere(`code = ?`, code)
} }
// LoadAny fetches a game by join code or by ID, whichever the argument looks
// like — the convenience the report command wants, since a bug report quotes
// whichever of the two the reporter had to hand.
func (s *Store) LoadAny(idOrCode string) (*game.Game, error) {
if g, err := s.LoadByCode(idOrCode); err == nil {
return g, nil
} else if !errors.Is(err, ErrNotFound) {
return nil, err
}
return s.Load(idOrCode)
}
// Recent returns the most recently updated games, newest first, for picking one
// out by hand. Each is fully loaded, so callers can report on it directly.
func (s *Store) Recent(limit int) ([]*game.Game, error) {
if limit <= 0 {
limit = 20
}
rows, err := s.db.Query(`SELECT state FROM games ORDER BY updated_at DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var games []*game.Game
for rows.Next() {
var blob string
if err := rows.Scan(&blob); err != nil {
return nil, err
}
var g game.Game
if err := json.Unmarshal([]byte(blob), &g); err != nil {
// One corrupt row shouldn't hide the rest of the list.
continue
}
games = append(games, &g)
}
return games, rows.Err()
}
func (s *Store) loadWhere(cond string, arg any) (*game.Game, error) { func (s *Store) loadWhere(cond string, arg any) (*game.Game, error) {
var blob string var blob string
err := s.db.QueryRow(`SELECT state FROM games WHERE `+cond, arg).Scan(&blob) err := s.db.QueryRow(`SELECT state FROM games WHERE `+cond, arg).Scan(&blob)
+4
View File
@@ -30,6 +30,10 @@ description = "Build everything: frontend + server binary (bin/server)"
depends = ["build-web"] depends = ["build-web"]
run = "go build -o bin/server ./cmd/server" run = "go build -o bin/server ./cmd/server"
[tasks.report]
description = "Dump a game's debug report (no args: list recent games)"
run = "go run ./cmd/report"
[tasks.test] [tasks.test]
description = "Run all Go tests" description = "Run all Go tests"
run = "go test ./..." run = "go test ./..."
+26 -3
View File
@@ -23,13 +23,36 @@ export function joinGame(code: string, name: string): Promise<Session> {
return post('/api/join', { code, name }) return post('/api/join', { code, name })
} }
export async function fetchCatalog(pack?: string): Promise<Card[]> { export async function fetchCatalog(packs?: string[]): Promise<Card[]> {
const url = pack ? `/api/catalog?pack=${encodeURIComponent(pack)}` : '/api/catalog' const query = (packs ?? [])
const res = await fetch(url) .map((p) => `pack=${encodeURIComponent(p)}`)
.join('&')
const res = await fetch(query ? `/api/catalog?${query}` : '/api/catalog')
if (!res.ok) throw new Error('failed to load catalog') if (!res.ok) throw new Error('failed to load catalog')
return (await res.json()) as Card[] return (await res.json()) as Card[]
} }
// debugReportURL builds the link to a game's debug report — the full state,
// battles and event log, enough to replay the situation in a test. Served only
// when the server runs with DEBUG on. `text` renders it for reading; otherwise
// it downloads as JSON, which is the form a test can replay.
export function debugReportURL(session: Session, format: 'json' | 'text', note?: string): string {
const params = new URLSearchParams({
game: session.gameId,
player: session.playerId,
token: session.token,
format,
})
if (note) params.set('note', note)
return `/api/debug/report?${params}`
}
export async function fetchDebugReportText(session: Session, note?: string): Promise<string> {
const res = await fetch(debugReportURL(session, 'text', note))
if (!res.ok) throw new Error('failed to fetch the debug report')
return res.text()
}
export function loadSession(): Session | null { export function loadSession(): Session | null {
try { try {
const raw = localStorage.getItem(SESSION_KEY) const raw = localStorage.getItem(SESSION_KEY)
+196 -47
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { Card, ClientMessage, GameView, PlayerView } from '../types' import type { Card, ClientMessage, GameView, PlayerView } from '../types'
import { CardView } from './CardView' import { CardView, CardZoom } from './CardView'
import { useCardAnimations } from '../anim' import { useCardAnimations } from '../anim'
import { useMediaQuery } from '../useMediaQuery'
interface Props { interface Props {
view: GameView view: GameView
@@ -9,6 +10,30 @@ interface Props {
send: (msg: ClientMessage) => void send: (msg: ClientMessage) => void
} }
// How far the pointer must travel before a mouse press counts as a drag rather
// than a click.
const DRAG_SLOP = 5
// How long a finger must rest on a card before it picks it up.
const HOLD_MS = 220
// How long after release the drag counts as still settling. The dropped card's
// own glide is 180ms (`.arrange-drag.is-dropping`), but the neighbours it passed
// keep gliding for MOVE_MS (240ms, anim.ts) — and rows sliding out from under a
// stationary cursor don't reliably fire `mouseleave`, which is how the hover
// magnifier used to get stuck open after a drop. So hold the drag state, and
// with it CardView's `noMagnify`, until every row has come to rest.
const SETTLE_MS = 260
// A press that hasn't become a drag yet: which pointer, where it landed, and
// (for touch) the pending hold timer that would lift the card.
interface Press {
pointerId: number
touch: boolean
x: number
y: number
index: number
timer: number
}
// ArrangePhase lets the player order their deck for battle. The list runs top to // ArrangePhase lets the player order their deck for battle. The list runs top to
// bottom in play order: the topmost card fights first, and food cards buff the // bottom in play order: the topmost card fights first, and food cards buff the
// next pet below them. `order` stays in play order (index 0 fights first) to // next pet below them. `order` stays in play order (index 0 fights first) to
@@ -17,6 +42,11 @@ interface Props {
export function ArrangePhase({ view, you, send }: Props) { export function ArrangePhase({ view, you, send }: Props) {
const [order, setOrder] = useState<Card[]>(you.deck ?? []) const [order, setOrder] = useState<Card[]>(you.deck ?? [])
const dragIndex = useRef<number | null>(null) const dragIndex = useRef<number | null>(null)
// `pressed` spans the whole gesture — pointerdown, through the press that may
// or may not become a drag, to release — so the window listeners cover all of
// it. `dragging` is the narrower state where a card is actually lifted.
const [pressed, setPressed] = useState(false)
const press = useRef<Press | null>(null)
const [dragging, setDragging] = useState(false) const [dragging, setDragging] = useState(false)
// The card currently under the finger/cursor: it lifts and follows the // The card currently under the finger/cursor: it lifts and follows the
// pointer (`dragTranslate`), while `dropping` glides it into its final slot // pointer (`dragTranslate`), while `dropping` glides it into its final slot
@@ -24,6 +54,8 @@ export function ArrangePhase({ view, you, send }: Props) {
const [dragId, setDragId] = useState<string | null>(null) const [dragId, setDragId] = useState<string | null>(null)
const [dragTranslate, setDragTranslate] = useState(0) const [dragTranslate, setDragTranslate] = useState(0)
const [dropping, setDropping] = useState(false) const [dropping, setDropping] = useState(false)
// The pending "drop glide finished" timer, so a fresh grab can cancel it.
const dropTimer = useRef(0)
// Drag bookkeeping: where the drag began, the pointer Y at grab time, and the // Drag bookkeeping: where the drag began, the pointer Y at grab time, and the
// slot-to-slot pixel stride (rows are uniform), so the lifted card can track // slot-to-slot pixel stride (rows are uniform), so the lifted card can track
// the finger even as the list reorders beneath it. // the finger even as the list reorders beneath it.
@@ -33,10 +65,17 @@ export function ArrangePhase({ view, you, send }: Props) {
// One entry per card row, in current play order, so a pointer drag can find // One entry per card row, in current play order, so a pointer drag can find
// which slot the finger/cursor is currently over by hit-testing rects. // which slot the finger/cursor is currently over by hit-testing rects.
const cardRefs = useRef<(HTMLDivElement | null)[]>([]) const cardRefs = useRef<(HTMLDivElement | null)[]>([])
// Holds the latest `onDragMove` so the window listener (attached once per // Hold the latest move/release handlers so the window listeners (attached once
// drag) always calls the fresh closure — see the drag effect below. // per gesture) always call the fresh closures — see the gesture effect below.
const moveRef = useRef<(clientY: number) => void>(() => {}) const moveRef = useRef<(e: PointerEvent) => void>(() => {})
const upRef = useRef<(tapped: boolean) => void>(() => {})
const locked = you.ready const locked = you.ready
// Phones can't hover to preview a card, so a tap opens the magnified view
// instead. A drag never counts as a tap (see `endPress`), so dragging a card
// around can't pop the magnified view open under your finger. Ignored on
// desktop, which keeps the hover magnifier.
const isPhone = useMediaQuery('(max-width: 600px)')
const [zoom, setZoom] = useState<{ card: Card; bonus: number } | null>(null)
// The arrow buttons reorder `order` and the cards glide to their new slot. // The arrow buttons reorder `order` and the cards glide to their new slot.
// A drag reorders live too, and the neighbors the dragged card passes should // A drag reorders live too, and the neighbors the dragged card passes should
@@ -69,26 +108,63 @@ export function ArrangePhase({ view, you, send }: Props) {
}) })
} }
// Drag-to-reorder via pointer events, driven by the grip handle. Native HTML5 // Drag-to-reorder via pointer events, grabbing the card itself — there's no
// drag doesn't fire on touch, so we use pointer events (mouse + touch alike). // separate grip handle. Native HTML5 drag doesn't fire on touch, so pointer
// `touch-action: none` on the handle stops the browser from scrolling the // events (mouse and finger alike) drive it, with one wrinkle per input type:
// page mid-drag.
// //
// We do NOT rely on `setPointerCapture` here: the captured handle lives inside // * A mouse lifts the card as soon as the cursor travels DRAG_SLOP from the
// press. Anything shorter is a click, which does nothing here — on
// desktop you read a card by hovering it.
// * A finger has to hold still for HOLD_MS first. A swipe starting on a card
// almost always means "scroll the page" (arrange is the one screen tall
// enough to need it), so the hold is what separates "move this card" from
// scrolling and from a plain tap, which opens the magnified view. It's
// also why the card does NOT set `touch-action: none` — that would kill
// scrolling outright. Instead, once the hold lands we cancel the scroll
// ourselves by preventing `touchmove`, which works precisely because a
// still finger hasn't started one yet.
//
// We do NOT rely on `setPointerCapture` here: the pressed card lives inside
// the keyed row that reorders mid-drag, and React moves that DOM node // the keyed row that reorders mid-drag, and React moves that DOM node
// (`insertBefore`) as the list changes — which makes browsers drop the active // (`insertBefore`) as the list changes — which makes browsers drop the active
// pointer capture, freezing the drag until the user re-grabs. Instead we // pointer capture, freezing the drag until the user re-grabs. Instead we
// listen on `window` for the drag's lifetime (see the effect below), so // listen on `window` for the gesture's lifetime (see the effect below), so
// reordering the rows can never interrupt the gesture. // reordering the rows can never interrupt it.
// //
// The lifted card follows the finger while the list reorders live beneath it. // The lifted card follows the finger while the list reorders live beneath it.
// The translate is applied to an inner wrapper, not the `.arrange-card` box // The translate is applied to an inner wrapper, not the `.arrange-card` box
// the FLIP animator measures, so the two never fight. // the FLIP animator measures, so the two never fight.
function startDrag(e: React.PointerEvent<HTMLElement>, i: number) { function clearPress() {
e.preventDefault() if (press.current) window.clearTimeout(press.current.timer)
press.current = null
}
function onPointerDown(e: React.PointerEvent<HTMLElement>, i: number) {
if (e.button !== 0 || press.current || dragIndex.current !== null) return
const { pointerId, clientX, clientY } = e
const touch = e.pointerType !== 'mouse'
// Stop a mouse press from selecting the card's text as it drags. A touch
// press is left alone so the browser can still scroll from here.
if (!touch) e.preventDefault()
press.current = {
pointerId,
touch,
x: clientX,
y: clientY,
index: i,
timer: touch ? window.setTimeout(() => beginDrag(i, clientY), HOLD_MS) : 0,
}
setPressed(true)
}
function beginDrag(i: number, clientY: number) {
clearPress()
// Cancel a previous card's settle, so re-grabbing inside that window doesn't
// have its timer tear down this drag's state mid-gesture.
window.clearTimeout(dropTimer.current)
dragIndex.current = i dragIndex.current = i
startIndex.current = i startIndex.current = i
grabY.current = e.clientY grabY.current = clientY
// Row stride = distance between two adjacent slots; rows are uniform. // Row stride = distance between two adjacent slots; rows are uniform.
const a = cardRefs.current[0]?.getBoundingClientRect() const a = cardRefs.current[0]?.getBoundingClientRect()
const b = cardRefs.current[1]?.getBoundingClientRect() const b = cardRefs.current[1]?.getBoundingClientRect()
@@ -99,6 +175,41 @@ export function ArrangePhase({ view, you, send }: Props) {
setDragging(true) setDragging(true)
} }
function onPointerMove(e: PointerEvent) {
const p = press.current
if (p) {
if (e.pointerId !== p.pointerId) return
if (Math.hypot(e.clientX - p.x, e.clientY - p.y) < DRAG_SLOP) return
if (p.touch) {
// The finger set off before the hold landed — that's a scroll, not a
// drag. Stand down and leave the page free to move.
endPress(false)
return
}
// Lift from where the press began, so the card sits under the cursor
// rather than jumping by the slop distance.
beginDrag(p.index, p.y)
}
if (dragIndex.current === null) return
onDragMove(e.clientY)
}
// Ends the gesture at whatever stage it reached: a lifted card glides into its
// slot, while a press that never became a drag counts as a tap.
function endPress(tapped: boolean) {
const p = press.current
clearPress()
setPressed(false)
if (dragIndex.current !== null) {
endDrag()
return
}
if (tapped && p && isPhone) {
const c = order[p.index]
if (c) setZoom({ card: c, bonus: bonuses.get(c.id) ?? 0 })
}
}
function onDragMove(clientY: number) { function onDragMove(clientY: number) {
if (dragIndex.current === null) return if (dragIndex.current === null) return
// Find the slot the pointer has crossed into by hit-testing the *other* // Find the slot the pointer has crossed into by hit-testing the *other*
@@ -122,41 +233,63 @@ export function ArrangePhase({ view, you, send }: Props) {
const shift = (dragIndex.current - startIndex.current) * stride.current const shift = (dragIndex.current - startIndex.current) * stride.current
setDragTranslate(clientY - grabY.current - shift) setDragTranslate(clientY - grabY.current - shift)
} }
// Keep the window listener pointed at the current-render closure (fresh // Keep the window listeners pointed at the current-render closures (fresh
// `order`/refs) without re-attaching the listener on every reorder. // `order`/refs) without re-attaching them on every reorder.
moveRef.current = onDragMove moveRef.current = onPointerMove
upRef.current = endPress
function endDrag() { function endDrag() {
if (dragIndex.current === null) return if (dragIndex.current === null) return
dragIndex.current = null dragIndex.current = null
// Glide the lifted card down into its resting slot, then clear drag state. // Glide the lifted card down into its resting slot, then — once the whole
// tray has settled, not just this card — clear drag state.
setDropping(true) setDropping(true)
setDragTranslate(0) setDragTranslate(0)
window.setTimeout(() => { dropTimer.current = window.setTimeout(() => {
setDragId(null) setDragId(null)
setDropping(false) setDropping(false)
setDragging(false) setDragging(false)
}, 180) }, SETTLE_MS)
} }
// While a drag is active, track the pointer on `window` so the gesture keeps // While a press is live, track the pointer on `window` so the gesture keeps
// running even as the list reorders under the finger (the handle's own // running even as the list reorders under the finger (a pointer capture on the
// pointer capture would be lost when React moves its row). Attached once per // card would be lost when React moves its row). Attached once per gesture;
// drag; `moveRef` keeps it calling the latest closure. // `moveRef`/`upRef` keep it calling the latest closures.
useEffect(() => { //
if (!dragging) return // A *layout* effect so the listeners are in place before the browser can
const onMove = (e: PointerEvent) => moveRef.current(e.clientY) // deliver the matching `pointerup`: a quick tap must not slip through, since
const onUp = () => endDrag() // that's what opens the magnified view on a phone.
useLayoutEffect(() => {
if (!pressed) return
const onMove = (e: PointerEvent) => moveRef.current(e)
const onUp = (e: PointerEvent) => upRef.current(e.type === 'pointerup')
// A touch drag must not scroll the page, and `touch-action` can't be flipped
// mid-gesture — so once a card is lifted, cancel the scroll here instead.
const onTouchMove = (e: TouchEvent) => {
if (dragIndex.current !== null && e.cancelable) e.preventDefault()
}
window.addEventListener('pointermove', onMove) window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp) window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp) window.addEventListener('pointercancel', onUp)
window.addEventListener('touchmove', onTouchMove, { passive: false })
return () => { return () => {
window.removeEventListener('pointermove', onMove) window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp) window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp) window.removeEventListener('pointercancel', onUp)
window.removeEventListener('touchmove', onTouchMove)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [dragging]) }, [pressed])
// Don't leave timers behind if the phase ends mid-gesture.
useEffect(
() => () => {
if (press.current) window.clearTimeout(press.current.timer)
window.clearTimeout(dropTimer.current)
},
[],
)
// Which pets do the foods land on? Foods buff the next pet later in play order // Which pets do the foods land on? Foods buff the next pet later in play order
// (i.e. the next pet below them in the list). Compute buff per card for preview. // (i.e. the next pet below them in the list). Compute buff per card for preview.
@@ -180,14 +313,18 @@ export function ArrangePhase({ view, you, send }: Props) {
return n return n
})() })()
const opponent = view.players.find((p) => p.seat !== view.youSeat) // Everyone arranges at the same time, so the wait is on whoever is left.
const waitingOn = view.players.filter((p) => p.seat !== view.youSeat && !p.ready)
const rival = view.players.find((p) => p.seat === view.yourOpponent)
if (locked) { if (locked) {
return ( return (
<div className="centered"> <div className="centered">
<h2>Order locked in </h2> <h2>Order locked in </h2>
<p className="muted"> <p className="muted">
Waiting for {opponent?.name ?? 'your opponent'} to arrange their deck {waitingOn.length === 1
? `Waiting for ${waitingOn[0].name} to arrange their deck…`
: `Waiting for ${waitingOn.length} more players to arrange their decks…`}
</p> </p>
</div> </div>
) )
@@ -196,11 +333,20 @@ export function ArrangePhase({ view, you, send }: Props) {
return ( return (
<div className="arrange"> <div className="arrange">
<div className="shop-status"> <div className="shop-status">
<span className="status-hot">Arrange your battle line</span> <span className="status-hot">
Arrange your battle line
{/* Who you face is public (the pairings are printed in the rulebook),
and at a bigger table it's a different rival every round. */}
{rival && <> you fight {rival.name} this round</>}
</span>
</div> </div>
<p className="hint"> <p className="hint">
The <strong>topmost</strong> card fights first. Food cards power up the The <strong>topmost</strong> card fights first. Food cards power up the
next pet <strong>below</strong> them. next pet <strong>below</strong> them.
<span className="arrange-tip">
Drag a card to move it on a touchscreen, hold it a moment first. The
arrows work too.
</span>
</p> </p>
<div className="arrange-col" ref={arrangeAnim.containerRef}> <div className="arrange-col" ref={arrangeAnim.containerRef}>
@@ -221,19 +367,8 @@ export function ArrangePhase({ view, you, send }: Props) {
style={isDragged ? { transform: `translateY(${dragTranslate}px)` } : undefined} style={isDragged ? { transform: `translateY(${dragTranslate}px)` } : undefined}
> >
<div className="arrange-controls"> <div className="arrange-controls">
<div
className="arrange-handle"
role="button"
tabIndex={-1}
aria-label="drag to reorder"
title="Drag to reorder"
onPointerDown={(e) => startDrag(e, i)}
>
</div>
<div className="arrange-arrows">
<button <button
className="btn btn-ghost btn-sm" className="btn btn-ghost arrange-arrow"
disabled={i === 0} disabled={i === 0}
onClick={() => move(i, i - 1)} onClick={() => move(i, i - 1)}
aria-label="move up (earlier)" aria-label="move up (earlier)"
@@ -241,7 +376,7 @@ export function ArrangePhase({ view, you, send }: Props) {
</button> </button>
<button <button
className="btn btn-ghost btn-sm" className="btn btn-ghost arrange-arrow"
disabled={i === order.length - 1} disabled={i === order.length - 1}
onClick={() => move(i, i + 1)} onClick={() => move(i, i + 1)}
aria-label="move down (later)" aria-label="move down (later)"
@@ -249,10 +384,20 @@ export function ArrangePhase({ view, you, send }: Props) {
</button> </button>
</div> </div>
</div> {/* The card is its own drag surface: press it and move. */}
<div
className="arrange-grab"
onPointerDown={(e) => onPointerDown(e, i)}
// The hold that picks a card up must not also raise the touch
// callout / context menu on top of the drag.
onContextMenu={(e) => {
if (press.current || dragIndex.current !== null) e.preventDefault()
}}
>
<CardView card={c} bonus={bonuses.get(c.id) ?? 0} noMagnify={dragging} /> <CardView card={c} bonus={bonuses.get(c.id) ?? 0} noMagnify={dragging} />
</div> </div>
</div> </div>
</div>
) )
})} })}
</div> </div>
@@ -272,6 +417,10 @@ export function ArrangePhase({ view, you, send }: Props) {
Lock in & battle Lock in & battle
</button> </button>
</div> </div>
{zoom && (
<CardZoom card={zoom.card} bonus={zoom.bonus} onClose={() => setZoom(null)} />
)}
</div> </div>
) )
} }
+187 -59
View File
@@ -1,10 +1,12 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react' import type { Dispatch, SetStateAction } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import type { BattleEvent, Card, ClientMessage, GameView } from '../types' import type { BattleEvent, BattleResult, Card, ClientMessage, GameView } from '../types'
import { CardView } from './CardView' import { sideOf } from '../types'
import { CardView, CardZoom } from './CardView'
import { DiceRoll, ROLL_MS } from './DiceRoll' import { DiceRoll, ROLL_MS } from './DiceRoll'
import { SPEED_OPTIONS, useBattleSpeed } from '../useBattleSpeed' import { SPEED_OPTIONS, useBattleSpeed } from '../useBattleSpeed'
import { useMediaQuery } from '../useMediaQuery'
// How long a settled rock roll (and its damage) stays on screen before the // How long a settled rock roll (and its damage) stays on screen before the
// battle advances to the next step. // battle advances to the next step.
@@ -16,6 +18,13 @@ interface Props {
// Step is owned by Table so the event log can stay in sync with the replay. // Step is owned by Table so the event log can stay in sync with the replay.
step: number step: number
setStep: Dispatch<SetStateAction<number>> setStep: Dispatch<SetStateAction<number>>
// The battle being replayed, and the switcher for picking another table's.
// With more than two players several battles resolve at once and any of them
// can be watched; at two players there is only ever one.
battle: BattleResult
battles: BattleResult[]
selected: number
onSelect: (idx: number) => void
} }
interface UnitVis { interface UnitVis {
@@ -244,40 +253,40 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
// unitPop decides the floating effect text over a seat's pet for the event // unitPop decides the floating effect text over a seat's pet for the event
// currently playing. Null = nothing. // currently playing. Null = nothing.
function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], step: number): string | null { function unitPop(ev: BattleEvent | null, side: number, events: BattleEvent[], step: number): string | null {
if (!ev) return null if (!ev) return null
switch (ev.type) { switch (ev.type) {
case 'rock': case 'rock':
if (ev.target !== seat) return null if (ev.target !== side) return null
return ev.roll === 0 ? 'miss!' : `${ev.roll}` return ev.roll === 0 ? 'miss!' : `${ev.roll}`
case 'clash': { case 'clash': {
const taken = clashDamageTaken(events, step - 1, seat) const taken = clashDamageTaken(events, step - 1, side)
return taken > 0 ? `${taken}` : null return taken > 0 ? `${taken}` : null
} }
case 'shield': case 'shield':
return ev.seat === seat ? '🛡️' : null return ev.seat === side ? '🛡️' : null
case 'prevent': case 'prevent':
return ev.seat === seat ? `🛡️ ${ev.count}` : null return ev.seat === side ? `🛡️ ${ev.count}` : null
case 'trumpet': case 'trumpet':
if (ev.seat !== seat) return null if (ev.seat !== side) return null
return (ev.count ?? 0) >= 0 ? `🎺 +${ev.count}` : `🎺 ${ev.count}` return (ev.count ?? 0) >= 0 ? `🎺 +${ev.count}` : `🎺 ${ev.count}`
case 'mana': case 'mana':
if (ev.seat !== seat) return null if (ev.seat !== side) return null
return (ev.count ?? 0) >= 0 ? `🔮 +${ev.count}` : `🔮 ${ev.count}` return (ev.count ?? 0) >= 0 ? `🔮 +${ev.count}` : `🔮 ${ev.count}`
case 'ailment': case 'ailment':
if (ev.seat !== seat) return null if (ev.seat !== side) return null
return ev.card?.ailment === 'spooked' ? '👻' : '🎯' return ev.card?.ailment === 'spooked' ? '👻' : '🎯'
case 'bounce': case 'bounce':
return ev.target === seat ? '🌀' : null return ev.target === side ? '🌀' : null
case 'eat': case 'eat':
return ev.seat === seat ? '🍎' : null return ev.seat === side ? '🍎' : null
case 'heal': case 'heal':
return ev.seat === seat ? '💚 +1' : null return ev.seat === side ? '💚 +1' : null
case 'strip': case 'strip':
return ev.target === seat ? '💨' : null return ev.target === side ? '💨' : null
case 'steal': case 'steal':
if (ev.seat === seat) return `+🍎×${ev.count}` if (ev.seat === side) return `+🍎×${ev.count}`
if (ev.target === seat) return `−🍎×${ev.count}` if (ev.target === side) return `−🍎×${ev.count}`
return null return null
default: default:
return null return null
@@ -286,19 +295,27 @@ function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], st
// BattlePhase plays back the battle log: cards flip off each deck, rocks // BattlePhase plays back the battle log: cards flip off each deck, rocks
// fly, pets clash, the fallen fade out, then the round result lands. // fly, pets clash, the fallen fade out, then the round result lands.
export function BattlePhase({ view, send, step, setStep }: Props) { export function BattlePhase({
const battle = view.battle! view,
send,
step,
setStep,
battle,
battles,
selected,
onSelect,
}: Props) {
const events = battle.events ?? [] const events = battle.events ?? []
// acked = the player committed to the next round (waiting on the opponent). // acked = the player committed to the next round (waiting on the opponent).
const [acked, setAcked] = useState(false) const [acked, setAcked] = useState(false)
// The result dialog pops when the replay ends. "Not yet" dismisses it so the // The result dialog pops when the replay ends. "Not yet" dismisses it so the
// battle can be rewatched; a toolbar button then remains to advance the round. // battle can be rewatched; a toolbar button then remains to advance the round.
const [resultDismissed, setResultDismissed] = useState(false) const [resultDismissed, setResultDismissed] = useState(false)
// The deck-peek popover lives inside .battlefield, which sets overflow-x // The deck-peek popover lives inside .battlefield, which scrolls on one axis
// (and thus overflow-y) to auto — so an absolutely-positioned popover gets // and hides the other — so an absolutely-positioned popover gets clipped to
// clipped to the battlefield. We anchor it to the hovered stack's viewport // the battlefield. We anchor it to the hovered stack's viewport rect and
// rect and portal it to <body> so it floats over the whole window instead. // portal it to <body> so it floats over the whole window instead.
const [peek, setPeek] = useState<{ seat: number; pos: 'top' | 'bottom'; rect: DOMRect } | null>( const [peek, setPeek] = useState<{ side: number; pos: 'top' | 'bottom'; rect: DOMRect } | null>(
null, null,
) )
const lineups = battle.lineups const lineups = battle.lineups
@@ -313,6 +330,25 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
// paused freezes auto-advance so the player can walk the log manually. // paused freezes auto-advance so the player can walk the log manually.
const [paused, setPaused] = useState(false) const [paused, setPaused] = useState(false)
// Phone tap-to-magnify: tapping a pet/food opens a big readable copy (phones
// can't hover to preview). Opening it pauses the replay; closing it resumes
// only if the replay was actually playing when we tapped, so a manual pause
// (or a finished battle) stays put.
const isPhone = useMediaQuery('(max-width: 600px)')
const [zoom, setZoom] = useState<{ card: Card; bonus: number; damage: number; dead: boolean } | null>(
null,
)
const resumeAfterZoom = useRef(false)
const openZoom = (z: { card: Card; bonus?: number; damage?: number; dead?: boolean }) => {
resumeAfterZoom.current = !paused && !done
setPaused(true)
setZoom({ card: z.card, bonus: z.bonus ?? 0, damage: z.damage ?? 0, dead: !!z.dead })
}
const closeZoom = () => {
setZoom(null)
if (resumeAfterZoom.current) setPaused(false)
}
// A rock event on screen scrambles its dice first; only once they settle do // A rock event on screen scrambles its dice first; only once they settle do
// we apply the damage and reveal the result. // we apply the damage and reveal the result.
const showingRock = !done && lastEvent?.type === 'rock' const showingRock = !done && lastEvent?.type === 'rock'
@@ -355,12 +391,23 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
[events, battle.stackSizes, upto], [events, battle.stackSizes, upto],
) )
const youSeat = view.youSeat // Everything inside a battle is indexed by side (0 or 1), not by seat, so the
const oppSeat = view.players.find((p) => p.seat !== youSeat)?.seat ?? 1 // arena works in sides and maps out to players only for names and results.
const you = view.players[youSeat] // When you're watching someone else's table you have no side in it: side 1
const opp = view.players[oppSeat] // takes the bottom half so the board still reads as two halves facing off.
const won = battle.winnerSeat === youSeat const seatAt = (side: number) => view.players.find((p) => p.seat === battle.seats?.[side])
const draw = battle.winnerSeat < 0 const mySide = sideOf(battle, view.youSeat)
const spectating = mySide < 0
const bottomSide = spectating ? 1 : mySide
const topSide = 1 - bottomSide
const bottom = seatAt(bottomSide)
const top = seatAt(topSide)
// The result banner and the round hand-off always speak about *your* battle,
// even while you're watching another table play out.
const ownBattle = battles.find((b) => sideOf(b, view.youSeat) >= 0) ?? battle
const won = ownBattle.winnerSeat === view.youSeat
const draw = ownBattle.winnerSeat < 0
// Commit to the next round: ack and hand off to the server. // Commit to the next round: ack and hand off to the server.
const proceed = () => { const proceed = () => {
@@ -385,19 +432,19 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
// two pets meet at a horizontal clash line. Each half is a row — // two pets meet at a horizontal clash line. Each half is a row —
// [set-aside | pet | apples] — with the deck under the pet (bottom) or over // [set-aside | pet | apples] — with the deck under the pet (bottom) or over
// it (top). Set-aside stays on the left and apples on the right for both. // it (top). Set-aside stays on the left and apples on the right for both.
function renderSide(seat: number, pos: 'top' | 'bottom') { function renderSide(side: number, pos: 'top' | 'bottom') {
const s = sides[seat] const s = sides[side]
const clashing = !done && lastEvent?.type === 'clash' && s.unit && !s.unit.dying const clashing = !done && lastEvent?.type === 'clash' && s.unit && !s.unit.dying
const clashDying = lastEvent?.type === 'clash' && s.unit?.dying const clashDying = lastEvent?.type === 'clash' && s.unit?.dying
const rockVictim = lastEvent?.type === 'rock' && lastEvent.target === seat const rockVictim = lastEvent?.type === 'rock' && lastEvent.target === side
const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === seat const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === side
const milling = !done && lastEvent?.type === 'mill' && lastEvent.seat === seat const milling = !done && lastEvent?.type === 'mill' && lastEvent.seat === side
const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === seat const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === side
// A food (apple) that just landed on this side's fan — reveal off the deck or // A food (apple) that just landed on this side's fan — reveal off the deck or
// a Battle Prep hand-out — animates in from the deck rather than popping. // a Battle Prep hand-out — animates in from the deck rather than popping.
const newFoodId = const newFoodId =
!done && !done &&
lastEvent?.seat === seat && lastEvent?.seat === side &&
(lastEvent.type === 'prep' || (lastEvent.type === 'prep' ||
(lastEvent.type === 'reveal' && (lastEvent.type === 'reveal' &&
(lastEvent.card?.kind === 'food' || lastEvent.card?.kind === 'ailment'))) (lastEvent.card?.kind === 'food' || lastEvent.card?.kind === 'ailment')))
@@ -405,23 +452,23 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
: null : null
// A pet just set aside slides into the set-aside row beside the arena. // A pet just set aside slides into the set-aside row beside the arena.
const newSetAsideId = const newSetAsideId =
!done && lastEvent?.type === 'setaside' && lastEvent.seat === seat !done && lastEvent?.type === 'setaside' && lastEvent.seat === side
? lastEvent.card?.id ? lastEvent.card?.id
: null : null
// Hold the N / miss! pop until the dice settle. // Hold the N / miss! pop until the dice settle.
const pop = done || (showingRock && !rockSettled) ? null : unitPop(lastEvent, seat, events, step) const pop = done || (showingRock && !rockSettled) ? null : unitPop(lastEvent, side, events, step)
const lineup = lineups?.[seat] ?? [] const lineup = lineups?.[side] ?? []
const stackEl = ( const stackEl = (
<div className={`battle-deck battle-deck-${pos}`}> <div className={`battle-deck battle-deck-${pos}`}>
<div <div
className={`stackpile ${lineup.length ? 'peekable' : ''}`} className={`stackpile ${lineup.length ? 'peekable' : ''}`}
onMouseEnter={ onMouseEnter={
lineup.length lineup.length
? (e) => setPeek({ seat, pos, rect: e.currentTarget.getBoundingClientRect() }) ? (e) => setPeek({ side, pos, rect: e.currentTarget.getBoundingClientRect() })
: undefined : undefined
} }
onMouseLeave={() => setPeek((p) => (p?.seat === seat ? null : p))} onMouseLeave={() => setPeek((p) => (p?.side === side ? null : p))}
title={lineup.length ? 'Hover to see the whole deck' : undefined} title={lineup.length ? 'Hover to see the whole deck' : undefined}
> >
{s.stack > 0 ? ( {s.stack > 0 ? (
@@ -444,7 +491,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
<CardView card={lastEvent.card} size="sm" dead /> <CardView card={lastEvent.card} size="sm" dead />
</div> </div>
)} )}
{rockDice && lastEvent?.seat === seat && ( {rockDice && lastEvent?.seat === side && (
// The dice roll sits beside the throwing side's deck. // The dice roll sits beside the throwing side's deck.
<div className="stack-dice"> <div className="stack-dice">
<DiceRoll key={step} dice={rockDice} side={pos} speed={speed} /> <DiceRoll key={step} dice={rockDice} side={pos} speed={speed} />
@@ -485,7 +532,11 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
}`} }`}
style={{ zIndex: i + 1 }} style={{ zIndex: i + 1 }}
> >
<CardView card={f} size="sm" /> <CardView
card={f}
size="sm"
onClick={isPhone ? () => openZoom({ card: f }) : undefined}
/>
</div> </div>
))} ))}
</div> </div>
@@ -512,6 +563,17 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
bonus={s.unit.bonus} bonus={s.unit.bonus}
damage={s.unit.damage} damage={s.unit.damage}
dead={s.unit.dying} dead={s.unit.dying}
onClick={
isPhone
? () =>
openZoom({
card: s.unit!.card,
bonus: s.unit!.bonus,
damage: s.unit!.damage,
dead: s.unit!.dying,
})
: undefined
}
/> />
{(s.unit.spooked > 0 || s.unit.exposed > 0) && ( {(s.unit.spooked > 0 || s.unit.exposed > 0) && (
<div className="ailment-badges"> <div className="ailment-badges">
@@ -567,7 +629,9 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
return ( return (
<div className="battle" style={{ '--battle-speed': speed } as React.CSSProperties}> <div className="battle" style={{ '--battle-speed': speed } as React.CSSProperties}>
<div className="battle-header"> <div className="battle-header">
<h2>Battle! Round {battle.round}</h2> <h2>
{spectating ? 'Watching' : 'Battle!'} Round {battle.round}
</h2>
<div className="battle-controls"> <div className="battle-controls">
<button className="btn btn-ghost btn-sm" onClick={restart} title="Replay from the start"> <button className="btn btn-ghost btn-sm" onClick={restart} title="Replay from the start">
@@ -632,14 +696,43 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
</div> </div>
</div> </div>
{/* With four or six players several battles resolve at once. Tabs switch
the arena between them so you can watch the whole field, not just your
own fight; at two players there's only one battle and no tab bar. */}
{battles.length > 1 && (
<div className="battle-tabs" role="tablist" aria-label="Battles this round">
{battles.map((b, i) => {
const mine = sideOf(b, view.youSeat) >= 0
const names = (b.seats ?? []).map(
(s) => view.players.find((p) => p.seat === s)?.name ?? '?',
)
return (
<button
key={i}
role="tab"
aria-selected={i === selected}
className={`battle-tab ${i === selected ? 'is-active' : ''} ${mine ? 'is-mine' : ''}`}
onClick={() => onSelect(i)}
title={mine ? 'Your battle' : `Watch ${names.join(' vs ')}`}
>
{mine ? 'Your fight' : names.join(' vs ')}
</button>
)
})}
</div>
)}
<div className="battle-names"> <div className="battle-names">
<span>{you?.name} (you)</span> <span>
{bottom?.name}
{!spectating && ' (you)'}
</span>
<span className="battle-vs">VS</span> <span className="battle-vs">VS</span>
<span>{opp?.name}</span> <span>{top?.name}</span>
</div> </div>
<div className="battlefield"> <div className="battlefield">
{renderSide(oppSeat, 'top')} {renderSide(topSide, 'top')}
<div <div
key={centerClash ? `clash-${step}` : 'center'} key={centerClash ? `clash-${step}` : 'center'}
className={`battle-center ${centerClash ? 'battle-center-clash' : ''}`} className={`battle-center ${centerClash ? 'battle-center-clash' : ''}`}
@@ -647,12 +740,12 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
> >
<span className="battle-center-bolt"></span> <span className="battle-center-bolt"></span>
</div> </div>
{renderSide(youSeat, 'bottom')} {renderSide(bottomSide, 'bottom')}
</div> </div>
{peek && {peek &&
(() => { (() => {
const lineup = lineups?.[peek.seat] ?? [] const lineup = lineups?.[peek.side] ?? []
if (!lineup.length) return null if (!lineup.length) return null
// Your deck sits at the bottom of the board, so float the peek above // Your deck sits at the bottom of the board, so float the peek above
// it; the rival's sits at the top, so float it below. // it; the rival's sits at the top, so float it below.
@@ -664,13 +757,14 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
? { bottom: window.innerHeight - peek.rect.top + 8 } ? { bottom: window.innerHeight - peek.rect.top + 8 }
: { top: peek.rect.bottom + 8 }), : { top: peek.rect.bottom + 8 }),
} }
const mine = peek.seat === youSeat const owner = seatAt(peek.side)
const whose = owner?.seat === view.youSeat ? 'Your' : `${owner?.name ?? 'Their'}s`
// Each player's first pet is the one nearest the clash line; show the // Each player's first pet is the one nearest the clash line; show the
// lineup first-to-last, left to right, for both. // lineup first-to-last, left to right, for both.
return createPortal( return createPortal(
<div className={`deck-peek deck-peek-${peek.pos}`} style={style}> <div className={`deck-peek deck-peek-${peek.pos}`} style={style}>
<div className="deck-peek-label"> <div className="deck-peek-label">
{mine ? 'Your' : 'Opponents'} deck · {lineup.length} card {whose} deck · {lineup.length} card
{lineup.length !== 1 ? 's' : ''} (first on the left) {lineup.length !== 1 ? 's' : ''} (first on the left)
</div> </div>
<div className="deck-peek-cards first-left"> <div className="deck-peek-cards first-left">
@@ -696,13 +790,37 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
</div> </div>
{!draw ? ( {!draw ? (
<div className="battle-result-sub"> <div className="battle-result-sub">
{view.players[battle.winnerSeat]?.name} wins {'🏆'.repeat(battle.trophies)} {view.players.find((p) => p.seat === ownBattle.winnerSeat)?.name} wins{' '}
{'🏆'.repeat(ownBattle.trophies)}
</div> </div>
) : ( ) : (
<div className="battle-result-sub">No trophies awarded</div> <div className="battle-result-sub">No trophies awarded</div>
)} )}
{/* At a bigger table the rest of the field matters as much as your
own result, so the round's other tables are summarised here. */}
{battles.length > 1 && (
<div className="battle-result-others">
<div className="battle-result-others-title">Elsewhere this round</div>
{battles
.filter((b) => b !== ownBattle)
.map((b, i) => {
const winner = view.players.find((p) => p.seat === b.winnerSeat)
const names = (b.seats ?? []).map(
(s) => view.players.find((p) => p.seat === s)?.name ?? '?',
)
return (
<div key={i} className="battle-result-other">
<span>{names.join(' vs ')}</span>
<span className="muted">
{winner ? `${winner.name} ${'🏆'.repeat(b.trophies)}` : 'draw'}
</span>
</div>
)
})}
</div>
)}
{acked ? ( {acked ? (
<p className="muted">Waiting for opponent</p> <p className="muted">Waiting for the other players</p>
) : ( ) : (
<div className="actions"> <div className="actions">
<button className="btn btn-ghost" onClick={() => setResultDismissed(true)}> <button className="btn btn-ghost" onClick={() => setResultDismissed(true)}>
@@ -717,27 +835,37 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
</div>, </div>,
document.body, document.body,
)} )}
{zoom && (
<CardZoom
card={zoom.card}
bonus={zoom.bonus}
damage={zoom.damage}
dead={zoom.dead}
onClose={closeZoom}
/>
)}
</div> </div>
) )
} }
// clashDamageTaken computes how much damage a seat's pet took in the clash // clashDamageTaken computes how much damage a seat's pet took in the clash
// at event index `idx` (its damage total there minus its total beforehand). // at event index `idx` (its damage total there minus its total beforehand).
function clashDamageTaken(events: BattleEvent[], idx: number, seat: number): number { function clashDamageTaken(events: BattleEvent[], idx: number, side: number): number {
const ev = events[idx] const ev = events[idx]
if (ev?.type !== 'clash') return 0 if (ev?.type !== 'clash') return 0
const after = ev.damage?.[seat] ?? 0 const after = ev.damage?.[side] ?? 0
// Walk back to the pet's damage before this clash. // Walk back to the pet's damage before this clash.
let before = 0 let before = 0
for (let k = idx - 1; k >= 0; k--) { for (let k = idx - 1; k >= 0; k--) {
const e = events[k] const e = events[k]
if (e.type === 'reveal' && e.seat === seat && e.card?.kind === 'pet') break if (e.type === 'reveal' && e.seat === side && e.card?.kind === 'pet') break
if ((e.type === 'rock' || e.type === 'heal') && (e.target ?? e.seat) === seat) { if ((e.type === 'rock' || e.type === 'heal') && (e.target ?? e.seat) === side) {
before = e.damageAfter ?? 0 before = e.damageAfter ?? 0
break break
} }
if (e.type === 'clash') { if (e.type === 'clash') {
before = e.damage?.[seat] ?? 0 before = e.damage?.[side] ?? 0
break break
} }
} }
+66
View File
@@ -0,0 +1,66 @@
import { useState } from 'react'
import { createPortal } from 'react-dom'
import type { Session } from '../types'
import { debugReportURL, fetchDebugReportText } from '../api'
// BugReport is the dialog behind the game menu's "Report a bug": it hands the
// player the game's debug report — the whole state, both hands, every battle
// with the dice it rolled, and the event log — to attach to a bug. Downloading
// the JSON is the useful one, since that form replays in a test.
//
// Unlike the debug card panel this is always available, not DEBUG-gated: a bug
// is worth more than the hidden information the report gives away, and it only
// ever goes to the player who asked for it.
export function BugReport({ session, onClose }: { session: Session; onClose: () => void }) {
const [note, setNote] = useState('')
const [status, setStatus] = useState('')
const copyText = async () => {
setStatus('Fetching…')
try {
await navigator.clipboard.writeText(await fetchDebugReportText(session, note))
setStatus('Copied to the clipboard.')
} catch {
setStatus('Could not copy it — try the download instead.')
}
}
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div className="modal bug-modal" onClick={(e) => e.stopPropagation()}>
<h2 className="bug-title">🐛 Report a bug</h2>
<p className="bug-blurb">
This saves everything about the game as it stands both decks, the shop, every
battle with the dice it rolled, and the full event log so the situation can be
replayed exactly and fixed.
</p>
<textarea
className="bug-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="What went wrong? (optional, but it helps a lot)"
rows={3}
autoFocus
/>
<div className="bug-actions">
<a
className="btn"
href={debugReportURL(session, 'json', note)}
onClick={() => setStatus('Downloaded — attach that file to the bug.')}
download
>
Download report
</a>
<button className="btn btn-ghost" onClick={copyText}>
📋 Copy as text
</button>
</div>
{status && <div className="muted">{status}</div>}
<button className="btn btn-ghost btn-sm" onClick={onClose}>
Close
</button>
</div>
</div>,
document.body,
)
}
+68 -12
View File
@@ -1,4 +1,4 @@
import { useLayoutEffect, useRef, useState } from 'react' import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import type { Card } from '../types' import type { Card } from '../types'
import { artFor, artUrlFor } from '../petArt' import { artFor, artUrlFor } from '../petArt'
@@ -177,6 +177,39 @@ export function CardMagnify({
) )
} }
// CardZoom is the touch-friendly "tap to read" overlay: a big centered copy of
// a card with a Close button, portaled over everything. Phones have no hover to
// pop the magnifier, so a tap opens this instead (the shop has its own richer
// version with Buy/Sell). Battle decorations (buffs, damage, death) ride along
// so the zoomed card matches what's on the board.
export function CardZoom({
card,
bonus = 0,
damage = 0,
dead,
onClose,
}: {
card: Card
bonus?: number
damage?: number
dead?: boolean
onClose: () => void
}) {
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div className="card-focus" onClick={(e) => e.stopPropagation()}>
<CardView card={card} size="lg" bonus={bonus} damage={damage} dead={dead} preview />
<div className="card-focus-actions">
<button className="btn btn-ghost" onClick={onClose}>
Close
</button>
</div>
</div>
</div>,
document.body,
)
}
interface Props { interface Props {
card: Card card: Card
size?: 'sm' | 'md' | 'lg' size?: 'sm' | 'md' | 'lg'
@@ -189,8 +222,9 @@ interface Props {
dead?: boolean dead?: boolean
// preview marks the floating magnified copy so it doesn't magnify itself. // preview marks the floating magnified copy so it doesn't magnify itself.
preview?: boolean preview?: boolean
// noMagnify suppresses the hover magnifier (e.g. while this card is being // noMagnify turns the hover magnifier off entirely — not just hidden but not
// dragged, so the frozen preview copy doesn't linger over the drag ghost). // tracked, so nothing is left half-armed to spring open when it flips back
// (e.g. while cards are being dragged and the rows slide under the cursor).
noMagnify?: boolean noMagnify?: boolean
} }
@@ -212,6 +246,17 @@ export function CardView({
// Hover magnifier: show a large floating copy beside the cursor. The // Hover magnifier: show a large floating copy beside the cursor. The
// preview copy itself opts out so it can't recurse. // preview copy itself opts out so it can't recurse.
const [hover, setHover] = useState<{ x: number; y: number } | null>(null) const [hover, setHover] = useState<{ x: number; y: number } | null>(null)
// When the magnifier is off, don't track hover at all — and forget anything
// already tracked. Suppressing only the rendering isn't enough: a drag slides
// rows around under the cursor, so the `mouseleave` that would normally clear
// this frequently never reaches the card it belongs to (the cursor outruns the
// lifted card, and React re-inserts the rows it passes). The stale hover then
// sprang the preview open the instant the drag ended and stuck there until
// that exact card was hovered and left again.
const hoverOff = preview || !!noMagnify || !CAN_HOVER
useEffect(() => {
if (hoverOff) setHover(null)
}, [hoverOff])
const power = (card.power ?? 0) + bonus const power = (card.power ?? 0) + bonus
// Shrink long ability text to fit the (fixed-height) card, re-fitting when // Shrink long ability text to fit the (fixed-height) card, re-fitting when
// the text or card size changes. Only the branch that renders attaches it. // the text or card size changes. Only the branch that renders attaches it.
@@ -232,8 +277,10 @@ export function CardView({
.join(' ') .join(' ')
// Place the magnified preview near the cursor, clamped into the viewport. // Place the magnified preview near the cursor, clamped into the viewport.
// Checking `hoverOff` here as well as in the effect keeps the preview from
// flashing for the one render between it turning on and the effect clearing.
const previewEl = const previewEl =
hover && !preview && !noMagnify ? ( hover && !hoverOff ? (
<CardMagnify card={card} x={hover.x} y={hover.y} bonus={bonus} damage={damage} dead={dead} /> <CardMagnify card={card} x={hover.x} y={hover.y} bonus={bonus} damage={damage} dead={dead} />
) : null ) : null
@@ -243,9 +290,9 @@ export function CardView({
data-card-id={card.id || undefined} data-card-id={card.id || undefined}
onClick={disabled ? undefined : onClick} onClick={disabled ? undefined : onClick}
role={onClick ? 'button' : undefined} role={onClick ? 'button' : undefined}
onMouseEnter={preview || !CAN_HOVER ? undefined : (e) => setHover({ x: e.clientX, y: e.clientY })} onMouseEnter={hoverOff ? undefined : (e) => setHover({ x: e.clientX, y: e.clientY })}
onMouseMove={preview || !CAN_HOVER ? undefined : (e) => setHover({ x: e.clientX, y: e.clientY })} onMouseMove={hoverOff ? undefined : (e) => setHover({ x: e.clientX, y: e.clientY })}
onMouseLeave={preview || !CAN_HOVER ? undefined : () => setHover(null)} onMouseLeave={hoverOff ? undefined : () => setHover(null)}
> >
{previewEl} {previewEl}
{/* Upper illustration: a stylised landscape with the pet as a sticker and {/* Upper illustration: a stylised landscape with the pet as a sticker and
@@ -265,18 +312,27 @@ export function CardView({
</div> </div>
</div> </div>
{/* Lower panel: the suit hat, name, and tier die on one row, the ability {/* Lower panel: the suit hat, name, and tier die on one row, the ability
beneath a white card in a rounded green frame. */} beneath a white card in a rounded green frame. Token cards (apples,
ailments) have neither a suit nor a tier, so their name takes the whole
row instead of being squeezed between two empty spacer slots (which, on
a small battle card, left "Apple" only a few pixels and clipped it). */}
<div className="card-panel"> <div className="card-panel">
<div className="card-panel-head"> <div className={`card-panel-head ${!card.suit && !card.tier ? 'is-bare' : ''}`}>
{card.suit ? ( {(card.suit || card.tier) &&
(card.suit ? (
<span className={`suit-dot card-suit suit-${card.suit}`} aria-hidden /> <span className={`suit-dot card-suit suit-${card.suit}`} aria-hidden />
) : ( ) : (
<span className="card-panel-slot" aria-hidden /> <span className="card-panel-slot" aria-hidden />
)} ))}
<span className="card-name" ref={nameRef}> <span className="card-name" ref={nameRef}>
{card.name} {card.name}
</span> </span>
{card.tier ? <TierDie tier={card.tier} /> : <span className="card-panel-slot" aria-hidden />} {(card.suit || card.tier) &&
(card.tier ? (
<TierDie tier={card.tier} />
) : (
<span className="card-panel-slot" aria-hidden />
))}
</div> </div>
<div className="card-effect" ref={effectRef}> <div className="card-effect" ref={effectRef}>
{card.effectText {card.effectText
+7 -5
View File
@@ -5,22 +5,24 @@ import { CardView } from './CardView'
interface Props { interface Props {
canGrant: boolean // shop phase — grants only land then canGrant: boolean // shop phase — grants only land then
pack: string // active pack, so the catalog matches the game packs: string[] // packs in play, so the catalog matches the game
send: (msg: ClientMessage) => void send: (msg: ClientMessage) => void
} }
// DebugPanel is a testing aid (server DEBUG mode only): a collapsible drawer // DebugPanel is a testing aid (server DEBUG mode only): a collapsible drawer
// listing every card in the active pack, tier by tier. Clicking one drops it // listing every card in the packs in play, tier by tier. Clicking one drops it
// into your deck for free, off-turn. // into your deck for free, off-turn.
export function DebugPanel({ canGrant, pack, send }: Props) { export function DebugPanel({ canGrant, packs, send }: Props) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [catalog, setCatalog] = useState<Card[]>([]) const [catalog, setCatalog] = useState<Card[]>([])
// Joined into a stable key so a fresh array identity each render doesn't refetch.
const packKey = packs.join(',')
useEffect(() => { useEffect(() => {
fetchCatalog(pack) fetchCatalog(packKey.split(','))
.then(setCatalog) .then(setCatalog)
.catch(() => setCatalog([])) .catch(() => setCatalog([]))
}, [pack]) }, [packKey])
const tiers = [...new Set(catalog.map((c) => c.tier ?? 0))].sort((a, b) => a - b) const tiers = [...new Set(catalog.map((c) => c.tier ?? 0))].sort((a, b) => a - b)
+43 -10
View File
@@ -1,26 +1,59 @@
import type { GameView } from '../types' import type { GameView } from '../types'
export function GameOver({ view, onLeave }: { view: GameView; onLeave: () => void }) { export function GameOver({ view, onLeave }: { view: GameView; onLeave: () => void }) {
const winner = view.winnerSeat >= 0 ? view.players[view.winnerSeat] : null // The title can be shared: players level on trophies whose round-by-round
const youWon = view.winnerSeat === view.youSeat // records are also identical split it (the rulebook's "share that victory!").
const winners = view.winnerSeats ?? (view.winnerSeat >= 0 ? [view.winnerSeat] : [])
const youWon = winners.includes(view.youSeat)
const shared = winners.length > 1
const winnerNames = winners
.map((s) => view.players.find((p) => p.seat === s)?.name ?? '?')
.join(' & ')
const title = !winners.length
? "It's a tie!"
: youWon
? shared
? 'You share the win!'
: 'You win!'
: shared
? `${winnerNames} share the win!`
: `${winnerNames} wins!`
// Standings run by trophies, and the countback that decided any tie is worth
// showing: which rounds each player actually took.
const standings = [...view.players].sort(
(a, b) => b.trophies - a.trophies || a.seat - b.seat,
)
return ( return (
<div className="gameover"> <div className="gameover">
<div className="gameover-emoji" aria-hidden> <div className="gameover-emoji" aria-hidden>
{winner ? (youWon ? '🎉' : '💀') : '🤝'} {!winners.length ? '🤝' : youWon ? '🎉' : shared ? '🤝' : '💀'}
</div> </div>
<h1 className="gameover-title"> <h1 className="gameover-title">{title}</h1>
{winner ? (youWon ? 'You win!' : `${winner.name} wins!`) : "It's a tie!"}
</h1>
<div className="gameover-scores"> <div className="gameover-scores">
{[...view.players] {standings.map((p) => (
.sort((a, b) => b.trophies - a.trophies) <div
.map((p) => ( key={p.id}
<div key={p.id} className={`score-line ${p.seat === view.youSeat ? 'is-you' : ''}`}> className={[
'score-line',
p.seat === view.youSeat ? 'is-you' : '',
winners.includes(p.seat) ? 'is-winner' : '',
]
.filter(Boolean)
.join(' ')}
>
<span className="score-name"> <span className="score-name">
{winners.includes(p.seat) && <span aria-label="winner">👑 </span>}
{p.name} {p.name}
{p.seat === view.youSeat ? ' (you)' : ''} {p.seat === view.youSeat ? ' (you)' : ''}
</span> </span>
{(p.roundWins?.length ?? 0) > 0 && (
<span className="score-rounds muted" title="Rounds won">
won {p.roundWins!.map((r) => `R${r}`).join(' ')}
</span>
)}
<span className="score-trophies"> <span className="score-trophies">
{'🏆'.repeat(p.trophies) || '—'} <strong>{p.trophies}</strong> {'🏆'.repeat(p.trophies) || '—'} <strong>{p.trophies}</strong>
</span> </span>
+77 -24
View File
@@ -6,9 +6,13 @@ const BOT_LEVELS: { value: string; label: string; blurb: string }[] = [
{ value: 'hard', label: '🦁 Hard', blurb: 'Shows no mercy' }, { value: 'hard', label: '🦁 Hard', blurb: 'Shows no mercy' },
] ]
// Lobby is the pre-game setup screen. The host picks a pack, fills the second // Lobby is the pre-game setup screen. The host picks the packs, fills seats
// seat with a bot or a friend, can remove players, and starts the game. // with friends or computer players, can remove anyone, and starts the game.
// Everyone else sees the same lineup read-only and waits for the host. // Everyone else sees the same lineup read-only and waits for the host.
//
// Two rules gate the start button, and the host needs to see both at a glance:
// the table has to be an even size (players battle in pairs every round), and
// the rulebook asks for one card pack per pair.
export function Lobby({ export function Lobby({
view, view,
send, send,
@@ -17,10 +21,33 @@ export function Lobby({
send: (m: ClientMessage) => void send: (m: ClientMessage) => void
}) { }) {
const isHost = view.youSeat === view.hostSeat const isHost = view.youSeat === view.hostSeat
const openSeats = view.maxPlayers - view.players.length const seated = view.players.length
const selectedPlayable = const openSeats = view.maxPlayers - seated
view.packs.find((p) => p.id === view.pack)?.playable ?? false const packs = view.packs ?? []
const canStart = view.players.length >= view.minPlayers && selectedPlayable
const evenTable = view.playerCounts.includes(seated)
const enoughPacks = packs.length >= view.packsNeeded
const allPlayable = packs.every(
(id) => view.packCatalog.find((p) => p.id === id)?.playable ?? false,
)
const canStart = evenTable && enoughPacks && allPlayable
// Toggling a pack sends the whole new selection — the server validates it as
// a set, so there's no partial state to get stuck in.
const togglePack = (id: string) => {
const next = packs.includes(id) ? packs.filter((p) => p !== id) : [...packs, id]
if (next.length === 0) return // there's always at least one pack in play
send({ type: 'setPacks', packs: next })
}
// The one thing standing between the host and a game, in their words.
const blocker = !evenTable
? seated < view.minPlayers
? 'Waiting for a second player…'
: `${seated} players cant pair off — add or remove a seat`
: !enoughPacks
? `${seated} players needs ${view.packsNeeded} packs shuffled together`
: null
return ( return (
<div className="lobby"> <div className="lobby">
@@ -38,41 +65,66 @@ export function Lobby({
</div> </div>
<section className="lobby-section"> <section className="lobby-section">
<h3 className="lobby-section-title">Card pack</h3> <h3 className="lobby-section-title">
Card packs ({packs.length}/{view.packCatalog.length})
</h3>
<p className="muted lobby-note">
{view.packsNeeded > 1
? `Every packs tier 1 cards shuffle together, tier 2 together, and so on. ${seated} players needs at least ${view.packsNeeded}.`
: 'Pick one, or combine several for a deeper shop.'}
</p>
<div className="pack-grid"> <div className="pack-grid">
{view.packs.map((pack) => { {view.packCatalog.map((pack) => {
const selected = pack.id === view.pack const selected = packs.includes(pack.id)
const disabled = !pack.playable || !isHost const onlyOne = selected && packs.length === 1
return ( return (
<button <button
key={pack.id} key={pack.id}
className={`pack-card ${selected ? 'is-selected' : ''} ${ className={`pack-card ${selected ? 'is-selected' : ''} ${
pack.playable ? '' : 'is-locked' pack.playable ? '' : 'is-locked'
}`} }`}
disabled={disabled} disabled={!pack.playable || !isHost || onlyOne}
title={pack.playable ? pack.name : `${pack.name} — coming soon`} title={
onClick={() => send({ type: 'setPack', pack: pack.id })} !pack.playable
? `${pack.name} — coming soon`
: onlyOne
? 'At least one pack has to stay in play'
: selected
? `Remove ${pack.name}`
: `Add ${pack.name}`
}
onClick={() => togglePack(pack.id)}
> >
<span className="pack-emoji" aria-hidden> <span className="pack-emoji" aria-hidden>
{pack.emoji} {pack.emoji}
</span> </span>
<span className="pack-name">{pack.name}</span> <span className="pack-name">{pack.name}</span>
<span className="pack-tag"> <span className="pack-tag">
{pack.playable ? (selected ? 'Selected' : 'Available') : 'Coming soon'} {!pack.playable ? 'Coming soon' : selected ? '✓ In play' : 'Add'}
</span> </span>
</button> </button>
) )
})} })}
</div> </div>
{!isHost && ( {!isHost && (
<p className="muted lobby-note">Only the host can change the pack.</p> <p className="muted lobby-note">Only the host can change the packs.</p>
)}
{isHost && !enoughPacks && (
<p className="lobby-warn">
Add {view.packsNeeded - packs.length} more pack
{view.packsNeeded - packs.length !== 1 ? 's' : ''} to seat {seated} players.
</p>
)} )}
</section> </section>
<section className="lobby-section"> <section className="lobby-section">
<h3 className="lobby-section-title"> <h3 className="lobby-section-title">
Players ({view.players.length}/{view.maxPlayers}) Players ({seated}/{view.maxPlayers})
</h3> </h3>
<p className="muted lobby-note">
Play happens in pairs, so the table needs {view.playerCounts.join(', ')} players.
Fill any odd seat with a computer player.
</p>
<ul className="seat-list"> <ul className="seat-list">
{view.players.map((p) => ( {view.players.map((p) => (
<SeatRow <SeatRow
@@ -85,12 +137,13 @@ export function Lobby({
/> />
))} ))}
{Array.from({ length: openSeats }).map((_, i) => ( {openSeats > 0 && (
<li key={`open-${i}`} className="seat-row seat-open"> <li className="seat-row seat-open">
{isHost ? ( {isHost ? (
<div className="seat-open-host"> <div className="seat-open-host">
<span className="muted"> <span className="muted">
Add a computer opponent, or share the code to invite a friend: {openSeats} open seat{openSeats !== 1 ? 's' : ''} add a computer
player, or share the code to invite a friend:
</span> </span>
<div className="seat-bot-picker"> <div className="seat-bot-picker">
{BOT_LEVELS.map((b) => ( {BOT_LEVELS.map((b) => (
@@ -106,10 +159,12 @@ export function Lobby({
</div> </div>
</div> </div>
) : ( ) : (
<span className="muted">Waiting for the host to fill this seat</span> <span className="muted">
{openSeats} open seat{openSeats !== 1 ? 's' : ''} waiting for the host
</span>
)} )}
</li> </li>
))} )}
</ul> </ul>
</section> </section>
@@ -119,9 +174,7 @@ export function Lobby({
disabled={!canStart} disabled={!canStart}
onClick={() => send({ type: 'start' })} onClick={() => send({ type: 'start' })}
> >
{view.players.length < view.minPlayers {blocker ?? 'Start game'}
? 'Waiting for a second player…'
: 'Start game'}
</button> </button>
) : ( ) : (
<p className="lobby-waiting muted">Waiting for the host to start the game</p> <p className="lobby-waiting muted">Waiting for the host to start the game</p>
+10 -4
View File
@@ -122,7 +122,10 @@ export function ShopPhase({ view, you, send }: Props) {
const totalCoins = purse.current.max const totalCoins = purse.current.max
const deck = you.deck ?? [] const deck = you.deck ?? []
const opponent = view.players.find((p) => p.seat !== view.youSeat) // Whoever the shop is currently waiting on, by name — at a bigger table
// that's a specific player taking their turn, not "the opponent".
const acting = view.players.find((p) => p.seat === view.turn)
const stillShopping = view.players.filter((p) => p.seat !== view.youSeat && !p.ready)
const pending = view.pending const pending = view.pending
const myPending = pending?.playerId === you.id const myPending = pending?.playerId === you.id
@@ -263,12 +266,15 @@ export function ShopPhase({ view, you, send }: Props) {
<div className="shop-status"> <div className="shop-status">
{pending && !myPending ? ( {pending && !myPending ? (
<span className="muted"> <span className="muted">
{opponent?.name ?? 'Opponent'} is tripling up a tier {acting?.name ?? 'Someone'} is tripling up a tier
</span> </span>
) : you.ready ? ( ) : you.ready ? (
<span className="muted"> <span className="muted">
You passed waiting for {opponent?.name ?? 'opponent'} to finish You passed waiting for{' '}
shopping {stillShopping.length === 1
? stillShopping[0].name
: `${stillShopping.length} more players`}{' '}
to finish shopping
</span> </span>
) : myTurn && overPets ? ( ) : myTurn && overPets ? (
<span className="status-hot"> <span className="status-hot">
+107 -33
View File
@@ -11,29 +11,47 @@ import { ArrangePhase } from './ArrangePhase'
import { BattlePhase } from './BattlePhase' import { BattlePhase } from './BattlePhase'
import { GameOver } from './GameOver' import { GameOver } from './GameOver'
import { DebugPanel } from './DebugPanel' import { DebugPanel } from './DebugPanel'
import { BugReport } from './BugReport'
import { EventLog, battleLogLines } from './EventLog' import { EventLog, battleLogLines } from './EventLog'
// Table connects to the game and routes to the right phase screen. // Table connects to the game and routes to the right phase screen.
export function Table({ session, onLeave }: { session: Session; onLeave: () => void }) { export function Table({ session, onLeave }: { session: Session; onLeave: () => void }) {
const { view, error, connected, send } = useGame(session) const { view, error, connected, send } = useGame(session)
// Battle replay step lives here (not inside BattlePhase) so the event log, // A round runs one battle per pairing, so with four or six players there are
// a sibling, can render the battle narration up to the same step. Deriving // several to watch. Which one is on screen lives here, alongside the replay
// the effective step from the current battle round resets it to 0 whenever a // step, because the event log is a sibling and narrates whichever battle is
// new battle arrives, without a separate effect. These hooks must run on // playing. Both reset when a new round's battles arrive.
// every render (before any early return) to satisfy the rules of hooks. //
const battleRound = view?.battle?.round ?? -1 // These hooks must run on every render (before any early return) to satisfy
const [stepState, setStepState] = useState<{ round: number; step: number }>({ // the rules of hooks.
round: -1, const battles = view?.battles ?? []
const battleRound = battles[0]?.round ?? -1
// Your own fight is what a round opens on; spectators of a game they aren't
// seated in (or a malformed round) fall back to the first table.
const ownIdx = Math.max(
0,
battles.findIndex((b) => (b.seats ?? []).includes(view?.youSeat ?? -1)),
)
const [sel, setSel] = useState<{ round: number; idx: number }>({ round: -1, idx: 0 })
const selected = sel.round === battleRound ? Math.min(sel.idx, battles.length - 1) : ownIdx
const battle = battles[selected]
// Deriving the effective step from the round and the selected battle resets
// it to 0 whenever either changes, without a separate effect.
const [stepState, setStepState] = useState<{ key: string; step: number }>({
key: '',
step: 0, step: 0,
}) })
const step = stepState.round === battleRound ? stepState.step : 0 const stepKey = `${battleRound}:${selected}`
const step = stepState.key === stepKey ? stepState.step : 0
const setStep: Dispatch<SetStateAction<number>> = (upd) => const setStep: Dispatch<SetStateAction<number>> = (upd) =>
setStepState((prev) => { setStepState((prev) => {
const cur = prev.round === battleRound ? prev.step : 0 const cur = prev.key === stepKey ? prev.step : 0
const next = typeof upd === 'function' ? (upd as (n: number) => number)(cur) : upd const next = typeof upd === 'function' ? (upd as (n: number) => number)(cur) : upd
return { round: battleRound, step: next } return { key: stepKey, step: next }
}) })
const selectBattle = (idx: number) => setSel({ round: battleRound, idx })
// A shop-phase peek at an opponent's deck from the previous round's battle // A shop-phase peek at an opponent's deck from the previous round's battle
// (its arranged lineup is already public). Shown as a centered modal. // (its arranged lineup is already public). Shown as a centered modal.
@@ -44,16 +62,17 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
// this open flag is simply ignored. // this open flag is simply ignored.
const [logOpen, setLogOpen] = useState(false) const [logOpen, setLogOpen] = useState(false)
// The room code and Leave button live behind a "⋯" menu in the topbar rail, // The room code, the bug reporter and Leave live behind a "⋯" menu in the
// keeping the rail short enough for one row on a phone. // topbar rail, keeping the rail short enough for one row on a phone.
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [bugOpen, setBugOpen] = useState(false)
// The battle outcome is known before the replay plays out, so we hold its // The battle outcome is known before the replay plays out, so we hold its
// "result" log entry back: it's dropped from the persistent list during the // "result" log entry back: it's dropped from the persistent list during the
// battle and appended as the final battle line only once the replay reaches // battle and appended as the final battle line only once the replay reaches
// the end (and reappears in the persistent log in later phases). // the end (and reappears in the persistent log in later phases).
const inBattle = view?.phase === 'battle' const inBattle = view?.phase === 'battle'
const events = view?.battle?.events ?? null const events = battle?.events ?? null
const battleDone = !!(inBattle && events && step >= events.length) const battleDone = !!(inBattle && events && step >= events.length)
const entries = useMemo(() => { const entries = useMemo(() => {
@@ -66,9 +85,12 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
if (!inBattle || !events) return undefined if (!inBattle || !events) return undefined
const lines = battleLogLines(events, step) const lines = battleLogLines(events, step)
if (battleDone) { if (battleDone) {
const result = (view?.log ?? []).find( // The engine logs one result per battle, in the same order it resolves
// them into view.battles — so the selected battle's line is at the same
// index among the round's results.
const result = (view?.log ?? []).filter(
(e) => e.kind === 'result' && e.round === battleRound, (e) => e.kind === 'result' && e.round === battleRound,
) )[selected]
if (result) { if (result) {
lines.push({ lines.push({
key: `result-${result.seq}`, key: `result-${result.seq}`,
@@ -79,14 +101,14 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
} }
} }
return lines return lines
}, [view, inBattle, events, step, battleDone, battleRound]) }, [view, inBattle, events, step, battleDone, battleRound, selected])
// Map every card name we know about to a representative card, so the event // Map every card name we know about to a representative card, so the event
// log can preview pets/foods it mentions on hover. The catalog covers all // log can preview pets/foods it mentions on hover. The catalog covers all
// buyable pets and foods for the pack (even ones not currently in view); // buyable pets and foods across the packs in play (even ones not currently in
// concrete cards from the view fill in tokens and summons (Bee, Apple, …) // view); concrete cards from the view fill in tokens and summons (Bee,
// that never appear in the shop. // Apple, …) that never appear in the shop.
const catalog = useCatalog(view?.pack) const catalog = useCatalog(view?.packs)
const cardLookup = useMemo(() => { const cardLookup = useMemo(() => {
const map = new Map<string, Card>() const map = new Map<string, Card>()
for (const c of catalog) if (c.name) map.set(c.name, c) for (const c of catalog) if (c.name) map.set(c.name, c)
@@ -96,8 +118,10 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
if (view) { if (view) {
view.shopRow?.forEach(add) view.shopRow?.forEach(add)
view.players?.forEach((p) => p.deck?.forEach(add)) view.players?.forEach((p) => p.deck?.forEach(add))
view.battle?.lineups?.forEach((line) => line?.forEach(add)) view.battles?.forEach((b) => {
view.battle?.events?.forEach((ev) => add(ev.card)) b.lineups?.forEach((line) => line?.forEach(add))
b.events?.forEach((ev) => add(ev.card))
})
} }
return map return map
}, [catalog, view]) }, [catalog, view])
@@ -129,6 +153,17 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
const you = view.players[view.youSeat] const you = view.players[view.youSeat]
const opponents = view.players.filter((p) => p.seat !== view.youSeat) const opponents = view.players.filter((p) => p.seat !== view.youSeat)
// Last round's lineups are public, so any player's deck can be peeked at
// during the shop — whichever table they fought at. Indexed by seat here;
// inside a battle result the lineups are indexed by side.
const lastLineups = new Map<number, Card[]>()
for (const b of battles) {
;(b.seats ?? []).forEach((seat, side) => {
const line = b.lineups?.[side]
if (line?.length) lastLineups.set(seat, line)
})
}
return ( return (
<div className="table"> <div className="table">
<header className="topbar"> <header className="topbar">
@@ -144,11 +179,21 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
<DieFace value={view.round} className="topbar-die" /> <DieFace value={view.round} className="topbar-die" />
</div> </div>
)} )}
<div className="topbar-players"> {/* Up to six seats ride here, so each one stays terse: the extra chips
only appear when they have something to say, and this round's
opponent is flagged so you know who you're preparing for. */}
<div className={`topbar-players count-${view.players.length}`}>
{view.players.map((p) => ( {view.players.map((p) => (
<div <div
key={p.id} key={p.id}
className={`topbar-player ${p.seat === view.youSeat ? 'is-you' : ''}`} className={[
'topbar-player',
p.seat === view.youSeat ? 'is-you' : '',
p.seat === view.yourOpponent ? 'is-rival' : '',
view.phase === 'shop' && p.seat === view.turn ? 'is-turn' : '',
]
.filter(Boolean)
.join(' ')}
> >
{p.isBot ? ( {p.isBot ? (
<span className="bot-dot" title="Computer player"> <span className="bot-dot" title="Computer player">
@@ -158,16 +203,22 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
<span className={`conn-dot ${p.connected ? 'on' : 'off'}`} /> <span className={`conn-dot ${p.connected ? 'on' : 'off'}`} />
)} )}
<span className="topbar-name">{p.name}</span> <span className="topbar-name">{p.name}</span>
{p.seat === view.yourOpponent && view.phase !== 'lobby' && (
<span className="chip chip-rival" title="You fight them this round">
</span>
)}
<span className="chip">🏆 {p.trophies}</span> <span className="chip">🏆 {p.trophies}</span>
{/* Your own gold shows as big discs above the buy row (ShopPhase); {/* Your own gold shows as big discs above the buy row (ShopPhase);
the opponent's stays as a compact chip here. */} everyone else's stays as a compact chip here. */}
{view.phase === 'shop' && p.seat !== view.youSeat && ( {view.phase === 'shop' && p.seat !== view.youSeat && (
<span className="chip">🪙 {p.coins}</span> <span className="chip">🪙 {p.coins}</span>
)} )}
{/* Peek at the opponent's deck from last round's battle. */} {/* Peek at anyone's deck from last round's battle every table's
lineups are public once fought. */}
{view.phase === 'shop' && {view.phase === 'shop' &&
p.seat !== view.youSeat && p.seat !== view.youSeat &&
(view.battle?.lineups?.[p.seat]?.length ?? 0) > 0 && ( (lastLineups.get(p.seat)?.length ?? 0) > 0 && (
<button <button
className={`chip chip-btn ${deckPeek?.seat === p.seat ? 'is-active' : ''}`} className={`chip chip-btn ${deckPeek?.seat === p.seat ? 'is-active' : ''}`}
title="See their deck from last round's battle" title="See their deck from last round's battle"
@@ -208,6 +259,16 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
{view.code} {view.code}
</span> </span>
</div> </div>
<button
className="btn btn-ghost btn-sm"
role="menuitem"
onClick={() => {
setMenuOpen(false)
setBugOpen(true)
}}
>
🐛 Report a bug
</button>
<button <button
className="btn btn-ghost btn-sm" className="btn btn-ghost btn-sm"
role="menuitem" role="menuitem"
@@ -225,12 +286,23 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
</header> </header>
<div className="table-body"> <div className="table-body">
<main className="table-main"> {/* The shop lays out wide (a 4-card buy row plus a spreading deck), so it
gets the extra desktop width; other phases keep the portrait column. */}
<main className={`table-main ${view.phase === 'shop' ? 'is-wide' : ''}`}>
{view.phase === 'lobby' && <Lobby view={view} send={send} />} {view.phase === 'lobby' && <Lobby view={view} send={send} />}
{view.phase === 'shop' && <ShopPhase view={view} you={you} send={send} />} {view.phase === 'shop' && <ShopPhase view={view} you={you} send={send} />}
{view.phase === 'arrange' && <ArrangePhase view={view} you={you} send={send} />} {view.phase === 'arrange' && <ArrangePhase view={view} you={you} send={send} />}
{view.phase === 'battle' && ( {view.phase === 'battle' && battle && (
<BattlePhase view={view} send={send} step={step} setStep={setStep} /> <BattlePhase
view={view}
send={send}
step={step}
setStep={setStep}
battle={battle}
battles={battles}
selected={selected}
onSelect={selectBattle}
/>
)} )}
{view.phase === 'gameover' && <GameOver view={view} onLeave={onLeave} />} {view.phase === 'gameover' && <GameOver view={view} onLeave={onLeave} />}
</main> </main>
@@ -263,13 +335,15 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
)} )}
{error && <div className="toast">{error}</div>} {error && <div className="toast">{error}</div>}
{view.debug && ( {view.debug && (
<DebugPanel canGrant={view.phase === 'shop'} pack={view.pack} send={send} /> <DebugPanel canGrant={view.phase === 'shop'} packs={view.packs} send={send} />
)} )}
{bugOpen && <BugReport session={session} onClose={() => setBugOpen(false)} />}
{deckPeek && {deckPeek &&
view.phase === 'shop' && view.phase === 'shop' &&
(() => { (() => {
const lineup = view.battle?.lineups?.[deckPeek.seat] ?? [] const lineup = lastLineups.get(deckPeek.seat) ?? []
if (!lineup.length) return null if (!lineup.length) return null
const oppName = view.players.find((p) => p.seat === deckPeek.seat)?.name ?? 'Opponent' const oppName = view.players.find((p) => p.seat === deckPeek.seat)?.name ?? 'Opponent'
return createPortal( return createPortal(
+305 -62
View File
@@ -488,6 +488,43 @@ h3 {
0 1px 3px rgba(0, 0, 0, 0.35); 0 1px 3px rgba(0, 0, 0, 0.35);
} }
/* This round's opponent gets a cooler outline than your own gold one enough
to pick them out of five other seats without competing with it. */
.topbar-player.is-rival {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 0 0 2px rgba(255, 120, 120, 0.75),
0 1px 3px rgba(0, 0, 0, 0.35);
}
/* Whoever the shop is waiting on, so a six-seat rail still shows the turn. */
.topbar-player.is-turn {
background: linear-gradient(180deg, rgba(255, 214, 102, 0.22), rgba(0, 0, 0, 0.28));
}
/* Four or six pins won't fit at full size, so the rail tightens as it fills.
Names ellipsis rather than wrap, keeping the topbar one row deep. */
.topbar-players.count-4 .topbar-player,
.topbar-players.count-6 .topbar-player {
padding: 4px 9px;
font-size: 0.82rem;
gap: 5px;
min-width: 0;
}
/* Names still have to fit, but not so tight that "Robo Rookie" and "Robo
Rival" both collapse to "Robo R" and the seats stop being tellable apart. */
.topbar-players.count-6 .topbar-name {
max-width: 12ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip-rival {
filter: drop-shadow(0 0 3px rgba(255, 120, 120, 0.8));
}
.topbar-name { .topbar-name {
font-weight: 800; font-weight: 800;
} }
@@ -619,6 +656,18 @@ h3 {
margin: 0 auto; margin: 0 auto;
} }
/* Desktop: the shop wants horizontal room the portrait column can't give it
the 4-card buy row alone needs ~676px to stay on one line, and the deck
spreads wider still. Once the viewport can spare the width, let the shop
(only) expand past the 620px column the other phases keep. Below this
breakpoint, and on phones (see the max-width query), it falls back to the
full-width portrait layout and the buy row wraps as before. */
@media (min-width: 720px) {
.table-main.is-wide {
max-width: 1000px;
}
}
/* ---------- event log ---------- */ /* ---------- event log ---------- */
/* A pinned-up ledger of what happened, paper-warm against the felt. */ /* A pinned-up ledger of what happened, paper-warm against the felt. */
@@ -909,6 +958,14 @@ h3 {
font-size: 1.05rem; font-size: 1.05rem;
} }
/* What's still standing between the host and a startable game. */
.lobby-warn {
text-align: center;
font-size: 0.9rem;
font-weight: 700;
color: #ffcf8f;
}
/* ---------- pack picker ---------- */ /* ---------- pack picker ---------- */
.pack-grid { .pack-grid {
@@ -1237,6 +1294,14 @@ h3 {
padding-bottom: 2px; padding-bottom: 2px;
} }
/* Token cards (apples, ailments) carry no suit dot or tier die, so their name
spans the whole row instead of being pinched between two empty spacer slots
on a small battle card that pinch left only a few pixels and clipped the
title down to "A". */
.card-panel-head.is-bare {
grid-template-columns: minmax(0, 1fr);
}
/* The suit marker: a plain enamel dot (see .suit-dot), sized to sit opposite /* The suit marker: a plain enamel dot (see .suit-dot), sized to sit opposite
the tier die in the name row. */ the tier die in the name row. */
.card-suit { .card-suit {
@@ -1402,10 +1467,14 @@ h3 {
font-size: 0.74rem; font-size: 0.74rem;
} }
/* Damage marker sits at the bottom-centre of the illustration so it never
overlaps the power badge (top-centre) on a narrow battle card the old
top-right spot rode on top of the power and hid it. */
.card-damage { .card-damage {
position: absolute; position: absolute;
top: 5%; bottom: 4%;
right: 5%; left: 50%;
transform: translateX(-50%);
z-index: 3; z-index: 3;
font-family: var(--font-display); font-family: var(--font-display);
color: #fff; color: #fff;
@@ -1852,6 +1921,15 @@ h3 {
gap: 20px; gap: 20px;
} }
/* How-to-reorder line, tucked under the ordering rule a shade quieter the
drag gesture has no visible handle to advertise it. */
.arrange-tip {
display: block;
margin-top: 4px;
font-size: 0.85rem;
opacity: 0.78;
}
/* A vertical battle line laid into a felt tray: card 0 (fights first) sits at /* A vertical battle line laid into a felt tray: card 0 (fights first) sits at
the top and later pets flow downward. */ the top and later pets flow downward. */
.arrange-col { .arrange-col {
@@ -1892,9 +1970,7 @@ h3 {
justify-content: center; justify-content: center;
} }
/* The lifted card floats above its neighbors while being dragged. The z-index /* The lifted card floats above its neighbors while being dragged. */
sits on the inner wrapper (which carries the follow transform) so it wins
over sibling rows without disturbing the measured `.arrange-card` box. */
.arrange-card.is-dragging { .arrange-card.is-dragging {
z-index: 5; z-index: 5;
} }
@@ -1912,8 +1988,9 @@ h3 {
transition: transform 0.18s ease-out; transition: transform 0.18s ease-out;
} }
/* Reorder controls: the grip handle on top, the up/down arrows below. Hung to /* The up/down arrows, hung to the right of the card so the card itself stays
the right of the card so the card itself stays centered under the marker. */ centered under the marker. They're the precise alternative to dragging, so
they're sized as comfortable targets rather than tucked away. */
.arrange-controls { .arrange-controls {
position: absolute; position: absolute;
left: 100%; left: 100%;
@@ -1923,40 +2000,41 @@ h3 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
} }
/* Grab this to drag-reorder. `touch-action: none` keeps the browser from .arrange-arrow {
scrolling the page while a touch drag is in progress; the pointer handlers display: grid;
in ArrangePhase do the reordering. */ place-items: center;
.arrange-handle { width: 46px;
touch-action: none; height: 46px;
padding: 0;
font-size: 1.15rem;
line-height: 1;
border-radius: 12px;
}
/* The card is its own drag surface press it and move (see ArrangePhase for
how a mouse and a finger each start a drag). Deliberately no
`touch-action: none`: a finger has to hold still to pick a card up, so plain
swipes are left to scroll the page. Selection and the long-press callout are
off so the hold reads as a grab, not as text selection. */
.arrange-grab {
display: flex;
cursor: grab; cursor: grab;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
display: flex; -webkit-touch-callout: none;
align-items: center; border-radius: var(--card-radius);
justify-content: center; transition: transform 140ms ease, box-shadow 140ms ease;
min-width: 40px;
min-height: 40px;
font-size: 1.5rem;
line-height: 1;
color: var(--gold);
opacity: 0.75;
border: 1px solid rgba(0, 0, 0, 0.3);
border-radius: 10px;
background: rgba(0, 0, 0, 0.2);
} }
.arrange-handle:active { /* Picked up: the card swells off the tray and casts a shadow over the rows
below on touch that lift is the only confirmation the hold landed. */
.arrange-card.is-dragging .arrange-grab {
cursor: grabbing; cursor: grabbing;
opacity: 1; transform: scale(1.05);
} box-shadow: 0 14px 24px rgba(0, 0, 0, 0.45);
.arrange-arrows {
display: flex;
flex-direction: column;
gap: 6px;
} }
/* ---------- battle ---------- */ /* ---------- battle ---------- */
@@ -1966,6 +2044,15 @@ h3 {
animation divides its base duration by this, so the visuals quicken in animation divides its base duration by this, so the visuals quicken in
lockstep with the JS step timers. Fallback keeps calc() valid if unset. */ lockstep with the JS step timers. Fallback keeps calc() valid if unset. */
--battle-speed: 1; --battle-speed: 1;
/* Arena slot metrics. Every slot in the battlefield the pet in play and the
deck beneath/above it is sized to these exactly, whether or not a card
currently occupies it. That keeps the arena a fixed reserved area so its
height never shifts as cards enter and leave play. Phones override them in
the media query below; nothing else should hardcode these sizes. */
--unit-w: 96px;
--unit-h: 162px;
--deck-w: 84px;
--deck-h: 116px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 18px;
@@ -2012,6 +2099,56 @@ h3 {
text-align: center; text-align: center;
} }
/* With four or six players a round holds several battles at once; these tabs
switch the arena between them. They read as folder tabs sitting on top of
the battlefield, with your own fight marked. */
.battle-tabs {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 6px;
}
.battle-tab {
border: 0;
cursor: pointer;
font: inherit;
font-size: 0.82rem;
font-weight: 700;
color: inherit;
padding: 5px 14px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.28);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
opacity: 0.72;
transition:
opacity 0.15s ease,
background 0.15s ease;
}
.battle-tab:hover {
opacity: 1;
}
.battle-tab.is-active {
opacity: 1;
background: rgba(255, 255, 255, 0.14);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 1px 3px rgba(0, 0, 0, 0.35);
}
.battle-tab.is-mine {
color: var(--gold);
}
.battle-tab.is-mine.is-active {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 0 0 2px var(--gold);
}
.battle-names { .battle-names {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -2043,13 +2180,24 @@ h3 {
(right) to fan out beside each pet; a big apple stack (Manatee, Fire Ant) (right) to fan out beside each pet; a big apple stack (Manatee, Fire Ant)
scrolls horizontally rather than clipping. */ scrolls horizontally rather than clipping. */
padding: 18px 14px; padding: 18px 14px;
min-height: 150px; /* That sideways scroll keeps its scrollbar hidden: on classic-scrollbar
platforms it would appear and vanish as fans grow and shrink, and since the
arena's height is content-driven, that alone made it jump by the
scrollbar's thickness mid-battle. Touch, trackpad and shift+wheel still
scroll it. Vertical is hidden outright pops that reach past the felt
(spawns, ailment badges) were never scrollable to anyway. */
overflow-x: auto; overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
box-shadow: box-shadow:
var(--tray-inset), var(--tray-inset),
inset 0 0 60px rgba(0, 0, 0, 0.25); inset 0 0 60px rgba(0, 0, 0, 0.25);
} }
.battlefield::-webkit-scrollbar {
display: none;
}
.battle-center { .battle-center {
position: relative; position: relative;
font-size: 1.7rem; font-size: 1.7rem;
@@ -2085,10 +2233,12 @@ h3 {
min-width: 0; min-width: 0;
} }
/* Centres the deck stack under (you) / over (opponent) the pet. */ /* Centres the deck stack under (you) / over (opponent) the pet. Its height is
the deck slot's, held whether the deck still has cards or has run dry. */
.battle-deck { .battle-deck {
display: flex; display: flex;
justify-content: center; justify-content: center;
height: var(--deck-h);
} }
/* --- deck stacks --- */ /* --- deck stacks --- */
@@ -2101,8 +2251,8 @@ h3 {
/* The face-down deck: a walnut-backed card with a woven diamond pattern and a /* The face-down deck: a walnut-backed card with a woven diamond pattern and a
little stacked-depth shadow beneath it. */ little stacked-depth shadow beneath it. */
.card-back { .card-back {
width: 84px; width: var(--deck-w);
height: 116px; height: var(--deck-h);
border-radius: var(--card-radius); border-radius: var(--card-radius);
border: 2px solid var(--cocoa); border: 2px solid var(--cocoa);
background: background:
@@ -2132,9 +2282,11 @@ h3 {
box-shadow: inset 0 0 0 2px rgba(255, 230, 200, 0.25); box-shadow: inset 0 0 0 2px rgba(255, 230, 200, 0.25);
} }
/* The empty deck slot holds the face-down card's footprint exactly, so a deck
running dry doesn't resize the arena. */
.stack-empty { .stack-empty {
width: 84px; width: var(--deck-w);
height: 116px; height: var(--deck-h);
} }
@keyframes summon-pop { @keyframes summon-pop {
@@ -2279,10 +2431,14 @@ h3 {
/* --- the pet in play --- */ /* --- the pet in play --- */
/* The pet's slot in the arena: a fixed reserved box the size of a battle card,
held even while it's empty (between a faint and the next reveal) so the
arena's height stays put as cards enter and leave play. Set-aside pets and the
food fan hang off it absolutely, so they never resize it either. */
.battle-unit-zone { .battle-unit-zone {
position: relative; position: relative;
min-width: 100px; width: var(--unit-w);
min-height: 150px; height: var(--unit-h);
display: grid; display: grid;
place-items: center; place-items: center;
} }
@@ -2395,11 +2551,11 @@ h3 {
flex-shrink: 0; flex-shrink: 0;
} }
/* Fills its reserved slot. The slot is taller than a shop card so the full
ability text fits without shrinking to an unreadable size during battle. */
.battle-unit .card { .battle-unit .card {
width: 96px; width: var(--unit-w);
/* Taller than a shop card so the full ability text fits without shrinking to height: var(--unit-h);
an unreadable size during the battle. */
height: 162px;
} }
/* Battle cards are short, so give the ability text more of the card by /* Battle cards are short, so give the ability text more of the card by
@@ -2783,6 +2939,33 @@ h3 {
font-weight: 700; font-weight: 700;
} }
/* How the rest of the field did this round, under your own result. */
.battle-result-others {
width: 100%;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 12px;
margin-top: 4px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
font-size: 0.88rem;
}
.battle-result-others-title {
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.6;
}
.battle-result-other {
display: flex;
justify-content: space-between;
gap: 16px;
font-weight: 600;
}
/* The toolbar's "advance the round" button stands out from the ghost controls. */ /* The toolbar's "advance the round" button stands out from the ghost controls. */
.battle-next { .battle-next {
margin-left: 2px; margin-left: 2px;
@@ -2828,6 +3011,7 @@ h3 {
.score-line { .score-line {
display: flex; display: flex;
align-items: baseline;
justify-content: space-between; justify-content: space-between;
gap: 24px; gap: 24px;
font-size: 1.1rem; font-size: 1.1rem;
@@ -2840,6 +3024,26 @@ h3 {
font-weight: 900; font-weight: 900;
} }
.score-line.is-winner {
background: rgba(255, 214, 102, 0.1);
border-radius: 8px;
padding: 4px 8px;
margin: 0 -8px;
}
/* The rounds a player took — the countback that settles a tie, spelled out. */
.score-rounds {
font-size: 0.78rem;
font-weight: 600;
margin-left: auto;
margin-right: 8px;
white-space: nowrap;
}
.score-name {
white-space: nowrap;
}
/* ---------- toasts & banners ---------- */ /* ---------- toasts & banners ---------- */
.toast { .toast {
@@ -3171,16 +3375,22 @@ h3 {
} }
.arrange-controls { .arrange-controls {
margin-left: 8px; margin-left: 8px;
gap: 8px;
} }
.arrange-handle { .arrange-arrow {
min-width: 34px; width: 40px;
min-height: 34px; height: 40px;
font-size: 1.25rem; font-size: 1rem;
} }
/* --- battle: fit both sides and the clash between the rails --- */ /* --- battle: fit both sides and the clash between the rails --- */
.battle { .battle {
gap: 8px; gap: 8px;
/* Smaller arena slots; the pet and deck boxes follow automatically. */
--unit-w: 66px;
--unit-h: 112px;
--deck-w: 56px;
--deck-h: 78px;
} }
.battle-header { .battle-header {
gap: 8px; gap: 8px;
@@ -3203,7 +3413,6 @@ h3 {
} }
.battlefield { .battlefield {
padding: 8px 8px; padding: 8px 8px;
min-height: 0;
gap: 3px; gap: 3px;
border-radius: 16px; border-radius: 16px;
} }
@@ -3214,14 +3423,6 @@ h3 {
.battle-side { .battle-side {
gap: 5px; gap: 5px;
} }
.battle-unit .card {
width: 66px;
height: 112px;
}
.battle-unit-zone {
min-width: 72px;
min-height: 112px;
}
/* A battle card is small and glanced at, not read so on a phone strip it to /* A battle card is small and glanced at, not read so on a phone strip it to
the essentials: big art, the power badge and any damage marker, and the the essentials: big art, the power badge and any damage marker, and the
name. No suit dot, tier die, or ability text (the magnified view still shows name. No suit dot, tier die, or ability text (the magnified view still shows
@@ -3246,10 +3447,6 @@ h3 {
.battle-unit .card-art { .battle-unit .card-art {
font-size: 2.1rem; font-size: 2.1rem;
} }
.card-back {
width: 56px;
height: 78px;
}
.card-back-count { .card-back-count {
width: 28px; width: 28px;
height: 28px; height: 28px;
@@ -3339,6 +3536,52 @@ h3 {
margin-bottom: 8px; margin-bottom: 8px;
} }
/* --- bug report dialog (always available, not a DEBUG affordance) --- */
.bug-modal {
width: min(460px, 92vw);
gap: 14px;
}
.bug-title {
font-family: var(--font-display);
font-size: 1.3rem;
margin: 0;
}
.bug-blurb {
margin: 0;
font-size: 0.85rem;
line-height: 1.5;
opacity: 0.8;
}
.bug-note {
background: rgba(0, 0, 0, 0.3);
color: var(--cream);
border: 1px solid rgba(253, 243, 220, 0.25);
border-radius: 10px;
padding: 8px 10px;
font: inherit;
font-size: 0.9rem;
resize: vertical;
}
.bug-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
/* The download is an <a> so the browser saves the file; make it sit level with
the button beside it. */
.bug-actions a.btn {
text-decoration: none;
display: inline-flex;
align-items: center;
}
.debug-tier-label { .debug-tier-label {
color: var(--cream); color: var(--cream);
font-size: 0.75rem; font-size: 0.75rem;
+24 -6
View File
@@ -25,6 +25,7 @@ export interface PlayerView {
seat: number seat: number
coins: number coins: number
trophies: number trophies: number
roundWins?: number[] // rounds this player won a battle in (public)
ready: boolean ready: boolean
connected: boolean connected: boolean
isBot?: boolean isBot?: boolean
@@ -100,15 +101,25 @@ export interface BattleEvent {
text?: string text?: string
} }
// A round runs one battle per pairing, so a six-player round has three of
// these. Everything inside is indexed by *side* — 0 or 1 within this battle —
// rather than by seat at the table, including BattleEvent.seat/target. `seats`
// maps the two apart. winnerSeat is the exception: it's a real seat.
export interface BattleResult { export interface BattleResult {
round: number round: number
seats: number[] // the two seats fighting, first player first
stackSizes: number[] stackSizes: number[]
lineups?: Card[][] // each seat's arranged deck (top first); public for peeking lineups?: Card[][] // each side's arranged deck (top first); public for peeking
events: BattleEvent[] | null events: BattleEvent[] | null
winnerSeat: number winnerSeat: number
trophies: number trophies: number
} }
// sideOf maps a seat to its side index in a battle, or -1 if it wasn't in it.
export function sideOf(battle: BattleResult, seat: number): number {
return battle.seats?.indexOf(seat) ?? -1
}
export interface LogEntry { export interface LogEntry {
seq: number seq: number
round: number round: number
@@ -135,27 +146,34 @@ export interface GameView {
round: number round: number
maxRounds: number maxRounds: number
maxPets: number maxPets: number
pack: string packs: string[] // selected packs, shuffled together
packs: PackInfo[] packCatalog: PackInfo[] // the choices offered in the lobby
packsNeeded: number // packs the current table size requires
hostSeat: number hostSeat: number
minPlayers: number minPlayers: number
maxPlayers: number maxPlayers: number
playerCounts: number[] // table sizes a game can start at (2, 4, 6)
youSeat: number youSeat: number
turn: number turn: number
prioritySeat: number
shopRow: Card[] shopRow: Card[]
deckCounts: number[] deckCounts: number[]
players: PlayerView[] players: PlayerView[]
matchups?: [number, number][] // this round's battle pairings (public)
yourOpponent: number // the seat you face this round, or -1
pending?: PendingTrade pending?: PendingTrade
pendingReveal?: PendingReveal pendingReveal?: PendingReveal
pendingSacrifice?: PendingSacrifice pendingSacrifice?: PendingSacrifice
battle?: BattleResult battle?: BattleResult // your own battle this round
winnerSeat: number battles?: BattleResult[] // every table's, all replayable
winnerSeat: number // outright winner, or -1 when shared
winnerSeats?: number[] // everyone holding the title
log?: LogEntry[] log?: LogEntry[]
debug?: boolean // server DEBUG mode: unlocks the buy-any-card panel debug?: boolean // server DEBUG mode: unlocks the buy-any-card panel
} }
export type ClientMessage = export type ClientMessage =
| { type: 'setPack'; pack: string } | { type: 'setPacks'; packs: string[] }
| { type: 'addBot'; difficulty: string } | { type: 'addBot'; difficulty: string }
| { type: 'removePlayer'; target: string } | { type: 'removePlayer'; target: string }
| { type: 'start' } | { type: 'start' }
+9 -7
View File
@@ -2,15 +2,17 @@ import { useEffect, useState } from 'react'
import { fetchCatalog } from './api' import { fetchCatalog } from './api'
import type { Card } from './types' import type { Card } from './types'
// useCatalog loads the representative card for every pet and food in a pack and // useCatalog loads the representative card for every pet and food across the
// caches the result per pack. It's used to look up cards by name (e.g. to // packs in play. It's used to look up cards by name (e.g. to preview pets
// preview pets mentioned in the event log), even ones not currently in view. // mentioned in the event log), even ones not currently in view. The packs are
export function useCatalog(pack?: string): Card[] { // joined into a stable key so a new array identity each render doesn't refetch.
export function useCatalog(packs?: string[]): Card[] {
const [cards, setCards] = useState<Card[]>([]) const [cards, setCards] = useState<Card[]>([])
const key = (packs ?? []).join(',')
useEffect(() => { useEffect(() => {
if (!pack) return if (!key) return
let cancelled = false let cancelled = false
fetchCatalog(pack) fetchCatalog(key.split(','))
.then((c) => { .then((c) => {
if (!cancelled) setCards(c) if (!cancelled) setCards(c)
}) })
@@ -20,6 +22,6 @@ export function useCatalog(pack?: string): Card[] {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [pack]) }, [key])
return cards return cards
} }
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/anim.ts","./src/api.ts","./src/main.tsx","./src/petArt.ts","./src/types.ts","./src/useBattleSpeed.ts","./src/useCatalog.ts","./src/useGame.ts","./src/useMediaQuery.ts","./src/vite-env.d.ts","./src/components/ArrangePhase.tsx","./src/components/BattlePhase.tsx","./src/components/CardView.tsx","./src/components/DebugPanel.tsx","./src/components/DiceRoll.tsx","./src/components/EventLog.tsx","./src/components/GameOver.tsx","./src/components/Home.tsx","./src/components/Lobby.tsx","./src/components/ShopPhase.tsx","./src/components/Table.tsx"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/anim.ts","./src/api.ts","./src/main.tsx","./src/petArt.ts","./src/types.ts","./src/useBattleSpeed.ts","./src/useCatalog.ts","./src/useGame.ts","./src/useMediaQuery.ts","./src/vite-env.d.ts","./src/components/ArrangePhase.tsx","./src/components/BattlePhase.tsx","./src/components/BugReport.tsx","./src/components/CardView.tsx","./src/components/DebugPanel.tsx","./src/components/DiceRoll.tsx","./src/components/EventLog.tsx","./src/components/GameOver.tsx","./src/components/Home.tsx","./src/components/Lobby.tsx","./src/components/ShopPhase.tsx","./src/components/Table.tsx"],"version":"5.9.3"}