@@ -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
79202func (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
256341func (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
284369func (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
468554func (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 }
0 commit comments