diff --git a/internal/ai/eval.go b/internal/ai/eval.go index 079114f..1a25fd9 100644 --- a/internal/ai/eval.go +++ b/internal/ai/eval.go @@ -9,16 +9,34 @@ import ( ) // 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 { + var base float64 switch res.WinnerSeat { case mySeat: - return 1 + base = 1 case -1: - return 0.5 + base = 0.5 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 diff --git a/internal/ai/eval_test.go b/internal/ai/eval_test.go new file mode 100644 index 0000000..551bed6 --- /dev/null +++ b/internal/ai/eval_test.go @@ -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) + } +} diff --git a/internal/game/battle.go b/internal/game/battle.go index ff13512..d78abbe 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -160,6 +160,11 @@ type BattleResult struct { Events []BattleEvent `json:"events"` WinnerSeat int `json:"winnerSeat"` // -1 = draw 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. @@ -1312,5 +1317,21 @@ func (g *Game) runBattle() *BattleResult { 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 } diff --git a/internal/game/golden45_test.go b/internal/game/golden45_test.go index 109f65b..bbeaf94 100644 --- a/internal/game/golden45_test.go +++ b/internal/game/golden45_test.go @@ -171,6 +171,11 @@ func TestSelfFaintWithReserveStillWins(t *testing.T) { if res.WinnerSeat != 0 { 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.