Add bot to play against.
This commit is contained in:
@@ -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.
|
||||
@@ -1,7 +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.
|
||||
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
|
||||
|
||||
@@ -94,8 +95,22 @@ All six tiers use the real card data:
|
||||
```
|
||||
cmd/server/ entrypoint
|
||||
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/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, 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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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), "|")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -874,10 +874,10 @@ func (g *Game) resolveBattle() {
|
||||
// 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).
|
||||
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)})
|
||||
} 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)})
|
||||
}
|
||||
for _, p := range g.Players {
|
||||
|
||||
+36
-9
@@ -3,6 +3,7 @@ package game
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
@@ -49,6 +50,13 @@ type Player struct {
|
||||
// TripledThisRound records whether the player used the Triple (trade-in)
|
||||
// action during the current round's shop (Bison's Battle Prep).
|
||||
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.
|
||||
@@ -196,6 +204,19 @@ func (g *Game) AddPlayer(name string) (*Player, error) {
|
||||
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.
|
||||
func (g *Game) PlayerByID(id string) *Player {
|
||||
for _, p := range g.Players {
|
||||
@@ -278,7 +299,8 @@ func (g *Game) Buy(playerID string, rowIdx int) error {
|
||||
p.Coins--
|
||||
bought := g.ShopRow[rowIdx]
|
||||
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.applyShopTrigger(p, bought, TriggerBuy)
|
||||
g.advanceShopTurn()
|
||||
@@ -320,7 +342,7 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
|
||||
for range n {
|
||||
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)})
|
||||
case ActionRefreshGold:
|
||||
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())
|
||||
}
|
||||
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)})
|
||||
}
|
||||
}
|
||||
@@ -363,7 +385,7 @@ func (g *Game) sellCards(p *Player, cardIDs []string) error {
|
||||
}
|
||||
for _, c := range sold {
|
||||
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)})
|
||||
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
|
||||
// though the pet ultimately chosen stays secret (see TradeChoose).
|
||||
names := make([]string, len(traded))
|
||||
ids := make([]string, len(traded))
|
||||
for i, c := range traded {
|
||||
names[i] = c.Name
|
||||
ids[i] = c.ID
|
||||
}
|
||||
g.logf(p.Seat, "🔄", "%s traded in %s (%s) for a tier %d pick.",
|
||||
p.Name, strings.Join(names, ", "), suit, nextTier)
|
||||
g.addLog(LogEntry{Seat: p.Seat, Icon: "🔄", Kind: LogTrade, Cards: ids,
|
||||
Text: fmt.Sprintf("%s traded in %s (%s) for a tier %d pick.",
|
||||
p.Name, strings.Join(names, ", "), suit, nextTier)})
|
||||
g.Pending = &PendingTrade{
|
||||
PlayerID: playerID,
|
||||
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
|
||||
// we have to reveal that pet (its effect log names it anyway).
|
||||
if hasBuyEffect(chosen) {
|
||||
g.logf(p.Seat, "🔄", "%s's trade pick is %s %s — its buy ability triggers.",
|
||||
p.Name, article(chosen.Name), chosen.Name)
|
||||
g.addLog(LogEntry{Seat: p.Seat, Icon: "🔄", Kind: LogTradePick, CardName: chosen.Name,
|
||||
Text: fmt.Sprintf("%s's trade pick is %s %s — its buy ability triggers.",
|
||||
p.Name, article(chosen.Name), chosen.Name)})
|
||||
} 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.
|
||||
g.applyShopTrigger(p, chosen, TriggerBuy)
|
||||
|
||||
+17
-1
@@ -14,12 +14,28 @@ type LogEntry struct {
|
||||
Phase Phase `json:"phase"`
|
||||
Seat int `json:"seat"` // acting seat, or -1 when none
|
||||
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
|
||||
Source string `json:"source,omitempty"` // card id that caused a spawn
|
||||
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
|
||||
// current round/phase. Callers set Seat/Icon/Text (and Source/Spawn when the
|
||||
// entry represents something spawning off a card).
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -10,6 +10,7 @@ type PlayerView struct {
|
||||
Trophies int `json:"trophies"`
|
||||
Ready bool `json:"ready"`
|
||||
Connected bool `json:"connected"`
|
||||
IsBot bool `json:"isBot,omitempty"`
|
||||
DeckSize int `json:"deckSize"`
|
||||
PetCount int `json:"petCount"`
|
||||
Deck []Card `json:"deck,omitempty"` // self only
|
||||
@@ -70,6 +71,7 @@ func (g *Game) ViewFor(playerID string) View {
|
||||
Trophies: p.Trophies,
|
||||
Ready: p.Ready,
|
||||
Connected: p.Connected,
|
||||
IsBot: p.IsBot,
|
||||
DeckSize: len(p.Deck),
|
||||
PetCount: p.PetCount(),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -62,6 +62,9 @@ type room struct {
|
||||
game *game.Game
|
||||
conns map[*client]struct{}
|
||||
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
|
||||
@@ -78,6 +81,11 @@ func (s *Server) getRoom(gameID string) (*room, error) {
|
||||
}
|
||||
r := &room{game: g, conns: make(map[*client]struct{}), debug: s.debug}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -116,24 +124,45 @@ type joinResponse struct {
|
||||
func (s *Server) handleCreate(w http.ResponseWriter, req *http.Request) {
|
||||
var body struct {
|
||||
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 {
|
||||
httpError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
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()
|
||||
p, err := g.AddPlayer(strings.TrimSpace(body.Name))
|
||||
if err != nil {
|
||||
httpError(w, http.StatusBadRequest, err.Error())
|
||||
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}
|
||||
s.mu.Lock()
|
||||
s.rooms[g.ID] = r
|
||||
s.mu.Unlock()
|
||||
|
||||
r.mu.Lock()
|
||||
s.persist(r)
|
||||
s.commitLocked(r)
|
||||
r.mu.Unlock()
|
||||
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())
|
||||
return
|
||||
}
|
||||
s.persist(r)
|
||||
resp := joinResponse{GameID: r.game.ID, Code: r.game.Code, PlayerID: p.ID, Token: p.Token}
|
||||
r.broadcastLocked()
|
||||
s.commitLocked(r)
|
||||
r.mu.Unlock()
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
@@ -73,6 +73,9 @@ func (s *Server) handleWS(w http.ResponseWriter, req *http.Request) {
|
||||
r.conns[c] = struct{}{}
|
||||
p.Connected = true
|
||||
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()
|
||||
|
||||
defer func() {
|
||||
@@ -147,8 +150,7 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) {
|
||||
c.sendError(err.Error())
|
||||
return
|
||||
}
|
||||
s.persist(r)
|
||||
r.broadcastLocked()
|
||||
s.commitLocked(r)
|
||||
}
|
||||
|
||||
// broadcastLocked sends each connected client its own view of the game.
|
||||
|
||||
+4
-2
@@ -13,8 +13,10 @@ async function post(path: string, body: unknown): Promise<Session> {
|
||||
return data as Session
|
||||
}
|
||||
|
||||
export function createGame(name: string): Promise<Session> {
|
||||
return post('/api/games', { name })
|
||||
export type BotDifficulty = 'easy' | 'medium' | 'hard'
|
||||
|
||||
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> {
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { createGame, joinGame } from '../api'
|
||||
import type { BotDifficulty } from '../api'
|
||||
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.
|
||||
export function Home({ onSession }: { onSession: (s: Session) => void }) {
|
||||
const [name, setName] = useState('')
|
||||
@@ -50,6 +57,24 @@ export function Home({ onSession }: { onSession: (s: Session) => void }) {
|
||||
Host a new game
|
||||
</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">
|
||||
<span>or join a friend</span>
|
||||
</div>
|
||||
|
||||
@@ -93,7 +93,13 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
key={p.id}
|
||||
className={`topbar-player ${p.seat === view.youSeat ? 'is-you' : ''}`}
|
||||
>
|
||||
{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="chip">🏆 {p.trophies}</span>
|
||||
{(view.phase === 'shop' || view.phase === 'cleanup') && (
|
||||
@@ -127,7 +133,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{error && <div className="toast">{error}</div>}
|
||||
|
||||
@@ -371,6 +371,17 @@ h3 {
|
||||
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 {
|
||||
color: var(--red-soft);
|
||||
font-weight: 700;
|
||||
@@ -479,6 +490,12 @@ h3 {
|
||||
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. */
|
||||
.topbar-code {
|
||||
font-family: var(--font-display);
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface PlayerView {
|
||||
trophies: number
|
||||
ready: boolean
|
||||
connected: boolean
|
||||
isBot?: boolean
|
||||
deckSize: number
|
||||
petCount: number
|
||||
deck?: Card[]
|
||||
|
||||
Reference in New Issue
Block a user