The debug report was only reachable in DEBUG mode, which is the one mode
a player hitting a real bug won't be running. Move it to a "🐛 Report a
bug" item in the game menu: a note field, a download, and a copy-as-text,
on any server.
That knowingly shows the reporter their opponents' hands and the shop
deck order. Credentials are still required, so a report only ever reaches
someone seated at that table. The buy-any-card panel stays DEBUG-only —
it changes the game; a report only reads it.
256 lines
12 KiB
Markdown
256 lines
12 KiB
Markdown
# Super Auto Pets: The Board Game — Online
|
||
|
||
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
|
||
|
||
- **Backend**: Go (stdlib-first). Pure game engine in `internal/game`,
|
||
WebSocket sync via `coder/websocket`, persistence as JSON blobs in SQLite
|
||
(`modernc.org/sqlite`, no cgo).
|
||
- **Frontend**: React + Vite + TypeScript in `web/`. DOM/CSS card rendering
|
||
and battle animations.
|
||
- **Tasks**: [mise](https://mise.jdx.dev) (`mise.toml`).
|
||
|
||
## Quick start
|
||
|
||
```sh
|
||
mise run install-web # once: npm install
|
||
mise run serve # build frontend + server, run on :8080
|
||
```
|
||
|
||
Open http://localhost:8080, host a game, and join from another browser (or
|
||
incognito window) with the 5-letter code.
|
||
|
||
### Development
|
||
|
||
```sh
|
||
mise run dev # Go server on :8080 + Vite hot reload on :5173
|
||
mise run test # Go tests (game engine)
|
||
mise run check # go vet + frontend type-check
|
||
```
|
||
|
||
During development open the Vite URL (http://localhost:5173); it proxies
|
||
`/api` (including the WebSocket) to the Go server.
|
||
|
||
## Configuration
|
||
|
||
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
|
||
|
||
Six rounds, each with its own shop tier deck. Per round:
|
||
|
||
1. **Shop** — each player has 3 gold and players alternate actions: **buy**
|
||
one of 4 face-up cards (1 gold — the only action that costs anything);
|
||
**sell** any number of hand cards for free (each becomes an 🍎 apple, +1
|
||
power food, and Sell effects fire); or **trade in** 3 same-suit pets (the
|
||
Triple action, also free) to pick 1 of the top 2 cards of the next tier's
|
||
deck — Triple effects fire on the traded cards and the received pet's Buy
|
||
effect fires. **Passing** is a final action, only legal at 5 pets or
|
||
fewer: it forfeits remaining gold and ends that player's shopping for the
|
||
round. The shop closes once everyone has passed.
|
||
2. **Arrange** — Battle Prep effects fire first (e.g. Giraffe hands out
|
||
apples), then players secretly order their decks. Food cards apply to the
|
||
next pet after them; trailing foods are wasted. A pet only benefits from
|
||
its last-applied **perk** (e.g. Honey, Garlic).
|
||
3. **Battle** — automatic stack machine. Cards reveal off the top of each
|
||
deck until a pet is in play. Play effects fire on reveal (rocks roll a d6
|
||
with faces 0/0/1/1/2/2 and hit the opposing pet before the clash; Skunk
|
||
strips foods; Wolverine steals apples; Chili mills the enemy deck). The
|
||
two pets simultaneously deal their full power to each other as damage
|
||
markers; a pet with markers ≥ power faints (attack never drops while
|
||
wounded). Faint effects push summons onto either deck, recycle apples
|
||
(Dodo), set aside delayed/recurring/conditional rock volleys (Badger,
|
||
Blowfish, Snake, Crocodile), raise auras (Turkey, Mammoth), or arm a
|
||
team shield (Turtle). Survivors that took damage fire Hurt effects
|
||
(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.
|
||
|
||
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, 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
|
||
|
||
| Tier | Pets | Food |
|
||
| ---- | ---- | ---- |
|
||
| 1 | Ant, Cricket, Duck, Otter, Mosquito, Fish | — |
|
||
| 2 | Worm, Flamingo, Peacock, Swan, Rat, Spider | Honey |
|
||
| 3 | Dog, Dolphin, Giraffe, Camel, Sheep, Dodo, Badger | Garlic |
|
||
| 4 | Squirrel, Turtle, Rooster, Bison, Blowfish, Skunk, Hippo | Pineapple |
|
||
| 5 | Monkey, Rhino, Crocodile, Scorpion, Seal, Shark, Turkey | Chili |
|
||
| 6 | Gorilla, Fly, Leopard, Mammoth, Cat, Snake, Wolverine | Melon |
|
||
|
||
### Golden pack
|
||
|
||
| Tier | Pets | Food |
|
||
| ---- | ---- | ---- |
|
||
| 1 | Groundhog, Pied Tamarin, Chipmunk, Cone Snail, Bulldog, Opossum | — |
|
||
| 2 | Black-Necked Stilt, Lizard, Hercules Beetle, Stoat, Desert Rain Frog, Honduran White Bat | — |
|
||
| 3 | Guinea Fowl, Surgeon Fish, Osprey, Anteater, Bear, Royal Flycatcher, Flea | Avocado |
|
||
| 4 | Saiga Antelope, Vaquita, Poison Dart Frog, Manta Ray, Slug, Cockatoo, Manatee | Potato |
|
||
| 5 | Nyala, Nurse Shark, Giant Isopod, Blue-Ringed Octopus, Raccoon, Fire Ant, Macaque | Durian |
|
||
| 6 | Highland Cow, Wildebeest, Grizzly Bear, Catfish, Komodo, Bird of Paradise, German Shepherd | Tomato |
|
||
|
||
The Golden pack adds mechanics the Turtle pack doesn't have:
|
||
|
||
- **Trumpets** — an ephemeral battle resource pets earn and spend mid-fight.
|
||
- **Golden Retriever** — once per battle, a side that runs out of cards but
|
||
still holds Trumpets fields one, its power equal to those Trumpets.
|
||
- **Avocado** — a persistent set-aside token you can discard in place of gold.
|
||
- **Nurse Shark** — on entry it spends every Trumpet it can (up to 3),
|
||
throwing two rocks at the enemy per Trumpet spent.
|
||
- **Cockatoo** — a shop-time reveal, plus Manta Ray's free first buy,
|
||
Blue-Ringed Octopus' per-buy apples, and more.
|
||
|
||
### Unicorn pack
|
||
|
||
| Tier | Pets | Food |
|
||
| ---- | ---- | ---- |
|
||
| 1 | Alchemedes, Cuddle Toad, Pengobble, Barghest, Basilisk, Baku | — |
|
||
| 2 | Thunderbird, Gargoyle, Frost Wolf, Mothman, Nightcrawler, Bigfoot | Fairy Dust |
|
||
| 3 | Fur-Bearing Trout, Calygreyhound, Tatzelwurm, Skeleton Dog, Mandrake, Lucky Cat, Slime | Water of Youth |
|
||
| 4 | Roc, Chimera, Kraken, Unicorn, Abomination, Rootlin, Fairy | Health Potion |
|
||
| 5 | Kitsune, Pixiu, Red Dragon, Amalgamation, Vampire Bat, Werewolf, Loveland Frogman | Big Mana Potion |
|
||
| 6 | Sleipnir, Sea Serpent, Bakunawa, Manticore, Team Spirit, Behemoth, Quetzalcoatl | Cornucopia |
|
||
|
||
The Unicorn pack adds two mechanics the others don't have:
|
||
|
||
- **Mana** — a **persistent** resource (unlike ephemeral Trumpets): pets earn
|
||
it in the shop (Cuddle Toad, Thunderbird, Roc) and in battle (Alchemedes,
|
||
Fur-Bearing Trout, Kitsune, Tatzelwurm per rock blank), and spend it to power
|
||
abilities (Pengobble, Gargoyle, Sea Serpent, Bakunawa; Sleipnir's very Power
|
||
equals your Mana). It carries over between rounds until spent, so you can bank
|
||
it up.
|
||
- **Ailments** — temporary debuff cards attached to a pet during battle,
|
||
removed at round end like apples. **Spooked** lowers the pet's clash attack
|
||
(−1 each, min 0); **Exposed** raises the damage it takes per hit (+1 each).
|
||
They stack, can be added to the enemy pet or dropped on top of the enemy deck
|
||
(Frost Wolf, Slime, Kraken, Red Dragon). **Baku** shrugs off the first one it
|
||
takes, **Unicorn** turns the next friendly one into apples, and **Manticore**
|
||
makes enemy ailments count for more.
|
||
- Plus a **game-wide discard pile** the higher tiers reach into (Chimera,
|
||
Abomination), pet-recycling (Fairy, Loveland Frogman's bounce), and shop
|
||
tricks — Bigfoot's deck peek, Water of Youth's upgrade, Quetzalcoatl's reveal.
|
||
|
||
## Layout
|
||
|
||
```
|
||
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
|
||
internal/store/ SQLite persistence
|
||
internal/env/ .env loading
|
||
web/ React frontend
|
||
```
|
||
|
||
## The computer opponent
|
||
|
||
Any seat can be a bot (`Player.IsBot`); humans and bots are interchangeable
|
||
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.
|