Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84900415b2 | ||
|
|
6ae4d41976 | ||
|
|
8ccde03023 | ||
|
|
a4f5f6910d | ||
|
|
e542118175 | ||
|
|
8383f0f718 | ||
|
|
a2024229ff | ||
|
|
b5a143d059 | ||
|
|
4fb5c5929f | ||
|
|
1070999b95 |
@@ -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
|
||||
|
||||
## 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
|
||||
|
||||
**Whenever game behavior changes — rules, cards, effects, phases, log
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Super Auto Pets: The Board Game — Online
|
||||
|
||||
A web app for playing the Super Auto Pets board game remotely. 1v1 for now;
|
||||
the engine is built to grow to more players. Play a friend by room code, or
|
||||
play solo against a computer opponent (easy / medium / hard).
|
||||
A web app for playing the Super Auto Pets board game remotely, at 2, 4, or 6
|
||||
players. Play friends by room code, fill any empty seat with a computer
|
||||
opponent (easy / medium / hard), or play solo against a table of them.
|
||||
|
||||
## 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`):
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| ------------ | ---------- | ------------------------------------ |
|
||||
| ------------ | ---------- | ------------------------------------------- |
|
||||
| `DATA_DIR` | `data` | Directory holding the SQLite DB |
|
||||
| `PORT` | `8080` | HTTP port |
|
||||
| `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
|
||||
|
||||
@@ -74,17 +116,49 @@ Six rounds, each with its own shop tier deck. Per round:
|
||||
(Peacock, Camel, Gorilla). Shields (Turtle/Gorilla/Melon) block whole
|
||||
hits, Garlic shaves 1 per attack, and Scorpion KOs anything its clash
|
||||
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
|
||||
as a stalemate draw. Last player able to field a pet wins: 1 trophy for
|
||||
rounds 1–5, 2 for round 6. Draws award nothing.
|
||||
enemy pet as it's played. A clash that changes nothing is a stalemate:
|
||||
both pets faint so the ones behind them can settle it. Last player able
|
||||
to field a pet wins: 1 trophy for rounds 1–5, 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.
|
||||
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
|
||||
|
||||
Three card packs are playable, chosen by the host in the lobby. Each pack is
|
||||
six tiers, one per round, and every pet ships as two copies.
|
||||
Three card packs are playable, and the host combines one or more of them in
|
||||
the lobby. Each pack is six tiers, one per round, and every pet ships as two
|
||||
copies.
|
||||
|
||||
### Turtle pack
|
||||
|
||||
@@ -153,6 +227,7 @@ The Unicorn pack adds two mechanics the others don't have:
|
||||
|
||||
```
|
||||
cmd/server/ entrypoint
|
||||
cmd/report/ debug report dumper (reads the DB directly)
|
||||
internal/game/ rules engine (pure, fully tested)
|
||||
internal/ai/ computer opponent (decides from a player View only)
|
||||
internal/server/ HTTP + WebSocket rooms; drives bot turns
|
||||
@@ -164,12 +239,18 @@ web/ React frontend
|
||||
## The computer opponent
|
||||
|
||||
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
|
||||
freely. The AI in `internal/ai` never touches the `Game` — it decides from a
|
||||
`game.View`, the same per-player state a human client is sent, plus a
|
||||
persisted memory of public observations (battle lineups, the event log, shop
|
||||
row changes). It cannot see your deck order, hidden trade picks, or the
|
||||
shuffled shop decks. It scores candidate moves by Monte-Carlo battle
|
||||
rollouts (`game.SimulateBattle`) against sampled guesses of your deck and
|
||||
ordering, blended with a long-term deck-value heuristic; difficulty tunes a
|
||||
softmax over the scored moves plus the rollout budget.
|
||||
to the engine, so a table can mix them freely. The AI in `internal/ai` never
|
||||
touches the `Game` — it decides from a `game.View`, the same per-player state
|
||||
a human client is sent, plus a persisted memory of public observations
|
||||
(battle lineups, the event log, shop row changes). It cannot see your deck
|
||||
order, hidden trade picks, or the shuffled shop decks. It scores candidate
|
||||
moves by Monte-Carlo battle rollouts (`game.SimulateBattle`) against sampled
|
||||
guesses of your deck and ordering, blended with a long-term deck-value
|
||||
heuristic; difficulty tunes a softmax over the scored moves plus the rollout
|
||||
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.
|
||||
|
||||
@@ -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
@@ -247,11 +247,18 @@ func immediateWeight(v *game.View) float64 {
|
||||
if 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
+67
-18
@@ -32,24 +32,36 @@ func forcePlayable(id string) func() {
|
||||
|
||||
func playBotGamePack(t *testing.T, pack string, levelA, levelB float64) *game.Game {
|
||||
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)()
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("AddBot A: %v", err)
|
||||
t.Fatalf("AddBot %d: %v", i, err)
|
||||
}
|
||||
pb, err := g.AddBot("Bot B", levelB)
|
||||
if err != nil {
|
||||
t.Fatalf("AddBot B: %v", err)
|
||||
bots[p.ID] = New(level)
|
||||
mems[p.ID] = &Memory{}
|
||||
}
|
||||
if err := g.SetPack(pack); err != nil {
|
||||
t.Fatalf("SetPack %s: %v", pack, err)
|
||||
if err := g.SetPacks(packs); err != nil {
|
||||
t.Fatalf("SetPacks %v: %v", packs, err)
|
||||
}
|
||||
if err := g.StartGame(); err != nil {
|
||||
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() {
|
||||
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
|
||||
// printed; 4-6 empty). It exercises the Trumpet/Golden Retriever/Cone Snail
|
||||
// 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]++
|
||||
}
|
||||
got := map[string]int{}
|
||||
for _, c := range mem.Opp.Known {
|
||||
for _, c := range mem.Opp(1).Known {
|
||||
got[c.Name]++
|
||||
}
|
||||
if len(mem.Opp.Hidden) != 0 {
|
||||
t.Errorf("model has %d hidden cards, want 0 (everything was public)", len(mem.Opp.Hidden))
|
||||
if len(mem.Opp(1).Hidden) != 0 {
|
||||
t.Errorf("model has %d hidden cards, want 0 (everything was public)", len(mem.Opp(1).Hidden))
|
||||
}
|
||||
for name, n := range want {
|
||||
if got[name] != n {
|
||||
@@ -306,8 +355,9 @@ func TestDecideShopNeverSellsLastPet(t *testing.T) {
|
||||
Round: game.MaxRounds, // alpha == 1: score is win-now only
|
||||
MaxRounds: game.MaxRounds,
|
||||
MaxPets: game.MaxPets,
|
||||
Pack: game.DefaultPack,
|
||||
Packs: []string{game.DefaultPack},
|
||||
YouSeat: 0,
|
||||
YourOpponent: 1,
|
||||
Turn: 0,
|
||||
PrioritySeat: 0,
|
||||
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.
|
||||
mem := &Memory{}
|
||||
mem.Opp.Seat = 1
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -360,8 +409,9 @@ func TestDecideShopKeepsHealthyBoard(t *testing.T) {
|
||||
Round: 3, // mid-game: future value still carries real weight
|
||||
MaxRounds: game.MaxRounds,
|
||||
MaxPets: game.MaxPets,
|
||||
Pack: game.DefaultPack,
|
||||
Packs: []string{game.DefaultPack},
|
||||
YouSeat: 0,
|
||||
YourOpponent: 1,
|
||||
Turn: 0,
|
||||
PrioritySeat: 0,
|
||||
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 real temptation, not a hopeless-position tie-break.
|
||||
mem := &Memory{}
|
||||
mem.Opp.Seat = 1
|
||||
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
|
||||
|
||||
@@ -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
@@ -36,9 +36,11 @@ func winScore(res *game.BattleResult, mySeat int) float64 {
|
||||
default:
|
||||
base = 0
|
||||
}
|
||||
// Survivors is indexed by battle side, not by seat.
|
||||
mySide := res.Side(mySeat)
|
||||
margin := 0
|
||||
for seat, s := range res.Survivors {
|
||||
if seat == mySeat {
|
||||
for side, s := range res.Survivors {
|
||||
if side == mySide {
|
||||
margin += s
|
||||
} else {
|
||||
margin -= s
|
||||
@@ -58,19 +60,30 @@ func (cx *ctx) winProb(myDeck []game.Card, oppDecks [][]game.Card, simsPer int)
|
||||
total, n := 0.0, 0
|
||||
for _, opp := range oppDecks {
|
||||
for range simsPer {
|
||||
var res *game.BattleResult
|
||||
if cx.me.Seat == 0 {
|
||||
res = game.SimulateBattle(cx.v.Round, cx.v.PrioritySeat, myDeck, opp, nil)
|
||||
} else {
|
||||
res = game.SimulateBattle(cx.v.Round, cx.v.PrioritySeat, opp, myDeck, nil)
|
||||
}
|
||||
total += winScore(res, cx.me.Seat)
|
||||
// The bot's own deck always takes scratch seat 0, so a rollout reads
|
||||
// the same however the real table happens to be seated.
|
||||
res := game.SimulateBattle(cx.v.Round, cx.simFirstSeat(), myDeck, opp, nil)
|
||||
total += winScore(res, 0)
|
||||
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
|
||||
// by selling or trading it away. Temporary cards (apples) are nearly free to
|
||||
// lose — they vanish after the next battle anyway.
|
||||
|
||||
@@ -11,8 +11,11 @@ import (
|
||||
// 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.
|
||||
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 {
|
||||
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)
|
||||
|
||||
+111
-57
@@ -17,7 +17,11 @@ type Memory struct {
|
||||
LastSeq int `json:"lastSeq"` // last event-log entry processed
|
||||
LastBattleRound int `json:"lastBattleRound"` // last battle lineup ingested
|
||||
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
|
||||
@@ -30,6 +34,28 @@ type OppModel struct {
|
||||
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
|
||||
// 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.
|
||||
@@ -44,6 +70,14 @@ func LoadMemory(raw json.RawMessage) *Memory {
|
||||
m := &Memory{}
|
||||
if len(raw) > 0 {
|
||||
_ = 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
|
||||
}
|
||||
@@ -64,112 +98,132 @@ func (m *Memory) Marshal() json.RawMessage {
|
||||
// 1. new event-log entries, whose structured tags describe opponent shop
|
||||
// actions (buys name the card, sells name what left, trades list the
|
||||
// discarded trio, spawn entries count apples gained);
|
||||
// 2. the latest battle's lineups, which reveal both decks in full and reset
|
||||
// the model to ground truth every round (so any drift lasts one round);
|
||||
// 3. the opponent's public deck size, as a reconciliation safety net.
|
||||
// 2. the round's battle lineups, which reveal every deck in full and reset
|
||||
// the models to ground truth every round (so any drift lasts one round);
|
||||
// 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) {
|
||||
if v.YouSeat < 0 {
|
||||
return
|
||||
}
|
||||
oppSeat := -1
|
||||
for _, p := range v.Players {
|
||||
if p.Seat != v.YouSeat {
|
||||
oppSeat = p.Seat
|
||||
break
|
||||
isOpponent := func(seat int) bool {
|
||||
return seat >= 0 && seat != v.YouSeat && v.PlayerView(seat) != nil
|
||||
}
|
||||
}
|
||||
if oppSeat < 0 {
|
||||
return
|
||||
}
|
||||
m.Opp.Seat = oppSeat
|
||||
|
||||
for _, e := range v.Log {
|
||||
if e.Seq <= m.LastSeq {
|
||||
continue
|
||||
}
|
||||
m.LastSeq = e.Seq
|
||||
if e.Seat != oppSeat {
|
||||
if !isOpponent(e.Seat) {
|
||||
continue
|
||||
}
|
||||
opp := m.opp(e.Seat)
|
||||
switch {
|
||||
case e.Kind == game.LogBuy:
|
||||
if c, ok := cardByID(m.PrevShopRow, e.Source); ok {
|
||||
// An Avocado buy is set aside, not kept in the deck (Golden
|
||||
// pack): don't add it to the deck model.
|
||||
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 {
|
||||
m.Opp.Known = append(m.Opp.Known, c)
|
||||
opp.Known = append(opp.Known, c)
|
||||
}
|
||||
}
|
||||
case e.Kind == game.LogSell:
|
||||
m.removeOppCard(e.Source, e.CardName)
|
||||
m.Opp.Known = append(m.Opp.Known, memApple(len(m.Opp.Known)))
|
||||
opp.remove(e.Source, e.CardName)
|
||||
opp.Known = append(opp.Known, memApple(len(opp.Known)))
|
||||
case e.Kind == game.LogTrade:
|
||||
for _, id := range e.Cards {
|
||||
m.removeOppCard(id, "")
|
||||
opp.remove(id, "")
|
||||
}
|
||||
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})
|
||||
case e.Spawn == "apple" && e.Kind == "":
|
||||
n := max(e.Count, 1)
|
||||
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
|
||||
// revealed deck, minus temporary cards (they expire with the battle).
|
||||
if v.Battle != nil && v.Battle.Round > m.LastBattleRound && oppSeat < len(v.Battle.Lineups) {
|
||||
m.LastBattleRound = v.Battle.Round
|
||||
m.Opp.Known = m.Opp.Known[:0]
|
||||
m.Opp.Hidden = nil
|
||||
for _, c := range v.Battle.Lineups[oppSeat] {
|
||||
// Battle lineups are ground truth: rebuild each opponent's model from their
|
||||
// revealed deck, minus temporary cards (they expire with the battle). Every
|
||||
// table's battle is public, so one round refreshes the whole field.
|
||||
for _, b := range v.Battles {
|
||||
if b == nil || b.Round <= m.LastBattleRound {
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
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,
|
||||
// where the live deck still holds temporaries the model excludes.
|
||||
// Reconcile with the public deck sizes. Skipped during the battle phase,
|
||||
// where the live decks still hold temporaries the models exclude.
|
||||
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 len(m.Opp.Known)+len(m.Opp.Hidden) < size {
|
||||
m.Opp.Hidden = append(m.Opp.Hidden, HiddenCard{Tier: v.Round})
|
||||
}
|
||||
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]
|
||||
for _, p := range v.Players {
|
||||
if !isOpponent(p.Seat) {
|
||||
continue
|
||||
}
|
||||
m.opp(p.Seat).reconcile(p.DeckSize, v.Round)
|
||||
}
|
||||
}
|
||||
|
||||
m.PrevShopRow = append(m.PrevShopRow[:0], v.ShopRow...)
|
||||
}
|
||||
|
||||
// removeOppCard drops one card from the model: by exact ID when we tracked
|
||||
// it, by name as a fallback (model-minted apples have synthetic IDs), and
|
||||
// failing both, one hidden card — something we didn't know they had left.
|
||||
func (m *Memory) removeOppCard(id, name string) {
|
||||
if i := slices.IndexFunc(m.Opp.Known, func(c game.Card) bool { return c.ID == id }); i >= 0 {
|
||||
m.Opp.Known = slices.Delete(m.Opp.Known, i, i+1)
|
||||
// remove drops one card from the model: by exact ID when we tracked it, by
|
||||
// name as a fallback (model-minted apples have synthetic IDs), and failing
|
||||
// both, one hidden card — something we didn't know they had, now gone.
|
||||
func (o *OppModel) remove(id, name string) {
|
||||
if i := slices.IndexFunc(o.Known, func(c game.Card) bool { return c.ID == id }); i >= 0 {
|
||||
o.Known = slices.Delete(o.Known, i, i+1)
|
||||
return
|
||||
}
|
||||
if name != "" {
|
||||
if i := slices.IndexFunc(m.Opp.Known, func(c game.Card) bool { return c.Name == name }); i >= 0 {
|
||||
m.Opp.Known = slices.Delete(m.Opp.Known, i, i+1)
|
||||
if i := slices.IndexFunc(o.Known, func(c game.Card) bool { return c.Name == name }); i >= 0 {
|
||||
o.Known = slices.Delete(o.Known, i, i+1)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(m.Opp.Hidden) > 0 {
|
||||
m.Opp.Hidden = m.Opp.Hidden[:len(m.Opp.Hidden)-1]
|
||||
if len(o.Hidden) > 0 {
|
||||
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
|
||||
}
|
||||
|
||||
// templateByName mints a reference copy of a named card from the pack's
|
||||
// printed tier contents. The suit is whatever the first printed copy has —
|
||||
// callers only rely on stats and effects.
|
||||
func templateByName(pack, name string) (game.Card, bool) {
|
||||
// templateByName mints a reference copy of a named card from the printed tier
|
||||
// contents of the packs in play. The suit is whatever the first printed copy
|
||||
// has — callers only rely on stats and effects.
|
||||
func templateByName(packs []string, name string) (game.Card, bool) {
|
||||
if name == "" {
|
||||
return game.Card{}, false
|
||||
}
|
||||
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 {
|
||||
return c, true
|
||||
}
|
||||
|
||||
+20
-9
@@ -19,27 +19,35 @@ type ctx struct {
|
||||
}
|
||||
|
||||
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 {
|
||||
// Memory hasn't observed yet (shouldn't happen in practice).
|
||||
for _, p := range v.Players {
|
||||
if p.Seat != v.YouSeat {
|
||||
cx.oppSeat = p.Seat
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
cx.simID++
|
||||
return fmt.Sprintf("sim-%d", cx.simID)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
if pool, ok := cx.pools[tier]; ok {
|
||||
return pool
|
||||
@@ -53,14 +61,16 @@ func (cx *ctx) unseenPool(tier int) []game.Card {
|
||||
for _, c := range cx.me.Deck {
|
||||
note(c)
|
||||
}
|
||||
for _, c := range cx.m.Opp.Known {
|
||||
for _, opp := range cx.m.Opps {
|
||||
for _, c := range opp.Known {
|
||||
note(c)
|
||||
}
|
||||
}
|
||||
for _, c := range cx.v.ShopRow {
|
||||
note(c)
|
||||
}
|
||||
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 {
|
||||
seen[c.Name]--
|
||||
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
|
||||
// (or their named template, when a pick was later revealed).
|
||||
func (cx *ctx) sampleOppDeck() []game.Card {
|
||||
deck := append([]game.Card(nil), cx.m.Opp.Known...)
|
||||
for _, h := range cx.m.Opp.Hidden {
|
||||
opp := cx.opp()
|
||||
deck := append([]game.Card(nil), opp.Known...)
|
||||
for _, h := range opp.Hidden {
|
||||
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
|
||||
} else if pool := cx.unseenPool(h.Tier); len(pool) > 0 {
|
||||
c = pool[rand.IntN(len(pool))]
|
||||
|
||||
+3
-4
@@ -88,7 +88,7 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g
|
||||
// with a sampled card of the current tier.
|
||||
pool := cx.unseenPool(cx.v.Round)
|
||||
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 {
|
||||
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)
|
||||
pool := cx.unseenPool(nextTier)
|
||||
if len(pool) == 0 {
|
||||
pool = game.TierContentsForPack(cx.v.Pack, nextTier)
|
||||
pool = game.TierContentsForPacks(cx.v.Packs, nextTier)
|
||||
}
|
||||
if len(pool) > 0 {
|
||||
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)
|
||||
if len(pool) == 0 {
|
||||
pool = game.TierContentsForPack(v.Pack, v.Round+1)
|
||||
pool = game.TierContentsForPacks(v.Packs, v.Round+1)
|
||||
}
|
||||
var decks [][]game.Card
|
||||
for range 3 {
|
||||
@@ -409,4 +409,3 @@ func (b *Bot) decideTradeChoose(v *game.View, mem *Memory) *Action {
|
||||
b.score(cx, cands)
|
||||
return b.pick(cands).act
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ func playHeadToHead(t *testing.T, pack string, level0, level1 float64) int {
|
||||
g := game.New()
|
||||
pa, _ := g.AddBot("Bot A", level0)
|
||||
pb, _ := g.AddBot("Bot B", level1)
|
||||
if err := g.SetPack(pack); err != nil {
|
||||
t.Fatalf("SetPack: %v", err)
|
||||
if err := g.SetPacks([]string{pack}); err != nil {
|
||||
t.Fatalf("SetPacks: %v", err)
|
||||
}
|
||||
if err := g.StartGame(); err != nil {
|
||||
t.Fatalf("StartGame: %v", err)
|
||||
|
||||
+206
-92
@@ -2,6 +2,7 @@ package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// BattleUnit is a pet in play with its attached foods applied. Power
|
||||
@@ -179,27 +180,82 @@ type BattleEvent struct {
|
||||
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 {
|
||||
Round int `json:"round"`
|
||||
StackSizes []int `json:"stackSizes"` // starting deck size per seat
|
||||
// Lineups is each seat's arranged deck at battle start (top of deck
|
||||
// Seats are the two players fighting, in first-player order: Seats[0] holds
|
||||
// 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
|
||||
// opponent's cards — during and after the fight.
|
||||
Lineups [][]Card `json:"lineups,omitempty"`
|
||||
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
|
||||
// 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
|
||||
// decisive the result was — the margin the AI uses to prefer a lineup that
|
||||
// fights harder, even in a battle it can't win.
|
||||
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
|
||||
// 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"`
|
||||
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.
|
||||
@@ -324,10 +380,10 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit, enemy *battleSide) int
|
||||
return n
|
||||
}
|
||||
|
||||
// resolveBattle simulates the battle from the players' arranged decks,
|
||||
// records the event log, awards trophies, and moves to PhaseBattle.
|
||||
// resolveBattles fights every pairing of the current round, records the event
|
||||
// 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
|
||||
// the last-applied perk counts). If anyone can no longer field a pet the
|
||||
// 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
|
||||
// took damage fire Hurt effects. Shields (Turtle, Gorilla, Melon) block
|
||||
// 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
|
||||
// stalemate.
|
||||
// attack manages to hurt. A clash that changes nothing is a stalemate: that
|
||||
// pair both faint so the pets behind them can settle the battle.
|
||||
//
|
||||
// resolveBattle is the orchestrator: it runs the (deterministic) simulation and
|
||||
// publishes the completed result.
|
||||
func (g *Game) resolveBattle() {
|
||||
res := g.runBattle()
|
||||
g.Battle = res
|
||||
// resolveBattles is the orchestrator: it runs each (deterministic) simulation
|
||||
// and publishes the completed results.
|
||||
func (g *Game) resolveBattles() {
|
||||
g.Battles = nil
|
||||
for _, m := range g.Pairings() {
|
||||
first, second := g.firstPlayer(m)
|
||||
res := g.runBattle(first, second)
|
||||
g.Battles = append(g.Battles, 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 {
|
||||
p.PendingApplesInPlay = 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
|
||||
// bank any apples destined for next round's hand (Skeleton Dog).
|
||||
if res.ManaAfter != nil && p.Seat < len(res.ManaAfter) {
|
||||
p.Mana = res.ManaAfter[p.Seat]
|
||||
if side < len(res.ManaAfter) {
|
||||
p.Mana = res.ManaAfter[side]
|
||||
}
|
||||
if res.NextRoundApples != nil && p.Seat < len(res.NextRoundApples) {
|
||||
p.NextRoundApples += res.NextRoundApples[p.Seat]
|
||||
if side < len(res.NextRoundApples) {
|
||||
p.NextRoundApples += res.NextRoundApples[side]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runBattle plays the simulation to completion, returning the result. It
|
||||
// mutates no persistent player state — that is finalizeBattle's job.
|
||||
func (g *Game) runBattle() *BattleResult {
|
||||
n := len(g.Players)
|
||||
res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n), Lineups: make([][]Card, n)}
|
||||
// runBattle plays one pairing's simulation to completion, returning the
|
||||
// result. It mutates no persistent player state — that is finalizeBattle's job.
|
||||
//
|
||||
// first and second are the seats fighting, first having priority. Everything
|
||||
// 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)
|
||||
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 := func(seat int) string { return g.Players[seat].Name }
|
||||
// pname is the owning player's display name for a side, for log text.
|
||||
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{}}
|
||||
// Unicorn pack: the persistent Mana pool comes into battle (read-only
|
||||
// here; written back by finalizeBattle so re-runs stay deterministic).
|
||||
s.mana = p.Mana
|
||||
sides[p.Seat] = s
|
||||
res.StackSizes[p.Seat] = len(p.Deck)
|
||||
res.Lineups[p.Seat] = append([]Card(nil), p.Deck...)
|
||||
sides[side] = s
|
||||
res.StackSizes[side] = len(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] }
|
||||
// seatOrder resolves the priority-token holder first, then everyone else.
|
||||
// Reveals, queued play effects, and cross-side triggers all follow it, so
|
||||
// when two pets would act simultaneously (e.g. both throwing rocks) the
|
||||
// holder acts first — its rocks can faint the enemy pet before that pet's
|
||||
// own queued rocks resolve.
|
||||
seatOrder := make([]int, 0, n)
|
||||
seatOrder = append(seatOrder, g.PrioritySeat)
|
||||
for seat := range sides {
|
||||
if seat != g.PrioritySeat {
|
||||
seatOrder = append(seatOrder, seat)
|
||||
}
|
||||
}
|
||||
// seatOrder resolves the first player before the second. Reveals, queued
|
||||
// play effects, and cross-side triggers all follow it, so when two pets
|
||||
// would act simultaneously (e.g. both throwing rocks) the first player acts
|
||||
// first — its rocks can faint the enemy pet before that pet's own queued
|
||||
// rocks resolve. Side 0 is the first player by construction.
|
||||
seatOrder := []int{0, 1}
|
||||
// startApple seeds one in-play apple onto a seat's first pet.
|
||||
startApple := func(seat int) {
|
||||
apple := g.newApple()
|
||||
@@ -425,27 +530,28 @@ func (g *Game) runBattle() *BattleResult {
|
||||
}
|
||||
// Battle-prep effects that start apples in play (Monkey): they attach
|
||||
// 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 _, e := range c.Effects {
|
||||
if e.Trigger == TriggerBattlePrep && e.Action == ActionApplesInPlay {
|
||||
for range e.count() {
|
||||
startApple(p.Seat)
|
||||
startApple(side)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Golden pack: apples-in-play banked by a sold Hercules Beetle this
|
||||
// round (read-only here; finalizeBattle clears it once the battle ends,
|
||||
// so re-runs bank the same amount).
|
||||
// round (read-only here; resolveBattles clears it once the round's
|
||||
// battles end, so re-runs bank the same amount).
|
||||
for range p.PendingApplesInPlay {
|
||||
startApple(p.Seat)
|
||||
startApple(side)
|
||||
}
|
||||
// Bird of Paradise: start the battle with Trumpets in the pool.
|
||||
if p.PendingTrumpets > 0 {
|
||||
sides[p.Seat].trumpets += p.PendingTrumpets
|
||||
emit(BattleEvent{Type: "trumpet", Seat: p.Seat, Count: p.PendingTrumpets,
|
||||
Text: fmt.Sprintf("%s starts with %d Trumpet%s.", pname(p.Seat), p.PendingTrumpets, plural(p.PendingTrumpets))})
|
||||
sides[side].trumpets += p.PendingTrumpets
|
||||
emit(BattleEvent{Type: "trumpet", Seat: side, Count: 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
|
||||
}
|
||||
|
||||
// Clash. Two-player for now; >2-player battle pairings come later
|
||||
// (the surrounding state is already per-seat).
|
||||
// Clash: the two sides' pets trade blows. A is the first player's side.
|
||||
ua, ub := sides[0].unit, sides[1].unit
|
||||
// Unicorn pack: Spooked lowers a pet's clash attack (Exposed is applied
|
||||
// to the defender inside hitUnit); a Manticore boosts enemy ailments.
|
||||
@@ -1702,12 +1807,18 @@ func (g *Game) runBattle() *BattleResult {
|
||||
if dealtB > 0 && ua.hasKnockout() {
|
||||
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
|
||||
// force a mutual knockout once they hit the limit.
|
||||
deadlocked := false
|
||||
if ua.Alive() && ub.Alive() {
|
||||
clashStreak++
|
||||
if clashStreak >= deadlockLimit {
|
||||
if stuck || clashStreak >= deadlockLimit {
|
||||
deadlocked = true
|
||||
ua.Damage = max(ua.Damage, ua.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.",
|
||||
pname(0), ua.Card.Name, pname(1), ub.Card.Name)
|
||||
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:
|
||||
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)
|
||||
@@ -1746,10 +1860,6 @@ func (g *Game) runBattle() *BattleResult {
|
||||
if prevB != nil {
|
||||
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}
|
||||
for seat, u := range []*BattleUnit{ua, ub} {
|
||||
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
|
||||
// out, or a stalemate with pets on both sides) is a draw. We test canField,
|
||||
// not unit, because the loop can break the instant one side runs out while
|
||||
// the other's current pet has just fainted — that side still has pets left
|
||||
// in its stack (it simply wasn't refilled) and is the rightful winner.
|
||||
// A single side that can still field a pet wins; anything else (both out,
|
||||
// or the iteration cap tripping with pets on both sides) is a draw. We test
|
||||
// canField, not unit, because the loop can break the instant one side runs
|
||||
// out while the other's current pet has just fainted — that side still has
|
||||
// pets left in its stack (it simply wasn't refilled) and is the rightful
|
||||
// winner.
|
||||
winner := -1
|
||||
for seat, s := range sides {
|
||||
for side, s := range sides {
|
||||
if s.canField() {
|
||||
if winner >= 0 {
|
||||
winner = -1 // stalemate / >2-player safety
|
||||
winner = -1 // both still standing: a draw
|
||||
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 {
|
||||
res.WinnerSeat = seats[winner]
|
||||
// The last round is worth double.
|
||||
res.Trophies = 1
|
||||
if g.Round == MaxRounds {
|
||||
res.Trophies = 2
|
||||
|
||||
@@ -96,7 +96,7 @@ func forceBattle(t *testing.T, g *Game, d1, d2 []Card) *BattleResult {
|
||||
if g.Phase != PhaseBattle {
|
||||
t.Fatalf("expected battle phase, got %s", g.Phase)
|
||||
}
|
||||
return g.Battle
|
||||
return g.Battles[0]
|
||||
}
|
||||
|
||||
func eventsOfType(res *BattleResult, typ string) []BattleEvent {
|
||||
@@ -712,8 +712,8 @@ func TestPriorityTokenTransfer(t *testing.T) {
|
||||
g, _, _ := testGame(t)
|
||||
g.PrioritySeat = 0
|
||||
forceBattle(t, g, []Card{g.pet("Champ", 9)}, []Card{g.pet("Chump", 1)})
|
||||
if g.Battle.WinnerSeat != 0 {
|
||||
t.Fatalf("seat 0 should win, got %d", g.Battle.WinnerSeat)
|
||||
if g.Battles[0].WinnerSeat != 0 {
|
||||
t.Fatalf("seat 0 should win, got %d", g.Battles[0].WinnerSeat)
|
||||
}
|
||||
if g.PrioritySeat != 1 {
|
||||
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.PrioritySeat = 1
|
||||
forceBattle(t, g, []Card{g.pet("Champ", 9)}, []Card{g.pet("Chump", 1)})
|
||||
if g.Battle.WinnerSeat != 0 {
|
||||
t.Fatalf("seat 0 should win, got %d", g.Battle.WinnerSeat)
|
||||
if g.Battles[0].WinnerSeat != 0 {
|
||||
t.Fatalf("seat 0 should win, got %d", g.Battles[0].WinnerSeat)
|
||||
}
|
||||
if g.PrioritySeat != 1 {
|
||||
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.PrioritySeat = 0
|
||||
forceBattle(t, g, []Card{g.pet("A", 3)}, []Card{g.pet("B", 3)})
|
||||
if g.Battle.WinnerSeat != -1 {
|
||||
t.Fatalf("mutual KO should draw, got %d", g.Battle.WinnerSeat)
|
||||
if g.Battles[0].WinnerSeat != -1 {
|
||||
t.Fatalf("mutual KO should draw, got %d", g.Battles[0].WinnerSeat)
|
||||
}
|
||||
if g.PrioritySeat != 0 {
|
||||
t.Fatalf("a draw should leave the token put, priority=%d", g.PrioritySeat)
|
||||
|
||||
+35
-14
@@ -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() {
|
||||
pets, foods := packTiers(g.Pack)
|
||||
g.ShopDecks = make([][]Card, MaxRounds)
|
||||
for _, pack := range g.packList() {
|
||||
pets, foods := packTiers(pack)
|
||||
for tierIdx := range pets {
|
||||
var deck []Card
|
||||
deck := g.ShopDecks[tierIdx]
|
||||
for _, t := range pets[tierIdx] {
|
||||
for _, suit := range t.Suits {
|
||||
deck = append(deck, Card{
|
||||
@@ -1328,17 +1333,30 @@ func (g *Game) buildShopDecks() {
|
||||
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.
|
||||
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
|
||||
// pack, tier by tier, for the debug "buy any card" panel. IDs are name-based
|
||||
// placeholders (not real instances); pets use their first printed suit.
|
||||
func CatalogForPack(pack string) []Card {
|
||||
pets, foods := packTiers(pack)
|
||||
// CatalogForPacks returns one representative card for every pet and food in
|
||||
// the given packs, tier by tier, for the debug "buy any card" panel. IDs are
|
||||
// name-based placeholders (not real instances); pets use their first printed
|
||||
// suit. Cards are grouped by tier across all packs, matching how the shop
|
||||
// decks combine.
|
||||
func CatalogForPacks(packs []string) []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] {
|
||||
suit := SuitRed
|
||||
if len(t.Suits) > 0 {
|
||||
@@ -1356,14 +1374,16 @@ func CatalogForPack(pack string) []Card {
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
// cardByName mints a fresh instance of the named pet or food from the current
|
||||
// pack's templates (pets take their first printed suit). Returns false if
|
||||
// unknown.
|
||||
// cardByName mints a fresh instance of the named pet or food from the
|
||||
// templates of any pack in play (pets take their first printed suit). Returns
|
||||
// false if unknown.
|
||||
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 _, t := range pets[tierIdx] {
|
||||
if t.Name == name {
|
||||
@@ -1386,6 +1406,7 @@ func (g *Game) cardByName(name string) (Card, bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Card{}, false
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -1,6 +1,7 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -11,8 +12,8 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Tunable rules. The engine supports any player count >= 2; MinPlayers /
|
||||
// MaxPlayers gate when a lobby can start (2 for now, more later).
|
||||
// Tunable rules. A game seats an even number of players (2, 4, or 6) so
|
||||
// everyone has an opponent in every round's pairings; see schedule.go.
|
||||
const (
|
||||
MaxRounds = 6
|
||||
CoinsPerRound = 3
|
||||
@@ -20,7 +21,7 @@ const (
|
||||
MaxPets = 5
|
||||
TradeInCount = 3
|
||||
MinPlayers = 2
|
||||
MaxPlayers = 2
|
||||
MaxPlayers = 6
|
||||
)
|
||||
|
||||
// Phase is the game's top-level state.
|
||||
@@ -44,6 +45,9 @@ type Player struct {
|
||||
Coins int `json:"coins"`
|
||||
Deck []Card `json:"deck"`
|
||||
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
|
||||
Connected bool `json:"connected"`
|
||||
// TripledThisRound records whether the player used the Triple (trade-in)
|
||||
@@ -139,9 +143,10 @@ type PendingSacrifice struct {
|
||||
type Game struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
// Pack is the selected card pack (see packs.go). Chosen in the lobby by
|
||||
// the host; determines which cards fill the shop decks.
|
||||
Pack string `json:"pack"`
|
||||
// Packs are the selected card packs (see packs.go). Chosen in the lobby by
|
||||
// the host; their tier decks shuffle together to fill the shop. Seating
|
||||
// more than two players requires more than one pack (see PacksNeeded).
|
||||
Packs []string `json:"packs"`
|
||||
Phase Phase `json:"phase"`
|
||||
Round int `json:"round"` // 1-based
|
||||
Players []*Player `json:"players"`
|
||||
@@ -152,10 +157,12 @@ type Game struct {
|
||||
// tier. Chimera and Abomination draw from it. Temporary cards never enter.
|
||||
Discards map[int][]Card `json:"discards,omitempty"`
|
||||
Turn int `json:"turn"` // seat with the current shop turn
|
||||
// PrioritySeat holds the priority token: that seat shops first each round
|
||||
// and wins simultaneity races in battle. Assigned randomly at game start;
|
||||
// a battle winner hands it to the loser, a loser keeps it, a draw leaves
|
||||
// it put.
|
||||
// PrioritySeat holds the first-shopper token: that seat shops first this
|
||||
// round. How it moves depends on the table size. With two players it is
|
||||
// also the battle's priority token — assigned randomly at game start, then
|
||||
// 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"`
|
||||
Pending *PendingTrade `json:"pending,omitempty"`
|
||||
// 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);
|
||||
// it blocks other shop actions on that seat until resolved, like Pending.
|
||||
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"`
|
||||
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 []LogEntry `json:"log,omitempty"`
|
||||
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
|
||||
// (including after loading from storage) means a fair random roll.
|
||||
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
|
||||
// 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 {
|
||||
switch {
|
||||
case n <= 0:
|
||||
if n <= 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.
|
||||
@@ -252,7 +286,7 @@ func New() *Game {
|
||||
g := &Game{
|
||||
ID: randomID(16),
|
||||
Code: randomCode(),
|
||||
Pack: DefaultPack,
|
||||
Packs: []string{DefaultPack},
|
||||
Phase: PhaseLobby,
|
||||
WinnerSeat: -1,
|
||||
}
|
||||
@@ -260,6 +294,33 @@ func New() *Game {
|
||||
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.
|
||||
// Called on creation and whenever the pack changes, so ShopDecks always match
|
||||
// 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
|
||||
}
|
||||
|
||||
// SetPack changes the game's card pack during the lobby and rebuilds the shop
|
||||
// decks to match. Only playable packs may be selected.
|
||||
func (g *Game) SetPack(packID string) error {
|
||||
// SetPacks changes the game's card packs during the lobby and rebuilds the
|
||||
// shop decks to match. Only distinct, playable packs may be selected; how many
|
||||
// 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 {
|
||||
return fmt.Errorf("%w: game already started", ErrWrongPhase)
|
||||
}
|
||||
pack, ok := packByID(packID)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: unknown pack", ErrInvalidAction)
|
||||
packs, err := validatePacks(packIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !pack.Playable {
|
||||
return fmt.Errorf("%w: that pack isn't available yet", ErrInvalidAction)
|
||||
}
|
||||
g.Pack = pack.ID
|
||||
g.Packs = packs
|
||||
g.buildDecks()
|
||||
return nil
|
||||
}
|
||||
@@ -344,23 +404,46 @@ func (g *Game) RemovePlayer(targetID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartGame begins the match from the lobby once enough players are seated.
|
||||
// The shop decks are already built for g.Pack (see buildDecks); this just
|
||||
// StartGame begins the match from the lobby once the seats and packs line up.
|
||||
// The shop decks are already built for g.Packs (see buildDecks); this just
|
||||
// validates and makes the transition.
|
||||
func (g *Game) StartGame() error {
|
||||
if g.Phase != PhaseLobby {
|
||||
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)
|
||||
}
|
||||
if pack, ok := packByID(g.Pack); !ok || !pack.Playable {
|
||||
return fmt.Errorf("%w: that pack isn't available yet", ErrInvalidAction)
|
||||
// Every round pairs players off, so the table has to be even. A lobby with
|
||||
// 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()
|
||||
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.
|
||||
func (g *Game) PlayerByID(id string) *Player {
|
||||
for _, p := range g.Players {
|
||||
@@ -373,8 +456,16 @@ func (g *Game) PlayerByID(id string) *Player {
|
||||
|
||||
func (g *Game) start() {
|
||||
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))
|
||||
} else {
|
||||
g.PrioritySeat = 0
|
||||
}
|
||||
g.logf(-1, "🎴", "Game on — %d players, %s.", len(g.Players), PackNames(g.Packs))
|
||||
g.startShopRound()
|
||||
}
|
||||
|
||||
@@ -385,6 +476,12 @@ func (g *Game) startShopRound() {
|
||||
g.Pending = nil
|
||||
g.PendingReveal = 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 {
|
||||
p.Coins = CoinsPerRound
|
||||
p.Ready = false
|
||||
@@ -410,12 +507,36 @@ func (g *Game) startShopRound() {
|
||||
for _, c := range slices.Clone(p.Deck) {
|
||||
g.applyShopTrigger(p, c, TriggerShopStart)
|
||||
}
|
||||
g.sortShopDeck(p)
|
||||
}
|
||||
// The priority-token holder shops first.
|
||||
g.Turn = g.PrioritySeat
|
||||
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).
|
||||
// Returns a zero Card if the deck is empty.
|
||||
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
|
||||
// needed here.
|
||||
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++ {
|
||||
seat := (g.Turn + i) % len(g.Players)
|
||||
if !g.Players[seat].Ready {
|
||||
@@ -1075,13 +1200,32 @@ func (g *Game) SubmitOrder(playerID string, orderedIDs []string) error {
|
||||
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() {
|
||||
for _, p := range g.Players {
|
||||
p.Ready = false
|
||||
}
|
||||
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
|
||||
@@ -1111,21 +1255,55 @@ func (g *Game) AcknowledgeBattle(playerID string) error {
|
||||
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() {
|
||||
g.Phase = PhaseGameOver
|
||||
best, bestSeat, tie := -1, -1, false
|
||||
best := -1
|
||||
for _, p := range g.Players {
|
||||
switch {
|
||||
case p.Trophies > best:
|
||||
best, bestSeat, tie = p.Trophies, p.Seat, false
|
||||
case p.Trophies == best:
|
||||
tie = true
|
||||
best = max(best, p.Trophies)
|
||||
}
|
||||
var tied []*Player
|
||||
for _, p := range g.Players {
|
||||
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
|
||||
} else {
|
||||
g.WinnerSeat = bestSeat
|
||||
if len(g.WinnerSeats) == 1 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,16 @@ func TestLobbyManualStart(t *testing.T) {
|
||||
if _, err := g.AddPlayer("Bob"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := g.AddPlayer("Carol"); err == nil {
|
||||
t.Fatal("third player should be rejected while MaxPlayers=2")
|
||||
// An odd table can't pair off, so a third player has to be matched by a
|
||||
// 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.
|
||||
if g.Phase != PhaseLobby {
|
||||
@@ -503,11 +511,11 @@ func TestFullGameFlow(t *testing.T) {
|
||||
if g.Phase != PhaseBattle {
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
for _, p := range g.Players {
|
||||
|
||||
@@ -10,7 +10,7 @@ import "testing"
|
||||
func goldenGame(t *testing.T) (*Game, *Player, *Player) {
|
||||
t.Helper()
|
||||
g := New()
|
||||
g.Pack = "golden"
|
||||
g.Packs = []string{"golden"}
|
||||
g.buildDecks()
|
||||
p1, err := g.AddPlayer("Alice")
|
||||
if err != nil {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -31,3 +36,73 @@ func packByID(id string) (PackInfo, bool) {
|
||||
}
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,3 +24,25 @@ func TestCrocodileLastPetVolleyFiresWhenOwnerOut(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,14 +1,16 @@
|
||||
package game
|
||||
|
||||
// SimulateBattle resolves a hypothetical two-player battle between the given
|
||||
// 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
|
||||
// player) can roll out as many what-if battles as they like. Dice rolls are
|
||||
// random unless rollDie is non-nil.
|
||||
func SimulateBattle(round, prioritySeat int, deckA, deckB []Card, rollDie func() int) *BattleResult {
|
||||
import "fmt"
|
||||
|
||||
// SimulateBattle resolves a hypothetical battle between the given arranged
|
||||
// decks (top of deck first) and returns the result. deckA sits at seat 0 and
|
||||
// deckB at seat 1; firstSeat (0 or 1) is the one holding priority. It runs on
|
||||
// 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{
|
||||
Round: round,
|
||||
PrioritySeat: prioritySeat,
|
||||
RollDie: rollDie,
|
||||
// Cards minted during the simulation (apples, bees) get IDs far away
|
||||
// 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...)},
|
||||
},
|
||||
}
|
||||
g.startBattle()
|
||||
return g.Battle
|
||||
if firstSeat == 1 {
|
||||
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
|
||||
// default pack. Cards carry placeholder IDs; they are reference data, not live
|
||||
// instances.
|
||||
func TierContents(tier int) []Card {
|
||||
return TierContentsForPack(DefaultPack, tier)
|
||||
return TierContentsForPacks([]string{DefaultPack}, tier)
|
||||
}
|
||||
|
||||
// TierContentsForPack returns a pack's printed tier contents — public
|
||||
// information from the box. Used by the AI, which decides from a View that
|
||||
// names its pack.
|
||||
func TierContentsForPack(pack string, tier int) []Card {
|
||||
scratch := &Game{Pack: pack}
|
||||
// TierContentsForPacks returns the printed tier contents of a pack selection,
|
||||
// combined the way the shop decks combine them — public information from the
|
||||
// boxes. Used by the AI, which decides from a View that names its packs.
|
||||
func TierContentsForPacks(packs []string, tier int) []Card {
|
||||
scratch := &Game{Packs: packs}
|
||||
scratch.buildShopDecks()
|
||||
if tier < 1 || tier > len(scratch.ShopDecks) {
|
||||
return nil
|
||||
|
||||
@@ -9,7 +9,7 @@ import "testing"
|
||||
func unicornGame(t *testing.T) (*Game, *Player, *Player) {
|
||||
t.Helper()
|
||||
g := New()
|
||||
g.Pack = "unicorn"
|
||||
g.Packs = []string{"unicorn"}
|
||||
g.buildDecks()
|
||||
p1, err := g.AddPlayer("Alice")
|
||||
if err != nil {
|
||||
|
||||
+52
-12
@@ -8,6 +8,9 @@ type PlayerView struct {
|
||||
Seat int `json:"seat"`
|
||||
Coins int `json:"coins"`
|
||||
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"`
|
||||
Connected bool `json:"connected"`
|
||||
IsBot bool `json:"isBot,omitempty"`
|
||||
@@ -40,21 +43,31 @@ type View struct {
|
||||
Round int `json:"round"`
|
||||
MaxRounds int `json:"maxRounds"`
|
||||
MaxPets int `json:"maxPets"`
|
||||
// Pack is the selected card pack; Packs is the catalog of choices for the
|
||||
// lobby. HostSeat is the seat that controls the lobby (always 0 for now);
|
||||
// MinPlayers is how many seats must be filled before the host can start.
|
||||
Pack string `json:"pack"`
|
||||
Packs []PackInfo `json:"packs"`
|
||||
// Packs are the selected card packs, whose tiers shuffle together;
|
||||
// PackCatalog is the list of choices for the lobby and PacksNeeded is how
|
||||
// many the current table size requires. HostSeat is the seat that controls
|
||||
// the lobby (always 0 for now); PlayerCounts lists the table sizes a game
|
||||
// can start at.
|
||||
Packs []string `json:"packs"`
|
||||
PackCatalog []PackInfo `json:"packCatalog"`
|
||||
PacksNeeded int `json:"packsNeeded"`
|
||||
HostSeat int `json:"hostSeat"`
|
||||
MinPlayers int `json:"minPlayers"`
|
||||
MaxPlayers int `json:"maxPlayers"`
|
||||
PlayerCounts []int `json:"playerCounts"`
|
||||
YouSeat int `json:"youSeat"`
|
||||
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"`
|
||||
ShopRow []Card `json:"shopRow"`
|
||||
DeckCounts []int `json:"deckCounts"` // remaining shop cards per tier
|
||||
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
|
||||
// progress, but the revealed options are only shown to the trader.
|
||||
Pending *PendingTrade `json:"pending,omitempty"`
|
||||
@@ -65,8 +78,14 @@ type View struct {
|
||||
// PendingSacrifice (Unicorn pack: Water of Youth) mirrors PendingReveal: the
|
||||
// options (the buyer's own pets) are only sent to the buyer.
|
||||
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"`
|
||||
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"`
|
||||
WinnerSeats []int `json:"winnerSeats,omitempty"`
|
||||
// Log is the shared, public event log shown across every phase.
|
||||
Log []LogEntry `json:"log,omitempty"`
|
||||
// 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,
|
||||
MaxRounds: MaxRounds,
|
||||
MaxPets: MaxPets,
|
||||
Pack: g.Pack,
|
||||
Packs: Packs,
|
||||
Packs: g.packList(),
|
||||
PackCatalog: Packs,
|
||||
PacksNeeded: PacksNeeded(len(g.Players)),
|
||||
HostSeat: 0,
|
||||
MinPlayers: MinPlayers,
|
||||
MaxPlayers: MaxPlayers,
|
||||
PlayerCounts: PlayerCounts,
|
||||
YouSeat: -1,
|
||||
YourOpponent: -1,
|
||||
Turn: g.Turn,
|
||||
PrioritySeat: g.PrioritySeat,
|
||||
ShopRow: g.ShopRow,
|
||||
WinnerSeat: g.WinnerSeat,
|
||||
WinnerSeats: g.WinnerSeats,
|
||||
Matchups: g.Pairings(),
|
||||
Log: g.Log,
|
||||
}
|
||||
for _, deck := range g.ShopDecks {
|
||||
@@ -105,6 +129,7 @@ func (g *Game) ViewFor(playerID string) View {
|
||||
Seat: p.Seat,
|
||||
Coins: p.Coins,
|
||||
Trophies: p.Trophies,
|
||||
RoundWins: p.RoundWins,
|
||||
Ready: p.Ready,
|
||||
Connected: p.Connected,
|
||||
IsBot: p.IsBot,
|
||||
@@ -115,6 +140,7 @@ func (g *Game) ViewFor(playerID string) View {
|
||||
}
|
||||
if p.ID == playerID {
|
||||
v.YouSeat = p.Seat
|
||||
v.YourOpponent = OpponentOf(len(g.Players), g.Round, p.Seat)
|
||||
pv.Deck = p.Deck
|
||||
pv.FirstBuyFree = p.FirstBuyFree
|
||||
pv.BuysThisRound = p.BuysThisRound
|
||||
@@ -144,9 +170,23 @@ func (g *Game) ViewFor(playerID string) View {
|
||||
}
|
||||
v.PendingSacrifice = &sac
|
||||
}
|
||||
// Battle results (lineups, events) are public once resolved. Keep the
|
||||
// battle around during the following shop phase too, so late joiners /
|
||||
// reconnects can still see the last result.
|
||||
v.Battle = g.Battle
|
||||
// Battle results (lineups, events) are public once resolved — every table's,
|
||||
// not just your own, so players can watch how the rest of the field did.
|
||||
// They stay around during the following shop phase too, so late joiners /
|
||||
// reconnects can still see the last round.
|
||||
v.Battles = g.Battles
|
||||
if v.YouSeat >= 0 {
|
||||
v.Battle = g.BattleFor(v.YouSeat)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -96,3 +96,113 @@ func TestE2EBotGame(t *testing.T) {
|
||||
}
|
||||
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
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
@@ -10,19 +11,23 @@ import (
|
||||
"github.com/greyson/super-auto-pets-board-game/internal/game"
|
||||
)
|
||||
|
||||
// botDifficulty maps the API's difficulty names to a skill level and a
|
||||
// table name for the bot. The levels are calibrated against a competent
|
||||
// player (ai.competentLevel, ~0.60): easy loses ~95% of games to them, medium
|
||||
// is an even match, and hard wins ~80% (see TestDiagWinRateCurve). Medium is
|
||||
// pinned to the competent level itself; hard is the strongest bot the engine
|
||||
// can field.
|
||||
// botDifficulty maps the API's difficulty names to a skill level and a pool of
|
||||
// table names. The levels are calibrated against a competent player
|
||||
// (ai.competentLevel, ~0.60): easy loses ~95% of games to them, medium is an
|
||||
// even match, and hard wins ~80% (see TestDiagWinRateCurve). Medium is pinned
|
||||
// to the competent level itself; hard is the strongest bot the engine can
|
||||
// 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 {
|
||||
level float64
|
||||
name string
|
||||
names []string
|
||||
}{
|
||||
"easy": {0.25, "Robo Rookie"},
|
||||
"medium": {0.60, "Robo Rival"},
|
||||
"hard": {1.00, "Robo Ace"},
|
||||
"easy": {0.25, []string{"Robo Rookie", "Bitsy", "Clunk", "Pip", "Sprocket"}},
|
||||
"medium": {0.60, []string{"Robo Rival", "Gizmo", "Widget", "Rusty", "Cogsworth"}},
|
||||
"hard": {1.00, []string{"Robo Ace", "Vex", "Apex", "Onyx", "Zenith"}},
|
||||
}
|
||||
|
||||
// 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
|
||||
// name to the engine's skill level. Capacity and phase are enforced by the
|
||||
// engine's AddPlayer.
|
||||
// name to the engine's skill level and giving it a name nobody at the table is
|
||||
// already using. Capacity and phase are enforced by the engine's AddPlayer.
|
||||
func addBot(g *game.Game, difficulty string) error {
|
||||
bot, ok := botDifficulty[difficulty]
|
||||
if !ok {
|
||||
return errors.New("unknown bot difficulty")
|
||||
}
|
||||
_, err := g.AddBot(bot.name, bot.level)
|
||||
_, err := g.AddBot(botName(g, bot.names), bot.level)
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -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+"¬e=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)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/greyson/super-auto-pets-board-game/internal/game"
|
||||
"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("GET /api/ws", s.handleWS)
|
||||
mux.HandleFunc("GET /api/catalog", s.handleCatalog)
|
||||
mux.HandleFunc("GET /api/debug/report", s.handleDebugReport)
|
||||
mux.HandleFunc("/", s.handleStatic)
|
||||
return mux
|
||||
}
|
||||
|
||||
// handleCatalog returns every card in a pack (?pack=…, default Turtle), for the
|
||||
// debug panel. Unknown packs fall back to the default.
|
||||
func (s *Server) handleCatalog(w http.ResponseWriter, req *http.Request) {
|
||||
pack := req.URL.Query().Get("pack")
|
||||
if pack == "" {
|
||||
pack = game.DefaultPack
|
||||
// handleDebugReport dumps a game's full state, battles and event log as a
|
||||
// debug report (see game.DebugReport) — everything needed to replay a situation
|
||||
// that went wrong in a test. Params match the WebSocket's: game, player, token.
|
||||
// `format=text` renders it for reading instead of as JSON, and `note=` records
|
||||
// what looked wrong.
|
||||
//
|
||||
// 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.
|
||||
|
||||
@@ -28,7 +28,7 @@ type clientMessage struct {
|
||||
Pick int `json:"pick"` // tradeChoose
|
||||
Order []string `json:"order"` // arrange
|
||||
Name string `json:"name"` // debugAdd
|
||||
Pack string `json:"pack"` // setPack
|
||||
Packs []string `json:"packs"` // setPacks
|
||||
Difficulty string `json:"difficulty"` // addBot
|
||||
Target string `json:"target"` // removePlayer (player 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
|
||||
var err error
|
||||
switch msg.Type {
|
||||
case "setPack":
|
||||
case "setPacks":
|
||||
if err = requireHost(g, c.playerID); err == nil {
|
||||
err = g.SetPack(msg.Pack)
|
||||
err = g.SetPacks(msg.Packs)
|
||||
}
|
||||
case "addBot":
|
||||
if err = requireHost(g, c.playerID); err == nil {
|
||||
|
||||
@@ -79,6 +79,45 @@ func (s *Store) LoadByCode(code string) (*game.Game, error) {
|
||||
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) {
|
||||
var blob string
|
||||
err := s.db.QueryRow(`SELECT state FROM games WHERE `+cond, arg).Scan(&blob)
|
||||
|
||||
@@ -30,6 +30,10 @@ description = "Build everything: frontend + server binary (bin/server)"
|
||||
depends = ["build-web"]
|
||||
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]
|
||||
description = "Run all Go tests"
|
||||
run = "go test ./..."
|
||||
|
||||
+26
-3
@@ -23,13 +23,36 @@ export function joinGame(code: string, name: string): Promise<Session> {
|
||||
return post('/api/join', { code, name })
|
||||
}
|
||||
|
||||
export async function fetchCatalog(pack?: string): Promise<Card[]> {
|
||||
const url = pack ? `/api/catalog?pack=${encodeURIComponent(pack)}` : '/api/catalog'
|
||||
const res = await fetch(url)
|
||||
export async function fetchCatalog(packs?: string[]): Promise<Card[]> {
|
||||
const query = (packs ?? [])
|
||||
.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')
|
||||
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 {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY)
|
||||
|
||||
@@ -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 { CardView } from './CardView'
|
||||
import { CardView, CardZoom } from './CardView'
|
||||
import { useCardAnimations } from '../anim'
|
||||
import { useMediaQuery } from '../useMediaQuery'
|
||||
|
||||
interface Props {
|
||||
view: GameView
|
||||
@@ -9,6 +10,30 @@ interface Props {
|
||||
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
|
||||
// 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
|
||||
@@ -17,6 +42,11 @@ interface Props {
|
||||
export function ArrangePhase({ view, you, send }: Props) {
|
||||
const [order, setOrder] = useState<Card[]>(you.deck ?? [])
|
||||
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)
|
||||
// The card currently under the finger/cursor: it lifts and follows the
|
||||
// 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 [dragTranslate, setDragTranslate] = useState(0)
|
||||
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
|
||||
// slot-to-slot pixel stride (rows are uniform), so the lifted card can track
|
||||
// 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
|
||||
// which slot the finger/cursor is currently over by hit-testing rects.
|
||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
// Holds the latest `onDragMove` so the window listener (attached once per
|
||||
// drag) always calls the fresh closure — see the drag effect below.
|
||||
const moveRef = useRef<(clientY: number) => void>(() => {})
|
||||
// Hold the latest move/release handlers so the window listeners (attached once
|
||||
// per gesture) always call the fresh closures — see the gesture effect below.
|
||||
const moveRef = useRef<(e: PointerEvent) => void>(() => {})
|
||||
const upRef = useRef<(tapped: boolean) => void>(() => {})
|
||||
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.
|
||||
// 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 doesn't fire on touch, so we use pointer events (mouse + touch alike).
|
||||
// `touch-action: none` on the handle stops the browser from scrolling the
|
||||
// page mid-drag.
|
||||
// Drag-to-reorder via pointer events, grabbing the card itself — there's no
|
||||
// separate grip handle. Native HTML5 drag doesn't fire on touch, so pointer
|
||||
// events (mouse and finger alike) drive it, with one wrinkle per input type:
|
||||
//
|
||||
// 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
|
||||
// (`insertBefore`) as the list changes — which makes browsers drop the active
|
||||
// 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
|
||||
// reordering the rows can never interrupt the gesture.
|
||||
// listen on `window` for the gesture's lifetime (see the effect below), so
|
||||
// reordering the rows can never interrupt 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 FLIP animator measures, so the two never fight.
|
||||
function startDrag(e: React.PointerEvent<HTMLElement>, i: number) {
|
||||
e.preventDefault()
|
||||
function clearPress() {
|
||||
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
|
||||
startIndex.current = i
|
||||
grabY.current = e.clientY
|
||||
grabY.current = clientY
|
||||
// Row stride = distance between two adjacent slots; rows are uniform.
|
||||
const a = cardRefs.current[0]?.getBoundingClientRect()
|
||||
const b = cardRefs.current[1]?.getBoundingClientRect()
|
||||
@@ -99,6 +175,41 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
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) {
|
||||
if (dragIndex.current === null) return
|
||||
// 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
|
||||
setDragTranslate(clientY - grabY.current - shift)
|
||||
}
|
||||
// Keep the window listener pointed at the current-render closure (fresh
|
||||
// `order`/refs) without re-attaching the listener on every reorder.
|
||||
moveRef.current = onDragMove
|
||||
// Keep the window listeners pointed at the current-render closures (fresh
|
||||
// `order`/refs) without re-attaching them on every reorder.
|
||||
moveRef.current = onPointerMove
|
||||
upRef.current = endPress
|
||||
|
||||
function endDrag() {
|
||||
if (dragIndex.current === null) return
|
||||
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)
|
||||
setDragTranslate(0)
|
||||
window.setTimeout(() => {
|
||||
dropTimer.current = window.setTimeout(() => {
|
||||
setDragId(null)
|
||||
setDropping(false)
|
||||
setDragging(false)
|
||||
}, 180)
|
||||
}, SETTLE_MS)
|
||||
}
|
||||
|
||||
// While a drag is active, track the pointer on `window` so the gesture keeps
|
||||
// running even as the list reorders under the finger (the handle's own
|
||||
// pointer capture would be lost when React moves its row). Attached once per
|
||||
// drag; `moveRef` keeps it calling the latest closure.
|
||||
useEffect(() => {
|
||||
if (!dragging) return
|
||||
const onMove = (e: PointerEvent) => moveRef.current(e.clientY)
|
||||
const onUp = () => endDrag()
|
||||
// While a press is live, track the pointer on `window` so the gesture keeps
|
||||
// running even as the list reorders under the finger (a pointer capture on the
|
||||
// card would be lost when React moves its row). Attached once per gesture;
|
||||
// `moveRef`/`upRef` keep it calling the latest closures.
|
||||
//
|
||||
// A *layout* effect so the listeners are in place before the browser can
|
||||
// deliver the matching `pointerup`: a quick tap must not slip through, since
|
||||
// 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('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onUp)
|
||||
window.removeEventListener('touchmove', onTouchMove)
|
||||
}
|
||||
// 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
|
||||
// (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
|
||||
})()
|
||||
|
||||
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) {
|
||||
return (
|
||||
<div className="centered">
|
||||
<h2>Order locked in ⚔️</h2>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
@@ -196,11 +333,20 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
return (
|
||||
<div className="arrange">
|
||||
<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>
|
||||
<p className="hint">
|
||||
The <strong>topmost</strong> card fights first. Food cards power up the
|
||||
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>
|
||||
|
||||
<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}
|
||||
>
|
||||
<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
|
||||
className="btn btn-ghost btn-sm"
|
||||
className="btn btn-ghost arrange-arrow"
|
||||
disabled={i === 0}
|
||||
onClick={() => move(i, i - 1)}
|
||||
aria-label="move up (earlier)"
|
||||
@@ -241,7 +376,7 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
className="btn btn-ghost arrange-arrow"
|
||||
disabled={i === order.length - 1}
|
||||
onClick={() => move(i, i + 1)}
|
||||
aria-label="move down (later)"
|
||||
@@ -249,10 +384,20 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
▼
|
||||
</button>
|
||||
</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} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -272,6 +417,10 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
Lock in & battle ⚔️
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{zoom && (
|
||||
<CardZoom card={zoom.card} bonus={zoom.bonus} onClose={() => setZoom(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { BattleEvent, Card, ClientMessage, GameView } from '../types'
|
||||
import { CardView } from './CardView'
|
||||
import type { BattleEvent, BattleResult, Card, ClientMessage, GameView } from '../types'
|
||||
import { sideOf } from '../types'
|
||||
import { CardView, CardZoom } from './CardView'
|
||||
import { DiceRoll, ROLL_MS } from './DiceRoll'
|
||||
import { SPEED_OPTIONS, useBattleSpeed } from '../useBattleSpeed'
|
||||
import { useMediaQuery } from '../useMediaQuery'
|
||||
|
||||
// How long a settled rock roll (and its damage) stays on screen before the
|
||||
// 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: 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 {
|
||||
@@ -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
|
||||
// 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
|
||||
switch (ev.type) {
|
||||
case 'rock':
|
||||
if (ev.target !== seat) return null
|
||||
if (ev.target !== side) return null
|
||||
return ev.roll === 0 ? 'miss!' : `−${ev.roll}`
|
||||
case 'clash': {
|
||||
const taken = clashDamageTaken(events, step - 1, seat)
|
||||
const taken = clashDamageTaken(events, step - 1, side)
|
||||
return taken > 0 ? `−${taken}` : null
|
||||
}
|
||||
case 'shield':
|
||||
return ev.seat === seat ? '🛡️' : null
|
||||
return ev.seat === side ? '🛡️' : null
|
||||
case 'prevent':
|
||||
return ev.seat === seat ? `🛡️ −${ev.count}` : null
|
||||
return ev.seat === side ? `🛡️ −${ev.count}` : null
|
||||
case 'trumpet':
|
||||
if (ev.seat !== seat) return null
|
||||
if (ev.seat !== side) return null
|
||||
return (ev.count ?? 0) >= 0 ? `🎺 +${ev.count}` : `🎺 ${ev.count}`
|
||||
case 'mana':
|
||||
if (ev.seat !== seat) return null
|
||||
if (ev.seat !== side) return null
|
||||
return (ev.count ?? 0) >= 0 ? `🔮 +${ev.count}` : `🔮 ${ev.count}`
|
||||
case 'ailment':
|
||||
if (ev.seat !== seat) return null
|
||||
if (ev.seat !== side) return null
|
||||
return ev.card?.ailment === 'spooked' ? '👻' : '🎯'
|
||||
case 'bounce':
|
||||
return ev.target === seat ? '🌀' : null
|
||||
return ev.target === side ? '🌀' : null
|
||||
case 'eat':
|
||||
return ev.seat === seat ? '🍎' : null
|
||||
return ev.seat === side ? '🍎' : null
|
||||
case 'heal':
|
||||
return ev.seat === seat ? '💚 +1' : null
|
||||
return ev.seat === side ? '💚 +1' : null
|
||||
case 'strip':
|
||||
return ev.target === seat ? '💨' : null
|
||||
return ev.target === side ? '💨' : null
|
||||
case 'steal':
|
||||
if (ev.seat === seat) return `+🍎×${ev.count}`
|
||||
if (ev.target === seat) return `−🍎×${ev.count}`
|
||||
if (ev.seat === side) return `+🍎×${ev.count}`
|
||||
if (ev.target === side) return `−🍎×${ev.count}`
|
||||
return null
|
||||
default:
|
||||
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
|
||||
// fly, pets clash, the fallen fade out, then the round result lands.
|
||||
export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
const battle = view.battle!
|
||||
export function BattlePhase({
|
||||
view,
|
||||
send,
|
||||
step,
|
||||
setStep,
|
||||
battle,
|
||||
battles,
|
||||
selected,
|
||||
onSelect,
|
||||
}: Props) {
|
||||
const events = battle.events ?? []
|
||||
// acked = the player committed to the next round (waiting on the opponent).
|
||||
const [acked, setAcked] = useState(false)
|
||||
// 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.
|
||||
const [resultDismissed, setResultDismissed] = useState(false)
|
||||
// The deck-peek popover lives inside .battlefield, which sets overflow-x
|
||||
// (and thus overflow-y) to auto — so an absolutely-positioned popover gets
|
||||
// clipped to the battlefield. We anchor it to the hovered stack's viewport
|
||||
// rect and portal it to <body> so it floats over the whole window instead.
|
||||
const [peek, setPeek] = useState<{ seat: number; pos: 'top' | 'bottom'; rect: DOMRect } | null>(
|
||||
// The deck-peek popover lives inside .battlefield, which scrolls on one axis
|
||||
// and hides the other — so an absolutely-positioned popover gets clipped to
|
||||
// the battlefield. We anchor it to the hovered stack's viewport rect and
|
||||
// portal it to <body> so it floats over the whole window instead.
|
||||
const [peek, setPeek] = useState<{ side: number; pos: 'top' | 'bottom'; rect: DOMRect } | null>(
|
||||
null,
|
||||
)
|
||||
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.
|
||||
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
|
||||
// we apply the damage and reveal the result.
|
||||
const showingRock = !done && lastEvent?.type === 'rock'
|
||||
@@ -355,12 +391,23 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
[events, battle.stackSizes, upto],
|
||||
)
|
||||
|
||||
const youSeat = view.youSeat
|
||||
const oppSeat = view.players.find((p) => p.seat !== youSeat)?.seat ?? 1
|
||||
const you = view.players[youSeat]
|
||||
const opp = view.players[oppSeat]
|
||||
const won = battle.winnerSeat === youSeat
|
||||
const draw = battle.winnerSeat < 0
|
||||
// Everything inside a battle is indexed by side (0 or 1), not by seat, so the
|
||||
// arena works in sides and maps out to players only for names and results.
|
||||
// When you're watching someone else's table you have no side in it: side 1
|
||||
// takes the bottom half so the board still reads as two halves facing off.
|
||||
const seatAt = (side: number) => view.players.find((p) => p.seat === battle.seats?.[side])
|
||||
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.
|
||||
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 —
|
||||
// [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.
|
||||
function renderSide(seat: number, pos: 'top' | 'bottom') {
|
||||
const s = sides[seat]
|
||||
function renderSide(side: number, pos: 'top' | 'bottom') {
|
||||
const s = sides[side]
|
||||
const clashing = !done && lastEvent?.type === 'clash' && s.unit && !s.unit.dying
|
||||
const clashDying = lastEvent?.type === 'clash' && s.unit?.dying
|
||||
const rockVictim = lastEvent?.type === 'rock' && lastEvent.target === seat
|
||||
const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === seat
|
||||
const milling = !done && lastEvent?.type === 'mill' && lastEvent.seat === seat
|
||||
const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === seat
|
||||
const rockVictim = lastEvent?.type === 'rock' && lastEvent.target === side
|
||||
const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === side
|
||||
const milling = !done && lastEvent?.type === 'mill' && lastEvent.seat === side
|
||||
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 Battle Prep hand-out — animates in from the deck rather than popping.
|
||||
const newFoodId =
|
||||
!done &&
|
||||
lastEvent?.seat === seat &&
|
||||
lastEvent?.seat === side &&
|
||||
(lastEvent.type === 'prep' ||
|
||||
(lastEvent.type === 'reveal' &&
|
||||
(lastEvent.card?.kind === 'food' || lastEvent.card?.kind === 'ailment')))
|
||||
@@ -405,23 +452,23 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
: null
|
||||
// A pet just set aside slides into the set-aside row beside the arena.
|
||||
const newSetAsideId =
|
||||
!done && lastEvent?.type === 'setaside' && lastEvent.seat === seat
|
||||
!done && lastEvent?.type === 'setaside' && lastEvent.seat === side
|
||||
? lastEvent.card?.id
|
||||
: null
|
||||
// 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 = (
|
||||
<div className={`battle-deck battle-deck-${pos}`}>
|
||||
<div
|
||||
className={`stackpile ${lineup.length ? 'peekable' : ''}`}
|
||||
onMouseEnter={
|
||||
lineup.length
|
||||
? (e) => setPeek({ seat, pos, rect: e.currentTarget.getBoundingClientRect() })
|
||||
? (e) => setPeek({ side, pos, rect: e.currentTarget.getBoundingClientRect() })
|
||||
: 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}
|
||||
>
|
||||
{s.stack > 0 ? (
|
||||
@@ -444,7 +491,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
<CardView card={lastEvent.card} size="sm" dead />
|
||||
</div>
|
||||
)}
|
||||
{rockDice && lastEvent?.seat === seat && (
|
||||
{rockDice && lastEvent?.seat === side && (
|
||||
// The dice roll sits beside the throwing side's deck.
|
||||
<div className="stack-dice">
|
||||
<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 }}
|
||||
>
|
||||
<CardView card={f} size="sm" />
|
||||
<CardView
|
||||
card={f}
|
||||
size="sm"
|
||||
onClick={isPhone ? () => openZoom({ card: f }) : undefined}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -512,6 +563,17 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
bonus={s.unit.bonus}
|
||||
damage={s.unit.damage}
|
||||
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) && (
|
||||
<div className="ailment-badges">
|
||||
@@ -567,7 +629,9 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
return (
|
||||
<div className="battle" style={{ '--battle-speed': speed } as React.CSSProperties}>
|
||||
<div className="battle-header">
|
||||
<h2>Battle! Round {battle.round}</h2>
|
||||
<h2>
|
||||
{spectating ? 'Watching' : 'Battle!'} Round {battle.round}
|
||||
</h2>
|
||||
<div className="battle-controls">
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
<span>{you?.name} (you)</span>
|
||||
<span>
|
||||
{bottom?.name}
|
||||
{!spectating && ' (you)'}
|
||||
</span>
|
||||
<span className="battle-vs">VS</span>
|
||||
<span>{opp?.name}</span>
|
||||
<span>{top?.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="battlefield">
|
||||
{renderSide(oppSeat, 'top')}
|
||||
{renderSide(topSide, 'top')}
|
||||
<div
|
||||
key={centerClash ? `clash-${step}` : 'center'}
|
||||
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>
|
||||
</div>
|
||||
{renderSide(youSeat, 'bottom')}
|
||||
{renderSide(bottomSide, 'bottom')}
|
||||
</div>
|
||||
|
||||
{peek &&
|
||||
(() => {
|
||||
const lineup = lineups?.[peek.seat] ?? []
|
||||
const lineup = lineups?.[peek.side] ?? []
|
||||
if (!lineup.length) return null
|
||||
// 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.
|
||||
@@ -664,13 +757,14 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
? { bottom: window.innerHeight - peek.rect.top + 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
|
||||
// lineup first-to-last, left to right, for both.
|
||||
return createPortal(
|
||||
<div className={`deck-peek deck-peek-${peek.pos}`} style={style}>
|
||||
<div className="deck-peek-label">
|
||||
{mine ? 'Your' : 'Opponent’s'} deck · {lineup.length} card
|
||||
{whose} deck · {lineup.length} card
|
||||
{lineup.length !== 1 ? 's' : ''} (first on the left)
|
||||
</div>
|
||||
<div className="deck-peek-cards first-left">
|
||||
@@ -696,13 +790,37 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
</div>
|
||||
{!draw ? (
|
||||
<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 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 ? (
|
||||
<p className="muted">Waiting for opponent…</p>
|
||||
<p className="muted">Waiting for the other players…</p>
|
||||
) : (
|
||||
<div className="actions">
|
||||
<button className="btn btn-ghost" onClick={() => setResultDismissed(true)}>
|
||||
@@ -717,27 +835,37 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{zoom && (
|
||||
<CardZoom
|
||||
card={zoom.card}
|
||||
bonus={zoom.bonus}
|
||||
damage={zoom.damage}
|
||||
dead={zoom.dead}
|
||||
onClose={closeZoom}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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).
|
||||
function clashDamageTaken(events: BattleEvent[], idx: number, seat: number): number {
|
||||
function clashDamageTaken(events: BattleEvent[], idx: number, side: number): number {
|
||||
const ev = events[idx]
|
||||
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.
|
||||
let before = 0
|
||||
for (let k = idx - 1; k >= 0; k--) {
|
||||
const e = events[k]
|
||||
if (e.type === 'reveal' && e.seat === seat && e.card?.kind === 'pet') break
|
||||
if ((e.type === 'rock' || e.type === 'heal') && (e.target ?? e.seat) === seat) {
|
||||
if (e.type === 'reveal' && e.seat === side && e.card?.kind === 'pet') break
|
||||
if ((e.type === 'rock' || e.type === 'heal') && (e.target ?? e.seat) === side) {
|
||||
before = e.damageAfter ?? 0
|
||||
break
|
||||
}
|
||||
if (e.type === 'clash') {
|
||||
before = e.damage?.[seat] ?? 0
|
||||
before = e.damage?.[side] ?? 0
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { Card } from '../types'
|
||||
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 {
|
||||
card: Card
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
@@ -189,8 +222,9 @@ interface Props {
|
||||
dead?: boolean
|
||||
// preview marks the floating magnified copy so it doesn't magnify itself.
|
||||
preview?: boolean
|
||||
// noMagnify suppresses the hover magnifier (e.g. while this card is being
|
||||
// dragged, so the frozen preview copy doesn't linger over the drag ghost).
|
||||
// noMagnify turns the hover magnifier off entirely — not just hidden but not
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -212,6 +246,17 @@ export function CardView({
|
||||
// Hover magnifier: show a large floating copy beside the cursor. The
|
||||
// preview copy itself opts out so it can't recurse.
|
||||
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
|
||||
// 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.
|
||||
@@ -232,8 +277,10 @@ export function CardView({
|
||||
.join(' ')
|
||||
|
||||
// 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 =
|
||||
hover && !preview && !noMagnify ? (
|
||||
hover && !hoverOff ? (
|
||||
<CardMagnify card={card} x={hover.x} y={hover.y} bonus={bonus} damage={damage} dead={dead} />
|
||||
) : null
|
||||
|
||||
@@ -243,9 +290,9 @@ export function CardView({
|
||||
data-card-id={card.id || undefined}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
onMouseEnter={preview || !CAN_HOVER ? undefined : (e) => setHover({ x: e.clientX, y: e.clientY })}
|
||||
onMouseMove={preview || !CAN_HOVER ? undefined : (e) => setHover({ x: e.clientX, y: e.clientY })}
|
||||
onMouseLeave={preview || !CAN_HOVER ? undefined : () => setHover(null)}
|
||||
onMouseEnter={hoverOff ? undefined : (e) => setHover({ x: e.clientX, y: e.clientY })}
|
||||
onMouseMove={hoverOff ? undefined : (e) => setHover({ x: e.clientX, y: e.clientY })}
|
||||
onMouseLeave={hoverOff ? undefined : () => setHover(null)}
|
||||
>
|
||||
{previewEl}
|
||||
{/* Upper illustration: a stylised landscape with the pet as a sticker and
|
||||
@@ -265,18 +312,27 @@ export function CardView({
|
||||
</div>
|
||||
</div>
|
||||
{/* 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-head">
|
||||
{card.suit ? (
|
||||
<div className={`card-panel-head ${!card.suit && !card.tier ? 'is-bare' : ''}`}>
|
||||
{(card.suit || card.tier) &&
|
||||
(card.suit ? (
|
||||
<span className={`suit-dot card-suit suit-${card.suit}`} aria-hidden />
|
||||
) : (
|
||||
<span className="card-panel-slot" aria-hidden />
|
||||
)}
|
||||
))}
|
||||
<span className="card-name" ref={nameRef}>
|
||||
{card.name}
|
||||
</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 className="card-effect" ref={effectRef}>
|
||||
{card.effectText
|
||||
|
||||
@@ -5,22 +5,24 @@ import { CardView } from './CardView'
|
||||
|
||||
interface Props {
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
export function DebugPanel({ canGrant, pack, send }: Props) {
|
||||
export function DebugPanel({ canGrant, packs, send }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
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(() => {
|
||||
fetchCatalog(pack)
|
||||
fetchCatalog(packKey.split(','))
|
||||
.then(setCatalog)
|
||||
.catch(() => setCatalog([]))
|
||||
}, [pack])
|
||||
}, [packKey])
|
||||
|
||||
const tiers = [...new Set(catalog.map((c) => c.tier ?? 0))].sort((a, b) => a - b)
|
||||
|
||||
|
||||
@@ -1,26 +1,59 @@
|
||||
import type { GameView } from '../types'
|
||||
|
||||
export function GameOver({ view, onLeave }: { view: GameView; onLeave: () => void }) {
|
||||
const winner = view.winnerSeat >= 0 ? view.players[view.winnerSeat] : null
|
||||
const youWon = view.winnerSeat === view.youSeat
|
||||
// The title can be shared: players level on trophies whose round-by-round
|
||||
// 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 (
|
||||
<div className="gameover">
|
||||
<div className="gameover-emoji" aria-hidden>
|
||||
{winner ? (youWon ? '🎉' : '💀') : '🤝'}
|
||||
{!winners.length ? '🤝' : youWon ? '🎉' : shared ? '🤝' : '💀'}
|
||||
</div>
|
||||
<h1 className="gameover-title">
|
||||
{winner ? (youWon ? 'You win!' : `${winner.name} wins!`) : "It's a tie!"}
|
||||
</h1>
|
||||
<h1 className="gameover-title">{title}</h1>
|
||||
<div className="gameover-scores">
|
||||
{[...view.players]
|
||||
.sort((a, b) => b.trophies - a.trophies)
|
||||
.map((p) => (
|
||||
<div key={p.id} className={`score-line ${p.seat === view.youSeat ? 'is-you' : ''}`}>
|
||||
{standings.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={[
|
||||
'score-line',
|
||||
p.seat === view.youSeat ? 'is-you' : '',
|
||||
winners.includes(p.seat) ? 'is-winner' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<span className="score-name">
|
||||
{winners.includes(p.seat) && <span aria-label="winner">👑 </span>}
|
||||
{p.name}
|
||||
{p.seat === view.youSeat ? ' (you)' : ''}
|
||||
</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">
|
||||
{'🏆'.repeat(p.trophies) || '—'} <strong>{p.trophies}</strong>
|
||||
</span>
|
||||
|
||||
@@ -6,9 +6,13 @@ const BOT_LEVELS: { value: string; label: string; blurb: string }[] = [
|
||||
{ value: 'hard', label: '🦁 Hard', blurb: 'Shows no mercy' },
|
||||
]
|
||||
|
||||
// Lobby is the pre-game setup screen. The host picks a pack, fills the second
|
||||
// seat with a bot or a friend, can remove players, and starts the game.
|
||||
// Lobby is the pre-game setup screen. The host picks the packs, fills seats
|
||||
// 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.
|
||||
//
|
||||
// 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({
|
||||
view,
|
||||
send,
|
||||
@@ -17,10 +21,33 @@ export function Lobby({
|
||||
send: (m: ClientMessage) => void
|
||||
}) {
|
||||
const isHost = view.youSeat === view.hostSeat
|
||||
const openSeats = view.maxPlayers - view.players.length
|
||||
const selectedPlayable =
|
||||
view.packs.find((p) => p.id === view.pack)?.playable ?? false
|
||||
const canStart = view.players.length >= view.minPlayers && selectedPlayable
|
||||
const seated = view.players.length
|
||||
const openSeats = view.maxPlayers - seated
|
||||
const packs = view.packs ?? []
|
||||
|
||||
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 can’t pair off — add or remove a seat`
|
||||
: !enoughPacks
|
||||
? `${seated} players needs ${view.packsNeeded} packs shuffled together`
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="lobby">
|
||||
@@ -38,41 +65,66 @@ export function Lobby({
|
||||
</div>
|
||||
|
||||
<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 pack’s 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">
|
||||
{view.packs.map((pack) => {
|
||||
const selected = pack.id === view.pack
|
||||
const disabled = !pack.playable || !isHost
|
||||
{view.packCatalog.map((pack) => {
|
||||
const selected = packs.includes(pack.id)
|
||||
const onlyOne = selected && packs.length === 1
|
||||
return (
|
||||
<button
|
||||
key={pack.id}
|
||||
className={`pack-card ${selected ? 'is-selected' : ''} ${
|
||||
pack.playable ? '' : 'is-locked'
|
||||
}`}
|
||||
disabled={disabled}
|
||||
title={pack.playable ? pack.name : `${pack.name} — coming soon`}
|
||||
onClick={() => send({ type: 'setPack', pack: pack.id })}
|
||||
disabled={!pack.playable || !isHost || onlyOne}
|
||||
title={
|
||||
!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>
|
||||
{pack.emoji}
|
||||
</span>
|
||||
<span className="pack-name">{pack.name}</span>
|
||||
<span className="pack-tag">
|
||||
{pack.playable ? (selected ? 'Selected' : 'Available') : 'Coming soon'}
|
||||
{!pack.playable ? 'Coming soon' : selected ? '✓ In play' : 'Add'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{!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 className="lobby-section">
|
||||
<h3 className="lobby-section-title">
|
||||
Players ({view.players.length}/{view.maxPlayers})
|
||||
Players ({seated}/{view.maxPlayers})
|
||||
</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">
|
||||
{view.players.map((p) => (
|
||||
<SeatRow
|
||||
@@ -85,12 +137,13 @@ export function Lobby({
|
||||
/>
|
||||
))}
|
||||
|
||||
{Array.from({ length: openSeats }).map((_, i) => (
|
||||
<li key={`open-${i}`} className="seat-row seat-open">
|
||||
{openSeats > 0 && (
|
||||
<li className="seat-row seat-open">
|
||||
{isHost ? (
|
||||
<div className="seat-open-host">
|
||||
<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>
|
||||
<div className="seat-bot-picker">
|
||||
{BOT_LEVELS.map((b) => (
|
||||
@@ -106,10 +159,12 @@ export function Lobby({
|
||||
</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>
|
||||
))}
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -119,9 +174,7 @@ export function Lobby({
|
||||
disabled={!canStart}
|
||||
onClick={() => send({ type: 'start' })}
|
||||
>
|
||||
{view.players.length < view.minPlayers
|
||||
? 'Waiting for a second player…'
|
||||
: 'Start game'}
|
||||
{blocker ?? 'Start game'}
|
||||
</button>
|
||||
) : (
|
||||
<p className="lobby-waiting muted">Waiting for the host to start the game…</p>
|
||||
|
||||
@@ -122,7 +122,10 @@ export function ShopPhase({ view, you, send }: Props) {
|
||||
const totalCoins = purse.current.max
|
||||
|
||||
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 myPending = pending?.playerId === you.id
|
||||
|
||||
@@ -263,12 +266,15 @@ export function ShopPhase({ view, you, send }: Props) {
|
||||
<div className="shop-status">
|
||||
{pending && !myPending ? (
|
||||
<span className="muted">
|
||||
{opponent?.name ?? 'Opponent'} is tripling up a tier…
|
||||
{acting?.name ?? 'Someone'} is tripling up a tier…
|
||||
</span>
|
||||
) : you.ready ? (
|
||||
<span className="muted">
|
||||
You passed — waiting for {opponent?.name ?? 'opponent'} to finish
|
||||
shopping…
|
||||
You passed — waiting for{' '}
|
||||
{stillShopping.length === 1
|
||||
? stillShopping[0].name
|
||||
: `${stillShopping.length} more players`}{' '}
|
||||
to finish shopping…
|
||||
</span>
|
||||
) : myTurn && overPets ? (
|
||||
<span className="status-hot">
|
||||
|
||||
+107
-33
@@ -11,29 +11,47 @@ import { ArrangePhase } from './ArrangePhase'
|
||||
import { BattlePhase } from './BattlePhase'
|
||||
import { GameOver } from './GameOver'
|
||||
import { DebugPanel } from './DebugPanel'
|
||||
import { BugReport } from './BugReport'
|
||||
import { EventLog, battleLogLines } from './EventLog'
|
||||
|
||||
// Table connects to the game and routes to the right phase screen.
|
||||
export function Table({ session, onLeave }: { session: Session; onLeave: () => void }) {
|
||||
const { view, error, connected, send } = useGame(session)
|
||||
|
||||
// Battle replay step lives here (not inside BattlePhase) so the event log,
|
||||
// a sibling, can render the battle narration up to the same step. Deriving
|
||||
// the effective step from the current battle round resets it to 0 whenever a
|
||||
// new battle arrives, without a separate effect. These hooks must run on
|
||||
// every render (before any early return) to satisfy the rules of hooks.
|
||||
const battleRound = view?.battle?.round ?? -1
|
||||
const [stepState, setStepState] = useState<{ round: number; step: number }>({
|
||||
round: -1,
|
||||
// A round runs one battle per pairing, so with four or six players there are
|
||||
// several to watch. Which one is on screen lives here, alongside the replay
|
||||
// step, because the event log is a sibling and narrates whichever battle is
|
||||
// playing. Both reset when a new round's battles arrive.
|
||||
//
|
||||
// These hooks must run on every render (before any early return) to satisfy
|
||||
// the rules of hooks.
|
||||
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,
|
||||
})
|
||||
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) =>
|
||||
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
|
||||
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
|
||||
// (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.
|
||||
const [logOpen, setLogOpen] = useState(false)
|
||||
|
||||
// The room code and Leave button live behind a "⋯" menu in the topbar rail,
|
||||
// keeping the rail short enough for one row on a phone.
|
||||
// The room code, the bug reporter and Leave live behind a "⋯" menu in the
|
||||
// topbar rail, keeping the rail short enough for one row on a phone.
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [bugOpen, setBugOpen] = useState(false)
|
||||
|
||||
// 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
|
||||
// battle and appended as the final battle line only once the replay reaches
|
||||
// the end (and reappears in the persistent log in later phases).
|
||||
const inBattle = view?.phase === 'battle'
|
||||
const events = view?.battle?.events ?? null
|
||||
const events = battle?.events ?? null
|
||||
const battleDone = !!(inBattle && events && step >= events.length)
|
||||
|
||||
const entries = useMemo(() => {
|
||||
@@ -66,9 +85,12 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
if (!inBattle || !events) return undefined
|
||||
const lines = battleLogLines(events, step)
|
||||
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,
|
||||
)
|
||||
)[selected]
|
||||
if (result) {
|
||||
lines.push({
|
||||
key: `result-${result.seq}`,
|
||||
@@ -79,14 +101,14 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
}
|
||||
}
|
||||
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
|
||||
// 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);
|
||||
// concrete cards from the view fill in tokens and summons (Bee, Apple, …)
|
||||
// that never appear in the shop.
|
||||
const catalog = useCatalog(view?.pack)
|
||||
// buyable pets and foods across the packs in play (even ones not currently in
|
||||
// view); concrete cards from the view fill in tokens and summons (Bee,
|
||||
// Apple, …) that never appear in the shop.
|
||||
const catalog = useCatalog(view?.packs)
|
||||
const cardLookup = useMemo(() => {
|
||||
const map = new Map<string, Card>()
|
||||
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) {
|
||||
view.shopRow?.forEach(add)
|
||||
view.players?.forEach((p) => p.deck?.forEach(add))
|
||||
view.battle?.lineups?.forEach((line) => line?.forEach(add))
|
||||
view.battle?.events?.forEach((ev) => add(ev.card))
|
||||
view.battles?.forEach((b) => {
|
||||
b.lineups?.forEach((line) => line?.forEach(add))
|
||||
b.events?.forEach((ev) => add(ev.card))
|
||||
})
|
||||
}
|
||||
return map
|
||||
}, [catalog, view])
|
||||
@@ -129,6 +153,17 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
const you = view.players[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 (
|
||||
<div className="table">
|
||||
<header className="topbar">
|
||||
@@ -144,11 +179,21 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
<DieFace value={view.round} className="topbar-die" />
|
||||
</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) => (
|
||||
<div
|
||||
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 ? (
|
||||
<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="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>
|
||||
{/* 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 && (
|
||||
<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' &&
|
||||
p.seat !== view.youSeat &&
|
||||
(view.battle?.lineups?.[p.seat]?.length ?? 0) > 0 && (
|
||||
(lastLineups.get(p.seat)?.length ?? 0) > 0 && (
|
||||
<button
|
||||
className={`chip chip-btn ${deckPeek?.seat === p.seat ? 'is-active' : ''}`}
|
||||
title="See their deck from last round's battle"
|
||||
@@ -208,6 +259,16 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
{view.code}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
setBugOpen(true)
|
||||
}}
|
||||
>
|
||||
🐛 Report a bug
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
role="menuitem"
|
||||
@@ -225,12 +286,23 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
</header>
|
||||
|
||||
<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 === 'shop' && <ShopPhase view={view} you={you} send={send} />}
|
||||
{view.phase === 'arrange' && <ArrangePhase view={view} you={you} send={send} />}
|
||||
{view.phase === 'battle' && (
|
||||
<BattlePhase view={view} send={send} step={step} setStep={setStep} />
|
||||
{view.phase === 'battle' && battle && (
|
||||
<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} />}
|
||||
</main>
|
||||
@@ -263,13 +335,15 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
)}
|
||||
{error && <div className="toast">{error}</div>}
|
||||
{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 &&
|
||||
view.phase === 'shop' &&
|
||||
(() => {
|
||||
const lineup = view.battle?.lineups?.[deckPeek.seat] ?? []
|
||||
const lineup = lastLineups.get(deckPeek.seat) ?? []
|
||||
if (!lineup.length) return null
|
||||
const oppName = view.players.find((p) => p.seat === deckPeek.seat)?.name ?? 'Opponent'
|
||||
return createPortal(
|
||||
|
||||
+305
-62
@@ -488,6 +488,43 @@ h3 {
|
||||
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 {
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -619,6 +656,18 @@ h3 {
|
||||
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 ---------- */
|
||||
/* A pinned-up ledger of what happened, paper-warm against the felt. */
|
||||
|
||||
@@ -909,6 +958,14 @@ h3 {
|
||||
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-grid {
|
||||
@@ -1237,6 +1294,14 @@ h3 {
|
||||
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 tier die in the name row. */
|
||||
.card-suit {
|
||||
@@ -1402,10 +1467,14 @@ h3 {
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 5%;
|
||||
right: 5%;
|
||||
bottom: 4%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 3;
|
||||
font-family: var(--font-display);
|
||||
color: #fff;
|
||||
@@ -1852,6 +1921,15 @@ h3 {
|
||||
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
|
||||
the top and later pets flow downward. */
|
||||
.arrange-col {
|
||||
@@ -1892,9 +1970,7 @@ h3 {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* The lifted card floats above its neighbors while being dragged. The z-index
|
||||
sits on the inner wrapper (which carries the follow transform) so it wins
|
||||
over sibling rows without disturbing the measured `.arrange-card` box. */
|
||||
/* The lifted card floats above its neighbors while being dragged. */
|
||||
.arrange-card.is-dragging {
|
||||
z-index: 5;
|
||||
}
|
||||
@@ -1912,8 +1988,9 @@ h3 {
|
||||
transition: transform 0.18s ease-out;
|
||||
}
|
||||
|
||||
/* Reorder controls: the grip handle on top, the up/down arrows below. Hung to
|
||||
the right of the card so the card itself stays centered under the marker. */
|
||||
/* The up/down arrows, hung to the right of the card so the card itself stays
|
||||
centered under the marker. They're the precise alternative to dragging, so
|
||||
they're sized as comfortable targets rather than tucked away. */
|
||||
.arrange-controls {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
@@ -1923,40 +2000,41 @@ h3 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Grab this to drag-reorder. `touch-action: none` keeps the browser from
|
||||
scrolling the page while a touch drag is in progress; the pointer handlers
|
||||
in ArrangePhase do the reordering. */
|
||||
.arrange-handle {
|
||||
touch-action: none;
|
||||
.arrange-arrow {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 46px;
|
||||
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;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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);
|
||||
-webkit-touch-callout: none;
|
||||
border-radius: var(--card-radius);
|
||||
transition: transform 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.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;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.arrange-arrows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 14px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* ---------- battle ---------- */
|
||||
@@ -1966,6 +2044,15 @@ h3 {
|
||||
animation divides its base duration by this, so the visuals quicken in
|
||||
lockstep with the JS step timers. Fallback keeps calc() valid if unset. */
|
||||
--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;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
@@ -2012,6 +2099,56 @@ h3 {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -2043,13 +2180,24 @@ h3 {
|
||||
(right) to fan out beside each pet; a big apple stack (Manatee, Fire Ant)
|
||||
scrolls horizontally rather than clipping. */
|
||||
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-y: hidden;
|
||||
scrollbar-width: none;
|
||||
box-shadow:
|
||||
var(--tray-inset),
|
||||
inset 0 0 60px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.battlefield::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.battle-center {
|
||||
position: relative;
|
||||
font-size: 1.7rem;
|
||||
@@ -2085,10 +2233,12 @@ h3 {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: var(--deck-h);
|
||||
}
|
||||
|
||||
/* --- deck stacks --- */
|
||||
@@ -2101,8 +2251,8 @@ h3 {
|
||||
/* The face-down deck: a walnut-backed card with a woven diamond pattern and a
|
||||
little stacked-depth shadow beneath it. */
|
||||
.card-back {
|
||||
width: 84px;
|
||||
height: 116px;
|
||||
width: var(--deck-w);
|
||||
height: var(--deck-h);
|
||||
border-radius: var(--card-radius);
|
||||
border: 2px solid var(--cocoa);
|
||||
background:
|
||||
@@ -2132,9 +2282,11 @@ h3 {
|
||||
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 {
|
||||
width: 84px;
|
||||
height: 116px;
|
||||
width: var(--deck-w);
|
||||
height: var(--deck-h);
|
||||
}
|
||||
|
||||
@keyframes summon-pop {
|
||||
@@ -2279,10 +2431,14 @@ h3 {
|
||||
|
||||
/* --- 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 {
|
||||
position: relative;
|
||||
min-width: 100px;
|
||||
min-height: 150px;
|
||||
width: var(--unit-w);
|
||||
height: var(--unit-h);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
@@ -2395,11 +2551,11 @@ h3 {
|
||||
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 {
|
||||
width: 96px;
|
||||
/* Taller than a shop card so the full ability text fits without shrinking to
|
||||
an unreadable size during the battle. */
|
||||
height: 162px;
|
||||
width: var(--unit-w);
|
||||
height: var(--unit-h);
|
||||
}
|
||||
|
||||
/* Battle cards are short, so give the ability text more of the card by
|
||||
@@ -2783,6 +2939,33 @@ h3 {
|
||||
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. */
|
||||
.battle-next {
|
||||
margin-left: 2px;
|
||||
@@ -2828,6 +3011,7 @@ h3 {
|
||||
|
||||
.score-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
font-size: 1.1rem;
|
||||
@@ -2840,6 +3024,26 @@ h3 {
|
||||
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 ---------- */
|
||||
|
||||
.toast {
|
||||
@@ -3171,16 +3375,22 @@ h3 {
|
||||
}
|
||||
.arrange-controls {
|
||||
margin-left: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
.arrange-handle {
|
||||
min-width: 34px;
|
||||
min-height: 34px;
|
||||
font-size: 1.25rem;
|
||||
.arrange-arrow {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* --- battle: fit both sides and the clash between the rails --- */
|
||||
.battle {
|
||||
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 {
|
||||
gap: 8px;
|
||||
@@ -3203,7 +3413,6 @@ h3 {
|
||||
}
|
||||
.battlefield {
|
||||
padding: 8px 8px;
|
||||
min-height: 0;
|
||||
gap: 3px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
@@ -3214,14 +3423,6 @@ h3 {
|
||||
.battle-side {
|
||||
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
|
||||
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
|
||||
@@ -3246,10 +3447,6 @@ h3 {
|
||||
.battle-unit .card-art {
|
||||
font-size: 2.1rem;
|
||||
}
|
||||
.card-back {
|
||||
width: 56px;
|
||||
height: 78px;
|
||||
}
|
||||
.card-back-count {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
@@ -3339,6 +3536,52 @@ h3 {
|
||||
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 {
|
||||
color: var(--cream);
|
||||
font-size: 0.75rem;
|
||||
|
||||
+24
-6
@@ -25,6 +25,7 @@ export interface PlayerView {
|
||||
seat: number
|
||||
coins: number
|
||||
trophies: number
|
||||
roundWins?: number[] // rounds this player won a battle in (public)
|
||||
ready: boolean
|
||||
connected: boolean
|
||||
isBot?: boolean
|
||||
@@ -100,15 +101,25 @@ export interface BattleEvent {
|
||||
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 {
|
||||
round: number
|
||||
seats: number[] // the two seats fighting, first player first
|
||||
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
|
||||
winnerSeat: 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 {
|
||||
seq: number
|
||||
round: number
|
||||
@@ -135,27 +146,34 @@ export interface GameView {
|
||||
round: number
|
||||
maxRounds: number
|
||||
maxPets: number
|
||||
pack: string
|
||||
packs: PackInfo[]
|
||||
packs: string[] // selected packs, shuffled together
|
||||
packCatalog: PackInfo[] // the choices offered in the lobby
|
||||
packsNeeded: number // packs the current table size requires
|
||||
hostSeat: number
|
||||
minPlayers: number
|
||||
maxPlayers: number
|
||||
playerCounts: number[] // table sizes a game can start at (2, 4, 6)
|
||||
youSeat: number
|
||||
turn: number
|
||||
prioritySeat: number
|
||||
shopRow: Card[]
|
||||
deckCounts: number[]
|
||||
players: PlayerView[]
|
||||
matchups?: [number, number][] // this round's battle pairings (public)
|
||||
yourOpponent: number // the seat you face this round, or -1
|
||||
pending?: PendingTrade
|
||||
pendingReveal?: PendingReveal
|
||||
pendingSacrifice?: PendingSacrifice
|
||||
battle?: BattleResult
|
||||
winnerSeat: number
|
||||
battle?: BattleResult // your own battle this round
|
||||
battles?: BattleResult[] // every table's, all replayable
|
||||
winnerSeat: number // outright winner, or -1 when shared
|
||||
winnerSeats?: number[] // everyone holding the title
|
||||
log?: LogEntry[]
|
||||
debug?: boolean // server DEBUG mode: unlocks the buy-any-card panel
|
||||
}
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: 'setPack'; pack: string }
|
||||
| { type: 'setPacks'; packs: string[] }
|
||||
| { type: 'addBot'; difficulty: string }
|
||||
| { type: 'removePlayer'; target: string }
|
||||
| { type: 'start' }
|
||||
|
||||
@@ -2,15 +2,17 @@ import { useEffect, useState } from 'react'
|
||||
import { fetchCatalog } from './api'
|
||||
import type { Card } from './types'
|
||||
|
||||
// useCatalog loads the representative card for every pet and food in a pack and
|
||||
// caches the result per pack. It's used to look up cards by name (e.g. to
|
||||
// preview pets mentioned in the event log), even ones not currently in view.
|
||||
export function useCatalog(pack?: string): Card[] {
|
||||
// useCatalog loads the representative card for every pet and food across the
|
||||
// packs in play. It's used to look up cards by name (e.g. to preview pets
|
||||
// mentioned in the event log), even ones not currently in view. The packs are
|
||||
// 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 key = (packs ?? []).join(',')
|
||||
useEffect(() => {
|
||||
if (!pack) return
|
||||
if (!key) return
|
||||
let cancelled = false
|
||||
fetchCatalog(pack)
|
||||
fetchCatalog(key.split(','))
|
||||
.then((c) => {
|
||||
if (!cancelled) setCards(c)
|
||||
})
|
||||
@@ -20,6 +22,6 @@ export function useCatalog(pack?: string): Card[] {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [pack])
|
||||
}, [key])
|
||||
return cards
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user