Skip to content

Commit 16336b4

Browse files
authored
Merge pull request #458 from domino14/track-exposed-opponent-racks
Track exposed opponent racks
2 parents af72cf1 + 32ff8f1 commit 16336b4

File tree

12 files changed

+415
-133
lines changed

12 files changed

+415
-133
lines changed

api/proto/macondo/macondo.proto

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,9 @@ message TurnAnalysis {
263263
bool optimal_is_bingo = 19;
264264
bool played_is_bingo = 20;
265265
bool missed_bingo = 21;
266+
267+
// Known opponent rack (from challenged phony)
268+
string known_opp_rack = 22; // e.g., "JAM" - tiles revealed by challenged phony
266269
}
267270

268271
enum GamePhase {

gameanalysis/analyzer.go

Lines changed: 172 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ type AnalysisConfig struct {
4141
// Optional: analyze only one player (-1 = both, 0 = player 0, 1 = player 1, or player nickname)
4242
OnlyPlayer int
4343
OnlyPlayerByName string
44+
45+
// UseExposedOppRacks enables using opponent rack information revealed
46+
// through challenged phonies when analyzing the subsequent turn.
47+
UseExposedOppRacks bool
4448
}
4549

4650
// DefaultAnalysisConfig returns sensible defaults
@@ -55,6 +59,7 @@ func DefaultAnalysisConfig() *AnalysisConfig {
5559
PEGEarlyCutoff: true,
5660
Threads: 0, // 0 means use default
5761
OnlyPlayer: -1,
62+
UseExposedOppRacks: true, // Enable by default for more accurate analysis
5863
}
5964
}
6065

@@ -75,6 +80,124 @@ func New(cfg *config.Config, analysisCfg *AnalysisConfig) *Analyzer {
7580
}
7681
}
7782

83+
// AnalyzeSingleTurnFromHistory analyzes a single turn, building the game rules from history
84+
// This is a convenience wrapper around AnalyzeSingleTurn for shell commands
85+
func (a *Analyzer) AnalyzeSingleTurnFromHistory(ctx context.Context, history *pb.GameHistory, turnNum int) (*TurnAnalysis, error) {
86+
// Build the game rules from the history
87+
boardLayout, ldName, variant := game.HistoryToVariant(history)
88+
rules, err := game.NewBasicGameRules(
89+
a.cfg,
90+
history.Lexicon,
91+
boardLayout,
92+
ldName,
93+
game.CrossScoreAndSet,
94+
variant,
95+
)
96+
if err != nil {
97+
return nil, fmt.Errorf("failed to create game rules: %w", err)
98+
}
99+
100+
// Set challenge rule to DOUBLE
101+
if history.ChallengeRule == pb.ChallengeRule_VOID {
102+
history.ChallengeRule = pb.ChallengeRule_DOUBLE
103+
}
104+
105+
return a.AnalyzeSingleTurn(ctx, history, rules, turnNum)
106+
}
107+
108+
// AnalyzeSingleTurn analyzes a single turn in the game
109+
// This can be used standalone or called in a loop by AnalyzeGame
110+
func (a *Analyzer) AnalyzeSingleTurn(ctx context.Context, history *pb.GameHistory, rules *game.GameRules, turnNum int) (*TurnAnalysis, error) {
111+
if turnNum < 0 || turnNum >= len(history.Events) {
112+
return nil, fmt.Errorf("turn %d out of range", turnNum)
113+
}
114+
115+
evt := history.Events[turnNum]
116+
if !a.isAnalyzableEvent(evt) {
117+
return nil, fmt.Errorf("turn %d is not analyzable (type: %s)", turnNum, evt.Type)
118+
}
119+
120+
playerIndex := int(evt.PlayerIndex)
121+
122+
// Create a game at this turn
123+
g, err := game.NewFromHistory(history, rules, turnNum)
124+
if err != nil {
125+
return nil, fmt.Errorf("failed to create game at turn %d: %w", turnNum, err)
126+
}
127+
128+
// Skip if game not in playing state
129+
if g.Playing() != pb.PlayState_PLAYING {
130+
return nil, fmt.Errorf("game not in playing state at turn %d", turnNum)
131+
}
132+
133+
// Get the move that was played
134+
playedMove, err := game.MoveFromEvent(evt, g.Alphabet(), g.Board())
135+
if err != nil {
136+
return nil, fmt.Errorf("failed to create move from event at turn %d: %w", turnNum, err)
137+
}
138+
139+
// Check if previous opponent move was a phony that wasn't challenged
140+
missedChallenge := false
141+
if turnNum > 0 {
142+
prevEvent := history.Events[turnNum-1]
143+
if int(prevEvent.PlayerIndex) != playerIndex {
144+
if a.isPhony(prevEvent, g) {
145+
missedChallenge = true
146+
}
147+
}
148+
}
149+
150+
// Check if this move is a phony
151+
isPhony := a.isPhony(evt, g)
152+
phonyChallenged := false
153+
154+
// Check if the next event is a PHONY_TILES_RETURNED for this move
155+
if turnNum+1 < len(history.Events) {
156+
nextEvt := history.Events[turnNum+1]
157+
if nextEvt.Type == pb.GameEvent_PHONY_TILES_RETURNED {
158+
phonyChallenged = true
159+
// Substitute with a pass move for analysis since the phony was challenged off
160+
playedMove = move.NewPassMove(g.RackFor(playerIndex).TilesOn(), g.Alphabet())
161+
}
162+
}
163+
164+
// Check if we have a known opponent rack from a previous challenged phony
165+
var knownOppRack []tilemapping.MachineLetter
166+
if a.analysisCfg.UseExposedOppRacks && turnNum > 0 {
167+
// Look back to see if the previous event was a PHONY_TILES_RETURNED
168+
prevEvent := history.Events[turnNum-1]
169+
if prevEvent.Type == pb.GameEvent_PHONY_TILES_RETURNED {
170+
// The event before that should be the phony play by the opponent
171+
if turnNum >= 2 {
172+
phonyPlayEvent := history.Events[turnNum-2]
173+
if int(phonyPlayEvent.PlayerIndex) != playerIndex {
174+
// Opponent's phony was challenged off, we know their rack
175+
knownOppRack = extractExposedTiles(prevEvent.PlayedTiles, g.Alphabet())
176+
log.Info().
177+
Str("exposedTiles", prevEvent.PlayedTiles).
178+
Msg("extracted known opponent rack from challenged phony")
179+
}
180+
}
181+
}
182+
}
183+
184+
// Analyze the position
185+
analysis, err := a.analyzeTurn(ctx, g, playedMove, turnNum, playerIndex, history.Players, knownOppRack)
186+
if err != nil {
187+
return nil, err
188+
}
189+
190+
// Add phony information
191+
analysis.IsPhony = isPhony
192+
analysis.PhonyChallenged = phonyChallenged
193+
analysis.MissedChallenge = missedChallenge
194+
195+
// Categorize the mistake
196+
analysis.MistakeCategory = categorizeMistake(analysis)
197+
198+
return analysis, nil
199+
}
200+
78201
// AnalyzeGame analyzes every move in a game and returns the results
79202
func (a *Analyzer) AnalyzeGame(ctx context.Context, history *pb.GameHistory) (*GameAnalysisResult, error) {
80203
if history == nil {
@@ -132,9 +255,6 @@ func (a *Analyzer) AnalyzeGame(ctx context.Context, history *pb.GameHistory) (*G
132255
return playerIndex == a.analysisCfg.OnlyPlayer
133256
}
134257

135-
// Track the previous event for phony detection
136-
var prevEvent *pb.GameEvent
137-
138258
// Count total analyzable turns for progress reporting
139259
totalTurns := 0
140260
for _, evt := range history.Events {
@@ -148,76 +268,23 @@ func (a *Analyzer) AnalyzeGame(ctx context.Context, history *pb.GameHistory) (*G
148268
for turnNum, evt := range history.Events {
149269
// Skip non-analyzable events
150270
if !a.isAnalyzableEvent(evt) {
151-
prevEvent = evt
152271
continue
153272
}
154273

155274
playerIndex := int(evt.PlayerIndex)
156275

157276
// Check if we should analyze this player
158277
if !shouldAnalyzePlayer(playerIndex) {
159-
prevEvent = evt
160278
continue
161279
}
162280

163-
// Create a game at this turn
164-
g, err := game.NewFromHistory(history, rules, turnNum)
165-
if err != nil {
166-
return nil, fmt.Errorf("failed to create game at turn %d: %w", turnNum, err)
167-
}
168-
169-
// Skip analyzing moves when the game is already over or waiting for final pass
170-
// This happens for the final pass after someone went out
171-
if g.Playing() != pb.PlayState_PLAYING {
172-
log.Debug().
173-
Int("turn", turnNum).
174-
Str("playState", g.Playing().String()).
175-
Msg("skipping turn - game not in playing state")
176-
prevEvent = evt
177-
continue
178-
}
179-
180-
// Get the move that was played
181-
playedMove, err := game.MoveFromEvent(evt, g.Alphabet(), g.Board())
182-
if err != nil {
183-
return nil, fmt.Errorf("failed to create move from event at turn %d: %w", turnNum, err)
184-
}
185-
186-
// Check if previous opponent move was a phony that wasn't challenged
187-
missedChallenge := false
188-
if prevEvent != nil && int(prevEvent.PlayerIndex) != playerIndex {
189-
if a.isPhony(prevEvent, g) {
190-
missedChallenge = true
191-
}
192-
}
193-
194-
// Check if this move is a phony
195-
isPhony := a.isPhony(evt, g)
196-
phonyChallenged := false
197-
198-
// Check if the next event is a PHONY_TILES_RETURNED for this move
199-
if turnNum+1 < len(history.Events) {
200-
nextEvt := history.Events[turnNum+1]
201-
if nextEvt.Type == pb.GameEvent_PHONY_TILES_RETURNED {
202-
phonyChallenged = true
203-
// Substitute with a pass move for analysis since the phony was challenged off
204-
playedMove = move.NewPassMove(g.RackFor(playerIndex).TilesOn(), g.Alphabet())
205-
}
206-
}
207-
208-
// Analyze the position
209-
analysis, err := a.analyzeTurn(ctx, g, playedMove, turnNum, playerIndex, history.Players)
281+
// Analyze this turn using the shared helper function
282+
analysis, err := a.AnalyzeSingleTurn(ctx, history, rules, turnNum)
210283
if err != nil {
211284
log.Warn().Err(err).Int("turn", turnNum).Msg("failed to analyze turn")
212-
prevEvent = evt
213285
continue
214286
}
215287

216-
// Add phony information
217-
analysis.IsPhony = isPhony
218-
analysis.PhonyChallenged = phonyChallenged
219-
analysis.MissedChallenge = missedChallenge
220-
221288
result.Turns = append(result.Turns, analysis)
222289

223290
// Log progress
@@ -226,7 +293,7 @@ func (a *Analyzer) AnalyzeGame(ctx context.Context, history *pb.GameHistory) (*G
226293
Int("player-turn", analyzedCount).
227294
Int("total", totalTurns).
228295
Str("player", analysis.PlayerName).
229-
Str("move", playedMove.ShortDescription()).
296+
Str("move", analysis.PlayedMove.ShortDescription()).
230297
Msg("analyzed turn")
231298

232299
// Update player summary
@@ -235,8 +302,6 @@ func (a *Analyzer) AnalyzeGame(ctx context.Context, history *pb.GameHistory) (*G
235302
if analysis.WasOptimal {
236303
summary.OptimalMoves++
237304
}
238-
239-
prevEvent = evt
240305
}
241306

242307
// Calculate aggregate statistics
@@ -252,6 +317,26 @@ func (a *Analyzer) isAnalyzableEvent(evt *pb.GameEvent) bool {
252317
evt.Type == pb.GameEvent_PASS
253318
}
254319

320+
// extractExposedTiles converts PlayedTiles from PHONY_TILES_RETURNED event
321+
// to MachineLetters, filtering out play-through tiles (0) and converting
322+
// designated blanks to undesignated blanks (since blanks return to rack undesignated).
323+
func extractExposedTiles(playedTiles string, alph *tilemapping.TileMapping) []tilemapping.MachineLetter {
324+
tiles, err := tilemapping.ToMachineLetters(playedTiles, alph)
325+
if err != nil {
326+
log.Err(err).Str("playedTiles", playedTiles).Msg("unable-to-convert-exposed-tiles")
327+
return nil
328+
}
329+
// Filter out play-through tiles and convert blanks to undesignated (rack format)
330+
var result []tilemapping.MachineLetter
331+
for _, t := range tiles {
332+
if t != 0 {
333+
// Use IntrinsicTileIdx to convert designated blanks (blank-A) to undesignated blank (0)
334+
result = append(result, t.IntrinsicTileIdx())
335+
}
336+
}
337+
return result
338+
}
339+
255340
// isPhony checks if a move is a phony by validating the words formed
256341
func (a *Analyzer) isPhony(evt *pb.GameEvent, g *game.Game) bool {
257342
// Only tile placements can be phonies
@@ -282,7 +367,8 @@ func (a *Analyzer) isPhony(evt *pb.GameEvent, g *game.Game) bool {
282367

283368
// analyzeTurn analyzes a single turn and returns the analysis
284369
func (a *Analyzer) analyzeTurn(ctx context.Context, g *game.Game, playedMove *move.Move,
285-
turnNum, playerIndex int, players []*pb.PlayerInfo) (*TurnAnalysis, error) {
370+
turnNum, playerIndex int, players []*pb.PlayerInfo,
371+
knownOppRack []tilemapping.MachineLetter) (*TurnAnalysis, error) {
286372

287373
tilesInBag := g.Bag().TilesRemaining()
288374
phase := a.determinePhase(tilesInBag)
@@ -301,12 +387,12 @@ func (a *Analyzer) analyzeTurn(ctx context.Context, g *game.Game, playedMove *mo
301387
switch phase {
302388
case PhaseEarlyMid:
303389
err = a.analyzeWithSim(ctx, g, analysis, a.analysisCfg.SimPlaysEarlyMid,
304-
a.analysisCfg.SimPliesEarlyMid, a.analysisCfg.SimStopEarlyMid)
390+
a.analysisCfg.SimPliesEarlyMid, a.analysisCfg.SimStopEarlyMid, knownOppRack)
305391
case PhaseEarlyPreEndgame:
306392
err = a.analyzeWithSim(ctx, g, analysis, a.analysisCfg.SimPlaysEarlyPreEndgame,
307-
a.analysisCfg.SimPliesEarlyPreEndgame, a.analysisCfg.SimStopEarlyPreEndgame)
393+
a.analysisCfg.SimPliesEarlyPreEndgame, a.analysisCfg.SimStopEarlyPreEndgame, knownOppRack)
308394
case PhasePreEndgame:
309-
err = a.analyzeWithPEG(ctx, g, analysis)
395+
err = a.analyzeWithPEG(ctx, g, analysis, knownOppRack)
310396
case PhaseEndgame:
311397
err = a.analyzeWithEndgame(ctx, g, analysis)
312398
}
@@ -466,7 +552,7 @@ func (a *Analyzer) determinePhase(tilesInBag int) GamePhase {
466552

467553
// analyzeWithSim analyzes a turn using Monte Carlo simulation
468554
func (a *Analyzer) analyzeWithSim(ctx context.Context, g *game.Game, analysis *TurnAnalysis,
469-
numPlays, plies, stopCondition int) error {
555+
numPlays, plies, stopCondition int, knownOppRack []tilemapping.MachineLetter) error {
470556

471557
// Create bot turn player for move generation
472558
botConfig := &bot.BotConfig{Config: *a.cfg}
@@ -501,6 +587,15 @@ func (a *Analyzer) analyzeWithSim(ctx context.Context, g *game.Game, analysis *T
501587
return fmt.Errorf("failed to prepare simulation: %w", err)
502588
}
503589

590+
// Set known opponent rack if available (MUST be after PrepareSim which clears it)
591+
if len(knownOppRack) > 0 {
592+
simmer.SetKnownOppRack(knownOppRack)
593+
analysis.KnownOppRack = tilemapping.MachineWord(knownOppRack).UserVisible(g.Alphabet())
594+
log.Info().
595+
Str("knownOppRack", analysis.KnownOppRack).
596+
Msg("analyzing with known opponent rack from challenged phony")
597+
}
598+
504599
// Ensure the played move is simmed (avoid pruning it)
505600
simmer.AvoidPruningMoves([]*move.Move{analysis.PlayedMove})
506601

@@ -576,7 +671,8 @@ func (a *Analyzer) analyzeWithSim(ctx context.Context, g *game.Game, analysis *T
576671
}
577672

578673
// analyzeWithPEG analyzes a turn using the PEG solver (1 tile in bag)
579-
func (a *Analyzer) analyzeWithPEG(ctx context.Context, g *game.Game, analysis *TurnAnalysis) error {
674+
func (a *Analyzer) analyzeWithPEG(ctx context.Context, g *game.Game, analysis *TurnAnalysis,
675+
knownOppRack []tilemapping.MachineLetter) error {
580676
// Get the KWG for the lexicon
581677
gd, err := kwg.GetKWG(a.cfg.WGLConfig(), g.LexiconName())
582678
if err != nil {
@@ -591,6 +687,15 @@ func (a *Analyzer) analyzeWithPEG(ctx context.Context, g *game.Game, analysis *T
591687
pegSolver.SetEarlyCutoffOptim(a.analysisCfg.PEGEarlyCutoff)
592688
pegSolver.SetAvoidPrune([]*move.Move{analysis.PlayedMove})
593689

690+
// Set known opponent rack if available
691+
if len(knownOppRack) > 0 {
692+
pegSolver.SetKnownOppRack(knownOppRack)
693+
analysis.KnownOppRack = tilemapping.MachineWord(knownOppRack).UserVisible(g.Alphabet())
694+
log.Info().
695+
Str("knownOppRack", analysis.KnownOppRack).
696+
Msg("analyzing with known opponent rack from challenged phony")
697+
}
698+
594699
if a.analysisCfg.Threads > 0 {
595700
pegSolver.SetThreads(a.analysisCfg.Threads)
596701
}

gameanalysis/analyzer_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ func TestDefaultAnalysisConfig(t *testing.T) {
5656
if cfg.OnlyPlayer != -1 {
5757
t.Errorf("expected OnlyPlayer=-1, got %d", cfg.OnlyPlayer)
5858
}
59+
60+
if cfg.UseExposedOppRacks != true {
61+
t.Error("expected UseExposedOppRacks=true")
62+
}
5963
}
6064

6165
func TestPhaseString(t *testing.T) {

gameanalysis/exposed_rack_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package gameanalysis
2+
3+
import (
4+
"testing"
5+
)
6+
7+
// TestExtractExposedTiles_Logic tests the logic of extractExposedTiles
8+
// Note: This test is skipped because it requires full config setup with data paths.
9+
// The function is tested implicitly through integration tests with actual game data.
10+
func TestExtractExposedTiles_Logic(t *testing.T) {
11+
t.Skip("Requires config setup - function is tested via integration tests")
12+
}

gameanalysis/proto.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ func (t *TurnAnalysis) ToProto() *pb.TurnAnalysis {
4848
OptimalIsBingo: t.OptimalIsBingo,
4949
PlayedIsBingo: t.PlayedIsBingo,
5050
MissedBingo: t.MissedBingo,
51+
KnownOppRack: t.KnownOppRack,
5152
}
5253
}
5354

0 commit comments

Comments
 (0)