Skip to content

Commit

Permalink
working heatmap
Browse files Browse the repository at this point in the history
  • Loading branch information
domino14 committed Nov 20, 2024
1 parent 57ad7d0 commit 6fc04c1
Show file tree
Hide file tree
Showing 8 changed files with 383 additions and 24 deletions.
4 changes: 2 additions & 2 deletions board/board.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ const (
NoBonus BonusSquare = 32 // space (hex 20)
)

func (b BonusSquare) displayString() string {
func (b BonusSquare) displayString(colorSupport bool) string {
repr := string(rune(b))
if !ColorSupport {
if !colorSupport {
return repr
}
switch b {
Expand Down
10 changes: 5 additions & 5 deletions board/board_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,16 @@ func padString(str string, width int) string {
return str
}

func (g *GameBoard) sqDisplayStr(row, col int, alph *tilemapping.TileMapping) string {
func (g *GameBoard) SQDisplayStr(row, col int, alph *tilemapping.TileMapping, colorSupport bool) string {
pos := g.GetSqIdx(row, col)
var bonusdisp string
if g.bonuses[pos] != ' ' {
bonusdisp = g.bonuses[pos].displayString()
bonusdisp = g.bonuses[pos].displayString(colorSupport)
} else {
bonusdisp = " "
}
if g.squares[pos] == 0 {
return bonusdisp
return padString(bonusdisp, 2)
}
uv := g.squares[pos].UserVisible(alph, true)
// These are very specific cases in order to be able to display these characters
Expand All @@ -61,7 +61,7 @@ func (g *GameBoard) sqDisplayStr(row, col int, alph *tilemapping.TileMapping) st
return "RR"
}

return uv
return padString(uv, 2)
}

func (g *GameBoard) ToDisplayText(alph *tilemapping.TileMapping) string {
Expand All @@ -76,7 +76,7 @@ func (g *GameBoard) ToDisplayText(alph *tilemapping.TileMapping) string {
for i := 0; i < n; i++ {
row := fmt.Sprintf("%2d|", i+1)
for j := 0; j < n; j++ {
row += padString(g.sqDisplayStr(i, j, alph), 2)
row += g.SQDisplayStr(i, j, alph, ColorSupport)
}
row = row + "|"
str = str + row + "\n"
Expand Down
99 changes: 98 additions & 1 deletion montecarlo/montecarlo.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
package montecarlo

import (
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"math"
"os"
"runtime"
"sort"
"strings"
Expand Down Expand Up @@ -215,7 +217,9 @@ type Simmer struct {
cfg *config.Config
knownOppRack []tilemapping.MachineLetter

logStream io.Writer
logStream io.Writer
collectHeatMap bool
tempHeatMapFile *os.File

// See rangefinder.
inferences [][]tilemapping.MachineLetter
Expand Down Expand Up @@ -262,6 +266,91 @@ func (s *Simmer) Threads() int {
return s.threads
}

func (s *Simmer) SetCollectHeatmap(b bool) error {
if s.logStream != nil && b {
return errors.New("cannot collect heat map if log stream already used")
}
s.collectHeatMap = b
if b {
// Create a temporary file
tempFile, err := os.CreateTemp("", "heatmap-*.gz")
if err != nil {
return fmt.Errorf("could not create temp file: %w", err)
}
s.tempHeatMapFile = tempFile
// Create a gzip writer that wraps the temporary file
gzipWriter := gzip.NewWriter(tempFile)

// Set logStream to the gzip writer
s.logStream = gzipWriter
log.Info().Str("heatmap-file", tempFile.Name()).Msg("collecting-heatmap")
}
return nil
}

func (s *Simmer) closeHeatMap(ctx context.Context) error {
logger := zerolog.Ctx(ctx)

if s.logStream == nil {
return nil
}
if !s.collectHeatMap {
return nil
}
logger.Info().Msg("closing heatmap writer")
// Close the gzip writer (flushes remaining data to temp file)
if gzWriter, ok := s.logStream.(*gzip.Writer); ok {
if err := gzWriter.Close(); err != nil {
return fmt.Errorf("could not close gzip writer: %w", err)
}
}

// Close the temporary file
if err := s.tempHeatMapFile.Close(); err != nil {
return fmt.Errorf("could not close temp file: %w", err)
}

// Reset logStream
s.logStream = nil
s.collectHeatMap = false
return nil
}

// ReadHeatmap reads the gzipped data from the temporary file
func (s *Simmer) ReadHeatmap() ([]LogIteration, error) {
if s.tempHeatMapFile == nil {
return nil, errors.New("no heatmap data to read")
}

// Reopen the temporary file for reading
file, err := os.Open(s.tempHeatMapFile.Name())
if err != nil {
return nil, fmt.Errorf("could not open temp file for reading: %w", err)
}
defer file.Close()

// Create a gzip reader
gzReader, err := gzip.NewReader(file)
if err != nil {
return nil, fmt.Errorf("could not create gzip reader: %w", err)
}
defer gzReader.Close()

// Read all uncompressed data
data, err := io.ReadAll(gzReader)
if err != nil {
return nil, fmt.Errorf("could not read gzip data: %w", err)
}
// Parse the YAML data into []LogIteration
var logIterations []LogIteration
err = yaml.Unmarshal(data, &logIterations)
if err != nil {
return nil, fmt.Errorf("could not unmarshal YAML data: %w", err)
}

return logIterations, nil
}

func (s *Simmer) SetLogStream(l io.Writer) {
s.logStream = l
}
Expand Down Expand Up @@ -356,6 +445,10 @@ func (s *Simmer) Simulate(ctx context.Context) error {
Msg("sim-ended")
}()

if !s.collectHeatMap {
s.tempHeatMapFile = nil
}

nodes := s.nodeCount.Load()
// This should be zero, but I think something is wrong with Lambda.
logger.Info().Uint64("starting-node-count", nodes).Msg("nodes")
Expand Down Expand Up @@ -460,11 +553,15 @@ func (s *Simmer) Simulate(ctx context.Context) error {
nodes = s.nodeCount.Load()
nps := float64(nodes) / elapsed.Seconds()
logger.Info().Msgf("time taken: %v, nps: %f, nodes: %d", elapsed.Seconds(), nps, nodes)

// Writer thread will exit now:
if s.logStream != nil {
close(done)
writer.Wait()
}
if err = s.closeHeatMap(ctx); err != nil {
logger.Err(err).Msg("close-heat-map")
}

ctrlErr := ctrl.Wait()
logger.Debug().Msgf("ctrl errgroup returned err %v", ctrlErr)
Expand Down
178 changes: 178 additions & 0 deletions shell/heatmap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package shell

import (
"errors"
"fmt"
"regexp"
"strings"

"github.com/domino14/macondo/ai/bot"
"github.com/domino14/macondo/board"
"github.com/domino14/macondo/game"
"github.com/domino14/macondo/montecarlo"
"github.com/domino14/macondo/move"
"github.com/domino14/word-golib/tilemapping"
"github.com/rs/zerolog/log"
)

type Heat struct {
numHits int
fractionOfMax float64
}

type HeatMap struct {
board *board.GameBoard
squares [][]Heat
alphabet *tilemapping.TileMapping
}

func (sc *ShellController) PlaceMove(g *bot.BotTurnPlayer, play string) error {
normalizedPlay := normalize(play)
m, err := g.ParseMove(
g.PlayerOnTurn(), sc.options.lowercaseMoves, strings.Fields(normalizedPlay))

if err != nil {
return err
}
g.SetBackupMode(game.SimulationMode)
log.Debug().Str("move", m.ShortDescription()).Msg("Playing move")
err = g.PlayMove(m, false, 0)
return err
}

func (sc *ShellController) UnplaceMove(g *bot.BotTurnPlayer) {
log.Debug().Msg("Undoing last move")
g.UnplayLastMove()
g.SetBackupMode(game.NoBackup)
}

func (sc *ShellController) CalculateHeatmap(s *montecarlo.Simmer, g *bot.BotTurnPlayer, play string,
ply int) (*HeatMap, error) {
iters, err := s.ReadHeatmap()
if err != nil {
return nil, err
}
log.Debug().Msgf("Read %d log lines", len(iters))
h := &HeatMap{
squares: make([][]Heat, g.Board().Dim()),
board: g.Board(),
alphabet: g.Alphabet(),
}
for idx := range h.squares {
h.squares[idx] = make([]Heat, h.board.Dim())
}

maxNumHits := 0
log.Debug().Msg("parsing-iterations")
normalizedPlay := normalize(play)

for i := range iters {
for j := range iters[i].Plays {
if normalizedPlay != normalize(iters[i].Plays[j].Play) {
continue
}
if len(iters[i].Plays[j].Plies) <= ply {
continue
}
analyzedPlay := normalize(iters[i].Plays[j].Plies[ply].Play)

if strings.HasPrefix(analyzedPlay, "exchange ") ||
analyzedPlay == "pass" || analyzedPlay == "UNHANDLED" {
continue
}

// this is a tile-play move.
playFields := strings.Fields(analyzedPlay)
if len(playFields) != 2 {
return nil, errors.New("unexpected play " + analyzedPlay)
}
coords := strings.ToUpper(playFields[0])
row, col, vertical := move.FromBoardGameCoords(coords)
mw, err := tilemapping.ToMachineWord(playFields[1], g.Alphabet())
if err != nil {
return nil, err
}
ri, ci := 1, 0
if !vertical {
ri, ci = 0, 1
}

for idx := range mw {
if mw[idx] == 0 {
continue // playthrough doesn't create heat map
}
newRow := row + (ri * idx)
newCol := col + (ci * idx)
h.squares[newRow][newCol].numHits++
if h.squares[newRow][newCol].numHits > maxNumHits {
maxNumHits = h.squares[newRow][newCol].numHits
}
}

}
}

for ri := range h.squares {
for ci := range h.squares[ri] {
h.squares[ri][ci].fractionOfMax = float64(h.squares[ri][ci].numHits) / float64(maxNumHits)
}
}

return h, nil
}

var exchRe = regexp.MustCompile(`\((exch|exchange) ([^)]+)\)`)
var throughPlayRe = regexp.MustCompile(`\(([^)]+)\)`)

func normalize(p string) string {
// Trim leading and trailing whitespace
trimmed := strings.TrimSpace(p)

if trimmed == "(Pass)" {
return "pass"
}

// Check for "(exch FOO)" or "(exchange FOO)" and extract the content
if strings.HasPrefix(trimmed, "(exch ") || strings.HasPrefix(trimmed, "(exchange ") {
// Define a regex to extract "exchange FOO" from "(exch FOO)" or "(exchange FOO)"
matches := exchRe.FindStringSubmatch(trimmed)
if len(matches) == 3 {
return "exchange " + matches[2]
}
}

// Define a regular expression to match groups in parentheses

// Replace each match with as many dots as there are characters inside the parentheses
normalized := throughPlayRe.ReplaceAllStringFunc(trimmed, func(match string) string {
// Extract the content inside parentheses using capturing group
content := throughPlayRe.FindStringSubmatch(match)[1]
return strings.Repeat(".", len(content))
})

return normalized
}

// getHeatColor returns an ANSI escape sequence for a given heat level.
func getHeatColor(fraction float64) string {
// Map the fraction (0 to 1) to grayscale colors (232 to 255 in ANSI 256-color palette)
// 232 is darkest (black), 255 is lightest (white)
start := 232
end := 255
colorCode := int(float64(start) + fraction*float64(end-start))
return fmt.Sprintf("\033[48;5;%dm", colorCode) // Background color
}

// display renders the heatmap to the terminal.
func (h HeatMap) display() {
fmt.Println()
reset := "\033[0m" // Reset color
for ri, row := range h.squares {
for ci, heat := range row {
color := getHeatColor(heat.fractionOfMax)
letter := h.board.SQDisplayStr(ri, ci, h.alphabet, false)
fmt.Printf("%s%s%s", color, letter, reset) // Colored block
}
fmt.Println() // Newline after each row
}
}
Loading

0 comments on commit 6fc04c1

Please sign in to comment.