Add bot to play against.

This commit is contained in:
Greyson Parrelli
2026-07-23 15:58:12 -04:00
parent 3825dbacec
commit db1a6ef290
23 changed files with 1852 additions and 23 deletions
+38
View File
@@ -0,0 +1,38 @@
# Super Auto Pets: The Board Game
Go backend (pure rules engine in `internal/game`, WebSocket rooms in
`internal/server`, computer opponent in `internal/ai`) + React frontend in
`web/`. See README.md for the full layout and rules summary.
- `mise run test` — Go tests · `mise run check` — go vet + frontend tsc
## Keep the AI in sync with game behavior
**Whenever game behavior changes — rules, cards, effects, phases, log
entries, or views — make sure the computer opponent accounts for it.** The
AI plays from the same information a human sees, so changes ripple into it
in specific ways:
- **Battle rules**: `internal/ai` evaluates moves by running the real
resolver (`game.SimulateBattle`), so battle changes are picked up
automatically — but re-check the hand-written heuristics that summarize
battle wisdom: `leadScore`, `keepValue`, `deckValue` (eval.go) and the
food-placement strategies / synergy pet list (arrange.go).
- **New or changed cards/effects**: shop-time deck effects are mirrored in
`applyTemplateShopEffects` (shop.go); a new shop-time trigger or action
must be added there or the bot will misvalue it.
- **New public actions or log changes**: the bot tracks the opponent via
structured tags on public log entries (`LogBuy`, `LogSell`, `LogTrade`,
`LogTradePick`, spawn counts — see log.go). New public actions need tags
plus handling in `Observe` (memory.go). Tags must only ever duplicate
facts the entry's text already states publicly.
- **View changes**: the AI decides from `game.View` only — never hand it
the `Game`. If a field is added to the view, confirm it doesn't leak
hidden information (deck order, trade options, shop decks), because the
AI (and any client) would legitimately see it.
- **Phase/flow changes**: the server's bot driver (`internal/server/bots.go`)
must know when a bot owes an action (`ai.Pending`) and have a legal
fallback (`botFallback`) for any new phase or forced decision.
After any such change, run `go test ./internal/ai/` — it plays complete
bot-vs-bot games and fails on any illegal or missing bot action.
+17 -2
View File
@@ -1,7 +1,8 @@
# Super Auto Pets: The Board Game — Online # Super Auto Pets: The Board Game — Online
A web app for playing the Super Auto Pets board game remotely. 1v1 for now; A web app for playing the Super Auto Pets board game remotely. 1v1 for now;
the engine is built to grow to more players. 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).
## Stack ## Stack
@@ -94,8 +95,22 @@ All six tiers use the real card data:
``` ```
cmd/server/ entrypoint cmd/server/ entrypoint
internal/game/ rules engine (pure, fully tested) internal/game/ rules engine (pure, fully tested)
internal/server/ HTTP + WebSocket rooms internal/ai/ computer opponent (decides from a player View only)
internal/server/ HTTP + WebSocket rooms; drives bot turns
internal/store/ SQLite persistence internal/store/ SQLite persistence
internal/env/ .env loading internal/env/ .env loading
web/ React frontend 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.
+173
View File
@@ -0,0 +1,173 @@
// Package ai implements a computer-controlled player.
//
// The bot is strictly information-hygienic: every decision is made from a
// game.View — the exact same state the server would send a human sitting in
// that seat — plus a Memory built purely from past public observations
// (battle lineups, the shared event log, and shop-row changes). The bot never
// touches the Game struct, so it cannot read the opponent's secret deck
// order, hidden trade picks, or upcoming shop cards even by accident.
//
// Decisions are made by generating candidate moves and scoring each one as a
// blend of two signals:
//
// - immediate: the estimated probability of winning the next battle,
// measured by Monte-Carlo rollouts (game.SimulateBattle) against sampled
// guesses of the opponent's deck and ordering;
// - future: a heuristic value of the resulting deck (power, tiers, suit
// synergy toward Triples) that only pays off in later rounds.
//
// The blend shifts toward "immediate" as the game nears its end and when the
// bot trails on trophies, which is what lets it deliberately take a weak
// round early to set up a stronger one later.
//
// Difficulty is a single level in [0, 1]: it sets the softmax temperature
// used to choose among scored candidates (a perfect bot always takes the top
// move; an easy bot often takes merely decent ones) and scales the rollout
// budget (an easy bot estimates win chances more noisily).
package ai
import (
"math"
"math/rand/v2"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// Action is one move the bot wants to make, mirroring the client protocol.
type Action struct {
Type string // "buy" | "sell" | "trade" | "tradeChoose" | "pass" | "arrange" | "ready"
Row int // buy
Cards []string // sell / trade
Pick int // tradeChoose
Order []string // arrange
}
// Bot is a computer player at a fixed difficulty level.
type Bot struct {
level float64
}
// New creates a bot with the given skill level in [0, 1].
func New(level float64) *Bot {
return &Bot{level: min(max(level, 0), 1)}
}
// Act computes the bot's next move from its view of the game, or nil when no
// input is owed. It does not modify the memory.
func (b *Bot) Act(v *game.View, mem *Memory) *Action {
if v.YouSeat < 0 || v.YouSeat >= len(v.Players) {
return nil
}
me := &v.Players[v.YouSeat]
switch v.Phase {
case game.PhaseShop:
if v.Pending != nil {
if v.Pending.PlayerID == me.ID {
return b.decideTradeChoose(v, mem)
}
return nil
}
if v.Turn == v.YouSeat && me.Coins > 0 {
return b.decideShop(v, mem)
}
case game.PhaseCleanup:
if !me.Ready {
return b.decideCleanup(v, mem)
}
case game.PhaseArrange:
if !me.Ready {
return b.decideArrange(v, mem)
}
case game.PhaseBattle:
if !me.Ready {
return &Action{Type: "ready"}
}
}
return nil
}
// Pending reports whether the seat owes the game an action right now — the
// server uses it to decide when to schedule a bot move.
func Pending(v *game.View) bool {
if v.YouSeat < 0 || v.YouSeat >= len(v.Players) {
return false
}
me := &v.Players[v.YouSeat]
switch v.Phase {
case game.PhaseShop:
if v.Pending != nil {
return v.Pending.PlayerID == me.ID
}
return v.Turn == v.YouSeat && me.Coins > 0
case game.PhaseCleanup, game.PhaseArrange, game.PhaseBattle:
return !me.Ready
}
return false
}
// candidate is one scored move option. Most candidates map to a single
// hypothetical deck; a trade maps to several (one per sampled reward card)
// whose scores are averaged.
type candidate struct {
act *Action
decks [][]game.Card
bias float64 // small nudge applied on top of the evaluated score
score float64
}
// pick chooses among candidates with a softmax over their scores. The
// difficulty level sets the temperature: near 0 the bot always takes the
// best move; higher temperatures make it increasingly willing to take
// second-best (or worse) options.
func (b *Bot) pick(cands []candidate) candidate {
if len(cands) == 1 {
return cands[0]
}
temp := 0.02 + 0.30*(1-b.level)
best := math.Inf(-1)
for _, c := range cands {
best = max(best, c.score)
}
weights := make([]float64, len(cands))
total := 0.0
for i, c := range cands {
weights[i] = math.Exp((c.score - best) / temp)
total += weights[i]
}
r := rand.Float64() * total
for i, w := range weights {
r -= w
if r <= 0 {
return cands[i]
}
}
return cands[len(cands)-1]
}
// budget returns the rollout counts for this difficulty: how many opponent
// deck/order guesses to test against, and how many dice-randomized battle
// simulations to run per guess. Fewer samples means noisier estimates, which
// is itself part of what makes an easy bot easy.
func (b *Bot) budget() (oppSamples, simsPer int) {
oppSamples = 6 + int(b.level*8) // 6 .. 14
simsPer = 1 + int(b.level*2) // 1 .. 3
return
}
// immediateWeight is how much of a move's score comes from the next battle
// versus long-term deck value. Later rounds shift weight toward "win now"
// (round 6 is worth double and there is no later); trailing on trophies
// pushes the same way, while a comfortable lead frees the bot to invest.
func immediateWeight(v *game.View) float64 {
w := 0.40
if v.MaxRounds > 1 {
w += 0.60 * float64(v.Round-1) / float64(v.MaxRounds-1)
}
me := v.Players[v.YouSeat]
for _, p := range v.Players {
if p.Seat != v.YouSeat {
w += 0.08 * float64(p.Trophies-me.Trophies)
}
}
return min(max(w, 0.25), 1)
}
+204
View File
@@ -0,0 +1,204 @@
package ai
import (
"testing"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// playBotGame drives a full game with bots in both seats, 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 playBotGame(t *testing.T, levelA, levelB float64) *game.Game {
t.Helper()
g := game.New()
pa, err := g.AddBot("Bot A", levelA)
if err != nil {
t.Fatalf("AddBot A: %v", err)
}
pb, err := g.AddBot("Bot B", levelB)
if err != nil {
t.Fatalf("AddBot B: %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 {
v := g.ViewFor(p.ID)
Observe(&v, mems[p.ID])
}
}
observe()
for steps := 0; g.Phase != game.PhaseGameOver; steps++ {
if steps > 2000 {
t.Fatalf("game made no progress; stuck in phase %s round %d", g.Phase, g.Round)
}
acted := false
for _, p := range g.Players {
v := g.ViewFor(p.ID)
if !Pending(&v) {
continue
}
act := bots[p.ID].Act(&v, mems[p.ID])
if act == nil {
t.Fatalf("bot %s owes an action in phase %s but returned none", p.Name, g.Phase)
}
if err := applyAction(g, p.ID, act); err != nil {
t.Fatalf("bot %s illegal action %q in phase %s round %d: %v", p.Name, act.Type, g.Phase, g.Round, err)
}
observe()
acted = true
break // one action per iteration, like one message per broadcast
}
if !acted {
t.Fatalf("no bot owes an action but the game is not over (phase %s)", g.Phase)
}
}
return g
}
// applyAction mirrors the server's dispatch of bot actions onto the engine.
func applyAction(g *game.Game, playerID string, a *Action) error {
switch a.Type {
case "buy":
return g.Buy(playerID, a.Row)
case "sell":
if g.Phase == game.PhaseCleanup {
return g.CleanupSell(playerID, a.Cards)
}
return g.Sell(playerID, a.Cards)
case "trade":
return g.TradeStart(playerID, a.Cards)
case "tradeChoose":
return g.TradeChoose(playerID, a.Pick)
case "pass":
return g.Pass(playerID)
case "arrange":
return g.SubmitOrder(playerID, a.Order)
case "ready":
return g.AcknowledgeBattle(playerID)
}
return game.ErrInvalidAction
}
// TestBotsFinishGames plays complete games at each difficulty pairing. This
// is the main safety net: every phase, every action type, every round, with
// two independent AIs generating whatever situations they generate.
func TestBotsFinishGames(t *testing.T) {
for _, levels := range [][2]float64{{1, 1}, {0.25, 1}, {0, 0}, {0.6, 0.25}} {
for range 3 {
g := playBotGame(t, levels[0], levels[1])
if g.Round != game.MaxRounds {
t.Errorf("game ended on round %d, want %d", g.Round, game.MaxRounds)
}
}
}
}
// TestObserveTracksOpponentDeck checks the memory's opponent model against
// the opponent's real deck after known public actions. The model may only
// contain information a human spectator would have.
func TestObserveTracksOpponentDeck(t *testing.T) {
g := game.New()
pa, _ := g.AddBot("Bot A", 1)
pb, _ := g.AddBot("Bot B", 1)
mem := &Memory{}
obs := func() {
v := g.ViewFor(pa.ID)
Observe(&v, mem)
}
obs()
// Whoever holds priority shops first; walk both players through buys.
first, second := g.Players[g.PrioritySeat], g.Players[1-g.PrioritySeat]
for range 3 { // 3 coins each, alternating
for _, p := range []*game.Player{first, second} {
if err := g.Buy(p.ID, 0); err != nil {
t.Fatalf("buy: %v", err)
}
obs()
}
}
// The model of B's deck must now match B's real deck card-for-card:
// every buy was public (and buy effects like Otter's apple are printed
// on the card).
assertModelMatches(t, mem, pb)
// Play out the round; the battle lineup resync must also match.
for g.Phase == game.PhaseCleanup {
t.Fatal("unexpected cleanup with 3 buys")
}
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("submit: %v", err)
}
obs()
}
if g.Phase != game.PhaseBattle {
t.Fatalf("phase = %s, want battle", g.Phase)
}
obs()
for _, p := range g.Players {
if err := g.AcknowledgeBattle(p.ID); err != nil {
t.Fatalf("ack: %v", err)
}
obs()
}
// Round 2 shop: temporaries expired; model must match B's real deck.
assertModelMatches(t, mem, pb)
}
// assertModelMatches requires the opponent model to agree with the real deck
// as a multiset of card names (IDs can legitimately differ for cards the bot
// reconstructed from public information).
func assertModelMatches(t *testing.T, mem *Memory, opp *game.Player) {
t.Helper()
want := map[string]int{}
for _, c := range opp.Deck {
want[c.Name]++
}
got := map[string]int{}
for _, c := range mem.Opp.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))
}
for name, n := range want {
if got[name] != n {
t.Errorf("model has %d × %s, real deck has %d", got[name], name, n)
}
}
for name, n := range got {
if want[name] == 0 {
t.Errorf("model claims %d × %s that the real deck lacks", n, name)
}
}
}
// TestSimulateBattleIsPure verifies rollouts don't corrupt anything the
// caller hands in.
func TestSimulateBattleIsPure(t *testing.T) {
deckA := []game.Card{
{ID: "a1", Kind: game.KindPet, Name: "Ant", Power: 1,
Effects: []game.Effect{{Trigger: game.TriggerFaint, Action: game.ActionSummonTop, Card: "apple"}}},
}
deckB := []game.Card{
{ID: "b1", Kind: game.KindPet, Name: "Duck", Power: 2},
}
res := game.SimulateBattle(1, 0, deckA, deckB, nil)
if res == nil || res.WinnerSeat != 1 {
t.Fatalf("expected seat 1 (Duck) to win, got %+v", res)
}
if len(deckA) != 1 || len(deckB) != 1 || deckA[0].ID != "a1" || deckB[0].ID != "b1" {
t.Error("SimulateBattle mutated its input decks")
}
}
+195
View File
@@ -0,0 +1,195 @@
package ai
import (
"slices"
"strings"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// decideArrange searches for the best secret battle ordering of the bot's
// deck. This is where "guess what the opponent will do" matters most: every
// candidate ordering is judged by simulated battles against a spread of
// sampled opponent decks and orderings, never against the opponent's real
// (hidden) choice.
//
// The search runs in two stages to stay cheap:
// 1. every permutation of the pets (≤ 5! = 120), each with a default food
// placement, gets a quick screening score;
// 2. the best few permutations are re-scored precisely, each trying several
// food-placement variants (front-loaded, on the strongest pet, spread,
// on an apple-synergy pet like Rooster or Leopard).
func (b *Bot) decideArrange(v *game.View, mem *Memory) *Action {
cx := newCtx(v, mem)
deck := cx.me.Deck
var pets, foods []game.Card
for _, c := range deck {
if c.IsPet() {
pets = append(pets, c)
} else {
foods = append(foods, c)
}
}
if len(pets) == 0 {
// Nothing can fight; any order loses identically.
return &Action{Type: "arrange", Order: cardIDs(deck)}
}
oppSamples, simsPer := b.budget()
oppDecks := cx.oppArrangements(oppSamples)
// Stage 1: screen every pet permutation with the default food placement
// against a subset of the opponent guesses.
perms := permutations(len(pets), 200)
screen := oppDecks[:min(4+int(b.level*4), len(oppDecks))]
type scored struct {
perm []int
score float64
}
ranked := make([]scored, 0, len(perms))
for _, perm := range perms {
arr := buildArrangement(pets, perm, foods, placeFront)
ranked = append(ranked, scored{perm, cx.winProb(arr, screen, 1)})
}
slices.SortStableFunc(ranked, func(a, b scored) int {
switch {
case a.score > b.score:
return -1
case a.score < b.score:
return 1
}
return 0
})
// Stage 2: refine the leaders with every food-placement variant and the
// full opponent sample set.
var cands []candidate
seen := map[string]bool{}
for _, r := range ranked[:min(5, len(ranked))] {
for _, place := range []foodPlacement{placeFront, placeStrongest, placeSpread, placeSynergy} {
arr := buildArrangement(pets, r.perm, foods, place)
key := fingerprint(arr)
if seen[key] {
continue
}
seen[key] = true
cands = append(cands, candidate{
act: &Action{Type: "arrange", Order: cardIDs(arr)},
score: cx.winProb(arr, oppDecks, simsPer),
})
}
}
return b.pick(cands).act
}
// foodPlacement decides which pet slot (index into the pet order) each food
// card sits in front of.
type foodPlacement func(pets []game.Card, foodIdx int, food game.Card) int
// placeFront stacks everything on the leading pet: it fights the most
// clashes, so buffs there see the most use.
func placeFront([]game.Card, int, game.Card) int { return 0 }
// placeStrongest feeds the biggest pet — apples on a heavy hitter compound,
// and perks protect the pet that fights longest.
func placeStrongest(pets []game.Card, _ int, _ game.Card) int {
best := 0
for i, p := range pets {
if p.Power > pets[best].Power {
best = i
}
}
return best
}
// placeSpread deals foods round-robin so one Skunk or Wolverine can't strip
// the whole stockpile at once.
func placeSpread(pets []game.Card, foodIdx int, _ game.Card) int {
return foodIdx % len(pets)
}
// placeSynergy targets pets whose abilities key off attached apples
// (Rooster's bees, Dodo's recycling, Leopard's per-power rocks, Peacock and
// Scorpion wanting to survive); falls back to the strongest pet.
func placeSynergy(pets []game.Card, foodIdx int, food game.Card) int {
for i, p := range pets {
switch p.Name {
case "Rooster", "Dodo", "Leopard", "Peacock", "Scorpion":
return i
}
}
return placeStrongest(pets, foodIdx, food)
}
// buildArrangement lays out the deck: foods assigned to a pet slot appear
// directly above that pet, and no food ever trails uselessly at the bottom.
// Perks assigned to the same pet keep only the last one applied, so extras
// are pushed to later pets.
func buildArrangement(pets []game.Card, perm []int, foods []game.Card, place foodPlacement) []game.Card {
ordered := make([]game.Card, len(perm))
for i, pi := range perm {
ordered[i] = pets[pi]
}
assign := make([][]game.Card, len(ordered))
perkUsed := make([]bool, len(ordered))
for fi, f := range foods {
at := place(ordered, fi, f)
if f.Perk {
// Slide duplicate perks onto the next unperked pet.
for at < len(ordered) && perkUsed[at] {
at++
}
if at >= len(ordered) {
at = len(ordered) - 1
}
perkUsed[at] = true
}
assign[at] = append(assign[at], f)
}
out := make([]game.Card, 0, len(pets)+len(foods))
for i, p := range ordered {
out = append(out, assign[i]...)
out = append(out, p)
}
return out
}
// permutations enumerates permutations of n indices, up to limit (5 pets is
// 120, so the limit only guards hypothetical future rule changes).
func permutations(n, limit int) [][]int {
idx := make([]int, n)
for i := range idx {
idx[i] = i
}
var out [][]int
var rec func(k int)
rec = func(k int) {
if len(out) >= limit {
return
}
if k == n {
out = append(out, slices.Clone(idx))
return
}
for i := k; i < n; i++ {
idx[k], idx[i] = idx[i], idx[k]
rec(k + 1)
idx[k], idx[i] = idx[i], idx[k]
}
}
rec(0)
return out
}
func cardIDs(cards []game.Card) []string {
ids := make([]string, len(cards))
for i, c := range cards {
ids[i] = c.ID
}
return ids
}
func fingerprint(cards []game.Card) string {
return strings.Join(cardIDs(cards), "|")
}
+201
View File
@@ -0,0 +1,201 @@
package ai
import (
"math"
"math/rand/v2"
"slices"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// winScore converts a simulated battle outcome to a utility for mySeat:
// win 1, draw 0.5 (nobody gains ground), loss 0.
func winScore(res *game.BattleResult, mySeat int) float64 {
switch res.WinnerSeat {
case mySeat:
return 1
case -1:
return 0.5
default:
return 0
}
}
// winProb estimates the chance the arranged deck wins the upcoming battle by
// simulating it against every sampled opponent arrangement, simsPer times
// each (dice rerolled every time). All candidates in one decision share the
// same opponent samples, so comparisons between them are paired and fair.
func (cx *ctx) winProb(myDeck []game.Card, oppDecks [][]game.Card, simsPer int) float64 {
if len(oppDecks) == 0 || simsPer <= 0 {
return 0.5
}
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)
n++
}
}
return total / float64(n)
}
// 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.
func keepValue(c game.Card) float64 {
if c.Temporary {
return 0.15
}
if c.IsFood() {
if c.Perk {
return 1.6
}
return 0.3
}
val := float64(c.Power)*0.55 + float64(c.Tier)*0.8
if len(c.Effects) > 0 {
val += 0.6
}
return val
}
// deckValue is the future-facing worth of a deck: card quality plus suit
// synergy (pairs and triples enable the Triple trade-in, the only path to
// higher-tier cards than the current round offers). Temporary cards count
// for nothing here — their value shows up in the battle rollouts instead.
func deckValue(deck []game.Card, round, maxRounds int) float64 {
total := 0.0
suits := map[game.Suit]int{}
for _, c := range deck {
if c.Temporary {
continue
}
total += keepValue(c)
if c.IsPet() {
suits[c.Suit]++
}
}
if round < maxRounds {
for _, k := range suits {
switch {
case k >= 3:
total += 1.5
case k == 2:
total += 0.6
}
}
}
return total
}
// normFuture squashes an unbounded deck value into (0, 1) so it can be
// blended with a win probability.
func normFuture(val float64) float64 {
return val / (val + 15)
}
// leadScore ranks pets for early battle positions: raw power fights longest,
// faint effects want to actually faint (early), play effects fire on entry
// wherever they are but are worth protecting slightly less.
func leadScore(c game.Card) float64 {
s := float64(c.Power)
for _, e := range c.Effects {
switch e.Trigger {
case game.TriggerFaint:
s += 1.5
case game.TriggerPlay:
s += 0.8
case game.TriggerHurt:
s += 0.5
}
}
return s
}
// heuristicOrder arranges a deck the way a reasonable player might: pets
// sorted by leadScore, apples front-loaded, perks spread across the
// strongest pets, never a food trailing at the bottom. temp adds Gumbel
// noise to every placement — 0 gives the deterministic "book" order, higher
// values give increasingly scrambled-but-plausible alternatives (used to
// model the range of orders an opponent might pick).
func heuristicOrder(deck []game.Card, temp float64) []game.Card {
var pets, apples, perks, otherFood []game.Card
for _, c := range deck {
switch {
case c.IsPet():
pets = append(pets, c)
case c.Perk:
perks = append(perks, c)
case c.Food == game.FoodApple:
apples = append(apples, c)
default:
otherFood = append(otherFood, c)
}
}
noisy := func(base float64) float64 {
if temp <= 0 {
return base
}
// Gumbel-perturbed scores turn a sort into a plausibility-weighted
// random ranking.
return base - temp*math.Log(-math.Log(rand.Float64()))
}
slices.SortStableFunc(pets, func(a, b game.Card) int {
av, bv := noisy(leadScore(a)), noisy(leadScore(b))
switch {
case av > bv:
return -1
case av < bv:
return 1
}
return 0
})
if len(pets) == 0 {
// No pets means an immediate loss; foods are wasted regardless.
return append(append(append(apples, perks...), otherFood...), pets...)
}
// Assign foods to pet indices, then interleave.
assign := make([][]game.Card, len(pets))
strongest := 0
for i, p := range pets {
if p.Power > pets[strongest].Power {
strongest = i
}
}
for _, a := range apples {
at := 0
if temp > 0 && rand.Float64() < 0.4 {
at = rand.IntN(len(pets))
}
assign[at] = append(assign[at], a)
}
// Perks one per pet, best pets first (a pet only keeps its last perk).
perkOrder := []int{strongest}
for i := range pets {
if i != strongest {
perkOrder = append(perkOrder, i)
}
}
for i, p := range perks {
at := perkOrder[min(i, len(perkOrder)-1)]
if temp > 0 && rand.Float64() < 0.3 {
at = rand.IntN(len(pets))
}
assign[at] = append(assign[at], p)
}
for i, f := range otherFood {
assign[i%len(pets)] = append(assign[i%len(pets)], f)
}
out := make([]game.Card, 0, len(deck))
for i, p := range pets {
out = append(out, assign[i]...)
out = append(out, p)
}
return out
}
+209
View File
@@ -0,0 +1,209 @@
package ai
import (
"encoding/json"
"fmt"
"slices"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// Memory is the bot's private notebook: everything it has legitimately
// learned from public information, carried between turns (and, serialized
// into the game state, across server restarts). It is the bot's substitute
// for a human player's attention — nothing in here is unavailable to a human
// watching the same screen.
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"`
}
// OppModel is the bot's belief about one opponent's deck. Known holds cards
// it has actually seen there (battle lineups reveal entire decks each round;
// shop buys are public); Hidden counts cards it knows exist but has never
// seen — trade-in picks, whose tier is public but whose identity is not.
type OppModel struct {
Seat int `json:"seat"`
Known []game.Card `json:"known"`
Hidden []HiddenCard `json:"hidden,omitempty"`
}
// 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.
type HiddenCard struct {
Tier int `json:"tier"`
Name string `json:"name,omitempty"`
}
// LoadMemory decodes a bot's stored memory; a nil or corrupt blob yields a
// fresh one (the model self-heals from the next battle lineup anyway).
func LoadMemory(raw json.RawMessage) *Memory {
m := &Memory{}
if len(raw) > 0 {
_ = json.Unmarshal(raw, m)
}
return m
}
// Marshal encodes the memory for storage on the bot's Player.
func (m *Memory) Marshal() json.RawMessage {
raw, err := json.Marshal(m)
if err != nil {
return nil
}
return raw
}
// Observe updates the memory from the bot's latest view. The server calls
// this on every state change, so consecutive observations are one action
// apart. It reads three public sources, in order:
//
// 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.
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
}
}
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 {
continue
}
switch {
case e.Kind == game.LogBuy:
if c, ok := cardByID(m.PrevShopRow, e.Source); ok {
m.Opp.Known = append(m.Opp.Known, c)
} else if c, ok := templateByName(e.CardName); ok {
m.Opp.Known = append(m.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)))
case e.Kind == game.LogTrade:
for _, id := range e.Cards {
m.removeOppCard(id, "")
}
case e.Kind == game.LogTradePick:
m.Opp.Hidden = append(m.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)))
}
}
}
// 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] {
if !c.Temporary {
m.Opp.Known = append(m.Opp.Known, c)
}
}
}
// Reconcile with the public deck size. Skipped during the battle phase,
// where the live deck still holds temporaries the model excludes.
if v.Phase == game.PhaseShop || v.Phase == game.PhaseCleanup || 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]
}
}
}
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)
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)
return
}
}
if len(m.Opp.Hidden) > 0 {
m.Opp.Hidden = m.Opp.Hidden[:len(m.Opp.Hidden)-1]
}
}
func cardByID(cards []game.Card, id string) (game.Card, bool) {
if id == "" {
return game.Card{}, false
}
for _, c := range cards {
if c.ID == id {
return c, true
}
}
return game.Card{}, false
}
// templateByName mints a reference copy of a named card from the printed
// tier contents. The suit is whatever the first printed copy has — callers
// only rely on stats and effects.
func templateByName(name string) (game.Card, bool) {
if name == "" {
return game.Card{}, false
}
for tier := 1; tier <= game.MaxRounds; tier++ {
for _, c := range game.TierContents(tier) {
if c.Name == name {
return c, true
}
}
}
return game.Card{}, false
}
// memApple mints an apple for the opponent model. The ID is synthetic — it
// only needs to not collide with real card IDs.
func memApple(n int) game.Card {
return game.Card{
ID: fmt.Sprintf("mem-apple-%d", n),
Kind: game.KindFood,
Name: "Apple",
Food: game.FoodApple,
Temporary: true,
}
}
+109
View File
@@ -0,0 +1,109 @@
package ai
import (
"fmt"
"math/rand/v2"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// ctx is per-decision scratch state: the view, the memory, and caches shared
// by every candidate evaluated in the same decision.
type ctx struct {
v *game.View
m *Memory
me *game.PlayerView
oppSeat int
pools map[int][]game.Card // unseen cards per tier, for hidden-card guesses
simID int
}
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{}}
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
}
}
}
return cx
}
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.
// Hidden opponent cards are drawn from this pool, so the bot's guesses
// respect card counting without peeking at the real decks.
func (cx *ctx) unseenPool(tier int) []game.Card {
if pool, ok := cx.pools[tier]; ok {
return pool
}
seen := map[string]int{}
note := func(c game.Card) {
if c.Tier == tier {
seen[c.Name]++
}
}
for _, c := range cx.me.Deck {
note(c)
}
for _, c := range cx.m.Opp.Known {
note(c)
}
for _, c := range cx.v.ShopRow {
note(c)
}
var pool []game.Card
for _, c := range game.TierContents(tier) {
if seen[c.Name] > 0 {
seen[c.Name]--
continue
}
pool = append(pool, c)
}
cx.pools[tier] = pool
return pool
}
// sampleOppDeck instantiates one concrete guess at the opponent's deck:
// 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 {
var c game.Card
if t, ok := templateByName(h.Name); ok {
c = t
} else if pool := cx.unseenPool(h.Tier); len(pool) > 0 {
c = pool[rand.IntN(len(pool))]
} else {
continue // tier exhausted and fully accounted for; nothing to guess
}
c.ID = cx.nextSimID()
deck = append(deck, c)
}
return deck
}
// oppArrangements produces n independent guesses of what the opponent will
// field: each is a sampled deck put in a plausible order by the same
// heuristic the bot itself uses, with enough noise that the bot prepares for
// a range of opponent plans rather than assuming one. The first guess is the
// noise-free "book" ordering.
func (cx *ctx) oppArrangements(n int) [][]game.Card {
out := make([][]game.Card, 0, n)
for i := range n {
temp := 0.8
if i == 0 {
temp = 0
}
out = append(out, heuristicOrder(cx.sampleOppDeck(), temp))
}
return out
}
+260
View File
@@ -0,0 +1,260 @@
package ai
import (
"math/rand/v2"
"slices"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// applyTemplateShopEffects mirrors the engine's shop-time triggers on a
// hypothetical deck: buying an Otter really does come with an apple, and the
// bot should value that. Only deck-changing effects matter here (coin
// refunds don't alter the deck being scored).
func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger game.EffectTrigger) []game.Card {
for _, e := range c.Effects {
if e.Trigger != trigger || cx.v.Round < e.MinRound {
continue
}
switch e.Action {
case game.ActionGainApple:
for range max(e.Count, 1) {
deck = append(deck, cx.simApple())
}
case game.ActionDoubleApples:
apples := 0
for _, dc := range deck {
if dc.Food == game.FoodApple {
apples++
}
}
for range apples {
deck = append(deck, cx.simApple())
}
}
}
return deck
}
func (cx *ctx) simApple() game.Card {
return game.Card{
ID: cx.nextSimID(),
Kind: game.KindFood,
Name: "Apple",
Food: game.FoodApple,
Temporary: true,
}
}
// previewCleanup applies the forced end-of-shop sale to a hypothetical deck:
// while over the pet limit, the lowest-value pet is sold for an apple. This
// lets the bot buy a sixth pet on purpose, knowing what it will cost.
func (cx *ctx) previewCleanup(deck []game.Card) []game.Card {
for {
pets := 0
worst, worstVal := -1, 0.0
for i, c := range deck {
if !c.IsPet() {
continue
}
pets++
if v := keepValue(c); worst < 0 || v < worstVal {
worst, worstVal = i, v
}
}
if pets <= cx.v.MaxPets || worst < 0 {
return deck
}
sold := deck[worst]
deck = slices.Delete(deck, worst, worst+1)
deck = append(deck, cx.simApple())
deck = cx.applyTemplateShopEffects(deck, sold, game.TriggerSell)
}
}
// score fills in every candidate's score: a weighted blend of the estimated
// next-battle win chance (deck arranged by the book ordering — the full
// ordering search happens later, at arrange time) and the deck's future
// value. All candidates face the same opponent guesses.
func (b *Bot) score(cx *ctx, cands []candidate) {
oppSamples, simsPer := b.budget()
oppDecks := cx.oppArrangements(oppSamples)
alpha := immediateWeight(cx.v)
for i := range cands {
total := 0.0
for _, deck := range cands[i].decks {
imm := cx.winProb(heuristicOrder(deck, 0), oppDecks, simsPer)
fut := normFuture(deckValue(deck, cx.v.Round, cx.v.MaxRounds))
total += alpha*imm + (1-alpha)*fut
}
cands[i].score = total/float64(len(cands[i].decks)) + cands[i].bias
}
}
// decideShop picks one shop action: buy a row card, sell some own cards,
// trade in a suit triple, or pass.
func (b *Bot) decideShop(v *game.View, mem *Memory) *Action {
cx := newCtx(v, mem)
deck := cx.me.Deck
var cands []candidate
// Passing forfeits the bot's remaining coins; it is the baseline every
// other option must beat, with a nudge because spending is usually right.
cands = append(cands, candidate{
act: &Action{Type: "pass"},
decks: [][]game.Card{slices.Clone(deck)},
bias: -0.02,
})
for i, c := range v.ShopRow {
if c.ID == "" {
continue
}
nd := append(slices.Clone(deck), c)
nd = cx.applyTemplateShopEffects(nd, c, game.TriggerBuy)
nd = cx.previewCleanup(nd)
cands = append(cands, candidate{
act: &Action{Type: "buy", Row: i},
decks: [][]game.Card{nd},
})
}
// Sell candidates: the worst 1, 2, or 3 keepers. One gold sells any
// number of cards, so bulk-dumping junk before a battle is one action.
// Temporary cards are excluded — selling an apple for an apple is a pure
// waste of gold.
sellable := slices.Clone(deck)
sellable = slices.DeleteFunc(sellable, func(c game.Card) bool { return c.Temporary })
slices.SortStableFunc(sellable, func(a, b game.Card) int {
av, bv := keepValue(a), keepValue(b)
switch {
case av < bv:
return -1
case av > bv:
return 1
}
return 0
})
for k := 1; k <= min(3, len(sellable)); k++ {
ids := make([]string, 0, k)
nd := slices.Clone(deck)
for _, s := range sellable[:k] {
ids = append(ids, s.ID)
idx := slices.IndexFunc(nd, func(c game.Card) bool { return c.ID == s.ID })
nd = slices.Delete(nd, idx, idx+1)
nd = append(nd, cx.simApple())
nd = cx.applyTemplateShopEffects(nd, s, game.TriggerSell)
}
cands = append(cands, candidate{
act: &Action{Type: "sell", Cards: ids},
decks: [][]game.Card{nd},
})
}
// Trade candidates: for each suit with three or more pets, trade the
// three lowest-value ones. The reward card is unknown (top two of the
// next tier's deck), so each trade is scored across several sampled
// rewards.
if v.Round < v.MaxRounds && v.Round < len(v.DeckCounts) && v.DeckCounts[v.Round] >= 2 {
bySuit := map[game.Suit][]game.Card{}
for _, c := range deck {
if c.IsPet() && c.Suit != "" {
bySuit[c.Suit] = append(bySuit[c.Suit], c)
}
}
for _, pets := range bySuit {
if len(pets) < game.TradeInCount {
continue
}
slices.SortStableFunc(pets, func(a, b game.Card) int {
av, bv := keepValue(a), keepValue(b)
switch {
case av < bv:
return -1
case av > bv:
return 1
}
return 0
})
trio := pets[:game.TradeInCount]
base := slices.Clone(deck)
ids := make([]string, 0, game.TradeInCount)
for _, t := range trio {
ids = append(ids, t.ID)
idx := slices.IndexFunc(base, func(c game.Card) bool { return c.ID == t.ID })
base = slices.Delete(base, idx, idx+1)
base = cx.applyTemplateShopEffects(base, t, game.TriggerTriple)
}
pool := cx.unseenPool(v.Round + 1)
if len(pool) == 0 {
pool = game.TierContents(v.Round + 1)
}
var decks [][]game.Card
for range 3 {
reward := pool[rand.IntN(len(pool))]
reward.ID = cx.nextSimID()
nd := append(slices.Clone(base), reward)
nd = cx.applyTemplateShopEffects(nd, reward, game.TriggerBuy)
nd = cx.previewCleanup(nd)
decks = append(decks, nd)
}
cands = append(cands, candidate{
act: &Action{Type: "trade", Cards: ids},
decks: decks,
})
}
}
b.score(cx, cands)
return b.pick(cands).act
}
// decideTradeChoose resolves the bot's own pending trade: score keeping
// either revealed card and pick.
func (b *Bot) decideTradeChoose(v *game.View, mem *Memory) *Action {
cx := newCtx(v, mem)
var cands []candidate
for pick, c := range v.Pending.Options {
nd := append(slices.Clone(cx.me.Deck), c)
nd = cx.applyTemplateShopEffects(nd, c, game.TriggerBuy)
nd = cx.previewCleanup(nd)
cands = append(cands, candidate{
act: &Action{Type: "tradeChoose", Pick: pick},
decks: [][]game.Card{nd},
})
}
b.score(cx, cands)
return b.pick(cands).act
}
// decideCleanup performs the forced sale down to the pet limit, dumping the
// lowest-value pets. This one is deterministic at every difficulty — even a
// weak player doesn't discard their best pet by accident.
func (b *Bot) decideCleanup(v *game.View, mem *Memory) *Action {
cx := newCtx(v, mem)
excess := cx.me.PetCount - v.MaxPets
if excess <= 0 {
return nil
}
pets := make([]game.Card, 0, cx.me.PetCount)
for _, c := range cx.me.Deck {
if c.IsPet() {
pets = append(pets, c)
}
}
slices.SortStableFunc(pets, func(a, b game.Card) int {
av, bv := keepValue(a), keepValue(b)
switch {
case av < bv:
return -1
case av > bv:
return 1
}
return 0
})
ids := make([]string, 0, excess)
for _, p := range pets[:excess] {
ids = append(ids, p.ID)
}
return &Action{Type: "sell", Cards: ids}
}
+2 -2
View File
@@ -874,10 +874,10 @@ func (g *Game) resolveBattle() {
// Tagged "result" so the client can hold it back until the replay finishes // Tagged "result" so the client can hold it back until the replay finishes
// (the outcome is known now, but showing it early would spoil the battle). // (the outcome is known now, but showing it early would spoil the battle).
if winner < 0 { if winner < 0 {
g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: "result", g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult,
Text: fmt.Sprintf("Round %d battle ends in a draw.", g.Round)}) Text: fmt.Sprintf("Round %d battle ends in a draw.", g.Round)})
} else { } else {
g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: "result", g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult,
Text: fmt.Sprintf("%s wins the round %d battle (+%d🏆).", pname(winner), g.Round, res.Trophies)}) Text: fmt.Sprintf("%s wins the round %d battle (+%d🏆).", pname(winner), g.Round, res.Trophies)})
} }
for _, p := range g.Players { for _, p := range g.Players {
+36 -9
View File
@@ -3,6 +3,7 @@ package game
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"math/big" "math/big"
@@ -49,6 +50,13 @@ type Player struct {
// TripledThisRound records whether the player used the Triple (trade-in) // TripledThisRound records whether the player used the Triple (trade-in)
// action during the current round's shop (Bison's Battle Prep). // action during the current round's shop (Bison's Battle Prep).
TripledThisRound bool `json:"tripledThisRound"` TripledThisRound bool `json:"tripledThisRound"`
// IsBot marks a computer-controlled seat. The engine treats bots exactly
// like humans; the server drives their actions. BotLevel is the bot's
// skill in [0, 1]; BotMemory is the bot's private notebook, opaque to the
// engine and persisted with the game so knowledge survives restarts.
IsBot bool `json:"isBot,omitempty"`
BotLevel float64 `json:"botLevel,omitempty"`
BotMemory json.RawMessage `json:"botMemory,omitempty"`
} }
// PetCount counts pet cards in the player's deck. // PetCount counts pet cards in the player's deck.
@@ -196,6 +204,19 @@ func (g *Game) AddPlayer(name string) (*Player, error) {
return p, nil return p, nil
} }
// AddBot seats a computer-controlled player. Bots count as connected from
// the start; the server is responsible for driving their actions.
func (g *Game) AddBot(name string, level float64) (*Player, error) {
p, err := g.AddPlayer(name)
if err != nil {
return nil, err
}
p.IsBot = true
p.BotLevel = min(max(level, 0), 1)
p.Connected = true
return p, nil
}
// PlayerByID returns the player, or nil. // PlayerByID returns the player, or nil.
func (g *Game) PlayerByID(id string) *Player { func (g *Game) PlayerByID(id string) *Player {
for _, p := range g.Players { for _, p := range g.Players {
@@ -278,7 +299,8 @@ func (g *Game) Buy(playerID string, rowIdx int) error {
p.Coins-- p.Coins--
bought := g.ShopRow[rowIdx] bought := g.ShopRow[rowIdx]
p.Deck = append(p.Deck, bought) p.Deck = append(p.Deck, bought)
g.logf(p.Seat, "🛒", "%s bought %s %s.", p.Name, article(bought.Name), bought.Name) g.addLog(LogEntry{Seat: p.Seat, Icon: "🛒", Kind: LogBuy, Source: bought.ID, CardName: bought.Name,
Text: fmt.Sprintf("%s bought %s %s.", p.Name, article(bought.Name), bought.Name)})
g.ShopRow[rowIdx] = g.drawFromTier(g.Round) g.ShopRow[rowIdx] = g.drawFromTier(g.Round)
g.applyShopTrigger(p, bought, TriggerBuy) g.applyShopTrigger(p, bought, TriggerBuy)
g.advanceShopTurn() g.advanceShopTurn()
@@ -320,7 +342,7 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
for range n { for range n {
p.Deck = append(p.Deck, g.newApple()) p.Deck = append(p.Deck, g.newApple())
} }
g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple", g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple", Count: n,
Text: fmt.Sprintf("%s adds %d apple%s to %s's deck.", c.Name, n, plural(n), p.Name)}) Text: fmt.Sprintf("%s adds %d apple%s to %s's deck.", c.Name, n, plural(n), p.Name)})
case ActionRefreshGold: case ActionRefreshGold:
p.Coins = min(p.Coins+e.count(), CoinsPerRound) p.Coins = min(p.Coins+e.count(), CoinsPerRound)
@@ -336,7 +358,7 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
p.Deck = append(p.Deck, g.newApple()) p.Deck = append(p.Deck, g.newApple())
} }
if apples > 0 { if apples > 0 {
g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple", g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple", Count: apples,
Text: fmt.Sprintf("%s doubles %s's apples (+%d).", c.Name, p.Name, apples)}) Text: fmt.Sprintf("%s doubles %s's apples (+%d).", c.Name, p.Name, apples)})
} }
} }
@@ -363,7 +385,7 @@ func (g *Game) sellCards(p *Player, cardIDs []string) error {
} }
for _, c := range sold { for _, c := range sold {
p.Deck = append(p.Deck, g.newApple()) p.Deck = append(p.Deck, g.newApple())
g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple", g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Kind: LogSell, Source: c.ID, CardName: c.Name, Spawn: "apple",
Text: fmt.Sprintf("%s sold %s — it becomes an apple.", p.Name, c.Name)}) Text: fmt.Sprintf("%s sold %s — it becomes an apple.", p.Name, c.Name)})
g.applyShopTrigger(p, c, TriggerSell) g.applyShopTrigger(p, c, TriggerSell)
} }
@@ -427,11 +449,14 @@ func (g *Game) TradeStart(playerID string, cardIDs []string) error {
// The discarded trio is public — everyone sees what was given up — even // The discarded trio is public — everyone sees what was given up — even
// though the pet ultimately chosen stays secret (see TradeChoose). // though the pet ultimately chosen stays secret (see TradeChoose).
names := make([]string, len(traded)) names := make([]string, len(traded))
ids := make([]string, len(traded))
for i, c := range traded { for i, c := range traded {
names[i] = c.Name names[i] = c.Name
ids[i] = c.ID
} }
g.logf(p.Seat, "🔄", "%s traded in %s (%s) for a tier %d pick.", g.addLog(LogEntry{Seat: p.Seat, Icon: "🔄", Kind: LogTrade, Cards: ids,
p.Name, strings.Join(names, ", "), suit, nextTier) Text: fmt.Sprintf("%s traded in %s (%s) for a tier %d pick.",
p.Name, strings.Join(names, ", "), suit, nextTier)})
g.Pending = &PendingTrade{ g.Pending = &PendingTrade{
PlayerID: playerID, PlayerID: playerID,
Tier: nextTier, Tier: nextTier,
@@ -463,10 +488,12 @@ func (g *Game) TradeChoose(playerID string, pick int) error {
// can't see the deck. But a pet with a Buy ability performs it publicly, so // can't see the deck. But a pet with a Buy ability performs it publicly, so
// we have to reveal that pet (its effect log names it anyway). // we have to reveal that pet (its effect log names it anyway).
if hasBuyEffect(chosen) { if hasBuyEffect(chosen) {
g.logf(p.Seat, "🔄", "%s's trade pick is %s %s — its buy ability triggers.", g.addLog(LogEntry{Seat: p.Seat, Icon: "🔄", Kind: LogTradePick, CardName: chosen.Name,
p.Name, article(chosen.Name), chosen.Name) Text: fmt.Sprintf("%s's trade pick is %s %s — its buy ability triggers.",
p.Name, article(chosen.Name), chosen.Name)})
} else { } else {
g.logf(p.Seat, "🔄", "%s keeps their trade pick hidden.", p.Name) g.addLog(LogEntry{Seat: p.Seat, Icon: "🔄", Kind: LogTradePick,
Text: fmt.Sprintf("%s keeps their trade pick hidden.", p.Name)})
} }
// Pets obtained via the Triple action trigger their Buy effects. // Pets obtained via the Triple action trigger their Buy effects.
g.applyShopTrigger(p, chosen, TriggerBuy) g.applyShopTrigger(p, chosen, TriggerBuy)
+17 -1
View File
@@ -14,12 +14,28 @@ type LogEntry struct {
Phase Phase `json:"phase"` Phase Phase `json:"phase"`
Seat int `json:"seat"` // acting seat, or -1 when none Seat int `json:"seat"` // acting seat, or -1 when none
Icon string `json:"icon,omitempty"` // leading emoji Icon string `json:"icon,omitempty"` // leading emoji
Kind string `json:"kind,omitempty"` // e.g. "result" (battle outcome) Kind string `json:"kind,omitempty"` // structured tag; see constants below
Text string `json:"text"` // the sentence itself Text string `json:"text"` // the sentence itself
Source string `json:"source,omitempty"` // card id that caused a spawn Source string `json:"source,omitempty"` // card id that caused a spawn
Spawn string `json:"spawn,omitempty"` // "apple" | "bee" for spawn entries Spawn string `json:"spawn,omitempty"` // "apple" | "bee" for spawn entries
// The fields below add machine-readable copies of facts the Text already
// states publicly, so observers (the AI player included) don't have to
// parse English. They must never carry information the text doesn't.
Count int `json:"count,omitempty"` // e.g. apples gained
CardName string `json:"cardName,omitempty"` // named card, when public
Cards []string `json:"cards,omitempty"` // card ids involved, when public
} }
// Structured LogEntry.Kind tags. Only "result" affects the client; the rest
// exist so observers can follow the public action stream structurally.
const (
LogResult = "result" // battle outcome (client holds it until the replay ends)
LogBuy = "buy" // Seat bought Source/CardName from the shop row
LogSell = "sell" // Seat sold Source/CardName (it became an apple)
LogTrade = "trade" // Seat traded in Cards for a next-tier pick
LogTradePick = "tradePick" // Seat took their pick; CardName set when revealed
)
// addLog appends an entry, stamping it with the next sequence number and the // addLog appends an entry, stamping it with the next sequence number and the
// current round/phase. Callers set Seat/Icon/Text (and Source/Spawn when the // current round/phase. Callers set Seat/Icon/Text (and Source/Spawn when the
// entry represents something spawning off a card). // entry represents something spawning off a card).
+35
View File
@@ -0,0 +1,35 @@
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 {
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.
NextCardID: 1_000_000,
Players: []*Player{
{Name: "A", Seat: 0, Deck: append([]Card(nil), deckA...)},
{Name: "B", Seat: 1, Deck: append([]Card(nil), deckB...)},
},
}
g.resolveBattle()
return g.Battle
}
// TierContents returns the full printed contents of a tier's shop deck —
// public information from the box. Cards carry placeholder IDs; they are
// reference data, not live instances.
func TierContents(tier int) []Card {
scratch := &Game{}
scratch.buildShopDecks()
if tier < 1 || tier > len(scratch.ShopDecks) {
return nil
}
return scratch.ShopDecks[tier-1]
}
+2
View File
@@ -10,6 +10,7 @@ type PlayerView struct {
Trophies int `json:"trophies"` Trophies int `json:"trophies"`
Ready bool `json:"ready"` Ready bool `json:"ready"`
Connected bool `json:"connected"` Connected bool `json:"connected"`
IsBot bool `json:"isBot,omitempty"`
DeckSize int `json:"deckSize"` DeckSize int `json:"deckSize"`
PetCount int `json:"petCount"` PetCount int `json:"petCount"`
Deck []Card `json:"deck,omitempty"` // self only Deck []Card `json:"deck,omitempty"` // self only
@@ -70,6 +71,7 @@ func (g *Game) ViewFor(playerID string) View {
Trophies: p.Trophies, Trophies: p.Trophies,
Ready: p.Ready, Ready: p.Ready,
Connected: p.Connected, Connected: p.Connected,
IsBot: p.IsBot,
DeckSize: len(p.Deck), DeckSize: len(p.Deck),
PetCount: p.PetCount(), PetCount: p.PetCount(),
} }
+83
View File
@@ -0,0 +1,83 @@
package server
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/greyson/super-auto-pets-board-game/internal/game"
"github.com/greyson/super-auto-pets-board-game/internal/store"
)
// TestE2EBotGame creates a vs-computer game over the API, plays the human's
// shop turns over the wire, and verifies the scheduled bot actually takes
// its own turns (spends coins) without any second client connected.
func TestE2EBotGame(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(), 30*time.Second)
defer cancel()
resp, err := http.Post(ts.URL+"/api/games", "application/json",
bytes.NewBufferString(`{"name":"Human","bot":"easy"}`))
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, "")
v := readState(t, ctx, ws)
if v.Phase != game.PhaseShop {
t.Fatalf("phase = %s, want shop (bot fills the lobby instantly)", v.Phase)
}
var bot *game.PlayerView
for i := range v.Players {
if v.Players[i].IsBot {
bot = &v.Players[i]
}
}
if bot == nil {
t.Fatal("no bot seat in the game")
}
// Play the human side: pass whenever it's our turn. The game can only
// reach the arrange phase if the bot spends its own three coins too.
deadline := time.Now().Add(25 * time.Second)
for v.Phase == game.PhaseShop && time.Now().Before(deadline) {
if v.Turn == v.YouSeat && v.Players[v.YouSeat].Coins > 0 && v.Pending == nil {
send(t, ctx, ws, map[string]any{"type": "pass"})
}
rctx, rcancel := context.WithTimeout(ctx, 10*time.Second)
v = readState(t, rctx, ws)
rcancel()
}
if v.Phase == game.PhaseShop {
t.Fatalf("shop never ended; bot coins=%d", v.Players[bot.Seat].Coins)
}
t.Logf("reached phase %s; bot played its shop turns", v.Phase)
}
+181
View File
@@ -0,0 +1,181 @@
package server
import (
"log/slog"
"math/rand/v2"
"time"
"github.com/greyson/super-auto-pets-board-game/internal/ai"
"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.
var botDifficulty = map[string]struct {
level float64
name string
}{
"easy": {0.25, "Robo Rookie"},
"medium": {0.60, "Robo Rival"},
"hard": {1.00, "Robo Ace"},
}
// 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
// must hold r.mu.
func (s *Server) commitLocked(r *room) {
observeBotsLocked(r.game)
s.persist(r)
r.broadcastLocked()
s.scheduleBotsLocked(r)
}
// observeBotsLocked gives each bot a look at the current state through its
// own player view — the same information a human in that seat would see.
func observeBotsLocked(g *game.Game) {
for _, p := range g.Players {
if !p.IsBot {
continue
}
mem := ai.LoadMemory(p.BotMemory)
view := g.ViewFor(p.ID)
ai.Observe(&view, mem)
p.BotMemory = mem.Marshal()
}
}
// scheduleBotsLocked arms a delayed move for the first bot that owes the
// game an action. The delay is there purely for feel — instant replies make
// the opponent seem like a vending machine. Only one timer runs per room;
// each fired move re-schedules the next.
func (s *Server) scheduleBotsLocked(r *room) {
if r.botArmed {
return
}
for _, p := range r.game.Players {
if !p.IsBot {
continue
}
view := r.game.ViewFor(p.ID)
if !ai.Pending(&view) {
continue
}
r.botArmed = true
playerID := p.ID
time.AfterFunc(botDelay(r.game.Phase), func() { s.runBot(r, playerID) })
return
}
}
// botDelay picks a humanlike pause before a bot move.
func botDelay(phase game.Phase) time.Duration {
ms := func(base, jitter int) time.Duration {
return time.Duration(base+rand.IntN(jitter+1)) * time.Millisecond
}
switch phase {
case game.PhaseShop:
return ms(700, 900)
case game.PhaseCleanup:
return ms(900, 600)
case game.PhaseArrange:
return ms(1600, 1600)
default: // battle acknowledgement
return ms(500, 300)
}
}
// runBot fires one scheduled bot move. The state may have changed while the
// timer ran, so everything is revalidated under the lock.
func (s *Server) runBot(r *room, playerID string) {
r.mu.Lock()
defer r.mu.Unlock()
r.botArmed = false
p := r.game.PlayerByID(playerID)
if p == nil || !p.IsBot {
return
}
view := r.game.ViewFor(playerID)
if !ai.Pending(&view) {
// Someone else moved the game on; check the other seats.
s.scheduleBotsLocked(r)
return
}
act := ai.New(p.BotLevel).Act(&view, ai.LoadMemory(p.BotMemory))
var err error
if act == nil {
err = game.ErrInvalidAction
} else {
err = applyBotAction(r.game, playerID, act)
}
if err != nil {
// A bot must never wedge the game: fall back to the simplest legal
// move for the phase.
slog.Warn("bot action failed; using fallback", "game", r.game.ID, "player", playerID, "err", err)
if err := botFallback(r.game, playerID); err != nil {
slog.Error("bot fallback failed", "game", r.game.ID, "player", playerID, "err", err)
return
}
}
s.commitLocked(r)
}
// applyBotAction maps a bot decision onto the engine, mirroring the client
// message dispatch in apply().
func applyBotAction(g *game.Game, playerID string, a *ai.Action) error {
switch a.Type {
case "buy":
return g.Buy(playerID, a.Row)
case "sell":
if g.Phase == game.PhaseCleanup {
return g.CleanupSell(playerID, a.Cards)
}
return g.Sell(playerID, a.Cards)
case "trade":
return g.TradeStart(playerID, a.Cards)
case "tradeChoose":
return g.TradeChoose(playerID, a.Pick)
case "pass":
return g.Pass(playerID)
case "arrange":
return g.SubmitOrder(playerID, a.Order)
case "ready":
return g.AcknowledgeBattle(playerID)
}
return game.ErrInvalidAction
}
// botFallback makes the trivially legal move for whatever the game is
// waiting on: pass the shop turn, take the first trade option, sell the
// first excess pets, submit the deck as-is, or acknowledge the battle.
func botFallback(g *game.Game, playerID string) error {
p := g.PlayerByID(playerID)
if p == nil {
return game.ErrInvalidAction
}
switch g.Phase {
case game.PhaseShop:
if g.Pending != nil && g.Pending.PlayerID == playerID {
return g.TradeChoose(playerID, 0)
}
return g.Pass(playerID)
case game.PhaseCleanup:
excess := p.PetCount() - game.MaxPets
ids := make([]string, 0, excess)
for _, c := range p.Deck {
if c.IsPet() && len(ids) < excess {
ids = append(ids, c.ID)
}
}
return g.CleanupSell(playerID, ids)
case game.PhaseArrange:
ids := make([]string, len(p.Deck))
for i, c := range p.Deck {
ids[i] = c.ID
}
return g.SubmitOrder(playerID, ids)
case game.PhaseBattle:
return g.AcknowledgeBattle(playerID)
}
return game.ErrInvalidAction
}
+31 -3
View File
@@ -62,6 +62,9 @@ type room struct {
game *game.Game game *game.Game
conns map[*client]struct{} conns map[*client]struct{}
debug bool // mirrors Server.debug, for broadcastLocked debug bool // mirrors Server.debug, for broadcastLocked
// botArmed is set while a delayed bot move is scheduled, so only one
// timer exists per room at a time.
botArmed bool
} }
// getRoom returns the room for a game ID, loading it from the store if it // getRoom returns the room for a game ID, loading it from the store if it
@@ -78,6 +81,11 @@ func (s *Server) getRoom(gameID string) (*room, error) {
} }
r := &room{game: g, conns: make(map[*client]struct{}), debug: s.debug} r := &room{game: g, conns: make(map[*client]struct{}), debug: s.debug}
s.rooms[gameID] = r s.rooms[gameID] = r
// If the game was persisted mid-bot-turn (e.g. across a server restart),
// get the bot moving again.
r.mu.Lock()
s.scheduleBotsLocked(r)
r.mu.Unlock()
return r, nil return r, nil
} }
@@ -116,24 +124,45 @@ type joinResponse struct {
func (s *Server) handleCreate(w http.ResponseWriter, req *http.Request) { func (s *Server) handleCreate(w http.ResponseWriter, req *http.Request) {
var body struct { var body struct {
Name string `json:"name"` Name string `json:"name"`
// Bot, when set, fills the other seat with a computer player:
// "easy" | "medium" | "hard".
Bot string `json:"bot"`
} }
if err := json.NewDecoder(req.Body).Decode(&body); err != nil { if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
httpError(w, http.StatusBadRequest, "invalid JSON body") httpError(w, http.StatusBadRequest, "invalid JSON body")
return return
} }
var bot struct {
level float64
name string
}
if body.Bot != "" {
var ok bool
bot, ok = botDifficulty[body.Bot]
if !ok {
httpError(w, http.StatusBadRequest, "unknown bot difficulty")
return
}
}
g := game.New() g := game.New()
p, err := g.AddPlayer(strings.TrimSpace(body.Name)) p, err := g.AddPlayer(strings.TrimSpace(body.Name))
if err != nil { if err != nil {
httpError(w, http.StatusBadRequest, err.Error()) httpError(w, http.StatusBadRequest, err.Error())
return return
} }
if body.Bot != "" {
if _, err := g.AddBot(bot.name, bot.level); err != nil {
httpError(w, http.StatusBadRequest, err.Error())
return
}
}
r := &room{game: g, conns: make(map[*client]struct{}), debug: s.debug} r := &room{game: g, conns: make(map[*client]struct{}), debug: s.debug}
s.mu.Lock() s.mu.Lock()
s.rooms[g.ID] = r s.rooms[g.ID] = r
s.mu.Unlock() s.mu.Unlock()
r.mu.Lock() r.mu.Lock()
s.persist(r) s.commitLocked(r)
r.mu.Unlock() r.mu.Unlock()
writeJSON(w, joinResponse{GameID: g.ID, Code: g.Code, PlayerID: p.ID, Token: p.Token}) writeJSON(w, joinResponse{GameID: g.ID, Code: g.Code, PlayerID: p.ID, Token: p.Token})
} }
@@ -163,9 +192,8 @@ func (s *Server) handleJoin(w http.ResponseWriter, req *http.Request) {
httpError(w, http.StatusConflict, err.Error()) httpError(w, http.StatusConflict, err.Error())
return return
} }
s.persist(r)
resp := joinResponse{GameID: r.game.ID, Code: r.game.Code, PlayerID: p.ID, Token: p.Token} resp := joinResponse{GameID: r.game.ID, Code: r.game.Code, PlayerID: p.ID, Token: p.Token}
r.broadcastLocked() s.commitLocked(r)
r.mu.Unlock() r.mu.Unlock()
writeJSON(w, resp) writeJSON(w, resp)
} }
+4 -2
View File
@@ -73,6 +73,9 @@ func (s *Server) handleWS(w http.ResponseWriter, req *http.Request) {
r.conns[c] = struct{}{} r.conns[c] = struct{}{}
p.Connected = true p.Connected = true
r.broadcastLocked() r.broadcastLocked()
// Safety net: if a scheduled bot move was ever lost (crash between
// persist and timer), a player connecting re-arms it.
s.scheduleBotsLocked(r)
r.mu.Unlock() r.mu.Unlock()
defer func() { defer func() {
@@ -147,8 +150,7 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) {
c.sendError(err.Error()) c.sendError(err.Error())
return return
} }
s.persist(r) s.commitLocked(r)
r.broadcastLocked()
} }
// broadcastLocked sends each connected client its own view of the game. // broadcastLocked sends each connected client its own view of the game.
+4 -2
View File
@@ -13,8 +13,10 @@ async function post(path: string, body: unknown): Promise<Session> {
return data as Session return data as Session
} }
export function createGame(name: string): Promise<Session> { export type BotDifficulty = 'easy' | 'medium' | 'hard'
return post('/api/games', { name })
export function createGame(name: string, bot?: BotDifficulty): Promise<Session> {
return post('/api/games', bot ? { name, bot } : { name })
} }
export function joinGame(code: string, name: string): Promise<Session> { export function joinGame(code: string, name: string): Promise<Session> {
+25
View File
@@ -1,7 +1,14 @@
import { useState } from 'react' import { useState } from 'react'
import { createGame, joinGame } from '../api' import { createGame, joinGame } from '../api'
import type { BotDifficulty } from '../api'
import type { Session } from '../types' import type { Session } from '../types'
const BOT_LEVELS: { value: BotDifficulty; label: string; blurb: string }[] = [
{ value: 'easy', label: '🐣 Easy', blurb: 'Learns you the ropes' },
{ value: 'medium', label: '🐺 Medium', blurb: 'Puts up a fight' },
{ value: 'hard', label: '🦁 Hard', blurb: 'Shows no mercy' },
]
// Home is the create/join screen shown when there's no active session. // Home is the create/join screen shown when there's no active session.
export function Home({ onSession }: { onSession: (s: Session) => void }) { export function Home({ onSession }: { onSession: (s: Session) => void }) {
const [name, setName] = useState('') const [name, setName] = useState('')
@@ -50,6 +57,24 @@ export function Home({ onSession }: { onSession: (s: Session) => void }) {
Host a new game Host a new game
</button> </button>
<div className="home-divider">
<span>or challenge the computer</span>
</div>
<div className="home-bots">
{BOT_LEVELS.map((b) => (
<button
key={b.value}
className="btn btn-secondary"
disabled={busy}
title={b.blurb}
onClick={() => run(() => createGame(name, b.value))}
>
{b.label}
</button>
))}
</div>
<div className="home-divider"> <div className="home-divider">
<span>or join a friend</span> <span>or join a friend</span>
</div> </div>
+8 -2
View File
@@ -93,7 +93,13 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
key={p.id} key={p.id}
className={`topbar-player ${p.seat === view.youSeat ? 'is-you' : ''}`} className={`topbar-player ${p.seat === view.youSeat ? 'is-you' : ''}`}
> >
<span className={`conn-dot ${p.connected ? 'on' : 'off'}`} /> {p.isBot ? (
<span className="bot-dot" title="Computer player">
🤖
</span>
) : (
<span className={`conn-dot ${p.connected ? 'on' : 'off'}`} />
)}
<span className="topbar-name">{p.name}</span> <span className="topbar-name">{p.name}</span>
<span className="chip">🏆 {p.trophies}</span> <span className="chip">🏆 {p.trophies}</span>
{(view.phase === 'shop' || view.phase === 'cleanup') && ( {(view.phase === 'shop' || view.phase === 'cleanup') && (
@@ -127,7 +133,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
)} )}
</div> </div>
{opponents.some((p) => !p.connected) && view.phase !== 'lobby' && ( {opponents.some((p) => !p.connected && !p.isBot) && view.phase !== 'lobby' && (
<div className="banner banner-warn">An opponent is disconnected</div> <div className="banner banner-warn">An opponent is disconnected</div>
)} )}
{error && <div className="toast">{error}</div>} {error && <div className="toast">{error}</div>}
+17
View File
@@ -371,6 +371,17 @@ h3 {
gap: 10px; gap: 10px;
} }
/* The three computer-opponent difficulty buttons share the row evenly. */
.home-bots {
display: flex;
gap: 10px;
}
.home-bots .btn {
flex: 1;
white-space: nowrap;
}
.home-error { .home-error {
color: var(--red-soft); color: var(--red-soft);
font-weight: 700; font-weight: 700;
@@ -479,6 +490,12 @@ h3 {
background: var(--red); background: var(--red);
} }
/* Bots swap the connection dot for a little robot face. */
.bot-dot {
font-size: 0.85rem;
line-height: 1;
}
/* The room code, stamped like a label on a card tray. */ /* The room code, stamped like a label on a card tray. */
.topbar-code { .topbar-code {
font-family: var(--font-display); font-family: var(--font-display);
+1
View File
@@ -26,6 +26,7 @@ export interface PlayerView {
trophies: number trophies: number
ready: boolean ready: boolean
connected: boolean connected: boolean
isBot?: boolean
deckSize: number deckSize: number
petCount: number petCount: number
deck?: Card[] deck?: Card[]