Make the AI a little smarter.
This commit is contained in:
+22
-4
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user