270 lines
8.3 KiB
Go
270 lines
8.3 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
|
|
// Opps models every other seat at the table, keyed by seat. A bot fights a
|
|
// different opponent each round (see the rulebook's pairings), and every
|
|
// battle is played in the open, so it tracks the whole field rather than
|
|
// one rival.
|
|
Opps map[int]*OppModel `json:"opps,omitempty"`
|
|
}
|
|
|
|
// OppModel is the bot's belief about one opponent's deck. Known holds cards
|
|
// 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"`
|
|
}
|
|
|
|
// opp returns the model for a seat, creating it on first sight.
|
|
func (m *Memory) opp(seat int) *OppModel {
|
|
if m.Opps == nil {
|
|
m.Opps = map[int]*OppModel{}
|
|
}
|
|
o, ok := m.Opps[seat]
|
|
if !ok {
|
|
o = &OppModel{Seat: seat}
|
|
m.Opps[seat] = o
|
|
}
|
|
return o
|
|
}
|
|
|
|
// Opp returns the bot's model of one seat. A seat it has never seen comes back
|
|
// empty rather than nil, so callers can read it unconditionally.
|
|
func (m *Memory) Opp(seat int) *OppModel {
|
|
if o, ok := m.Opps[seat]; ok {
|
|
return o
|
|
}
|
|
return &OppModel{Seat: seat}
|
|
}
|
|
|
|
// HiddenCard is a card the opponent holds that the bot has not seen. Name is
|
|
// set when the card was later named publicly (e.g. a trade pick revealed by
|
|
// its buy ability) — the suit still isn't known, but the stats are.
|
|
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)
|
|
// Notebooks written before the bot tracked a whole field held a single
|
|
// "opp"; file it under its seat.
|
|
var legacy struct {
|
|
Opp *OppModel `json:"opp"`
|
|
}
|
|
if json.Unmarshal(raw, &legacy) == nil && legacy.Opp != nil && len(m.Opps) == 0 {
|
|
m.Opps = map[int]*OppModel{legacy.Opp.Seat: legacy.Opp}
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// 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 round's battle lineups, which reveal every deck in full and reset
|
|
// the models to ground truth every round (so any drift lasts one round);
|
|
// 3. each opponent's public deck size, as a reconciliation safety net.
|
|
//
|
|
// Every source is table-wide: the bot follows all its rivals, not only the one
|
|
// it happens to be paired against, because it will face each of them later.
|
|
func Observe(v *game.View, m *Memory) {
|
|
if v.YouSeat < 0 {
|
|
return
|
|
}
|
|
isOpponent := func(seat int) bool {
|
|
return seat >= 0 && seat != v.YouSeat && v.PlayerView(seat) != nil
|
|
}
|
|
|
|
for _, e := range v.Log {
|
|
if e.Seq <= m.LastSeq {
|
|
continue
|
|
}
|
|
m.LastSeq = e.Seq
|
|
if !isOpponent(e.Seat) {
|
|
continue
|
|
}
|
|
opp := m.opp(e.Seat)
|
|
switch {
|
|
case e.Kind == game.LogBuy:
|
|
if c, ok := cardByID(m.PrevShopRow, e.Source); ok {
|
|
// An Avocado buy is set aside, not kept in the deck (Golden
|
|
// pack): don't add it to the deck model.
|
|
if c.Food != game.FoodAvocado {
|
|
opp.Known = append(opp.Known, c)
|
|
}
|
|
} else if c, ok := templateByName(v.Packs, e.CardName); ok {
|
|
if c.Food != game.FoodAvocado {
|
|
opp.Known = append(opp.Known, c)
|
|
}
|
|
}
|
|
case e.Kind == game.LogSell:
|
|
opp.remove(e.Source, e.CardName)
|
|
opp.Known = append(opp.Known, memApple(len(opp.Known)))
|
|
case e.Kind == game.LogTrade:
|
|
for _, id := range e.Cards {
|
|
opp.remove(id, "")
|
|
}
|
|
case e.Kind == game.LogTradePick:
|
|
opp.Hidden = append(opp.Hidden,
|
|
HiddenCard{Tier: min(e.Round+1, game.MaxRounds), Name: e.CardName})
|
|
case e.Spawn == "apple" && e.Kind == "":
|
|
n := max(e.Count, 1)
|
|
for range n {
|
|
opp.Known = append(opp.Known, memApple(len(opp.Known)))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Battle lineups are ground truth: rebuild each opponent's model from their
|
|
// revealed deck, minus temporary cards (they expire with the battle). Every
|
|
// table's battle is public, so one round refreshes the whole field.
|
|
for _, b := range v.Battles {
|
|
if b == nil || b.Round <= m.LastBattleRound {
|
|
continue
|
|
}
|
|
for side, seat := range b.Seats {
|
|
if !isOpponent(seat) || side >= len(b.Lineups) {
|
|
continue
|
|
}
|
|
opp := m.opp(seat)
|
|
opp.Known = opp.Known[:0]
|
|
opp.Hidden = nil
|
|
for _, c := range b.Lineups[side] {
|
|
if !c.Temporary {
|
|
opp.Known = append(opp.Known, c)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, b := range v.Battles {
|
|
if b != nil {
|
|
m.LastBattleRound = max(m.LastBattleRound, b.Round)
|
|
}
|
|
}
|
|
|
|
// Reconcile with the public deck sizes. Skipped during the battle phase,
|
|
// where the live decks still hold temporaries the models exclude.
|
|
if v.Phase == game.PhaseShop || v.Phase == game.PhaseArrange {
|
|
for _, p := range v.Players {
|
|
if !isOpponent(p.Seat) {
|
|
continue
|
|
}
|
|
m.opp(p.Seat).reconcile(p.DeckSize, v.Round)
|
|
}
|
|
}
|
|
|
|
m.PrevShopRow = append(m.PrevShopRow[:0], v.ShopRow...)
|
|
}
|
|
|
|
// remove drops one card from the model: by exact ID when we tracked it, by
|
|
// name as a fallback (model-minted apples have synthetic IDs), and failing
|
|
// both, one hidden card — something we didn't know they had, now gone.
|
|
func (o *OppModel) remove(id, name string) {
|
|
if i := slices.IndexFunc(o.Known, func(c game.Card) bool { return c.ID == id }); i >= 0 {
|
|
o.Known = slices.Delete(o.Known, i, i+1)
|
|
return
|
|
}
|
|
if name != "" {
|
|
if i := slices.IndexFunc(o.Known, func(c game.Card) bool { return c.Name == name }); i >= 0 {
|
|
o.Known = slices.Delete(o.Known, i, i+1)
|
|
return
|
|
}
|
|
}
|
|
if len(o.Hidden) > 0 {
|
|
o.Hidden = o.Hidden[:len(o.Hidden)-1]
|
|
}
|
|
}
|
|
|
|
// reconcile forces the model to hold exactly size cards, the count everyone can
|
|
// see, padding with unknowns of the current tier or dropping the excess.
|
|
func (o *OppModel) reconcile(size, round int) {
|
|
for len(o.Known)+len(o.Hidden) < size {
|
|
o.Hidden = append(o.Hidden, HiddenCard{Tier: round})
|
|
}
|
|
for len(o.Known)+len(o.Hidden) > size {
|
|
if len(o.Hidden) > 0 {
|
|
o.Hidden = o.Hidden[:len(o.Hidden)-1]
|
|
} else {
|
|
o.Known = o.Known[:len(o.Known)-1]
|
|
}
|
|
}
|
|
}
|
|
|
|
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 of the packs in play. The suit is whatever the first printed copy
|
|
// has — callers only rely on stats and effects.
|
|
func templateByName(packs []string, name string) (game.Card, bool) {
|
|
if name == "" {
|
|
return game.Card{}, false
|
|
}
|
|
for tier := 1; tier <= game.MaxRounds; tier++ {
|
|
for _, c := range game.TierContentsForPacks(packs, 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,
|
|
}
|
|
}
|