Add bot to play against.
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user