Files
super-auto-pets-board-game/internal/ai/memory.go
T
Greyson Parrelli 72e198a750 Fix shop action costs and pass semantics.
Only buying costs gold; selling and trading (Triple) are free. Pass is
now a final action, legal only at or under the pet limit: it forfeits
remaining gold and ends that player's shopping for the round, with the
shop closing once everyone has passed. That makes the separate cleanup
phase unreachable (you sell down in-shop before passing), so it is
removed. The client asks for confirmation before passing, and the bot
knows buys are the only coin sink, when passing is legal, and that it
must sell down before it can pass.
2026-07-23 16:11:07 -04:00

210 lines
6.5 KiB
Go

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.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,
}
}