Collect GameState snapshots during match execution (one per turn), then run 100 random-play rollouts per snapshot post-match to compute per-turn win probabilities and detect critical moments (|delta| > 0.15). Results are stored in the replay JSON as win_prob and critical_moments fields. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
181 lines
5.2 KiB
Go
181 lines
5.2 KiB
Go
package engine
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"math/rand"
|
|
)
|
|
|
|
// WinProbEntry holds per-turn win probabilities for each player.
|
|
type WinProbEntry []float64
|
|
|
|
// CriticalMoment identifies a turn where win probability shifted significantly.
|
|
type CriticalMoment struct {
|
|
Turn int `json:"turn"`
|
|
Delta float64 `json:"delta"`
|
|
Player int `json:"player"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
// ComputeWinProbability runs Monte Carlo rollouts from each snapshot to estimate
|
|
// per-turn win probability. For each turn T, it clones the state, runs numRollouts
|
|
// random-play rollouts to match end, and computes win_prob[T] = wins[i] / numRollouts.
|
|
func ComputeWinProbability(snapshots []*GameState, numRollouts int, rng *rand.Rand) ([]WinProbEntry, []CriticalMoment) {
|
|
if len(snapshots) == 0 || numRollouts <= 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
numPlayers := len(snapshots[0].Players)
|
|
winProbs := make([]WinProbEntry, len(snapshots))
|
|
|
|
for t, snap := range snapshots {
|
|
wins := make([]int, numPlayers)
|
|
draws := 0
|
|
|
|
for r := 0; r < numRollouts; r++ {
|
|
clone := snap.Clone()
|
|
clone.rng = rand.New(rand.NewSource(rng.Int63()))
|
|
winner := runRandomRollout(clone)
|
|
if winner >= 0 && winner < numPlayers {
|
|
wins[winner]++
|
|
} else {
|
|
draws++
|
|
}
|
|
}
|
|
|
|
entry := make(WinProbEntry, numPlayers)
|
|
for i := 0; i < numPlayers; i++ {
|
|
entry[i] = float64(wins[i]) / float64(numRollouts)
|
|
}
|
|
winProbs[t] = entry
|
|
}
|
|
|
|
criticalMoments := detectCriticalMoments(winProbs, snapshots)
|
|
|
|
return winProbs, criticalMoments
|
|
}
|
|
|
|
// runRandomRollout plays random moves from the given state until the match ends,
|
|
// returning the winner player ID (-1 for draw).
|
|
func runRandomRollout(gs *GameState) int {
|
|
directions := []Direction{DirNone, DirN, DirE, DirS, DirW}
|
|
|
|
for gs.Turn < gs.Config.MaxTurns {
|
|
gs.ClearTurnState()
|
|
submitRandomMoves(gs, directions)
|
|
result := gs.ExecuteTurn()
|
|
if result != nil {
|
|
return result.Winner
|
|
}
|
|
}
|
|
|
|
// Max turns reached — determine winner by score
|
|
winner := gs.findWinnerByScore()
|
|
return winner
|
|
}
|
|
|
|
// submitRandomMoves assigns a random direction to each living bot.
|
|
func submitRandomMoves(gs *GameState, directions []Direction) {
|
|
for _, b := range gs.Bots {
|
|
if !b.Alive {
|
|
continue
|
|
}
|
|
dir := directions[gs.rng.Intn(len(directions))]
|
|
if dir != DirNone {
|
|
dest := gs.Grid.Move(b.Position, dir)
|
|
if gs.Grid.IsPassable(dest) {
|
|
gs.SubmitMove(b.Position, dir)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// detectCriticalMoments finds turns where win probability shifted by more than
|
|
// threshold for any player. It uses events from the game state snapshots to
|
|
// generate human-readable descriptions.
|
|
func detectCriticalMoments(winProbs []WinProbEntry, snapshots []*GameState) []CriticalMoment {
|
|
const threshold = 0.15
|
|
|
|
var moments []CriticalMoment
|
|
|
|
for t := 1; t < len(winProbs); t++ {
|
|
prev := winProbs[t-1]
|
|
curr := winProbs[t]
|
|
|
|
for player := 0; player < len(curr); player++ {
|
|
delta := curr[player] - prev[player]
|
|
if math.Abs(delta) >= threshold {
|
|
desc := describeCriticalTurn(snapshots, t, player, delta)
|
|
moments = append(moments, CriticalMoment{
|
|
Turn: t,
|
|
Delta: math.Round(delta*100) / 100,
|
|
Player: player,
|
|
Description: desc,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return moments
|
|
}
|
|
|
|
// describeCriticalTurn generates a template-based description of why a turn was critical.
|
|
func describeCriticalTurn(snapshots []*GameState, turn int, player int, delta float64) string {
|
|
if turn >= len(snapshots) {
|
|
return fmt.Sprintf("Player %d win probability %s to %.0f%%", player, direction(delta), math.Round(math.Abs(delta)*100))
|
|
}
|
|
|
|
snap := snapshots[turn]
|
|
|
|
// Count events for this player
|
|
var combatDeaths, captures, botDied int
|
|
for _, ev := range snap.Events {
|
|
switch ev.Type {
|
|
case EventCombatDeath:
|
|
if details, ok := ev.Details.(map[string]interface{}); ok {
|
|
if owner, ok := details["owner"].(int); ok && owner == player {
|
|
combatDeaths++
|
|
}
|
|
}
|
|
case EventBotDied:
|
|
if details, ok := ev.Details.(map[string]interface{}); ok {
|
|
if owner, ok := details["owner"].(int); ok && owner == player {
|
|
botDied++
|
|
}
|
|
}
|
|
case EventCoreCaptured:
|
|
if details, ok := ev.Details.(map[string]interface{}); ok {
|
|
if newOwner, ok := details["new_owner"].(int); ok {
|
|
if newOwner == player {
|
|
captures++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case combatDeaths > 0 && delta < 0:
|
|
return fmt.Sprintf("Player %d loses %d unit(s) in combat, win probability %s to %.0f%%",
|
|
player, combatDeaths, direction(delta), math.Round(math.Abs(delta)*100))
|
|
case combatDeaths > 0 && delta > 0:
|
|
return fmt.Sprintf("Player %d wins engagement eliminating %d enemy unit(s), win probability %s to %.0f%%",
|
|
player, combatDeaths, direction(delta), math.Round(math.Abs(delta)*100))
|
|
case captures > 0:
|
|
return fmt.Sprintf("Player %d captures a core, win probability %s to %.0f%%",
|
|
player, direction(delta), math.Round(math.Abs(delta)*100))
|
|
case botDied > 0 && delta < 0:
|
|
return fmt.Sprintf("Player %d loses %d unit(s), win probability %s to %.0f%%",
|
|
player, botDied, direction(delta), math.Round(math.Abs(delta)*100))
|
|
default:
|
|
return fmt.Sprintf("Player %d win probability %s to %.0f%%",
|
|
player, direction(delta), math.Round(math.Abs(delta)*100))
|
|
}
|
|
}
|
|
|
|
func direction(delta float64) string {
|
|
if delta > 0 {
|
|
return "rises"
|
|
}
|
|
return "drops"
|
|
}
|