diff --git a/automatic/automatic_utils.go b/automatic/automatic_utils.go index 9b8bde39..e5e69a00 100644 --- a/automatic/automatic_utils.go +++ b/automatic/automatic_utils.go @@ -55,7 +55,7 @@ func (r *GameRunner) CompVsCompStatic(addToHistory bool) error { func (r *GameRunner) playFull(addToHistory bool, gidx int) error { r.StartGame(gidx) - log.Trace().Msgf("playing full, game %v", r.game.History().Uid) + log.Trace().Msgf("playing full, game %v", r.game.Uid()) for r.game.Playing() == pb.PlayState_PLAYING { err := r.PlayBestTurn(r.game.PlayerOnTurn(), addToHistory) diff --git a/automatic/logfile_analysis.go b/automatic/logfile_analysis.go index 3376edb0..e5600afc 100644 --- a/automatic/logfile_analysis.go +++ b/automatic/logfile_analysis.go @@ -231,7 +231,8 @@ func ExportGCG(cfg *config.Config, filename, letterdist, lexicon, boardlayout, g for _, row := range gameLines { pidx := 0 - if g.History().Players[1].Nickname == row[0] { + history := g.GenerateSerializableHistory() + if history.Players[1].Nickname == row[0] { pidx = 1 } err = g.SetRackFor(pidx, tilemapping.RackFromString(row[3], g.Alphabet())) @@ -270,7 +271,7 @@ func ExportGCG(cfg *config.Config, filename, letterdist, lexicon, boardlayout, g } } } - contents, err := gcgio.GameHistoryToGCG(g.History(), true) + contents, err := gcgio.GameToGCG(g.Game, true) if err != nil { return err } diff --git a/bot/bot.go b/bot/bot.go index 5e0192de..785b90d5 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -118,16 +118,18 @@ func (bot *Bot) Deserialize(data []byte) (*game.Game, *pb.BotRequest, error) { if err != nil { return nil, nil, err } - ng.PlayToTurn(nturns) + ngHistory := ng.GenerateSerializableHistory() + ng.PlayToTurn(nturns, ngHistory.LastKnownRacks) // debugWriteln(ng.ToDisplayText()) return ng, req, nil } func evalSingleMove(g *bot.BotTurnPlayer, evtIdx int) *pb.SingleEvaluation { - evts := g.History().Events + history := g.GenerateSerializableHistory() + evts := history.Events playedEvt := evts[evtIdx] - g.PlayToTurn(evtIdx) + g.PlayToTurn(evtIdx, history.LastKnownRacks) moves := g.GenerateMoves(100000) // find the played move in the list of moves topEquity := moves[0].Equity() @@ -170,8 +172,9 @@ func evalSingleMove(g *bot.BotTurnPlayer, evtIdx int) *pb.SingleEvaluation { func (bot *Bot) evaluationResponse(req *pb.EvaluationRequest) *pb.BotResponse { - evts := bot.game.History().Events - players := bot.game.History().Players + history := bot.game.GenerateSerializableHistory() + evts := history.Events + players := history.Players evals := []*pb.SingleEvaluation{} for idx, evt := range evts { @@ -200,7 +203,8 @@ func (b *Bot) handle(data []byte) *pb.BotResponse { botType := req.BotType leavesFile := "" - if ng.History().BoardLayout == board.SuperCrosswordGameLayout { + history := ng.GenerateSerializableHistory() + if history.BoardLayout == board.SuperCrosswordGameLayout { leavesFile = "super-leaves.klv2" } diff --git a/bot/client.go b/bot/client.go index 066d8bd5..c83961de 100644 --- a/bot/client.go +++ b/bot/client.go @@ -21,7 +21,7 @@ type Client struct { } func MakeRequest(game *bot.BotTurnPlayer, cfg *config.Config) ([]byte, error) { - history := game.History() + history := game.GenerateSerializableHistory() if history.Lexicon == "" { history.Lexicon = cfg.GetString(config.ConfigDefaultLexicon) } diff --git a/cgp/parse.go b/cgp/parse.go index 8492aae1..a72728b3 100644 --- a/cgp/parse.go +++ b/cgp/parse.go @@ -156,9 +156,9 @@ func ParseCGP(cfg *config.Config, cgpstr string) (*ParsedCGP, error) { } g.SetMaxScorelessTurns(maxScorelessTurns) g.SetScorelessTurns(nzero) - g.History().StartingCgp = cgpstr - g.History().Uid = gid - g.History().IdAuth = "" // maybe provide this later, id + g.SetStartingCGP(cgpstr) + g.SetUid(gid) + g.SetIdAuth("") // maybe provide this later, id log.Debug().Msgf("got gid %v", gid) return &ParsedCGP{Game: g, Opcodes: opcodes}, nil diff --git a/cmd/eval/main.go b/cmd/eval/main.go index b142dde0..96d6bc71 100644 --- a/cmd/eval/main.go +++ b/cmd/eval/main.go @@ -151,26 +151,22 @@ func getEquityLoss(filepath string, lexicon string, playerName string, gameid in panic(err) } - gameHistory, err := gcgio.ParseGCG(DefaultConfig, filepath) + g, err := gcgio.ParseGCG(DefaultConfig, filepath) if err != nil { panic(err) } + gameHistory := g.GenerateSerializableHistory() p1Nickname := gameHistory.Players[0].Nickname p2Nickname := gameHistory.Players[1].Nickname if playerName != p1Nickname && playerName != p2Nickname { panic(fmt.Sprintf("player %s not found in (%s, %s) for game %d", playerName, p1Nickname, p2Nickname, gameid)) } - gameHistory.ChallengeRule = pb.ChallengeRule_FIVE_POINT - - g, err := game.NewFromHistory(gameHistory, rules, 0) - if err != nil { - panic(err) - } + g.SetChallengeRule(pb.ChallengeRule_FIVE_POINT) totalEqloss := 0.0 numMoves := 0 - history := g.History() + history := g.GenerateSerializableHistory() players := history.Players botConfig := &bot.BotConfig{Config: *DefaultConfig} for evtIdx, evt := range history.Events { diff --git a/cmd/lambda/main.go b/cmd/lambda/main.go index 7eb88538..8b003ea6 100644 --- a/cmd/lambda/main.go +++ b/cmd/lambda/main.go @@ -70,7 +70,8 @@ func HandleRequest(ctx context.Context, evt bot.LambdaEvent) (string, error) { ctx, cancel = context.WithTimeout(ctx, time.Duration(maxTimeShouldTake)*time.Second) ctx = logger.WithContext(ctx) - lexicon := g.History().Lexicon + history := g.GenerateSerializableHistory() + lexicon := history.Lexicon if lexicon == "" { lexicon = cfg.GetString(config.ConfigDefaultLexicon) logger.Info().Msgf("cgp file had no lexicon, so using default lexicon %v", diff --git a/endgame/negamax/solver_test.go b/endgame/negamax/solver_test.go index 34ec4a95..21a3430e 100644 --- a/endgame/negamax/solver_test.go +++ b/endgame/negamax/solver_test.go @@ -69,10 +69,13 @@ func setUpSolver(lex, distName string, bvs board.VsWho, plies int, rack1, rack2 } cross_set.GenAllCrossSets(g.Board(), gd, dist) - g.SetRacksForBoth([]*tilemapping.Rack{ + err = g.SetRacksForBoth([]*tilemapping.Rack{ tilemapping.RackFromString(rack1, alph), tilemapping.RackFromString(rack2, alph), }) + if err != nil { + panic(err) + } g.SetPointsFor(0, p1pts) g.SetPointsFor(1, p2pts) g.SetPlayerOnTurn(onTurn) @@ -288,9 +291,10 @@ func TestPolishFromGcg(t *testing.T) { cfg.Set(config.ConfigDefaultLexicon, "OSPS49") cfg.Set(config.ConfigDefaultLetterDistribution, "polish") - gameHistory, err := gcgio.ParseGCG(cfg, "../../gcgio/testdata/polish_endgame.gcg") + parsedGame, err := gcgio.ParseGCG(cfg, "../../gcgio/testdata/polish_endgame.gcg") is.NoErr(err) - gameHistory.ChallengeRule = pb.ChallengeRule_SINGLE + parsedGame.SetChallengeRule(pb.ChallengeRule_SINGLE) + gameHistory := parsedGame.GenerateSerializableHistory() g, err := game.NewFromHistory(gameHistory, rules, 46) is.NoErr(err) @@ -357,8 +361,9 @@ func TestProperIterativeDeepening(t *testing.T) { is.NoErr(err) for _, plies := range plyCount { - gameHistory, err := gcgio.ParseGCG(DefaultConfig, "../../gcgio/testdata/noah_vs_mishu.gcg") + parsedGame, err := gcgio.ParseGCG(DefaultConfig, "../../gcgio/testdata/noah_vs_mishu.gcg") is.NoErr(err) + gameHistory := parsedGame.GenerateSerializableHistory() g, err := game.NewFromHistory(gameHistory, rules, 28) is.NoErr(err) @@ -401,8 +406,9 @@ func TestFromGCG(t *testing.T) { rules, err := game.NewBasicGameRules(DefaultConfig, "CSW19", board.CrosswordGameLayout, "English", game.CrossScoreAndSet, game.VarClassic) is.NoErr(err) - gameHistory, err := gcgio.ParseGCG(DefaultConfig, "../../gcgio/testdata/vs_frentz.gcg") + parsedGame, err := gcgio.ParseGCG(DefaultConfig, "../../gcgio/testdata/vs_frentz.gcg") is.NoErr(err) + gameHistory := parsedGame.GenerateSerializableHistory() g, err := game.NewFromHistory(gameHistory, rules, 22) is.NoErr(err) diff --git a/game/challenge.go b/game/challenge.go index 5f6cf6cc..b2a026c4 100644 --- a/game/challenge.go +++ b/game/challenge.go @@ -19,7 +19,7 @@ var ( // must already be started with StartGame above (call immediately afterwards). // It would default to the 0 state (VOID) otherwise. func (g *Game) SetChallengeRule(rule pb.ChallengeRule) { - g.history.ChallengeRule = rule + g.challengeRule = rule } // ChallengeEvent should only be called if there is a history of events. @@ -30,10 +30,10 @@ func (g *Game) SetChallengeRule(rule pb.ChallengeRule) { // out with a phony). // Return playLegal, error func (g *Game) ChallengeEvent(addlBonus int, millis int) (bool, error) { - if len(g.history.Events) == 0 { + if len(g.events) == 0 { return false, errors.New("this game has no history") } - if g.history.ChallengeRule == pb.ChallengeRule_VOID { + if g.challengeRule == pb.ChallengeRule_VOID { return false, errors.New("challenges are not valid in void") } if len(g.lastWordsFormed) == 0 { @@ -44,7 +44,7 @@ func (g *Game) ChallengeEvent(addlBonus int, millis int) (bool, error) { illegalWords := validateWords(g.lexicon, g.lastWordsFormed, g.rules.Variant()) playLegal := len(illegalWords) == 0 - lastEvent := g.history.Events[g.turnnum-1] + lastEvent := g.events[g.turnnum-1] log.Info().Interface("lastEvent", lastEvent).Msg("in challenge event") cumeScoreBeforeChallenge := lastEvent.Cumulative @@ -65,7 +65,7 @@ func (g *Game) ChallengeEvent(addlBonus int, millis int) (bool, error) { var err error // This ideal system makes it so someone always loses // the game. - if g.history.ChallengeRule == pb.ChallengeRule_TRIPLE { + if g.challengeRule == pb.ChallengeRule_TRIPLE { // Set the winner and loser before calling PlayMove, as // that changes who is on turn var winner int32 @@ -78,21 +78,20 @@ func (g *Game) ChallengeEvent(addlBonus int, millis int) (bool, error) { // Take the play off the board. g.addEventToHistory(offBoardEvent) g.UnplayLastMove() - g.history.LastKnownRacks[challengee] = lastEvent.Rack + // Note: rack will be restored from lastEvent.Rack later } - g.history.Winner = winner + g.winner = winner // Don't call AddFinalScoresToHistory, this will // overwrite the correct winner g.playing = pb.PlayState_GAME_OVER - g.history.PlayState = g.playing // This is the only case where the winner needs to be determined // independently from the score, so we copy just these lines from // AddFinalScoresToHistory. - g.history.FinalScores = make([]int32, len(g.players)) + g.finalScores = make([]int32, len(g.players)) for pidx, p := range g.players { - g.history.FinalScores[pidx] = int32(p.points) + g.finalScores[pidx] = int32(p.points) } } else if !playLegal { @@ -104,18 +103,25 @@ func (g *Game) ChallengeEvent(addlBonus int, millis int) (bool, error) { // Unplay the last move to restore everything as it was board-wise // (and un-end the game if it had ended) g.UnplayLastMove() - g.history.PlayState = g.playing - - // We must also set the last known rack of the challengee back to - // their rack before they played the phony. - g.history.LastKnownRacks[challengee] = lastEvent.Rack - // Explicitly set racks for both players. This prevents a bug where - // part of the game may have been loaded from a GameHistory (through the - // PlayGameToTurn flow) and the racks continually get reset. - err = g.SetRacksForBoth([]*tilemapping.Rack{ - tilemapping.RackFromString(g.history.LastKnownRacks[0], g.alph), - tilemapping.RackFromString(g.history.LastKnownRacks[1], g.alph), - }) + + // We must restore the challengee's rack to what it was before the phony. + // First get the current rack for the player who didn't play the phony + challengerRack := g.RackLettersFor(g.onturn) + + // Build the racks array with the challengee's rack from before the phony + var racksToSet []*tilemapping.Rack + if challengee == 0 { + racksToSet = []*tilemapping.Rack{ + tilemapping.RackFromString(lastEvent.Rack, g.alph), + tilemapping.RackFromString(challengerRack, g.alph), + } + } else { + racksToSet = []*tilemapping.Rack{ + tilemapping.RackFromString(challengerRack, g.alph), + tilemapping.RackFromString(lastEvent.Rack, g.alph), + } + } + err = g.SetRacksForBoth(racksToSet) if err != nil { return playLegal, err } @@ -152,7 +158,7 @@ func (g *Game) ChallengeEvent(addlBonus int, millis int) (bool, error) { } } - switch g.history.ChallengeRule { + switch g.challengeRule { case pb.ChallengeRule_DOUBLE: // This "draconian" American system makes it so someone always loses // their turn. @@ -186,7 +192,6 @@ func (g *Game) ChallengeEvent(addlBonus int, millis int) (bool, error) { if g.playing == pb.PlayState_WAITING_FOR_FINAL_PASS { g.playing = pb.PlayState_GAME_OVER - g.history.PlayState = g.playing // Game is actually over now, after the failed challenge. // do calculations with the player on turn being the player who // didn't challenge, as this is a special event where the turn @@ -199,7 +204,7 @@ func (g *Game) ChallengeEvent(addlBonus int, millis int) (bool, error) { // Finally set the last words formed to nil. g.lastWordsFormed = nil - g.turnnum = len(g.history.Events) + g.turnnum = len(g.events) return playLegal, err } diff --git a/game/challenge_test.go b/game/challenge_test.go index 502b8b93..55d4a851 100644 --- a/game/challenge_test.go +++ b/game/challenge_test.go @@ -46,7 +46,9 @@ func TestChallengeDoubleIsLegal(t *testing.T) { alph := g.Alphabet() g.StartGame() g.SetPlayerOnTurn(0) - g.SetRackFor(0, tilemapping.RackFromString("IFFIEST", alph)) + err = g.SetRackFor(0, tilemapping.RackFromString("IFFIEST", alph)) + is.NoErr(err) + // Rack syncing no longer needed g.SetChallengeRule(pb.ChallengeRule_DOUBLE) m := move.NewScoringMoveSimple(84, "8C", "IFFIEST", "", alph) _, err = g.ValidateMove(m) @@ -56,8 +58,9 @@ func TestChallengeDoubleIsLegal(t *testing.T) { legal, err := g.ChallengeEvent(0, 0) is.NoErr(err) is.True(legal) - is.Equal(len(g.History().Events), 2) - is.Equal(g.History().Events[1].Type, pb.GameEvent_UNSUCCESSFUL_CHALLENGE_TURN_LOSS) + history := g.GenerateSerializableHistory() + is.Equal(len(history.Events), 2) + is.Equal(history.Events[1].Type, pb.GameEvent_UNSUCCESSFUL_CHALLENGE_TURN_LOSS) } func TestChallengeDoubleIsIllegal(t *testing.T) { @@ -68,41 +71,41 @@ func TestChallengeDoubleIsIllegal(t *testing.T) { } rules, err := game.NewBasicGameRules(DefaultConfig, "NWL18", board.CrosswordGameLayout, "English", game.CrossScoreAndSet, game.VarClassic) is.NoErr(err) - g, _ := game.NewGame(rules, players) - alph := g.Alphabet() - g.StartGame() - g.SetBackupMode(game.InteractiveGameplayMode) - g.SetStateStackLength(1) - g.SetPlayerOnTurn(0) - g.SetRackFor(0, tilemapping.RackFromString("IFFIEST", alph)) - g.SetChallengeRule(pb.ChallengeRule_DOUBLE) - m := move.NewScoringMoveSimple(84, "8C", "IFFITES", "", alph) - _, err = g.ValidateMove(m) - is.NoErr(err) - err = g.PlayMove(m, true, 0) - is.NoErr(err) - legal, err := g.ChallengeEvent(0, 0) - is.NoErr(err) - is.True(!legal) - is.Equal(len(g.History().Events), 2) - is.Equal(g.History().Events[1].Type, pb.GameEvent_PHONY_TILES_RETURNED) - + for range 1000 { + g, _ := game.NewGame(rules, players) + alph := g.Alphabet() + g.StartGame() + g.SetBackupMode(game.InteractiveGameplayMode) + g.SetStateStackLength(1) + g.SetPlayerOnTurn(0) + err = g.SetRackFor(0, tilemapping.RackFromString("IFFIEST", alph)) + is.NoErr(err) + // Rack syncing no longer needed + g.SetChallengeRule(pb.ChallengeRule_DOUBLE) + m := move.NewScoringMoveSimple(84, "8C", "IFFITES", "", alph) + _, err = g.ValidateMove(m) + is.NoErr(err) + err = g.PlayMove(m, true, 0) + is.NoErr(err) + legal, err := g.ChallengeEvent(0, 0) + is.NoErr(err) + is.True(!legal) + history := g.GenerateSerializableHistory() + is.Equal(len(history.Events), 2) + is.Equal(history.Events[1].Type, pb.GameEvent_PHONY_TILES_RETURNED) + } } func TestChallengeEndOfGamePlusFive(t *testing.T) { is := is.New(t) - gameHistory, err := gcgio.ParseGCG(DefaultConfig, "../gcgio/testdata/some_isc_game.gcg") - is.NoErr(err) - rules, err := game.NewBasicGameRules(DefaultConfig, "NWL18", board.CrosswordGameLayout, "English", game.CrossScoreAndSet, game.VarClassic) - is.NoErr(err) - - g, err := game.NewFromHistory(gameHistory, rules, 0) + g, err := gcgio.ParseGCG(DefaultConfig, "../gcgio/testdata/some_isc_game.gcg") is.NoErr(err) g.SetBackupMode(game.InteractiveGameplayMode) g.SetStateStackLength(1) g.SetChallengeRule(pb.ChallengeRule_FIVE_POINT) - err = g.PlayToTurn(21) + gameHistory := g.GenerateSerializableHistory() + err = g.PlayToTurn(21, gameHistory.LastKnownRacks) is.NoErr(err) alph := g.Alphabet() m := move.NewScoringMoveSimple(22, "3K", "ABBE", "", alph) @@ -121,17 +124,13 @@ func TestChallengeEndOfGamePlusFive(t *testing.T) { func TestChallengeEndOfGamePhony(t *testing.T) { is := is.New(t) - gameHistory, err := gcgio.ParseGCG(DefaultConfig, "../gcgio/testdata/some_isc_game.gcg") - is.NoErr(err) - rules, err := game.NewBasicGameRules(DefaultConfig, "NWL18", board.CrosswordGameLayout, "English", game.CrossScoreAndSet, game.VarClassic) - is.NoErr(err) - - g, err := game.NewFromHistory(gameHistory, rules, 0) + g, err := gcgio.ParseGCG(DefaultConfig, "../gcgio/testdata/some_isc_game.gcg") is.NoErr(err) g.SetBackupMode(game.InteractiveGameplayMode) g.SetStateStackLength(1) g.SetChallengeRule(pb.ChallengeRule_FIVE_POINT) - err = g.PlayToTurn(21) + gameHistory := g.GenerateSerializableHistory() + err = g.PlayToTurn(21, gameHistory.LastKnownRacks) is.NoErr(err) alph := g.Alphabet() m := move.NewScoringMoveSimple(22, "3K", "ABEB", "", alph) @@ -162,7 +161,9 @@ func TestChallengeTripleUnsuccessful(t *testing.T) { alph := g.Alphabet() g.StartGame() g.SetPlayerOnTurn(0) - g.SetRackFor(0, tilemapping.RackFromString("IFFIEST", alph)) + err = g.SetRackFor(0, tilemapping.RackFromString("IFFIEST", alph)) + is.NoErr(err) + // Rack syncing no longer needed g.SetChallengeRule(pb.ChallengeRule_TRIPLE) m := move.NewScoringMoveSimple(84, "8C", "IFFIEST", "", alph) _, err = g.ValidateMove(m) @@ -172,9 +173,10 @@ func TestChallengeTripleUnsuccessful(t *testing.T) { legal, err := g.ChallengeEvent(0, 0) is.NoErr(err) is.True(legal) - is.Equal(len(g.History().Events), 1) - is.Equal(g.History().PlayState, pb.PlayState_GAME_OVER) - is.Equal(g.History().Winner, int32(0)) + history := g.GenerateSerializableHistory() + is.Equal(len(history.Events), 1) + is.Equal(history.PlayState, pb.PlayState_GAME_OVER) + is.Equal(history.Winner, int32(0)) } func TestChallengeTripleSuccessful(t *testing.T) { @@ -191,7 +193,9 @@ func TestChallengeTripleSuccessful(t *testing.T) { g.SetBackupMode(game.InteractiveGameplayMode) g.SetStateStackLength(1) g.SetPlayerOnTurn(0) - g.SetRackFor(0, tilemapping.RackFromString("IFFIEST", alph)) + err = g.SetRackFor(0, tilemapping.RackFromString("IFFIEST", alph)) + is.NoErr(err) + // Rack syncing no longer needed g.SetChallengeRule(pb.ChallengeRule_TRIPLE) m := move.NewScoringMoveSimple(84, "8C", "IFFISET", "", alph) _, err = g.ValidateMove(m) @@ -201,9 +205,10 @@ func TestChallengeTripleSuccessful(t *testing.T) { legal, err := g.ChallengeEvent(0, 0) is.NoErr(err) is.True(!legal) - is.Equal(len(g.History().Events), 2) - is.Equal(g.History().PlayState, pb.PlayState_GAME_OVER) - is.Equal(g.History().Winner, int32(1)) + history := g.GenerateSerializableHistory() + is.Equal(len(history.Events), 2) + is.Equal(history.PlayState, pb.PlayState_GAME_OVER) + is.Equal(history.Winner, int32(1)) } func TestChallengeRestoreRack(t *testing.T) { @@ -226,6 +231,7 @@ func TestChallengeRestoreRack(t *testing.T) { tilemapping.RackFromString("MATLIKE", alph), }) is.NoErr(err) + // Rack syncing no longer needed m2 := move.NewScoringMoveSimple(82, "L9", "MATLIKE", "", alph) _, err = g.ValidateMove(m2) diff --git a/game/display.go b/game/display.go index f46dfd15..21e62f90 100644 --- a/game/display.go +++ b/game/display.go @@ -101,32 +101,36 @@ func (g *Game) ToDisplayText() string { addText(bts, 12, hpadding, fmt.Sprintf("Turn %d:", g.turnnum)) vpadding = 13 - if g.history != nil { - for i, evt := range g.history.Events { - log.Debug().Msgf("Event %d: %v", i, evt) - } + for i, evt := range g.events { + log.Debug().Msgf("Event %d: %v", i, evt) + } - if g.turnnum-1 >= 0 && len(g.history.Events) > g.turnnum-1 { - addText(bts, vpadding, hpadding, - summary(g.history.Players, g.history.Events[g.turnnum-1])) + if g.turnnum-1 >= 0 && len(g.events) > g.turnnum-1 { + // Create player info array for summary + playerInfos := make([]*pb.PlayerInfo, len(g.players)) + for i, p := range g.players { + playerInfos[i] = &pb.PlayerInfo{ + Nickname: p.Nickname, + RealName: p.RealName, + } } + addText(bts, vpadding, hpadding, + summary(playerInfos, g.events[g.turnnum-1])) } vpadding = 17 - if g.playing == pb.PlayState_GAME_OVER && g.turnnum == len(g.history.Events) { + if g.playing == pb.PlayState_GAME_OVER && g.turnnum == len(g.events) { addText(bts, vpadding, hpadding, "Game is over.") } if g.playing == pb.PlayState_WAITING_FOR_FINAL_PASS { addText(bts, vpadding, hpadding, "Waiting for final pass/challenge...") } vpadding = 19 - if g.history != nil { - if g.turnnum-1 < len(g.history.Events) && g.turnnum-1 >= 0 && - g.history.Events[g.turnnum-1].Note != "" { - // add it all the way at the bottom - bts = append(bts, g.history.Events[g.turnnum-1].Note) - } + if g.turnnum-1 < len(g.events) && g.turnnum-1 >= 0 && + g.events[g.turnnum-1].Note != "" { + // add it all the way at the bottom + bts = append(bts, g.events[g.turnnum-1].Note) } return strings.Join(bts, "\n") diff --git a/game/game.go b/game/game.go index 214d1e5d..3cd12289 100644 --- a/game/game.go +++ b/game/game.go @@ -59,12 +59,22 @@ type Game struct { onturn int turnnum int players playerStates - // history has a history of all the moves in this game. Note that - // history only gets written to when someone plays a move that is NOT - // backed up. - history *pb.GameHistory + + // events stores all the game events. This replaces the history field. + events []*pb.GameEvent + // Store metadata that was previously in history + uid string + idAuth string + title string + description string + originalGcg string + challengeRule pb.ChallengeRule + finalScores []int32 + winner int32 + startingCGP string + // lastWordsFormed also does not need to be backed up, it only gets written - // to when the history is written to. See comment above. + // to when the events are written to. See comment above. lastWordsFormed []tilemapping.MachineWord backupMode BackupMode @@ -107,23 +117,23 @@ func (g *Game) SetRules(r *GameRules) { } func (g *Game) LastEvent() *pb.GameEvent { - last := len(g.history.Events) - 1 + last := len(g.events) - 1 if last < 0 { return nil } - return g.history.Events[last] + return g.events[last] } func (g *Game) addEventToHistory(evt *pb.GameEvent) { log.Debug().Msgf("Adding event to history: %v", evt) - if g.turnnum < len(g.history.Events) { + if g.turnnum < len(g.events) { log.Info().Interface("evt", evt).Msg("adding-overwriting-history") - // log.Info().Interface("history", g.history.Events).Int("turnnum", g.turnnum). - // Int("len", len(g.history.Events)).Msg("hist") - g.history.Events = g.history.Events[:g.turnnum] + // log.Info().Interface("history", g.events).Int("turnnum", g.turnnum). + // Int("len", len(g.events)).Msg("hist") + g.events = g.events[:g.turnnum] } - g.history.Events = append(g.history.Events, evt) + g.events = append(g.events, evt) } @@ -188,6 +198,14 @@ func NewGame(rules *GameRules, playerinfo []*pb.PlayerInfo) (*Game, error) { } log.Debug().Int("exch-limit", rules.exchangeLimit).Msg("setting-exchange-limit") + // Initialize new fields that replaced history + game.events = []*pb.GameEvent{} + game.uid = newRequestId().String() + game.idAuth = IdentificationAuthority + game.description = MacondoCreation + game.challengeRule = pb.ChallengeRule_VOID // Will be set by caller if needed + game.winner = -1 // No winner initially + if game.config.GetBool(config.ConfigTritonUseTriton) { tritonURL := game.config.GetString(config.ConfigTritonURL) modelName := game.config.GetString(config.ConfigTritonModelName) @@ -212,16 +230,33 @@ func NewFromHistory(history *pb.GameHistory, rules *GameRules, turnnum int) (*Ga if err != nil { return nil, err } - game.history = history + + // Copy over the history data to our new fields + game.events = history.Events[:turnnum] if history.Uid == "" { - history.Uid = newRequestId().String() - history.IdAuth = IdentificationAuthority + game.uid = newRequestId().String() + game.idAuth = IdentificationAuthority + } else { + game.uid = history.Uid + game.idAuth = history.IdAuth } if history.Description == "" { - history.Description = MacondoCreation - } - if history.LastKnownRacks == nil { - history.LastKnownRacks = []string{"", ""} + game.description = MacondoCreation + } else { + game.description = history.Description + } + game.challengeRule = history.ChallengeRule + game.finalScores = history.FinalScores + game.winner = history.Winner + game.startingCGP = history.StartingCgp + + // Store a reference to the last known racks for recovery after PlayToTurn + lastKnownRacks := []string{"", ""} + if turnnum == len(history.Events) { + lastKnownRacks = history.LastKnownRacks + if lastKnownRacks == nil { + lastKnownRacks = []string{"", ""} + } } // Initialize the bag and player rack structures to avoid panics. @@ -230,7 +265,7 @@ func NewFromHistory(history *pb.GameHistory, rules *GameRules, turnnum int) (*Ga game.players[i].rack = tilemapping.NewRack(game.alph) } // Then play to the passed-in turn. - err = game.PlayToTurn(turnnum) + err = game.PlayToTurn(turnnum, lastKnownRacks) if err != nil { return nil, err } @@ -246,7 +281,8 @@ func NewFromSnapshot(rules *GameRules, players []*pb.PlayerInfo, lastKnownRacks return nil, err } - game.history = newHistory(game.players) + // Initialize events array + game.events = []*pb.GameEvent{} game.bag = game.letterDistribution.MakeBag() for i := 0; i < game.NumPlayers(); i++ { @@ -263,16 +299,11 @@ func NewFromSnapshot(rules *GameRules, players []*pb.PlayerInfo, lastKnownRacks if err != nil { return nil, err } - game.history.LastKnownRacks = lastKnownRacks // Set racks and tiles racks := []*tilemapping.Rack{ - tilemapping.RackFromString(game.history.LastKnownRacks[0], game.Alphabet()), - tilemapping.RackFromString(game.history.LastKnownRacks[1], game.Alphabet()), + tilemapping.RackFromString(lastKnownRacks[0], game.Alphabet()), + tilemapping.RackFromString(lastKnownRacks[1], game.Alphabet()), } - game.history.Lexicon = game.Lexicon().Name() - game.history.Variant = string(game.rules.Variant()) - game.history.LetterDistribution = game.rules.LetterDistributionName() - game.history.BoardLayout = game.rules.BoardName() // set racks for both players; this removes the relevant letters from the bag. err = game.SetRacksForBoth(racks) @@ -288,11 +319,9 @@ func NewFromSnapshot(rules *GameRules, players []*pb.PlayerInfo, lastKnownRacks // onturn is 0 by default, which is always correct in this function, as // the player to go next is listed first in the players/racks/scores. game.playing = pb.PlayState_PLAYING - game.history.PlayState = game.playing if game.bag.TilesRemaining() == 0 && (game.RackFor(0).NumTiles() == 0 || game.RackFor(1).NumTiles() == 0) { game.playing = pb.PlayState_GAME_OVER - game.history.PlayState = game.playing log.Info().Msg("this game is already over") } @@ -316,9 +345,7 @@ func (g *Game) RenamePlayer(idx int, playerinfo *pb.PlayerInfo) error { g.players[idx].PlayerInfo.RealName = playerinfo.RealName g.players[idx].PlayerInfo.UserId = playerinfo.UserId - g.history.Players[idx].Nickname = playerinfo.Nickname - g.history.Players[idx].RealName = playerinfo.RealName - g.history.Players[idx].UserId = playerinfo.UserId + // Player info is already updated in g.players[idx] return nil } @@ -326,7 +353,15 @@ func (g *Game) RenamePlayer(idx int, playerinfo *pb.PlayerInfo) error { func (g *Game) StartGame() { g.Board().Clear() g.bag = g.letterDistribution.MakeBag() - g.history = newHistory(g.players) + + // Reset the game metadata + g.events = []*pb.GameEvent{} + g.uid = newRequestId().String() + g.idAuth = IdentificationAuthority + g.description = MacondoCreation + g.finalScores = nil + g.winner = -1 + // Deal out tiles for i := 0; i < g.NumPlayers(); i++ { @@ -338,15 +373,7 @@ func (g *Game) StartGame() { g.players[i].setRackTiles(g.players[i].placeholderRack[:7], g.alph) g.players[i].resetScore() } - g.history.LastKnownRacks = []string{ - g.RackLettersFor(0), g.RackLettersFor(1), - } - g.history.Lexicon = g.Lexicon().Name() - g.history.Variant = string(g.rules.Variant()) - g.history.LetterDistribution = g.rules.LetterDistributionName() - g.history.BoardLayout = g.rules.BoardName() g.playing = pb.PlayState_PLAYING - g.history.PlayState = g.playing g.turnnum = 0 g.scorelessTurns = 0 g.lastScorelessTurns = 0 @@ -434,7 +461,7 @@ func (g *Game) validateTilePlayMove(m *move.Move) ([]tilemapping.MachineWord, er if err != nil { return nil, err } - if g.history != nil && g.history.ChallengeRule == pb.ChallengeRule_VOID { + if g.challengeRule == pb.ChallengeRule_VOID { // Actually check the validity of the words. illegalWords := validateWords(g.lexicon, formedWords, g.rules.Variant()) @@ -547,23 +574,23 @@ func (g *Game) PlayMove(m *move.Move, addToHistory bool, millis int) error { evt := g.EventFromMove(m) evt.MillisRemaining = int32(millis) evt.WordsFormed = convertToVisible(g.lastWordsFormed, g.alph) - g.history.LastKnownRacks[g.onturn] = g.RackLettersFor(g.onturn) + // Rack tracking is now only in GenerateSerializableHistory g.addEventToHistory(evt) } if g.players[g.onturn].rack.NumTiles() == 0 { // make sure not in sim mode. sim mode (montecarlo, endgame, etc) does not // generate end-of-game passes. - if g.backupMode != SimulationMode && g.history != nil && g.history.ChallengeRule != pb.ChallengeRule_VOID { + if g.backupMode != SimulationMode && g.challengeRule != pb.ChallengeRule_VOID { // Basically, if the challenge rule is not void, // wait for the final pass (or challenge). g.playing = pb.PlayState_WAITING_FOR_FINAL_PASS - g.history.PlayState = g.playing + // Play state is tracked in g.playing field log.Trace().Msg("waiting for final pass... (commit pass)") } else { g.playing = pb.PlayState_GAME_OVER if addToHistory { - g.history.PlayState = g.playing + // Play state is tracked in g.playing field } g.endOfGameCalcs(g.onturn, addToHistory) if addToHistory { @@ -584,7 +611,7 @@ func (g *Game) PlayMove(m *move.Move, addToHistory bool, millis int) error { } if g.playing == pb.PlayState_WAITING_FOR_FINAL_PASS { g.playing = pb.PlayState_GAME_OVER - g.history.PlayState = g.playing + // Play state is tracked in g.playing field log.Trace().Msg("waiting -> gameover transition") // Note that the player "on turn" changes here, as we created // a fake virtual turn on the pass. We need to calculate @@ -613,7 +640,7 @@ func (g *Game) PlayMove(m *move.Move, addToHistory bool, millis int) error { if addToHistory { evt := g.EventFromMove(m) evt.MillisRemaining = int32(millis) - g.history.LastKnownRacks[g.onturn] = g.RackLettersFor(g.onturn) + // Rack tracking is now only in GenerateSerializableHistory g.addEventToHistory(evt) } } @@ -690,20 +717,20 @@ func (g *Game) PlaySmallMove(m *tinymove.SmallMove) ( } -// AddFinalScoresToHistory adds the final scores and winner to the history. +// AddFinalScoresToHistory adds the final scores and winner to the game. func (g *Game) AddFinalScoresToHistory() { - g.history.FinalScores = make([]int32, len(g.players)) + g.finalScores = make([]int32, len(g.players)) for pidx, p := range g.players { - g.history.FinalScores[pidx] = int32(p.points) + g.finalScores[pidx] = int32(p.points) } - if g.history.FinalScores[0] > g.history.FinalScores[1] { - g.history.Winner = 0 - } else if g.history.FinalScores[0] < g.history.FinalScores[1] { - g.history.Winner = 1 + if g.finalScores[0] > g.finalScores[1] { + g.winner = 0 + } else if g.finalScores[0] < g.finalScores[1] { + g.winner = 1 } else { - g.history.Winner = -1 + g.winner = -1 } - log.Debug().Interface("finalscores", g.history.FinalScores).Msg("added-final-scores") + log.Debug().Interface("finalscores", g.finalScores).Msg("added-final-scores") } func (g *Game) handleConsecutiveScorelessTurns(addToHistory bool) (bool, error) { @@ -712,7 +739,7 @@ func (g *Game) handleConsecutiveScorelessTurns(addToHistory bool) (bool, error) ended = true g.playing = pb.PlayState_GAME_OVER if addToHistory { - g.history.PlayState = g.playing + // Play state is tracked in g.playing field } pts := g.calculateRackPts(g.onturn) g.players[g.onturn].points -= pts @@ -819,22 +846,19 @@ func otherPlayer(idx int) int { } func (g *Game) AddNote(note string) error { - if g.history == nil { - return errors.New("nil history") - } evtNum := g.turnnum - 1 if evtNum < 0 { return errors.New("event number is negative") } - g.history.Events[evtNum].Note = note + g.events[evtNum].Note = note return nil } -func (g *Game) PlayToTurn(turnnum int) error { +func (g *Game) PlayToTurn(turnnum int, lastKnownRacks []string) error { log.Debug().Int("turnnum", turnnum).Msg("playing to turn") - if turnnum < 0 || turnnum > len(g.history.Events) { + if turnnum < 0 || turnnum > len(g.events) { return fmt.Errorf("game has %v turns, you have chosen a turn outside the range", - len(g.history.Events)) + len(g.events)) } if g.board == nil { return fmt.Errorf("board does not exist") @@ -850,7 +874,6 @@ func (g *Game) PlayToTurn(turnnum int) error { g.turnnum = 0 g.onturn = 0 g.playing = pb.PlayState_PLAYING - g.history.PlayState = g.playing var t int // Set backup mode to interactive gameplay mode so that we always have @@ -869,21 +892,21 @@ func (g *Game) PlayToTurn(turnnum int) error { } g.SetBackupMode(oldbackupMode) - if t >= len(g.history.Events) { - if len(g.history.LastKnownRacks[0]) > 0 && len(g.history.LastKnownRacks[1]) > 0 { + if t >= len(g.events) { + if len(lastKnownRacks) >= 2 && len(lastKnownRacks[0]) > 0 && len(lastKnownRacks[1]) > 0 { g.SetRacksForBoth([]*tilemapping.Rack{ - tilemapping.RackFromString(g.history.LastKnownRacks[0], g.alph), - tilemapping.RackFromString(g.history.LastKnownRacks[1], g.alph), + tilemapping.RackFromString(lastKnownRacks[0], g.alph), + tilemapping.RackFromString(lastKnownRacks[1], g.alph), }) - } else if len(g.history.LastKnownRacks[0]) > 0 { + } else if len(lastKnownRacks) >= 1 && len(lastKnownRacks[0]) > 0 { // Rack1 but not rack2 - err := g.SetRackFor(0, tilemapping.RackFromString(g.history.LastKnownRacks[0], g.alph)) + err := g.SetRackFor(0, tilemapping.RackFromString(lastKnownRacks[0], g.alph)) if err != nil { return err } - } else if len(g.history.LastKnownRacks[1]) > 0 { + } else if len(lastKnownRacks) >= 2 && len(lastKnownRacks[1]) > 0 { // Rack2 but not rack1 - err := g.SetRackFor(1, tilemapping.RackFromString(g.history.LastKnownRacks[1], g.alph)) + err := g.SetRackFor(1, tilemapping.RackFromString(lastKnownRacks[1], g.alph)) if err != nil { return err } @@ -891,6 +914,7 @@ func (g *Game) PlayToTurn(turnnum int) error { // They're both blank. // We don't have a recorded rack, so set it to a random one. g.SetRandomRack(g.onturn, nil) + g.SetRandomRack(1-g.onturn, nil) } log.Debug().Str("r0", g.players[0].rackLetters()).Str("r1", g.players[1].rackLetters()).Msg("PlayToTurn-set-racks") @@ -900,10 +924,10 @@ func (g *Game) PlayToTurn(turnnum int) error { // who was on turn. // So set the currently on turn's rack to whatever is in the history. log.Trace().Int("turn", t).Msg("setting rack from turn") - switch g.history.Events[t].Type { + switch g.events[t].Type { case pb.GameEvent_TILE_PLACEMENT_MOVE, pb.GameEvent_EXCHANGE: err := g.SetRackFor(g.onturn, tilemapping.RackFromString( - g.history.Events[t].Rack, g.alph)) + g.events[t].Rack, g.alph)) if err != nil { return err } @@ -914,7 +938,7 @@ func (g *Game) PlayToTurn(turnnum int) error { default: // do the same as in the first case for now? err := g.SetRackFor(g.onturn, tilemapping.RackFromString( - g.history.Events[t].Rack, g.alph)) + g.events[t].Rack, g.alph)) if err != nil { return err } @@ -924,7 +948,7 @@ func (g *Game) PlayToTurn(turnnum int) error { for _, p := range g.players { if p.rack.NumTiles() == 0 { log.Debug().Msgf("Player %v has no tiles, game might be over.", p) - if len(g.history.FinalScores) == 0 { + if len(g.finalScores) == 0 { // This game has never ended before, so it must not have gotten // past this "final pass" state. log.Debug().Msg("restoring waiting for final pass state") @@ -932,7 +956,7 @@ func (g *Game) PlayToTurn(turnnum int) error { } else { g.playing = pb.PlayState_GAME_OVER } - g.history.PlayState = g.playing + // Play state is tracked in g.playing field break } @@ -940,17 +964,24 @@ func (g *Game) PlayToTurn(turnnum int) error { return nil } -// PlayLatestEvent "plays" the latest event on the board. This is used for -// replaying a game from a GCG. -func (g *Game) PlayLatestEvent() error { - return g.PlayTurn(len(g.history.Events) - 1) +func (g *Game) SetEvents(events []*pb.GameEvent) { + g.events = events +} + +// PlayEvent adds an event to the game and plays it on the board. +// This is used for replaying a game from a GCG. +func (g *Game) PlayEvent(evt *pb.GameEvent) error { + // Add the event to our events array + g.events = append(g.events, evt) + // Play the event using the existing PlayTurn logic + return g.PlayTurn(len(g.events) - 1) } func (g *Game) PlayTurn(t int) error { // XXX: This function is pretty similar to PlayMove above. It has a // subset of the functionality as it's designed to replay an already // recorded turn on the board. - evt := g.history.Events[t] + evt := g.events[t] log.Trace().Int("event-type", int(evt.Type)).Int("turn", t).Msg("playTurn") g.onturn = int(evt.PlayerIndex) m, err := MoveFromEvent(evt, g.alph, g.board) @@ -1043,26 +1074,110 @@ func (g *Game) PlayTurn(t int) error { } // SetRackFor sets the player's current rack. It throws an error if -// the rack is impossible to set from the current unseen tiles. It -// puts tiles back from opponent racks and our own racks, then sets the rack, -// and finally redraws for opponent. +// the rack is impossible to set from the current unseen tiles. +// This method uses minimal tile reconciliation to avoid disrupting the +// opponent's rack unless necessary. If opponent has tiles that are needed +// for the target rack, only those specific tiles are replaced. +// This is primarily used in analysis mode (endgame solving, game annotation). func (g *Game) SetRackFor(playerIdx int, rack *tilemapping.Rack) error { - // Put our tiles back in the bag, as well as our opponent's tiles. - g.ThrowRacksIn() - // Check if we can actually set our rack now that these tiles are in the - // bag. - log.Trace().Str("rack", rack.TilesOn().UserVisible(g.alph)).Msg("removing from bag") - err := g.bag.RemoveTiles(rack.TilesOn()) + oppIdx := otherPlayer(playerIdx) + targetRack := rack.TilesOn() + oppRack := g.players[oppIdx].rack.TilesOn() + + // Step 1: Return current player's tiles to bag + g.players[playerIdx].throwRackIn(g.bag) + + // Step 2: Try to draw the target rack + err := g.bag.RemoveTiles(targetRack) + if err == nil { + // Easy case - we got everything we needed without touching opponent + g.players[playerIdx].rack = rack + log.Trace().Str("rack", rack.String()).Int("player", playerIdx).Msg("rack set without touching opponent") + // Make a copy of opponent's rack + // If they don't have a full rack at this time, we want to replenish it. + oppRackCopy := make([]tilemapping.MachineLetter, len(oppRack)) + copy(oppRackCopy, oppRack) + _, err = g.SetRandomRack(oppIdx, oppRackCopy) + if err != nil { + return fmt.Errorf("error restoring opponent rack: %v", err) + } + return nil + } + + // Step 3: We need some tiles potentially from opponent's rack + // Figure out what tiles we couldn't get from bag + log.Trace().Str("target", targetRack.UserVisible(g.alph)).Str("error", err.Error()).Msg("need tiles from opponent") + + // Make a copy of opponent's rack to track what we're taking + oppRackCopy := make([]tilemapping.MachineLetter, len(oppRack)) + copy(oppRackCopy, oppRack) + + // Try to identify which tiles from target are missing from bag + // and remove them from opponent's rack copy + removedFromBag := []tilemapping.MachineLetter{} + for _, needed := range targetRack { + err = g.bag.RemoveTiles(tilemapping.MachineWord{needed}) + if err != nil { + // Tile not in bag, try to take from opponent + found := false + for i, oppTile := range oppRackCopy { + if oppTile == needed { + // Remove this tile from opponent's rack copy (we're taking it) + oppRackCopy = append(oppRackCopy[:i], oppRackCopy[i+1:]...) + found = true + break + } + } + if !found { + // Can't form the requested rack even with all available tiles + // First, put back what we removed from bag + if len(removedFromBag) > 0 { + g.bag.PutBack(tilemapping.MachineWord(removedFromBag)) + } + return fmt.Errorf("cannot set rack: need %v but it's not available (already checked bag and opponent)", + needed.UserVisible(g.alph, false)) + } + } else { + // Temporarily store removed tiles to put back later + removedFromBag = append(removedFromBag, needed) + } + } + log.Debug(). + Str("targetRack", targetRack.UserVisible(g.alph)). + Str("oppRack", tilemapping.MachineWord(oppRack).UserVisible(g.alph)). + Str("opprackCopy (keeping)", tilemapping.MachineWord(oppRackCopy).UserVisible(g.alph)). + Msg("getting-from-opp") + // Put back any tiles we removed from bag, now we're going to do it for real. + if len(removedFromBag) > 0 { + g.bag.PutBack(tilemapping.MachineWord(removedFromBag)) + } + // oppRackCopy now contains what opponent keeps + oppKeeps := tilemapping.MachineWord(oppRackCopy) + + // Step 4: Put opponent's entire rack in bag, take what we need + g.players[oppIdx].throwRackIn(g.bag) + + err = g.bag.RemoveTiles(targetRack) if err != nil { - log.Error().Msgf("Unable to set rack for: %v", err) - return err + // This shouldn't happen given our checking above + return fmt.Errorf("unexpected error setting rack: %v", err) } - // success; set our rack + // Step 5: Set target player's rack g.players[playerIdx].rack = rack - // And redraw a random rack for opponent. - g.SetRandomRack(otherPlayer(playerIdx), nil) + // Step 6: Give opponent their kept tiles + random fills for what we took + _, err = g.SetRandomRack(oppIdx, oppKeeps) + if err != nil { + return fmt.Errorf("error restoring opponent rack: %v", err) + } + + log.Trace().Str("player_rack", rack.String()).Str("opp_keeps", oppKeeps.UserVisible(g.alph)). + Str("opp_new", g.players[oppIdx].rackLetters()).Msg("rack set with minimal disruption") + + // Note: We removed the automatic history sync here. + // Caller should explicitly call SyncRacksToHistory() if needed. + // g.SyncRacksToHistory() return nil } @@ -1077,6 +1192,9 @@ func (g *Game) SetRackForOnly(playerIdx int, rack *tilemapping.Rack) error { } // success; set our rack g.players[playerIdx].rack = rack + // Note: History sync removed. Caller should use SyncRacksToHistory() if needed. + // g.SyncRacksToHistory() + return nil } @@ -1093,10 +1211,8 @@ func (g *Game) SetRacksForBoth(racks []*tilemapping.Rack) error { for idx, player := range g.players { player.rack = racks[idx] } - if g.history != nil { - g.history.LastKnownRacks[0] = g.RackLettersFor(0) - g.history.LastKnownRacks[1] = g.RackLettersFor(1) - } + // Note: History sync removed. Caller should use SyncRacksToHistory() if needed. + // g.SyncRacksToHistory() return nil } @@ -1149,6 +1265,44 @@ func (g *Game) SetRandomRack(playerIdx int, knownRack []tilemapping.MachineLette return extraDrawn, nil } +// SyncRacksToHistory updates the history with current rack state. +// Call this only when you need to persist state for external consumption +// (e.g., before GCG export, after completing moves, etc.). +// GenerateSerializableHistory creates a GameHistory from the current game state. +// This should be called whenever a serializable history is needed. +func (g *Game) GenerateSerializableHistory() *pb.GameHistory { + // Create players array for history + historyPlayers := make([]*pb.PlayerInfo, len(g.players)) + for i, p := range g.players { + historyPlayers[i] = &pb.PlayerInfo{ + Nickname: p.Nickname, + RealName: p.RealName, + UserId: p.UserId, + } + } + + return &pb.GameHistory{ + Events: g.events, + Players: historyPlayers, + Version: CurrentGameHistoryVersion, + Lexicon: g.lexicon.Name(), + Title: g.title, + IdAuth: g.idAuth, + Uid: g.uid, + Description: g.description, + OriginalGcg: g.originalGcg, + LastKnownRacks: []string{g.RackLettersFor(0), g.RackLettersFor(1)}, + ChallengeRule: g.challengeRule, + PlayState: g.playing, + FinalScores: g.finalScores, + Variant: string(g.rules.Variant()), + Winner: g.winner, + BoardLayout: g.rules.BoardName(), + LetterDistribution: g.rules.LetterDistributionName(), + StartingCgp: g.startingCGP, + } +} + // RackFor returns the rack for the player with the passed-in index func (g *Game) RackFor(playerIdx int) *tilemapping.Rack { return g.players[playerIdx].rack @@ -1219,7 +1373,22 @@ func (g *Game) Turn() int { } func (g *Game) Uid() string { - return g.history.Uid + return g.uid +} + +// SetUid sets the unique identifier for this game +func (g *Game) SetUid(uid string) { + g.uid = uid +} + +// SetIdAuth sets the identification authority +func (g *Game) SetIdAuth(idAuth string) { + g.idAuth = idAuth +} + +// SetStartingCGP sets the starting CGP string +func (g *Game) SetStartingCGP(cgp string) { + g.startingCGP = cgp } func (g *Game) Playing() pb.PlayState { @@ -1262,14 +1431,6 @@ func (g *Game) CurrentSpread() int { return g.PointsFor(g.onturn) - g.PointsFor((g.onturn+1)%2) } -func (g *Game) History() *pb.GameHistory { - return g.history -} - -func (g *Game) SetHistory(h *pb.GameHistory) { - g.history = h -} - func (g *Game) FirstPlayer() *pb.PlayerInfo { return &g.players[0].PlayerInfo } @@ -1292,6 +1453,59 @@ func (g *Game) ExchangeLimit() int { return g.exchangeLimit } +// SetTitle sets the game title +func (g *Game) SetTitle(title string) { + g.title = title +} + +// GetTitle returns the game title +func (g *Game) GetTitle() string { + return g.title +} + +// SetDescription sets the game description +func (g *Game) SetDescription(description string) { + g.description = description +} + +func (g *Game) SetOriginalGcg(originalGcg string) { + g.originalGcg = originalGcg +} + +// SetUID sets the game UID +func (g *Game) SetUID(uid string) { + g.uid = uid +} + +// SetIDAuth sets the game ID authority +func (g *Game) SetIDAuth(idAuth string) { + g.idAuth = idAuth +} + +// GetLastEvent returns the last event in the events array, or nil if empty +func (g *Game) GetLastEvent() *pb.GameEvent { + if len(g.events) == 0 { + return nil + } + return g.events[len(g.events)-1] +} + +// AppendNoteToLastEvent appends a note to the last event +func (g *Game) AppendNoteToLastEvent(note string) { + if evt := g.GetLastEvent(); evt != nil { + evt.Note += note + } +} + +// GetPlayers returns the PlayerInfo for all players +func (g *Game) GetPlayers() []*pb.PlayerInfo { + players := make([]*pb.PlayerInfo, len(g.players)) + for i, p := range g.players { + players[i] = &p.PlayerInfo + } + return players +} + // ToCGP converts the game to a CGP string. See cgp directory. func (g *Game) ToCGP(formatForBot bool) string { fen := g.board.ToFEN(g.alph) @@ -1302,16 +1516,14 @@ func (g *Game) ToCGP(formatForBot bool) string { zeroPt := g.scorelessTurns lex := g.lexicon.Name() ld := "" - if g.history != nil { - ld = g.history.LetterDistribution - } + // Letter distribution is stored in g.rules if formatForBot { // Clear opponent rack -- if this is a bot move, bot should know // nothing of it. theirRack = "" tm := g.letterDistribution.TileMapping() - if g.history != nil && g.turnnum > 0 { - oppEvt := g.history.Events[g.turnnum-1] + if g.turnnum > 0 { + oppEvt := g.events[g.turnnum-1] if oppEvt.Type == pb.GameEvent_PHONY_TILES_RETURNED { // we know opp's last partial or full rack if tiles, err := tilemapping.ToMachineLetters(oppEvt.PlayedTiles, tm); err != nil { diff --git a/game/game_test.go b/game/game_test.go index 8c3d0ed9..921850d1 100644 --- a/game/game_test.go +++ b/game/game_test.go @@ -2,7 +2,6 @@ package game import ( "encoding/json" - "fmt" "io" "os" "testing" @@ -55,7 +54,6 @@ func TestBackup(t *testing.T) { // Overwrite the player on turn to be JD: game.SetPlayerOnTurn(0) alph := game.Alphabet() - fmt.Println("Here") game.SetRackFor(0, tilemapping.RackFromString("ACEOTV?", alph)) m := move.NewScoringMoveSimple(20, "H7", "AVOCET", "?", alph) @@ -90,31 +88,40 @@ func TestValidate(t *testing.T) { alph := g.Alphabet() g.StartGame() g.SetPlayerOnTurn(0) - g.SetRackFor(0, tilemapping.RackFromString("HIS", alph)) + err = g.SetRackFor(0, tilemapping.RackFromString("HIS", alph)) + is.NoErr(err) + // Rack syncing no longer needed - handled automatically g.SetChallengeRule(pb.ChallengeRule_DOUBLE) m := move.NewScoringMoveSimple(12, "H7", "HIS", "", alph) words, err := g.ValidateMove(m) is.NoErr(err) is.Equal(len(words), 1) g.PlayMove(m, true, 0) - is.Equal(g.history.Events[len(g.history.Events)-1].WordsFormed, + history := g.GenerateSerializableHistory() + is.Equal(history.Events[len(history.Events)-1].WordsFormed, []string{"HIS"}) - g.SetRackFor(1, tilemapping.RackFromString("OIK", alph)) + err = g.SetRackFor(1, tilemapping.RackFromString("OIK", alph)) + is.NoErr(err) + // Rack syncing no longer needed - handled automatically m = move.NewScoringMoveSimple(13, "G8", "OIK", "", alph) words, err = g.ValidateMove(m) is.NoErr(err) is.Equal(len(words), 3) g.PlayMove(m, true, 0) - is.Equal(g.history.Events[len(g.history.Events)-1].WordsFormed, + history2 := g.GenerateSerializableHistory() + is.Equal(history2.Events[len(history2.Events)-1].WordsFormed, []string{"OIK", "OI", "IS"}) - g.SetRackFor(0, tilemapping.RackFromString("ADITT", alph)) + err = g.SetRackFor(0, tilemapping.RackFromString("ADITT", alph)) + is.NoErr(err) + // Rack syncing no longer needed - handled automatically m = move.NewScoringMoveSimple(22, "10E", "DI.TAT", "", alph) words, err = g.ValidateMove(m) is.NoErr(err) is.Equal(len(words), 2) g.PlayMove(m, true, 0) - is.Equal(g.history.Events[len(g.history.Events)-1].WordsFormed, + history3 := g.GenerateSerializableHistory() + is.Equal(history3.Events[len(history3.Events)-1].WordsFormed, []string{"DIKTAT", "HIST"}) } @@ -149,8 +156,9 @@ func TestPlayToTurnWithPhony(t *testing.T) { is.Equal(valid, false) // check that game rolled back successfully - is.Equal(len(g.History().Events), 3) - is.Equal(g.History().Events[2].Type, pb.GameEvent_PHONY_TILES_RETURNED) + history := g.GenerateSerializableHistory() + is.Equal(len(history.Events), 3) + is.Equal(history.Events[2].Type, pb.GameEvent_PHONY_TILES_RETURNED) // The tiles in the phony "DORMINE" should be gone. // An already empty tile to the left of DORMINE* diff --git a/game/history_test.go b/game/history_test.go index ba8000d3..2e4a566b 100644 --- a/game/history_test.go +++ b/game/history_test.go @@ -9,6 +9,7 @@ import ( "github.com/domino14/macondo/gcgio" pb "github.com/domino14/macondo/gen/api/proto/macondo" "github.com/matryer/is" + "github.com/rs/zerolog/log" ) var DefaultConfig = config.DefaultConfig() @@ -21,8 +22,10 @@ func TestNewFromHistoryIncomplete1(t *testing.T) { DefaultConfig, "CSW19", board.CrosswordGameLayout, "english", game.CrossScoreOnly, "") is.NoErr(err) - gameHistory, err := gcgio.ParseGCG(DefaultConfig, "../gcgio/testdata/incomplete.gcg") + parsedGame, err := gcgio.ParseGCG(DefaultConfig, "../gcgio/testdata/incomplete.gcg") is.NoErr(err) + gameHistory := parsedGame.GenerateSerializableHistory() + log.Info().Interface("gameHistory", gameHistory).Msg("generated-gh") game, err := game.NewFromHistory(gameHistory, rules, 0) is.NoErr(err) @@ -35,8 +38,9 @@ func TestNewFromHistoryIncomplete2(t *testing.T) { DefaultConfig, "CSW19", board.CrosswordGameLayout, "english", game.CrossScoreOnly, "") is.NoErr(err) - gameHistory, err := gcgio.ParseGCG(DefaultConfig, "../gcgio/testdata/incomplete.gcg") + parsedGame, err := gcgio.ParseGCG(DefaultConfig, "../gcgio/testdata/incomplete.gcg") is.NoErr(err) + gameHistory := parsedGame.GenerateSerializableHistory() game, err := game.NewFromHistory(gameHistory, rules, 6) is.NoErr(err) @@ -49,8 +53,9 @@ func TestNewFromHistoryIncomplete3(t *testing.T) { DefaultConfig, "CSW19", board.CrosswordGameLayout, "english", game.CrossScoreOnly, "") is.NoErr(err) - gameHistory, err := gcgio.ParseGCG(DefaultConfig, "../gcgio/testdata/incomplete.gcg") + parsedGame, err := gcgio.ParseGCG(DefaultConfig, "../gcgio/testdata/incomplete.gcg") is.NoErr(err) + gameHistory := parsedGame.GenerateSerializableHistory() game, err := game.NewFromHistory(gameHistory, rules, 7) is.NoErr(err) @@ -63,13 +68,14 @@ func TestNewFromHistoryIncomplete4(t *testing.T) { DefaultConfig, "CSW19", board.CrosswordGameLayout, "english", game.CrossScoreOnly, "") is.NoErr(err) - gameHistory, err := gcgio.ParseGCG(DefaultConfig, "../gcgio/testdata/incomplete_elise.gcg") + parsedGame, err := gcgio.ParseGCG(DefaultConfig, "../gcgio/testdata/incomplete_elise.gcg") is.NoErr(err) + gameHistory := parsedGame.GenerateSerializableHistory() is.Equal(len(gameHistory.Events), 20) g, err := game.NewFromHistory(gameHistory, rules, 0) is.NoErr(err) - err = g.PlayToTurn(20) + err = g.PlayToTurn(20, gameHistory.LastKnownRacks) is.NoErr(err) // The Elise GCG is very malformed. Besides having play-through letters @@ -91,14 +97,15 @@ func TestNewFromHistoryIncomplete5(t *testing.T) { DefaultConfig, "CSW19", board.CrosswordGameLayout, "english", game.CrossScoreOnly, "") is.NoErr(err) - gameHistory, err := gcgio.ParseGCG(DefaultConfig, "../gcgio/testdata/incomplete.gcg") + parsedGame, err := gcgio.ParseGCG(DefaultConfig, "../gcgio/testdata/incomplete.gcg") is.NoErr(err) + gameHistory := parsedGame.GenerateSerializableHistory() is.Equal(len(gameHistory.Events), 20) g, err := game.NewFromHistory(gameHistory, rules, 0) is.NoErr(err) is.True(g != nil) - err = g.PlayToTurn(20) + err = g.PlayToTurn(20, gameHistory.LastKnownRacks) is.NoErr(err) is.Equal(g.Playing(), pb.PlayState_PLAYING) } @@ -109,14 +116,15 @@ func TestNewFromHistoryIncomplete6(t *testing.T) { DefaultConfig, "CSW19", board.CrosswordGameLayout, "english", game.CrossScoreOnly, "") is.NoErr(err) - gameHistory, err := gcgio.ParseGCG(DefaultConfig, "../gcgio/testdata/incomplete_3.gcg") + parsedGame, err := gcgio.ParseGCG(DefaultConfig, "../gcgio/testdata/incomplete_3.gcg") is.NoErr(err) + gameHistory := parsedGame.GenerateSerializableHistory() is.Equal(len(gameHistory.Events), 20) g, err := game.NewFromHistory(gameHistory, rules, 0) is.NoErr(err) is.True(g != nil) - err = g.PlayToTurn(20) + err = g.PlayToTurn(20, gameHistory.LastKnownRacks) is.NoErr(err) is.True(g.Playing() == pb.PlayState_PLAYING) is.Equal(g.RackLettersFor(0), "AEEIILZ") @@ -128,14 +136,15 @@ func TestNewFromHistoryIncomplete7(t *testing.T) { DefaultConfig, "CSW19", board.CrosswordGameLayout, "english", game.CrossScoreOnly, "") is.NoErr(err) - gameHistory, err := gcgio.ParseGCG(DefaultConfig, "../gcgio/testdata/incomplete4.gcg") + parsedGame, err := gcgio.ParseGCG(DefaultConfig, "../gcgio/testdata/incomplete4.gcg") is.NoErr(err) + gameHistory := parsedGame.GenerateSerializableHistory() is.Equal(len(gameHistory.Events), 5) g, err := game.NewFromHistory(gameHistory, rules, 0) is.NoErr(err) is.True(g != nil) - err = g.PlayToTurn(5) + err = g.PlayToTurn(5, gameHistory.LastKnownRacks) is.NoErr(err) is.True(g.Playing() == pb.PlayState_PLAYING) is.Equal(g.RackLettersFor(1), "AEEIILZ") diff --git a/gcgio/gcg.go b/gcgio/gcg.go index 33de83b6..032bd5be 100644 --- a/gcgio/gcg.go +++ b/gcgio/gcg.go @@ -103,8 +103,19 @@ var compiledEncodingRegexp *regexp.Regexp type parser struct { lastToken Token - history *pb.GameHistory - game *game.Game + // Pre-game metadata stored temporarily until game creation + players []*pb.PlayerInfo + lexiconName string + title string + description string + variant string + boardLayoutName string + letterDistributionName string + idAuth string + uid string + lastKnownRacks []string + + game *game.Game } // init initializes the regexp list. @@ -168,18 +179,30 @@ func (p *parser) addEventOrPragma(cfg *config.Config, token Token, match []strin if token == MoveToken || token == PassToken || token == ExchangeToken || token == Rack1Token || token == Rack2Token { // Start the game if we haven't already. - if len(p.history.Players) != 2 { + if len(p.players) != 2 { return errors.New("wrong number of players defined") } if p.game == nil { - if p.history.Lexicon == "" { - p.history.Lexicon = cfg.GetString(config.ConfigDefaultLexicon) + if p.lexiconName == "" { + p.lexiconName = cfg.GetString(config.ConfigDefaultLexicon) + } + // Use default values if not specified + boardLayout := p.boardLayoutName + if boardLayout == "" { + boardLayout = "CrosswordGame" + } + letterDistributionName := p.letterDistributionName + if letterDistributionName == "" { + letterDistributionName = "english" + } + variant := game.Variant(p.variant) + if variant == "" { + variant = game.VarClassic } - boardLayout, letterDistributionName, variant := game.HistoryToVariant(p.history) log.Info().Str("boardLayout", boardLayout). Str("letterDistributionName", letterDistributionName). - Str("lexicon", p.history.Lexicon). + Str("lexicon", p.lexiconName). Str("variant", string(variant)).Msg("creating game") // We have both players. Initialize a new game. @@ -191,22 +214,26 @@ func (p *parser) addEventOrPragma(cfg *config.Config, token Token, match []strin if err != nil { return err } - p.game, err = game.NewGame(rules, p.history.Players) + p.game, err = game.NewGame(rules, p.players) if err != nil { return err } p.game.StartGame() p.game.SetBackupMode(game.InteractiveGameplayMode) p.game.SetStateStackLength(1) - // And set the history to the gcg's history. - p.game.SetHistory(p.history) - p.history.PlayState = pb.PlayState_PLAYING + // Set metadata on the game + p.game.SetTitle(p.title) + p.game.SetDescription(p.description) + p.game.SetUID(p.uid) + p.game.SetIDAuth(p.idAuth) + // Default challenge rule - will be overridden at the end + p.game.SetChallengeRule(pb.ChallengeRule_SINGLE) } } switch token { case PlayerToken: - if len(p.history.Events) > 0 { + if p.game != nil { return errPragmaPrecedeEvent } pn, err := strconv.Atoi(match[1]) @@ -217,51 +244,69 @@ func (p *parser) addEventOrPragma(cfg *config.Config, token Token, match []strin return errPlayerNotSupported } if pn == 2 { - if match[2] == p.history.Players[0].Nickname { + if match[2] == p.players[0].Nickname { return errDuplicateNames } } - p.history.Players = append(p.history.Players, &pb.PlayerInfo{ + p.players = append(p.players, &pb.PlayerInfo{ Nickname: match[2], RealName: match[3], }) return nil case TitleToken: - if len(p.history.Events) > 0 { + if p.game != nil { return errPragmaPrecedeEvent } - p.history.Title = match[1] + p.title = match[1] return nil case DescriptionToken: - if len(p.history.Events) > 0 { + if p.game != nil { return errPragmaPrecedeEvent } - p.history.Description = match[1] + p.description = match[1] case IDToken: - if len(p.history.Events) > 0 { + if p.game != nil { return errPragmaPrecedeEvent } - p.history.IdAuth = match[1] - p.history.Uid = match[2] + p.idAuth = match[1] + p.uid = match[2] case Rack1Token: // assume if there is a rack2 token, that rack1 will come before it. - if p.history.LastKnownRacks == nil { - p.history.LastKnownRacks = []string{match[1], ""} + if len(p.lastKnownRacks) < 2 { + p.lastKnownRacks = []string{match[1], ""} + } else { + p.lastKnownRacks[0] = match[1] + } + // Set the rack on the game if it's already created + if p.game != nil { + rack := tilemapping.RackFromString(match[1], p.game.Alphabet()) + err := p.game.SetRackFor(0, rack) + if err != nil { + return err + } } case Rack2Token: - if p.history.LastKnownRacks == nil { - p.history.LastKnownRacks = []string{"", match[1]} + if len(p.lastKnownRacks) < 2 { + p.lastKnownRacks = []string{"", match[1]} } else { // There is already a rack1 at the [0] position. - p.history.LastKnownRacks[1] = match[1] + p.lastKnownRacks[1] = match[1] + } + // Set the rack on the game if it's already created + if p.game != nil { + rack := tilemapping.RackFromString(match[1], p.game.Alphabet()) + err := p.game.SetRackFor(1, rack) + if err != nil { + return err + } } case EncodingToken: return errEncodingWrongPlace case MoveToken: evt := &pb.GameEvent{} - evt.PlayerIndex, err = nickToPIndex(match[1], p.history.Players) + evt.PlayerIndex, err = nickToPIndex(match[1], p.players) if err != nil { return errPlayerDoesNotExist } @@ -293,48 +338,47 @@ func (p *parser) addEventOrPragma(cfg *config.Config, token Token, match []strin } evt.IsBingo = tp == game.RackTileLimit - p.history.Events = append(p.history.Events, evt) - // Try playing the move + // Try playing the move - this will add the event to the game's events array log.Debug().Msg("PLAYING LATEST EVENT for MoveToken") - return p.game.PlayLatestEvent() + return p.game.PlayEvent(evt) case NoteToken: - lastEvtIdx := len(p.history.Events) - 1 - if lastEvtIdx < 0 { + // Add note to the last event in the game + if p.game.GetLastEvent() == nil { log.Warn().Msg("note pragma may not precede events") } else { - p.history.Events[lastEvtIdx].Note += strings.TrimSpace(match[1]) + p.game.AppendNoteToLastEvent(strings.TrimSpace(match[1])) } return nil case LexiconToken: - if len(p.history.Events) > 0 { + if p.game != nil { return errPragmaPrecedeEvent } - p.history.Lexicon = match[1] + p.lexiconName = match[1] return nil case BoardLayoutToken: - if len(p.history.Events) > 0 { + if p.game != nil { return errPragmaPrecedeEvent } - p.history.BoardLayout = match[1] + p.boardLayoutName = match[1] return nil case TileDistributionNameToken: - if len(p.history.Events) > 0 { + if p.game != nil { return errPragmaPrecedeEvent } - p.history.LetterDistribution = match[1] + p.letterDistributionName = match[1] return nil case GameTypeToken: - if len(p.history.Events) > 0 { + if p.game != nil { return errPragmaPrecedeEvent } - p.history.Variant = match[1] + p.variant = match[1] return nil // need to handle continuation as well as the actual tileSet or gameBoard pragmas. case PhonyTilesReturnedToken: evt := &pb.GameEvent{} - evt.PlayerIndex, err = nickToPIndex(match[1], p.history.Players) + evt.PlayerIndex, err = nickToPIndex(match[1], p.players) if err != nil { return errPlayerDoesNotExist } @@ -350,18 +394,18 @@ func (p *parser) addEventOrPragma(cfg *config.Config, token Token, match []strin return err } // The PlayedTiles attribute should be set to the LAST event's played tiles - if len(p.history.Events) == 0 { + lastEvent := p.game.GetLastEvent() + if lastEvent == nil { return errors.New("malformed gcg; phony tiles returned without play") } - evt.PlayedTiles = p.history.Events[len(p.history.Events)-1].PlayedTiles + evt.PlayedTiles = lastEvent.PlayedTiles evt.Type = pb.GameEvent_PHONY_TILES_RETURNED - p.history.Events = append(p.history.Events, evt) log.Debug().Msg("PLAYING LATEST EVENT for PhonytilesReturned") - return p.game.PlayLatestEvent() + return p.game.PlayEvent(evt) case TimePenaltyToken: evt := &pb.GameEvent{} - evt.PlayerIndex, err = nickToPIndex(match[1], p.history.Players) + evt.PlayerIndex, err = nickToPIndex(match[1], p.players) if err != nil { return errPlayerDoesNotExist } @@ -382,17 +426,16 @@ func (p *parser) addEventOrPragma(cfg *config.Config, token Token, match []strin // (i.e. player2 goes out, and then time penalty is applied to player1) evt.Type = pb.GameEvent_TIME_PENALTY - p.history.Events = append(p.history.Events, evt) p.game.SetPlaying(pb.PlayState_GAME_OVER) - err = p.game.PlayLatestEvent() + err = p.game.PlayEvent(evt) if err != nil { return err } case LastRackPenaltyToken: evt := &pb.GameEvent{} - evt.PlayerIndex, err = nickToPIndex(match[1], p.history.Players) + evt.PlayerIndex, err = nickToPIndex(match[1], p.players) if err != nil { return errPlayerDoesNotExist } @@ -410,8 +453,7 @@ func (p *parser) addEventOrPragma(cfg *config.Config, token Token, match []strin return err } evt.Type = pb.GameEvent_END_RACK_PENALTY - p.history.Events = append(p.history.Events, evt) - err = p.game.PlayLatestEvent() + err = p.game.PlayEvent(evt) // End the game. p.game.SetPlaying(pb.PlayState_GAME_OVER) if err != nil { @@ -420,7 +462,7 @@ func (p *parser) addEventOrPragma(cfg *config.Config, token Token, match []strin case PassToken: evt := &pb.GameEvent{} - evt.PlayerIndex, err = nickToPIndex(match[1], p.history.Players) + evt.PlayerIndex, err = nickToPIndex(match[1], p.players) if err != nil { return errPlayerDoesNotExist } @@ -430,12 +472,11 @@ func (p *parser) addEventOrPragma(cfg *config.Config, token Token, match []strin return err } evt.Type = pb.GameEvent_PASS - p.history.Events = append(p.history.Events, evt) - return p.game.PlayLatestEvent() + return p.game.PlayEvent(evt) case ChallengeBonusToken, EndRackPointsToken: evt := &pb.GameEvent{} - evt.PlayerIndex, err = nickToPIndex(match[1], p.history.Players) + evt.PlayerIndex, err = nickToPIndex(match[1], p.players) if err != nil { return errPlayerDoesNotExist } @@ -459,12 +500,11 @@ func (p *parser) addEventOrPragma(cfg *config.Config, token Token, match []strin // End the game. p.game.SetPlaying(pb.PlayState_GAME_OVER) } - p.history.Events = append(p.history.Events, evt) - return p.game.PlayLatestEvent() + return p.game.PlayEvent(evt) case ExchangeToken: evt := &pb.GameEvent{} - evt.PlayerIndex, err = nickToPIndex(match[1], p.history.Players) + evt.PlayerIndex, err = nickToPIndex(match[1], p.players) if err != nil { return errPlayerDoesNotExist } @@ -485,8 +525,7 @@ func (p *parser) addEventOrPragma(cfg *config.Config, token Token, match []strin return err } evt.Type = pb.GameEvent_EXCHANGE - p.history.Events = append(p.history.Events, evt) - return p.game.PlayLatestEvent() + return p.game.PlayEvent(evt) case TileDeclarationToken: // for now, just ignore this token. We're going to go by the letter @@ -516,8 +555,7 @@ func (p *parser) parseLine(cfg *config.Config, line string) error { if !foundMatch { // maybe it's a multi-line note. if p.lastToken == NoteToken { - lastEventIdx := len(p.history.Events) - 1 - p.history.Events[lastEventIdx].Note += ("\n" + line) + p.game.AppendNoteToLastEvent("\n" + line) return nil } // ignore empty lines @@ -568,16 +606,11 @@ func encodingOrFirstLine(reader io.Reader) (string, string, error) { } } -func ParseGCGFromReader(cfg *config.Config, reader io.Reader) (*pb.GameHistory, error) { +func ParseGCGFromReader(cfg *config.Config, reader io.Reader) (*game.Game, error) { var err error parser := &parser{ - history: &pb.GameHistory{ - Events: []*pb.GameEvent{}, - Players: []*pb.PlayerInfo{}, - // We are making the challenge rule anything but VOID, which would - // check the validity of every play. - ChallengeRule: pb.ChallengeRule_SINGLE, - Version: game.CurrentGameHistoryVersion}, + players: []*pb.PlayerInfo{}, + lastKnownRacks: []string{"", ""}, } originalGCG := "" @@ -612,20 +645,21 @@ func ParseGCGFromReader(cfg *config.Config, reader io.Reader) (*pb.GameHistory, } originalGCG += line + "\n" } - parser.history.OriginalGcg = strings.TrimSpace(originalGCG) + + // Store the original GCG content in the game + parser.game.SetOriginalGcg(originalGCG) // Determine if the game ended. if parser.game.Playing() == pb.PlayState_GAME_OVER { - parser.history.PlayState = pb.PlayState_GAME_OVER parser.game.AddFinalScoresToHistory() } // Set challenge rule back to void since we don't know or care what it is. - parser.history.ChallengeRule = pb.ChallengeRule_VOID - return parser.history, nil + parser.game.SetChallengeRule(pb.ChallengeRule_VOID) + return parser.game, nil } -// ParseGCG parses a GCG file into a GameHistory. -func ParseGCG(cfg *config.Config, filename string) (*pb.GameHistory, error) { +// ParseGCG parses a GCG file into a Game. +func ParseGCG(cfg *config.Config, filename string) (*game.Game, error) { f, _, err := cache.Open(filename) if err != nil { return nil, err @@ -755,6 +789,11 @@ func isPassBeforeEndRackPoints(h *pb.GameHistory, i int) bool { h.Events[i+1].Type == pb.GameEvent_END_RACK_PTS } +// GameToGCG returns a string GCG representation of the Game. +func GameToGCG(g *game.Game, addlHeaderInfo bool) (string, error) { + return GameHistoryToGCG(g.GenerateSerializableHistory(), addlHeaderInfo) +} + // GameHistoryToGCG returns a string GCG representation of the GameHistory. func GameHistoryToGCG(h *pb.GameHistory, addlHeaderInfo bool) (string, error) { if h.StartingCgp != "" { diff --git a/gcgio/gcg_test.go b/gcgio/gcg_test.go index 8aeb410a..3ee88f64 100644 --- a/gcgio/gcg_test.go +++ b/gcgio/gcg_test.go @@ -3,8 +3,6 @@ package gcgio import ( "encoding/json" "flag" - "io/ioutil" - "log" "os" "path/filepath" "strings" @@ -12,6 +10,7 @@ import ( "github.com/domino14/word-golib/tilemapping" "github.com/matryer/is" + "github.com/rs/zerolog/log" "github.com/stretchr/testify/assert" "github.com/domino14/macondo/board" @@ -30,9 +29,9 @@ func init() { } func slurp(filename string) string { - contents, err := ioutil.ReadFile(filename) + contents, err := os.ReadFile(filename) if err != nil { - log.Fatal(err) + panic(err) } return string(contents) } @@ -66,9 +65,10 @@ func TestParseGCGs(t *testing.T) { } for _, tc := range testcases { - history, err := ParseGCG(DefaultConfig, filepath.Join("testdata", tc.gcgfile)) + game, err := ParseGCG(DefaultConfig, filepath.Join("testdata", tc.gcgfile)) assert.Nil(t, err) - assert.NotNil(t, history) + assert.NotNil(t, game) + history := game.GenerateSerializableHistory() history.Lexicon = tc.lexicon repr, err := json.MarshalIndent(history, "", " ") @@ -79,48 +79,53 @@ func TestParseGCGs(t *testing.T) { } func TestParseSpecialChar(t *testing.T) { - history, err := ParseGCG(DefaultConfig, "./testdata/name_iso8859-1.gcg") + game, err := ParseGCG(DefaultConfig, "./testdata/name_iso8859-1.gcg") assert.Nil(t, err) - assert.NotNil(t, history) + assert.NotNil(t, game) + history := game.GenerateSerializableHistory() assert.Equal(t, "césar", history.Players[0].Nickname) assert.Equal(t, "hércules", history.Players[1].Nickname) } func TestParseSpecialUTF8NoHeader(t *testing.T) { - history, err := ParseGCG(DefaultConfig, "./testdata/name_utf8_noheader.gcg") + game, err := ParseGCG(DefaultConfig, "./testdata/name_utf8_noheader.gcg") assert.Nil(t, err) - assert.NotNil(t, history) + assert.NotNil(t, game) + history := game.GenerateSerializableHistory() // Since there was no encoding header, the name gets all messed up: assert.Equal(t, "césar", history.Players[0].Nickname) } func TestParseSpecialUTF8WithHeader(t *testing.T) { - history, err := ParseGCG(DefaultConfig, "./testdata/name_utf8_with_header.gcg") + game, err := ParseGCG(DefaultConfig, "./testdata/name_utf8_with_header.gcg") assert.Nil(t, err) - assert.NotNil(t, history) + assert.NotNil(t, game) + history := game.GenerateSerializableHistory() assert.Equal(t, "césar", history.Players[0].Nickname) } func TestParseUnsupportedEncoding(t *testing.T) { - history, err := ParseGCG(DefaultConfig, "./testdata/name_weird_encoding_with_header.gcg") + game, err := ParseGCG(DefaultConfig, "./testdata/name_weird_encoding_with_header.gcg") assert.NotNil(t, err) - assert.Nil(t, history) + assert.Nil(t, game) } func TestParseDOSMode(t *testing.T) { // file has CRLF carriage returns. we should handle it. - history, err := ParseGCG(DefaultConfig, "./testdata/utf8_dos.gcg") + game, err := ParseGCG(DefaultConfig, "./testdata/utf8_dos.gcg") assert.Nil(t, err) - assert.NotNil(t, history) + assert.NotNil(t, game) + history := game.GenerateSerializableHistory() assert.Equal(t, "angwantibo", history.Players[0].Nickname) assert.Equal(t, "Michal_Josko", history.Players[1].Nickname) } func TestToGCG(t *testing.T) { - history, err := ParseGCG(DefaultConfig, "./testdata/doug_v_emely.gcg") + game, err := ParseGCG(DefaultConfig, "./testdata/doug_v_emely.gcg") assert.Nil(t, err) - assert.NotNil(t, history) + assert.NotNil(t, game) + history := game.GenerateSerializableHistory() gcgstr, err := GameHistoryToGCG(history, false) assert.Nil(t, err) @@ -147,16 +152,21 @@ func TestNewFromHistoryExcludePenultimatePass(t *testing.T) { "") is.NoErr(err) - gameHistory, err := ParseGCG(DefaultConfig, "./testdata/guy_vs_bot_almost_complete.gcg") + parsedGame, err := ParseGCG(DefaultConfig, "./testdata/guy_vs_bot_almost_complete.gcg") is.NoErr(err) + gameHistory := parsedGame.GenerateSerializableHistory() is.Equal(len(gameHistory.Events), 25) - g, err := game.NewFromHistory(gameHistory, rules, 0) - alph := g.Alphabet() - g.SetChallengeRule(pb.ChallengeRule_DOUBLE) + g, err := game.NewFromHistory(gameHistory, rules, 25) is.NoErr(err) is.True(g != nil) - err = g.PlayToTurn(25) + alph := g.Alphabet() + g.SetChallengeRule(pb.ChallengeRule_DOUBLE) + // XXX: The problem below is that the history is generated from the + // game at turn zero (see NewFromHistory call above). So the last known + // racks in that call are the first turn racks. + history := g.GenerateSerializableHistory() + err = g.PlayToTurn(25, history.LastKnownRacks) is.NoErr(err) is.True(g.Playing() == pb.PlayState_PLAYING) is.Equal(g.RackLettersFor(1), "U") @@ -175,7 +185,8 @@ func TestNewFromHistoryExcludePenultimatePass(t *testing.T) { err = g.PlayMove(m, true, 0) is.NoErr(err) - gcgstr, err := GameHistoryToGCG(g.History(), false) + history = g.GenerateSerializableHistory() + gcgstr, err := GameHistoryToGCG(history, false) assert.Nil(t, err) // ignore encoding line: @@ -199,16 +210,21 @@ func TestNewFromHistoryExcludePenultimateChallengeTurnLoss(t *testing.T) { "") is.NoErr(err) - gameHistory, err := ParseGCG(DefaultConfig, "./testdata/guy_vs_bot_almost_complete.gcg") + parsedGame, err := ParseGCG(DefaultConfig, "./testdata/guy_vs_bot_almost_complete.gcg") is.NoErr(err) + gameHistory := parsedGame.GenerateSerializableHistory() is.Equal(len(gameHistory.Events), 25) + log.Info().Interface("gameHistory", gameHistory).Msg("generated-gh") - g, err := game.NewFromHistory(gameHistory, rules, 0) + g, err := game.NewFromHistory(gameHistory, rules, 25) alph := g.Alphabet() g.SetChallengeRule(pb.ChallengeRule_DOUBLE) is.NoErr(err) is.True(g != nil) - err = g.PlayToTurn(25) + history := g.GenerateSerializableHistory() + log.Info().Interface("history2", history).Msg("generated-gh-2") + + err = g.PlayToTurn(25, history.LastKnownRacks) is.NoErr(err) is.True(g.Playing() == pb.PlayState_PLAYING) is.Equal(g.RackLettersFor(1), "U") @@ -227,7 +243,8 @@ func TestNewFromHistoryExcludePenultimateChallengeTurnLoss(t *testing.T) { err = g.PlayMove(m, true, 0) is.NoErr(err) - gcgstr, err := GameHistoryToGCG(g.History(), false) + history = g.GenerateSerializableHistory() + gcgstr, err := GameHistoryToGCG(history, false) assert.Nil(t, err) // ignore encoding line: @@ -245,8 +262,8 @@ func TestDuplicateNicknames(t *testing.T) { #player1 dougie Doungy B #player2 dougie Cesar D >dougie: FOO 8D FOO +12 12`) - history, err := ParseGCGFromReader(DefaultConfig, reader) - assert.Nil(t, history) + game, err := ParseGCGFromReader(DefaultConfig, reader) + assert.Nil(t, game) assert.Equal(t, errDuplicateNames, err) } @@ -256,8 +273,8 @@ func TestPragmaWrongPlace(t *testing.T) { #player2 cesar Cesar D >dougie: FOO 8H FOO +12 12 #lexicon OSPD4`) - history, err := ParseGCGFromReader(DefaultConfig, reader) - assert.Nil(t, history) + game, err := ParseGCGFromReader(DefaultConfig, reader) + assert.Nil(t, game) assert.Equal(t, errPragmaPrecedeEvent, err) } @@ -269,8 +286,9 @@ func TestIsBingo(t *testing.T) { >dougie: FOODIES 8D FOODIES +80 80 >cesar: ABCDEFG D7 E. +5 5 `) - history, err := ParseGCGFromReader(DefaultConfig, reader) + game, err := ParseGCGFromReader(DefaultConfig, reader) assert.Nil(t, err) + history := game.GenerateSerializableHistory() assert.True(t, history.Events[0].IsBingo) assert.False(t, history.Events[1].IsBingo) } diff --git a/gcgio/testdata/doug_v_emely.json b/gcgio/testdata/doug_v_emely.json index f2b1d1d8..e5413a0b 100644 --- a/gcgio/testdata/doug_v_emely.json +++ b/gcgio/testdata/doug_v_emely.json @@ -398,11 +398,18 @@ } ], "version": 2, - "original_gcg": "#player1 doug doug\n#player2 emely emely\n\u003edoug: DINNVWY 8D WINDY +32 32\n\u003eemely: ADEEGIL 7C GALE +16 16\n\u003edoug: AEJNOSV E3 JAVE..N +34 66\n\u003eemely: DEILOVX F2 VOX +39 55\n\u003edoug: ADENOST 10B DONATES +82 148\n\u003eemely: DEIILTZ 4B TIL.. +24 79\n\u003eemely: DEIILTZ -- -24 55\n\u003edoug: AAEINRU 9G EAU +16 164\n\u003eemely: DEIILTZ 4D Z.. +38 93\n\u003edoug: AILNORT A5 LATINO +27 191\n\u003eemely: DEEIILT B2 TEIID +29 122\n\u003edoug: ?BDERUW A1 WEB +30 221\n\u003eemely: AELLNST 11E SAT +51 173\n\u003edoug: ?DINRRU 6D R.D +22 243\n\u003eemely: ACELLMN C1 CAN +23 196\n\u003edoug: ?EFINRU B8 FU. +14 257\n\u003eemely: EILLMRR 7H RILL +12 208\n\u003edoug: ?EIINOR K5 RE.IgION +78 335\n\u003eemely: EKMORRU L11 MURK +28 236\n\u003edoug: AEIOORS 15H ARIOSE +33 368\n\u003eemely: ?CEORUY 14F COY +19 255\n\u003edoug: EGHMOPT L4 GET +12 380\n\u003eemely: ?BERSTU 2F .ERB +9 264\n\u003edoug: AEHIMOP 1G PEA +29 409\n\u003eemely: ?HOQSTU M2 QUOTH +46 310\n\u003edoug: EGHIMOP N1 HIM +42 451\n\u003eemely: ?FS 14L .aFS +21 331\n\u003eemely: (OPEG) +14 345", + "original_gcg": "#player1 doug doug\n#player2 emely emely\n\u003edoug: DINNVWY 8D WINDY +32 32\n\u003eemely: ADEEGIL 7C GALE +16 16\n\u003edoug: AEJNOSV E3 JAVE..N +34 66\n\u003eemely: DEILOVX F2 VOX +39 55\n\u003edoug: ADENOST 10B DONATES +82 148\n\u003eemely: DEIILTZ 4B TIL.. +24 79\n\u003eemely: DEIILTZ -- -24 55\n\u003edoug: AAEINRU 9G EAU +16 164\n\u003eemely: DEIILTZ 4D Z.. +38 93\n\u003edoug: AILNORT A5 LATINO +27 191\n\u003eemely: DEEIILT B2 TEIID +29 122\n\u003edoug: ?BDERUW A1 WEB +30 221\n\u003eemely: AELLNST 11E SAT +51 173\n\u003edoug: ?DINRRU 6D R.D +22 243\n\u003eemely: ACELLMN C1 CAN +23 196\n\u003edoug: ?EFINRU B8 FU. +14 257\n\u003eemely: EILLMRR 7H RILL +12 208\n\u003edoug: ?EIINOR K5 RE.IgION +78 335\n\u003eemely: EKMORRU L11 MURK +28 236\n\u003edoug: AEIOORS 15H ARIOSE +33 368\n\u003eemely: ?CEORUY 14F COY +19 255\n\u003edoug: EGHMOPT L4 GET +12 380\n\u003eemely: ?BERSTU 2F .ERB +9 264\n\u003edoug: AEHIMOP 1G PEA +29 409\n\u003eemely: ?HOQSTU M2 QUOTH +46 310\n\u003edoug: EGHIMOP N1 HIM +42 451\n\u003eemely: ?FS 14L .aFS +21 331\n\u003eemely: (OPEG) +14 345\n", "lexicon": "NWL18", + "last_known_racks": [ + "EGOP", + "" + ], "play_state": 2, "final_scores": [ 451, 345 - ] + ], + "variant": "classic", + "board_layout": "CrosswordGame", + "letter_distribution": "english" } \ No newline at end of file diff --git a/gcgio/testdata/josh2.json b/gcgio/testdata/josh2.json index 60da08b7..99a4ad81 100644 --- a/gcgio/testdata/josh2.json +++ b/gcgio/testdata/josh2.json @@ -441,11 +441,18 @@ } ], "version": 2, - "original_gcg": "#player1 jvc jvc\n#player2 Paula Paula\n\u003ejvc: DILOTWY 8H DOWLY +32 32\n\u003ejvc: DIMSTTW (challenge) +5 37\n\u003ePaula: IOP 9I POI +22 22\n\u003ejvc: DIMSTTW 7G MITT +25 62\n#note K5 MID(LI)ST #knowledgemedium\n\u003ePaula: EKZ 6F ZEK +48 70\n\u003ejvc: BDGNOSW L4 DOWN. +24 86\n\u003ePaula: AEMT K3 TAME +28 98\n\u003ejvc: ?ABGLSU 10D ALBUGoS +81 167\n\u003ejvc: CGILLRS (challenge) +5 172\n\u003ePaula: AHO 11D HAO +25 123\n\u003ejvc: CGILLRS 12C CIG +28 200\n#note Wow. 12C LIG is about 5 points better. I completely misevaluated the leave here. #tacticsSADDER\n\u003ePaula: EJTU J2 JUTE +38 161\n\u003ejvc: LLNRSUY H10 .ULLY +10 210\n\u003ePaula: EEEEEEE -E +0 161\n\u003ejvc: AEHNORS 13F HA.ON +10 220\n#note Didn't know what to do here, but I'm fairly certain none of the equity plays are correct after an exchange 1. This does okay in a sim, 80% as opposed to 84% for the best equity plays, so I'm alright with this for the defensive value. If you see something I missed let me know.\n\u003ePaula: ADEIRT 14B DIETAR. +35 196\n\u003ejvc: AEINORS 15A EINA +23 243\n\u003ePaula: O C12 .O.. +12 208\n\u003ejvc: EOQRSUX 9F OX +39 282\n\u003ePaula: FIR F3 FRI. +16 224\n\u003ejvc: AEQRRSU 4B SQUA.ER +34 316\n#note 4A QUARE(R) is a #visionSADDEST. I saw 4C QUA(R)ER but I was in such a defensive mindset I didn't think to hang anything in the triple line, especially after two 1 tile plays. I decided to put the S on this so she can't hook it and is forced to find eights.\n\u003ePaula: ?DEENOP B2 DE.PONEd +74 298\n\u003ePaula: ?DEENOP -- -74 224\n\u003ejvc: CENNRRT D3 C.NNER +9 325\n\u003ejvc: AEIIRRT (challenge) +5 330\n\u003ePaula: E 6K ..E +6 230\n\u003ejvc: AEIIRRT N1 AIRIER +25 355\n#note I thought EWER didn't take an S. SAD! Although, even when EWERS is a word, I think this is still the best play.\n\u003ePaula: F 4N .F +9 239\n\u003ejvc: AEGISTV 8A VAI. +21 376\n#note I think I'm blocking the last line. #KNOWLEDGESADDEST\n\u003ePaula: ?DEENOP O6 sPEEDO +31 270\n\u003ePaula: N (challenge) +5 275\n\u003ejvc: BEGSSTV 1M V.G +21 397\n#note Didn't know fricking EWERS. Feels like SADDEST but since it's only 5 points. #knowledgesad and also 1K VEG(A)S. I was completely out of time. #timeSADDEST\n\u003ePaula: N 11N N. +2 277\n\u003ePaula: (BESST) +14 291", + "original_gcg": "#player1 jvc jvc\n#player2 Paula Paula\n\u003ejvc: DILOTWY 8H DOWLY +32 32\n\u003ejvc: DIMSTTW (challenge) +5 37\n\u003ePaula: IOP 9I POI +22 22\n\u003ejvc: DIMSTTW 7G MITT +25 62\n#note K5 MID(LI)ST #knowledgemedium\n\u003ePaula: EKZ 6F ZEK +48 70\n\u003ejvc: BDGNOSW L4 DOWN. +24 86\n\u003ePaula: AEMT K3 TAME +28 98\n\u003ejvc: ?ABGLSU 10D ALBUGoS +81 167\n\u003ejvc: CGILLRS (challenge) +5 172\n\u003ePaula: AHO 11D HAO +25 123\n\u003ejvc: CGILLRS 12C CIG +28 200\n#note Wow. 12C LIG is about 5 points better. I completely misevaluated the leave here. #tacticsSADDER\n\u003ePaula: EJTU J2 JUTE +38 161\n\u003ejvc: LLNRSUY H10 .ULLY +10 210\n\u003ePaula: EEEEEEE -E +0 161\n\u003ejvc: AEHNORS 13F HA.ON +10 220\n#note Didn't know what to do here, but I'm fairly certain none of the equity plays are correct after an exchange 1. This does okay in a sim, 80% as opposed to 84% for the best equity plays, so I'm alright with this for the defensive value. If you see something I missed let me know.\n\u003ePaula: ADEIRT 14B DIETAR. +35 196\n\u003ejvc: AEINORS 15A EINA +23 243\n\u003ePaula: O C12 .O.. +12 208\n\u003ejvc: EOQRSUX 9F OX +39 282\n\u003ePaula: FIR F3 FRI. +16 224\n\u003ejvc: AEQRRSU 4B SQUA.ER +34 316\n#note 4A QUARE(R) is a #visionSADDEST. I saw 4C QUA(R)ER but I was in such a defensive mindset I didn't think to hang anything in the triple line, especially after two 1 tile plays. I decided to put the S on this so she can't hook it and is forced to find eights.\n\u003ePaula: ?DEENOP B2 DE.PONEd +74 298\n\u003ePaula: ?DEENOP -- -74 224\n\u003ejvc: CENNRRT D3 C.NNER +9 325\n\u003ejvc: AEIIRRT (challenge) +5 330\n\u003ePaula: E 6K ..E +6 230\n\u003ejvc: AEIIRRT N1 AIRIER +25 355\n#note I thought EWER didn't take an S. SAD! Although, even when EWERS is a word, I think this is still the best play.\n\u003ePaula: F 4N .F +9 239\n\u003ejvc: AEGISTV 8A VAI. +21 376\n#note I think I'm blocking the last line. #KNOWLEDGESADDEST\n\u003ePaula: ?DEENOP O6 sPEEDO +31 270\n\u003ePaula: N (challenge) +5 275\n\u003ejvc: BEGSSTV 1M V.G +21 397\n#note Didn't know fricking EWERS. Feels like SADDEST but since it's only 5 points. #knowledgesad and also 1K VEG(A)S. I was completely out of time. #timeSADDEST\n\u003ePaula: N 11N N. +2 277\n\u003ePaula: (BESST) +14 291\n", "lexicon": "CSW19", + "last_known_racks": [ + "BESST", + "" + ], "play_state": 2, "final_scores": [ 397, 291 - ] + ], + "variant": "classic", + "board_layout": "CrosswordGame", + "letter_distribution": "english" } \ No newline at end of file diff --git a/gcgio/testdata/vs_andy.json b/gcgio/testdata/vs_andy.json index a5495516..da6a1fb0 100644 --- a/gcgio/testdata/vs_andy.json +++ b/gcgio/testdata/vs_andy.json @@ -378,11 +378,18 @@ } ], "version": 2, - "original_gcg": "#player1 andy andy\n#player2 cesar cesar\n\u003eandy: GPY 8G GYP +18 18\n\u003ecesar: AAIRTUZ 7F ZA +16 16\n#note -3.5 seems like 7H AURA or 7I UTA are good. i did consider keeping the Z, but i couldn't think of a good play. but AIRTU kind of sucks, too.\n\u003eandy: EHTW 6G THEW +22 40\n\u003ecesar: AIMRTTU 5I MAUT +21 37\n\u003eandy: JLO L2 JOL. +22 62\n\u003ecesar: FIIORTT M1 TORI +23 60\n\u003eandy: BDE N2 BED +43 105\n\u003ecesar: FIILRST 7J LIFT +14 74\n\u003eandy: ABEIIRX -IIAB +0 105\n\u003ecesar: ?IINORS O4 ORIgINS +77 151\n#note i missed SORdINI. SIgNIOR is better defensively too, i wasn't 100% on it. -4.5\n\u003eandy: ?DEHRRU 10H DRUtHER. +65 170\n\u003ecesar: ABELMOO 11J MOOL +25 176\n#note i should not try to play defense when i'm down. how many times do i have to tell myself that? ABEL is better than ABE too. MOO is definitely better than my move. DOABLE is best which i just missed. (-6.5)\n\u003eandy: ACEIT H10 .ACITE +36 206\n\u003ecesar: ABEFGIN 12L EF +28 204\n\u003eandy: AEEGISS 5A AEGISES +71 277\n\u003ecesar: AABGINO A4 B.GNIO +36 240\n#note quackle thinks i should go for the leave by playing 4A BOA. it is probably right. (-2)\n\u003eandy: UV C3 VU. +14 291\n\u003ecesar: AAACESW 1M .AW +25 265\n\u003eandy: AUV 14F VA.U +15 306\n\u003ecesar: AACDEES 9M DA. +18 283\n#note damn vatu. DAMN IT. stupid game. i missed MOOLA, N9 ERA seems to be good there because it also sets up DELFS or something like that that i may need. DAN's leave isn't strong enough. -7\n\u003eandy: EENRTX B9 EXTERN +60 366\n\u003ecesar: ACEELPS 15A CEP +35 318\n\u003eandy: KNO 12A K.NO +26 392\n\u003ecesar: AAELNSY 13B .AY +21 339\n#note no chance he's gonna leave it open. raya. -7\n\u003eandy: DEIINQR 6N Q. +31 423\n#note 15G D(E)NIER to block my out.\n\u003ecesar: AELNOS 15H .NOLASE +10 349\n#note SOL is better than going out. -4\n\u003ecesar: (DEINIR) +14 363", + "original_gcg": "#player1 andy andy\n#player2 cesar cesar\n\u003eandy: GPY 8G GYP +18 18\n\u003ecesar: AAIRTUZ 7F ZA +16 16\n#note -3.5 seems like 7H AURA or 7I UTA are good. i did consider keeping the Z, but i couldn't think of a good play. but AIRTU kind of sucks, too.\n\u003eandy: EHTW 6G THEW +22 40\n\u003ecesar: AIMRTTU 5I MAUT +21 37\n\u003eandy: JLO L2 JOL. +22 62\n\u003ecesar: FIIORTT M1 TORI +23 60\n\u003eandy: BDE N2 BED +43 105\n\u003ecesar: FIILRST 7J LIFT +14 74\n\u003eandy: ABEIIRX -IIAB +0 105\n\u003ecesar: ?IINORS O4 ORIgINS +77 151\n#note i missed SORdINI. SIgNIOR is better defensively too, i wasn't 100% on it. -4.5\n\u003eandy: ?DEHRRU 10H DRUtHER. +65 170\n\u003ecesar: ABELMOO 11J MOOL +25 176\n#note i should not try to play defense when i'm down. how many times do i have to tell myself that? ABEL is better than ABE too. MOO is definitely better than my move. DOABLE is best which i just missed. (-6.5)\n\u003eandy: ACEIT H10 .ACITE +36 206\n\u003ecesar: ABEFGIN 12L EF +28 204\n\u003eandy: AEEGISS 5A AEGISES +71 277\n\u003ecesar: AABGINO A4 B.GNIO +36 240\n#note quackle thinks i should go for the leave by playing 4A BOA. it is probably right. (-2)\n\u003eandy: UV C3 VU. +14 291\n\u003ecesar: AAACESW 1M .AW +25 265\n\u003eandy: AUV 14F VA.U +15 306\n\u003ecesar: AACDEES 9M DA. +18 283\n#note damn vatu. DAMN IT. stupid game. i missed MOOLA, N9 ERA seems to be good there because it also sets up DELFS or something like that that i may need. DAN's leave isn't strong enough. -7\n\u003eandy: EENRTX B9 EXTERN +60 366\n\u003ecesar: ACEELPS 15A CEP +35 318\n\u003eandy: KNO 12A K.NO +26 392\n\u003ecesar: AAELNSY 13B .AY +21 339\n#note no chance he's gonna leave it open. raya. -7\n\u003eandy: DEIINQR 6N Q. +31 423\n#note 15G D(E)NIER to block my out.\n\u003ecesar: AELNOS 15H .NOLASE +10 349\n#note SOL is better than going out. -4\n\u003ecesar: (DEINIR) +14 363\n", "lexicon": "TWL06", + "last_known_racks": [ + "DEIINR", + "" + ], "play_state": 2, "final_scores": [ 423, 363 - ] + ], + "variant": "classic", + "board_layout": "CrosswordGame", + "letter_distribution": "english" } \ No newline at end of file diff --git a/gcgio/testdata/vs_frentz.json b/gcgio/testdata/vs_frentz.json index e8f4fc7c..8e4e4c6d 100644 --- a/gcgio/testdata/vs_frentz.json +++ b/gcgio/testdata/vs_frentz.json @@ -360,12 +360,19 @@ } ], "version": 2, - "original_gcg": "#player1 cesar cesar\n#player2 frentz frentz\n\u003ecesar: ?AACDER 8D CRAAlED +74 74\n#note an auspicious beginning. as a side note, i almost hate being obviously lucky as much as i hate being unlucky. caldera is better because it doesn't expose the vowels.\n\u003efrentz: DEENOSW E2 ENDOWE.S +74 74\n#note ok good, now i can stop feeling bad about being lucky!\n\u003ecesar: AABEIIW D4 AWA +28 102\n#note couldn't pull the trigger on WAI# unfortunately. wasn't sure if it was that or my friend Wei. (-8)\n\u003efrentz: KNOO F2 NOOK +30 104\n\u003ecesar: BEGIIJX 9G XI +35 137\n#note quackle also likes this better than the 37 pointer\n\u003efrentz: EPY 10F YEP +30 134\n\u003ecesar: BEFGIIJ 11C JIBE +31 168\n\u003efrentz: AEFS 12B SAFE +37 171\n\u003ecesar: FGIIIOU 13C IF +39 207\n#note unfortunately i don't know collins strategy enough to know if keeping the horrible leave for 39 points is worth it, but an exchange is too far behind. now JAI# i remember. it's still not a word.\n\u003efrentz: GLU 14A GUL +19 190\n\u003ecesar: EGIIORU 11H EUOI +13 220\n#note ourie is better, but i wasn't sure enough of LIPO#. this 5-pt challenge is pretty lame by the way. (-1.5)\n\u003efrentz: EEILRST 15C STERILE +86 276\n#note dammit\n\u003ecesar: EGILORR 10J GOR +17 237\n\u003efrentz: MTU 14E TUM +17 293\n\u003ecesar: EIILNRV 3E ..NVIRILE +78 315\n\u003ecesar: ADDIPYZ (challenge) +5 320\n#note that took guts!!\n\u003efrentz: ?ABCEER 13G ACErBER +80 373\n#note unfortunately, thanks to the lame challenge rule i don't get a chance to come back a bit more\n\u003ecesar: ADDIPYZ H1 DA.Y +45 365\n\u003efrentz: GOUV L1 VU.GO +26 399\n\u003ecesar: DINPTTZ K5 ZIT +46 411\n#note what do you guys think? PUTZ may be a tiny bit better because of the leave. the pool is clunky. this is an interesting move. i just wanted points unfortunately (but ZIP is too ugly). i guess drawing the Q here for me is not a bad thing, but maybe eliminating that volatility with PUTZ ends up being better.. but that barely puts me ahead. not sure what's right.\n\u003efrentz: HOQT 2K Q.OTH +47 446\n#note crappity crap crap\n\u003ecesar: ADNNOPT 12L POND +28 439\n#note i wanted to see if there was a word like HANDPOT or something insane like that but couldn't see anything. quackle suggests i am totally screwed, but J5 AD gives me a supposedly tiny shot of 2.78%. don't see how. POND gives me the same win % but a lower \"equity\". i was pretty sure i was screwed but was trying to get an out play with the best leave i could\n\u003efrentz: AILMNRS O6 RIMLAN.S +83 529\n\u003efrentz: (challenge) +5 534\n#note lame\n\u003efrentz: (AHNTT) +16 550", + "original_gcg": "#player1 cesar cesar\n#player2 frentz frentz\n\u003ecesar: ?AACDER 8D CRAAlED +74 74\n#note an auspicious beginning. as a side note, i almost hate being obviously lucky as much as i hate being unlucky. caldera is better because it doesn't expose the vowels.\n\u003efrentz: DEENOSW E2 ENDOWE.S +74 74\n#note ok good, now i can stop feeling bad about being lucky!\n\u003ecesar: AABEIIW D4 AWA +28 102\n#note couldn't pull the trigger on WAI# unfortunately. wasn't sure if it was that or my friend Wei. (-8)\n\u003efrentz: KNOO F2 NOOK +30 104\n\u003ecesar: BEGIIJX 9G XI +35 137\n#note quackle also likes this better than the 37 pointer\n\u003efrentz: EPY 10F YEP +30 134\n\u003ecesar: BEFGIIJ 11C JIBE +31 168\n\u003efrentz: AEFS 12B SAFE +37 171\n\u003ecesar: FGIIIOU 13C IF +39 207\n#note unfortunately i don't know collins strategy enough to know if keeping the horrible leave for 39 points is worth it, but an exchange is too far behind. now JAI# i remember. it's still not a word.\n\u003efrentz: GLU 14A GUL +19 190\n\u003ecesar: EGIIORU 11H EUOI +13 220\n#note ourie is better, but i wasn't sure enough of LIPO#. this 5-pt challenge is pretty lame by the way. (-1.5)\n\u003efrentz: EEILRST 15C STERILE +86 276\n#note dammit\n\u003ecesar: EGILORR 10J GOR +17 237\n\u003efrentz: MTU 14E TUM +17 293\n\u003ecesar: EIILNRV 3E ..NVIRILE +78 315\n\u003ecesar: ADDIPYZ (challenge) +5 320\n#note that took guts!!\n\u003efrentz: ?ABCEER 13G ACErBER +80 373\n#note unfortunately, thanks to the lame challenge rule i don't get a chance to come back a bit more\n\u003ecesar: ADDIPYZ H1 DA.Y +45 365\n\u003efrentz: GOUV L1 VU.GO +26 399\n\u003ecesar: DINPTTZ K5 ZIT +46 411\n#note what do you guys think? PUTZ may be a tiny bit better because of the leave. the pool is clunky. this is an interesting move. i just wanted points unfortunately (but ZIP is too ugly). i guess drawing the Q here for me is not a bad thing, but maybe eliminating that volatility with PUTZ ends up being better.. but that barely puts me ahead. not sure what's right.\n\u003efrentz: HOQT 2K Q.OTH +47 446\n#note crappity crap crap\n\u003ecesar: ADNNOPT 12L POND +28 439\n#note i wanted to see if there was a word like HANDPOT or something insane like that but couldn't see anything. quackle suggests i am totally screwed, but J5 AD gives me a supposedly tiny shot of 2.78%. don't see how. POND gives me the same win % but a lower \"equity\". i was pretty sure i was screwed but was trying to get an out play with the best leave i could\n\u003efrentz: AILMNRS O6 RIMLAN.S +83 529\n\u003efrentz: (challenge) +5 534\n#note lame\n\u003efrentz: (AHNTT) +16 550\n", "lexicon": "CSW12", + "last_known_racks": [ + "AHNTT", + "" + ], "play_state": 2, "final_scores": [ 439, 550 ], - "winner": 1 + "variant": "classic", + "winner": 1, + "board_layout": "CrosswordGame", + "letter_distribution": "english" } \ No newline at end of file diff --git a/puzzles/puzzles.go b/puzzles/puzzles.go index ada2d4c6..ed274361 100644 --- a/puzzles/puzzles.go +++ b/puzzles/puzzles.go @@ -35,7 +35,9 @@ func GetFunctionName(i interface{}) string { return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name() } func CreatePuzzlesFromGame(conf *config.Config, eqLossLimit int, g *game.Game, req *pb.PuzzleGenerationRequest) ([]*pb.PuzzleCreationResponse, error) { - evts := g.History().Events + history := g.GenerateSerializableHistory() + evts := history.Events + lastKnownRacks := history.LastKnownRacks puzzles := []*pb.PuzzleCreationResponse{} err := validatePuzzleGenerationRequest(req) if err != nil { @@ -54,7 +56,7 @@ func CreatePuzzlesFromGame(conf *config.Config, eqLossLimit int, g *game.Game, r evt.Type != pb.GameEvent_PASS { continue } - err := g.PlayToTurn(evtIdx) + err := g.PlayToTurn(evtIdx, lastKnownRacks) if err != nil { return nil, err } @@ -173,7 +175,7 @@ func CELOnlyPuzzle(g *game.Game, moves []*move.Move) (bool, pb.PuzzleTag) { return false, pb.PuzzleTag_CEL_ONLY } evt.WordsFormed = convertToVisible(wordsFormed, g.Alphabet()) - isCEL, err := isCELEvent(evt, g.History(), g.Config()) + isCEL, err := isCELEvent(evt, g.GenerateSerializableHistory(), g.Config()) if err != nil { log.Err(err).Msg("cel-only-phony-error") return false, pb.PuzzleTag_CEL_ONLY @@ -325,8 +327,8 @@ func IsEquityPuzzleStillValid(conf *config.Config, g *game.Game, turnNumber int, return false, err } // add the rack to the game event. It is saved without a rack. - - err = g.PlayToTurn(turnNumber) + history := g.GenerateSerializableHistory() + err = g.PlayToTurn(turnNumber, history.LastKnownRacks) if err != nil { return false, err } diff --git a/puzzles/puzzles_test.go b/puzzles/puzzles_test.go index e70ec094..ea576e24 100644 --- a/puzzles/puzzles_test.go +++ b/puzzles/puzzles_test.go @@ -112,17 +112,11 @@ func TestPuzzles(t *testing.T) { func TestPuzzleGeneration(t *testing.T) { is := is.New(t) zerolog.SetGlobalLevel(zerolog.Disabled) - gameHistory, err := gcgio.ParseGCG(DefaultConfig, "./testdata/phony_tiles_returned.gcg") + game, err := gcgio.ParseGCG(DefaultConfig, "./testdata/phony_tiles_returned.gcg") is.NoErr(err) // Set the correct challenge rule - gameHistory.ChallengeRule = pb.ChallengeRule_FIVE_POINT - - rules, err := game.NewBasicGameRules(DefaultConfig, "CSW21", board.CrosswordGameLayout, "english", game.CrossScoreAndSet, game.VarClassic) - is.NoErr(err) - - game, err := game.NewFromHistory(gameHistory, rules, 0) - is.NoErr(err) + game.SetChallengeRule(pb.ChallengeRule_FIVE_POINT) _, err = CreatePuzzlesFromGame(DefaultConfig, 1000, game, nil) is.True(err != nil) @@ -245,17 +239,11 @@ func TestPuzzleGeneration(t *testing.T) { func TestLostChallenge(t *testing.T) { is := is.New(t) - gameHistory, err := gcgio.ParseGCG(DefaultConfig, "./testdata/phony_tiles_returned.gcg") + game, err := gcgio.ParseGCG(DefaultConfig, "./testdata/phony_tiles_returned.gcg") is.NoErr(err) // Set the correct challenge rule - gameHistory.ChallengeRule = pb.ChallengeRule_FIVE_POINT - - rules, err := game.NewBasicGameRules(DefaultConfig, "CSW21", board.CrosswordGameLayout, "english", game.CrossScoreAndSet, game.VarClassic) - is.NoErr(err) - - game, err := game.NewFromHistory(gameHistory, rules, 0) - is.NoErr(err) + game.SetChallengeRule(pb.ChallengeRule_FIVE_POINT) dpgr := proto.Clone(DefaultPuzzleGenerationReq).(*pb.PuzzleGenerationRequest) err = InitializePuzzleGenerationRequest(dpgr) @@ -291,17 +279,11 @@ func TestEquityLossLimit(t *testing.T) { is := is.New(t) zerolog.SetGlobalLevel(zerolog.InfoLevel) // A little less than 23 total equity loss this game - gameHistory, err := gcgio.ParseGCG(DefaultConfig, "./testdata/well_played_game.gcg") + game, err := gcgio.ParseGCG(DefaultConfig, "./testdata/well_played_game.gcg") is.NoErr(err) // Set the correct challenge rule - gameHistory.ChallengeRule = pb.ChallengeRule_DOUBLE - - rules, err := game.NewBasicGameRules(DefaultConfig, "NWL18", board.CrosswordGameLayout, "english", game.CrossScoreAndSet, game.VarClassic) - is.NoErr(err) - - game, err := game.NewFromHistory(gameHistory, rules, 0) - is.NoErr(err) + game.SetChallengeRule(pb.ChallengeRule_DOUBLE) puzzleGenerationReq := &pb.PuzzleGenerationRequest{ Buckets: []*pb.PuzzleBucket{ @@ -336,17 +318,11 @@ func TestEquityLossLimit(t *testing.T) { func TestIsPuzzleStillValid(t *testing.T) { is := is.New(t) - gameHistory, err := gcgio.ParseGCG(DefaultConfig, "./testdata/well_played_game.gcg") + game, err := gcgio.ParseGCG(DefaultConfig, "./testdata/well_played_game.gcg") is.NoErr(err) // Set the correct challenge rule - gameHistory.ChallengeRule = pb.ChallengeRule_DOUBLE - - rules, err := game.NewBasicGameRules(DefaultConfig, "NWL18", board.CrosswordGameLayout, "english", game.CrossScoreAndSet, game.VarClassic) - is.NoErr(err) - - game, err := game.NewFromHistory(gameHistory, rules, 0) - is.NoErr(err) + game.SetChallengeRule(pb.ChallengeRule_DOUBLE) puzzleGenerationReq := &pb.PuzzleGenerationRequest{ Buckets: []*pb.PuzzleBucket{ @@ -371,7 +347,7 @@ func TestIsPuzzleStillValid(t *testing.T) { is.NoErr(err) is.True(len(pzls) > 0) for i := range pzls { - fmt.Println(pzls[i]) + log.Info().Interface("pzl", pzls[i]).Msg("puzzle") } is.Equal(pzls[2].TurnNumber, int32(2)) @@ -387,7 +363,7 @@ func TestIsPuzzleStillValid(t *testing.T) { } func puzzlesMatch(is *is.I, gcgfile string, puzzleGenerationReq *pb.PuzzleGenerationRequest, expectedPzl *pb.PuzzleCreationResponse) { - gameHistory, err := gcgio.ParseGCG(DefaultConfig, fmt.Sprintf("./testdata/%s.gcg", gcgfile)) + game, err := gcgio.ParseGCG(DefaultConfig, fmt.Sprintf("./testdata/%s.gcg", gcgfile)) if err != nil { panic(err) } @@ -395,16 +371,7 @@ func puzzlesMatch(is *is.I, gcgfile string, puzzleGenerationReq *pb.PuzzleGenera // Set the challenge rule to five point // so GCGs with challenges will load - gameHistory.ChallengeRule = pb.ChallengeRule_FIVE_POINT - - rules, err := game.NewBasicGameRules(DefaultConfig, "CSW21", board.CrosswordGameLayout, "english", game.CrossScoreAndSet, game.VarClassic) - if err != nil { - panic(err) - } - game, err := game.NewFromHistory(gameHistory, rules, 0) - if err != nil { - panic(err) - } + game.SetChallengeRule(pb.ChallengeRule_FIVE_POINT) pzls, err := CreatePuzzlesFromGame(DefaultConfig, 1000, game, puzzleGenerationReq) if err != nil { diff --git a/rangefinder/inference.go b/rangefinder/inference.go index 0ed7ffa0..4c36bf5c 100644 --- a/rangefinder/inference.go +++ b/rangefinder/inference.go @@ -109,7 +109,8 @@ func (r *RangeFinder) SetLogStream(l io.Writer) { func (r *RangeFinder) PrepareFinder(myRack []tilemapping.MachineLetter) error { r.inference = NewInference() - evts := r.origGame.History().Events[:r.origGame.Turn()] + history := r.origGame.GenerateSerializableHistory() + evts := history.Events[:r.origGame.Turn()] if len(evts) == 0 { return ErrNoEvents } @@ -144,19 +145,20 @@ func (r *RangeFinder) PrepareFinder(myRack []tilemapping.MachineLetter) error { var gameCopy *game.Game var err error - history := proto.Clone(r.origGame.History()).(*macondo.GameHistory) - history.Events = history.Events[:oppEvtIdx] + origHistory := r.origGame.GenerateSerializableHistory() + clonedHistory := proto.Clone(origHistory).(*macondo.GameHistory) + clonedHistory.Events = clonedHistory.Events[:oppEvtIdx] - if r.origGame.History().StartingCgp != "" { + if origHistory.StartingCgp != "" { - parsedCGP, err := cgp.ParseCGP(r.cfg, r.origGame.History().StartingCgp) + parsedCGP, err := cgp.ParseCGP(r.cfg, origHistory.StartingCgp) if err != nil { return err } gameCopy = parsedCGP.Game - gameCopy.History().Events = history.Events + gameCopy.SetEvents(clonedHistory.Events) - for t := 0; t < len(history.Events); t++ { + for t := 0; t < len(clonedHistory.Events); t++ { err = gameCopy.PlayTurn(t) if err != nil { return err @@ -165,7 +167,7 @@ func (r *RangeFinder) PrepareFinder(myRack []tilemapping.MachineLetter) error { gameCopy.SetPlayerOnTurn(int(oppIdx)) gameCopy.RecalculateBoard() } else { - gameCopy, err = game.NewFromHistory(history, r.origGame.Rules(), len(history.Events)) + gameCopy, err = game.NewFromHistory(clonedHistory, r.origGame.Rules(), len(clonedHistory.Events)) if err != nil { return err } diff --git a/shell/api.go b/shell/api.go index 8ae54f2f..03ddf5b2 100644 --- a/shell/api.go +++ b/shell/api.go @@ -120,9 +120,10 @@ func (sc *ShellController) gid(cmd *shellcmd) (*Response, error) { if sc.game == nil { return nil, errors.New("no currently loaded game") } - gid := sc.game.History().Uid + history := sc.game.GenerateSerializableHistory() + gid := history.Uid if gid != "" { - idauth := sc.game.History().IdAuth + idauth := history.IdAuth fullID := strings.TrimSpace(idauth + " " + gid) return msg(fullID), nil } @@ -229,7 +230,8 @@ func (sc *ShellController) last(cmd *shellcmd) (*Response, error) { if sc.solving() { return nil, errMacondoSolving } - err := sc.setToTurn(len(sc.game.History().Events)) + history := sc.game.GenerateSerializableHistory() + err := sc.setToTurn(len(history.Events)) if err != nil { return nil, err } @@ -259,7 +261,8 @@ func (sc *ShellController) name(cmd *shellcmd) (*Response, error) { return nil, err } p -= 1 - if p < 0 || p >= len(sc.game.History().Players) { + history := sc.game.GenerateSerializableHistory() + if p < 0 || p >= len(history.Players) { return nil, errors.New("player index not in range") } err = sc.game.RenamePlayer(p, &pb.PlayerInfo{ @@ -839,7 +842,7 @@ func (sc *ShellController) export(cmd *shellcmd) (*Response, error) { return nil, errors.New("please provide a filename to save to") } filename := cmd.args[0] - contents, err := gcgio.GameHistoryToGCG(sc.game.History(), true) + contents, err := gcgio.GameToGCG(sc.game.Game, true) if err != nil { return nil, err } @@ -847,7 +850,6 @@ func (sc *ShellController) export(cmd *shellcmd) (*Response, error) { if err != nil { return nil, err } - log.Debug().Interface("game-history", sc.game.History()).Msg("converted game history to gcg") f.WriteString(contents) f.Close() return msg("gcg written to " + filename), nil diff --git a/shell/script.go b/shell/script.go index feb33451..1c356ff3 100644 --- a/shell/script.go +++ b/shell/script.go @@ -166,7 +166,8 @@ func elitePlay(L *lua.LState) int { sc.botCtx, sc.botCtxCancel = context.WithTimeout(context.Background(), time.Second*time.Duration(60)) defer sc.botCtxCancel() - if sc.elitebot.History().PlayState == macondo.PlayState_GAME_OVER { + history := sc.elitebot.GenerateSerializableHistory() + if history.PlayState == macondo.PlayState_GAME_OVER { log.Error().Msg("game is over") return 0 } diff --git a/shell/shell.go b/shell/shell.go index f40f92d7..8f2fa4a3 100644 --- a/shell/shell.go +++ b/shell/shell.go @@ -381,7 +381,7 @@ func (sc *ShellController) IsPlaying() bool { func (sc *ShellController) loadGCG(args []string) error { var err error - var history *pb.GameHistory + var g *game.Game // Try to parse filepath as a network path. if args[0] == "xt" { if len(args) < 2 { @@ -413,7 +413,7 @@ func (sc *ShellController) loadGCG(args []string) error { } defer resp.Body.Close() - history, err = gcgio.ParseGCGFromReader(sc.config, resp.Body) + g, err = gcgio.ParseGCGFromReader(sc.config, resp.Body) if err != nil { return err } @@ -448,7 +448,7 @@ func (sc *ShellController) loadGCG(args []string) error { return err } - history, err = gcgio.ParseGCGFromReader(sc.config, strings.NewReader(gcgObj.Gcg)) + g, err = gcgio.ParseGCGFromReader(sc.config, strings.NewReader(gcgObj.Gcg)) if err != nil { return err } @@ -464,7 +464,7 @@ func (sc *ShellController) loadGCG(args []string) error { } defer resp.Body.Close() - history, err = gcgio.ParseGCGFromReader(sc.config, resp.Body) + g, err = gcgio.ParseGCGFromReader(sc.config, resp.Body) if err != nil { return err } @@ -478,27 +478,16 @@ func (sc *ShellController) loadGCG(args []string) error { dir := usr.HomeDir path = filepath.Join(dir, path[2:]) } - history, err = gcgio.ParseGCG(sc.config, path) + g, err = gcgio.ParseGCG(sc.config, path) if err != nil { return err } } + history := g.GenerateSerializableHistory() log.Debug().Msgf("Loaded game repr; players: %v", history.Players) - lexicon := history.Lexicon - if lexicon == "" { - lexicon = sc.config.GetString(config.ConfigDefaultLexicon) - log.Info().Msgf("gcg file had no lexicon, so using default lexicon %v", - lexicon) - } - boardLayout, ldName, variant := game.HistoryToVariant(history) - rules, err := game.NewBasicGameRules(sc.config, lexicon, boardLayout, ldName, game.CrossScoreAndSet, variant) - if err != nil { - return err - } - g, err := game.NewFromHistory(history, rules, 0) - if err != nil { - return err - } + + // Extract board layout info for leaves file detection + _, ldName, _ := game.HistoryToVariant(history) leavesFile := "" if strings.HasSuffix(ldName, "_super") { leavesFile = "super-leaves.klv2" @@ -523,7 +512,8 @@ func (sc *ShellController) loadCGP(cgpstr string) error { if err != nil { return err } - lexicon := g.History().Lexicon + history := g.GenerateSerializableHistory() + lexicon := history.Lexicon if lexicon == "" { lexicon = sc.config.GetString(config.ConfigDefaultLexicon) log.Info().Msgf("cgp file had no lexicon, so using default lexicon %v", @@ -531,7 +521,8 @@ func (sc *ShellController) loadCGP(cgpstr string) error { } leavesFile := "" - if g.History().BoardLayout == board.SuperCrosswordGameLayout { + history2 := g.GenerateSerializableHistory() + if history2.BoardLayout == board.SuperCrosswordGameLayout { leavesFile = "super-leaves.klv2" } @@ -556,7 +547,8 @@ func (sc *ShellController) setToTurn(turnnum int) error { if sc.game == nil { return errors.New("please load a game first with the `load` command") } - err := sc.game.PlayToTurn(turnnum) + history := sc.game.GenerateSerializableHistory() + err := sc.game.PlayToTurn(turnnum, history.LastKnownRacks) if err != nil { return err } @@ -1016,8 +1008,9 @@ func (sc *ShellController) Loop(sig chan os.Signal) { defer sc.l.Close() for { - if sc.game != nil && sc.game.History() != nil { - log.Debug().Msgf("loop-lastknownracks %v", sc.game.History().LastKnownRacks) + if sc.game != nil { + history := sc.game.GenerateSerializableHistory() + log.Debug().Msgf("loop-lastknownracks %v", history.LastKnownRacks) } line, err := sc.l.Readline() if err == readline.ErrInterrupt { diff --git a/turnplayer/base_turn_player.go b/turnplayer/base_turn_player.go index c9ec01f6..fc277854 100644 --- a/turnplayer/base_turn_player.go +++ b/turnplayer/base_turn_player.go @@ -37,11 +37,6 @@ func (p *BaseTurnPlayer) SetPlayerRack(playerid int, letters string) error { if err != nil { return err } - // Edit history for both players if it exists! - if p.History() != nil && len(p.History().LastKnownRacks) == 2 { - p.History().LastKnownRacks[0] = p.RackLettersFor(0) - p.History().LastKnownRacks[1] = p.RackLettersFor(1) - } return nil }