Make the AI a little smarter.

This commit is contained in:
Greyson Parrelli
2026-07-24 16:20:45 -04:00
parent e25a85e394
commit 0a1e2f9bee
4 changed files with 93 additions and 4 deletions
+22 -4
View File
@@ -9,16 +9,34 @@ import (
) )
// winScore converts a simulated battle outcome to a utility for mySeat: // winScore converts a simulated battle outcome to a utility for mySeat:
// win 1, draw 0.5 (nobody gains ground), loss 0. // win 1, draw 0.5 (nobody gains ground), loss 0, plus a small margin term.
//
// The margin — your surviving pets minus the enemy's — breaks ties among
// outcomes that share a verdict: a loss where you took most of the enemy down
// beats a wipe, and a decisive win beats a squeaker. It is capped well under
// 0.25 so it can never reorder win above draw above loss; it only decides
// between moves the coarse win/draw/loss signal rates identically. That
// gradient is what makes the bot play on sensibly when every option looks
// hopeless — fielding its strongest force instead of picking at random.
func winScore(res *game.BattleResult, mySeat int) float64 { func winScore(res *game.BattleResult, mySeat int) float64 {
var base float64
switch res.WinnerSeat { switch res.WinnerSeat {
case mySeat: case mySeat:
return 1 base = 1
case -1: case -1:
return 0.5 base = 0.5
default: default:
return 0 base = 0
} }
margin := 0
for seat, s := range res.Survivors {
if seat == mySeat {
margin += s
} else {
margin -= s
}
}
return base + 0.1*math.Tanh(float64(margin)/3)
} }
// winProb estimates the chance the arranged deck wins the upcoming battle by // winProb estimates the chance the arranged deck wins the upcoming battle by
+45
View File
@@ -0,0 +1,45 @@
package ai
import (
"testing"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// TestWinScoreMarginBreaksTiesNotVerdicts pins down the margin tie-breaker:
// within a single verdict it prefers the more decisive result (a win with pets
// to spare, a loss that took most of the enemy down), but no margin, however
// lopsided, may ever raise a loss above a draw or a draw above a win.
func TestWinScoreMarginBreaksTiesNotVerdicts(t *testing.T) {
mk := func(winner, mine, theirs int) *game.BattleResult {
return &game.BattleResult{WinnerSeat: winner, Survivors: []int{mine, theirs}}
}
decisiveWin := winScore(mk(0, 5, 0), 0)
narrowWin := winScore(mk(0, 1, 0), 0)
drawAhead := winScore(mk(-1, 3, 1), 0)
evenDraw := winScore(mk(-1, 0, 0), 0)
drawBehind := winScore(mk(-1, 1, 3), 0)
narrowLoss := winScore(mk(1, 0, 1), 0)
blowoutLoss := winScore(mk(1, 0, 6), 0)
// Ties within a verdict resolve toward the stronger finish.
if !(decisiveWin > narrowWin) {
t.Errorf("a win with survivors should beat a squeaker: %.4f !> %.4f", decisiveWin, narrowWin)
}
if !(drawAhead > evenDraw && evenDraw > drawBehind) {
t.Errorf("draws should order by margin: ahead %.4f, even %.4f, behind %.4f", drawAhead, evenDraw, drawBehind)
}
if !(narrowLoss > blowoutLoss) {
t.Errorf("a narrow loss should beat a blowout: %.4f !> %.4f", narrowLoss, blowoutLoss)
}
// Verdict ordering is inviolable: the best-possible loss still ranks below
// the worst-possible draw, and the best draw below the worst win.
if !(narrowWin > drawAhead) {
t.Errorf("the worst win must outrank the best draw: %.4f !> %.4f", narrowWin, drawAhead)
}
if !(drawBehind > narrowLoss) {
t.Errorf("the worst draw must outrank the best loss: %.4f !> %.4f", drawBehind, narrowLoss)
}
}
+21
View File
@@ -160,6 +160,11 @@ type BattleResult struct {
Events []BattleEvent `json:"events"` Events []BattleEvent `json:"events"`
WinnerSeat int `json:"winnerSeat"` // -1 = draw WinnerSeat int `json:"winnerSeat"` // -1 = draw
Trophies int `json:"trophies"` // awarded to the winner Trophies int `json:"trophies"` // awarded to the winner
// Survivors is each seat's remaining force at battle end: pets still in
// play plus any never reached in the stack. The loser is 0. It measures how
// decisive the result was — the margin the AI uses to prefer a lineup that
// fights harder, even in a battle it can't win.
Survivors []int `json:"survivors,omitempty"`
} }
// setAsideRocks is a fainted pet's pending rock payout. // setAsideRocks is a fainted pet's pending rock payout.
@@ -1312,5 +1317,21 @@ func (g *Game) runBattle() *BattleResult {
res.Trophies = 2 res.Trophies = 2
} }
} }
// Record each side's leftover force: a live pet in play plus any pets never
// reached in the stack. The loser lands on 0.
res.Survivors = make([]int, n)
for seat, s := range sides {
cnt := 0
if s.unit != nil && s.unit.Alive() {
cnt++
}
for _, c := range s.stack {
if c.IsPet() {
cnt++
}
}
res.Survivors[seat] = cnt
}
return res return res
} }
+5
View File
@@ -171,6 +171,11 @@ func TestSelfFaintWithReserveStillWins(t *testing.T) {
if res.WinnerSeat != 0 { if res.WinnerSeat != 0 {
t.Fatalf("seat 0 should win with a reserve pet vs an all-food deck, got winner %d", res.WinnerSeat) t.Fatalf("seat 0 should win with a reserve pet vs an all-food deck, got winner %d", res.WinnerSeat)
} }
// The survivor margin reflects the leftover force: seat 0's unrevealed
// Reserve still counts, seat 1 has nothing.
if got := res.Survivors; len(got) != 2 || got[0] < 1 || got[1] != 0 {
t.Fatalf("survivors should be [>=1, 0], got %v", got)
}
} }
// Poison Dart Frog, once set aside, throws rocks each time a Bee is played. // Poison Dart Frog, once set aside, throws rocks each time a Bee is played.