diff --git a/realm/chess.gno b/realm/chess.gno index 52a09d2..70bb765 100644 --- a/realm/chess.gno +++ b/realm/chess.gno @@ -221,6 +221,8 @@ func xNewGame(opponentRaw string, seconds, increment int) string { opponent := parsePlayer(opponentRaw) caller := std.GetOrigCaller() + getPlayerStats(opponent).StartedGames++ + getPlayerStats(caller).StartedGames++ assertUserNotInLobby(caller) return newGame(caller, opponent, seconds, increment).json() @@ -360,6 +362,10 @@ func MakeMove(gameID, from, to string, promote Piece) string { isBlack := len(g.Position.Moves)%2 == 1 caller := std.GetOrigCaller() + + // see stats.gno + getPlayerStats(caller).Moves++ + if (isBlack && g.Black != caller) || (!isBlack && g.White != caller) { // either not a player involved; or not the caller's turn. @@ -373,6 +379,7 @@ func MakeMove(gameID, from, to string, promote Piece) string { g.State = GameStateAborted g.Concluder = &caller g.Winner = WinnerNone + g.updateEndgameStats() return g.json() } if !valid { @@ -382,6 +389,7 @@ func MakeMove(gameID, from, to string, promote Piece) string { } else { g.Winner = WinnerWhite } + g.updateEndgameStats() g.saveResult() return g.json() } @@ -426,7 +434,6 @@ func MakeMove(gameID, from, to string, promote Piece) string { case o == Stalemate: g.State = GameStateStalemate g.Winner = WinnerDraw - case o == Drawn75Move: g.State = GameStateDrawn75Move g.Winner = WinnerDraw @@ -434,8 +441,10 @@ func MakeMove(gameID, from, to string, promote Piece) string { g.State = GameStateDrawn5Fold g.Winner = WinnerDraw } + g.DrawOfferer = nil g.saveResult() + g.updateEndgameStats() return g.json() } @@ -462,6 +471,7 @@ func (g *Game) claimTimeout() error { g.Concluder = &g.White } g.Winner = WinnerNone + g.updateEndgameStats() return nil } @@ -472,8 +482,8 @@ func (g *Game) claimTimeout() error { g.Winner = WinnerWhite } g.DrawOfferer = nil + g.updateEndgameStats() g.saveResult() - return nil } @@ -543,8 +553,8 @@ func resign(g *Game) error { return errors.New("you are not involved in this game") } g.DrawOfferer = nil + g.updateEndgameStats() g.saveResult() - return nil } @@ -607,9 +617,8 @@ func Draw(gameID string) string { g.State = GameStateDrawnByAgreement g.Winner = WinnerDraw g.Concluder = &caller - g.saveResult() - + g.updateEndgameStats() return g.json() } @@ -627,8 +636,7 @@ func Draw(gameID string) string { g.Concluder = &caller g.Winner = WinnerDraw g.DrawOfferer = nil - g.saveResult() - + g.updateEndgameStats() return g.json() } diff --git a/realm/chess_test.gno b/realm/chess_test.gno index a0543ef..5829802 100644 --- a/realm/chess_test.gno +++ b/realm/chess_test.gno @@ -22,6 +22,7 @@ func cleanup() { lobby = [tcLobbyMax][]lobbyPlayer{} lobbyPlayer2Game = avl.Tree{} playerRatings = [CategoryMax][]*PlayerRating{} + allPlayerStats = avl.Tree{} } func TestNewGame(t *testing.T) { @@ -116,6 +117,8 @@ var commandTests = [...]string{ newgame # contains "white":"g1black" "black":"g1white" #id equal 000000002 + stats white # contains moves:2 started:2 won:0 lost:1 timedout:0 resigned:1 drawn:0 serious:0 + stats black # contains moves:1 started:2 won:1 lost:0 timedout:0 resigned:0 drawn:0 serious:0 `, // Otherwise, invert from p1's history. ` name ColoursInvert3p @@ -141,6 +144,8 @@ var commandTests = [...]string{ # contains "address":"g1white" "position":0 "wins":1 "losses":0 "draws":0 player black # contains "address":"g1black" "position":1 "wins":0 "losses":1 "draws":0 + stats white # contains moves:4 started:1 won:1 lost:0 timedout:0 resigned:0 drawn:0 serious:1 + stats black # contains moves:3 started:1 won:0 lost:1 timedout:0 resigned:0 drawn:0 serious:1 `, ` name DrawByAgreement newgame @@ -155,12 +160,15 @@ var commandTests = [...]string{ # contains "open" "concluder":null "draw_offerer":"g1white" draw black # contains "drawn_by_agreement" "concluder":"g1black" "draw_offerer":"g1white" + stats white # contains moves:2 started:1 won:0 lost:0 timedout:0 resigned:0 drawn:1 serious:0 + stats black # contains moves:2 started:1 won:0 lost:0 timedout:0 resigned:0 drawn:1 serious:0 `, ` name AbortFirstMove newgame abort white # contains "winner":"none" "concluder":"g1white" + stats white # contains moves:0 started:1 won:0 lost:0 timedout:0 resigned:0 drawn:0 serious:0 + stats black # contains moves:0 started:1 won:0 lost:0 timedout:0 resigned:0 drawn:0 serious:0 `, - ` name ThreefoldRepetition newgame @@ -176,6 +184,8 @@ var commandTests = [...]string{ draw black # contains "winner":"draw" "concluder":"g1black" # contains "state":"drawn_3_fold" + stats white # contains moves:4 started:1 won:0 lost:0 timedout:0 resigned:0 drawn:1 serious:0 + stats black # contains moves:4 started:1 won:0 lost:0 timedout:0 resigned:0 drawn:1 serious:0 `, ` name FivefoldRepetition newgame @@ -203,6 +213,8 @@ var commandTests = [...]string{ # contains "winner":"draw" "concluder":null "state":"drawn_5_fold" move white g1f3 #panic contains game is already finished + stats white # contains moves:8 started:1 won:0 lost:0 timedout:0 resigned:0 drawn:1 serious:0 + stats black # contains moves:8 started:1 won:0 lost:0 timedout:0 resigned:0 drawn:1 serious:0 `, ` name TimeoutAborted newgame white black 3 @@ -213,6 +225,8 @@ var commandTests = [...]string{ # contains e2e4 # contains "aborted" # contains "concluder":"g1black" + stats white # contains moves:1 started:1 won:0 lost:0 timedout:1 resigned:0 drawn:0 serious:0 + stats black # contains moves:1 started:1 won:0 lost:0 timedout:1 resigned:0 drawn:0 serious:0 `, ` name TimeoutAbandoned newgame white black 1 @@ -221,6 +235,8 @@ var commandTests = [...]string{ sleep 61 timeout black # contains "state":"timeout" "winner":"black" + stats white # contains moves:1 started:1 won:0 lost:1 timedout:1 resigned:0 drawn:0 serious:0 + stats black # contains moves:1 started:1 won:1 lost:0 timedout:1 resigned:0 drawn:0 serious:0 `, } @@ -334,6 +350,14 @@ func (tc *testCommandSleep) Run(t *testing.T, bufs map[string]string) { os_test.Sleep(tc.dur) } +type testCommandStats struct { + addr string +} + +func (tc *testCommandStats) Run(t *testing.T, bufs map[string]string) { + bufs["result"] = GetPlayerStats(std.Address(tc.addr)).String() +} + type testChecker struct { fn func(t *testing.T, bufs map[string]string, tc *testChecker) tf func(*testing.T, string, ...interface{}) @@ -441,6 +465,8 @@ func parseCommandTest(t *testing.T, command string) (funcs []testCommandRunner, funcs = append(funcs, newTestCommandColorID(ClaimTimeout, "timeout", command[1])) case "resign": funcs = append(funcs, newTestCommandColorID(Resign, "resign", command[1])) + case "stats": + funcs = append(funcs, &testCommandStats{"g1" + command[1]}) case "game": if len(command) > 2 { panic("invalid game command " + line) diff --git a/realm/stats.gno b/realm/stats.gno new file mode 100644 index 0000000..7d2385e --- /dev/null +++ b/realm/stats.gno @@ -0,0 +1,130 @@ +package chess + +import ( + "std" + + "gno.land/p/demo/avl" + "gno.land/p/demo/ufmt" +) + +var allPlayerStats avl.Tree // std.Address -> *playerStats + +type playerStats struct { + Addr std.Address // Not stored when in avl.Tree, but lazily filled for public-facing helpers returning playerStats. + Moves uint + StartedGames uint + WonGames uint + LostGames uint + TimedoutGames uint + ResignedGames uint + DrawnGames uint + SeriousGames uint // finished, or resigned/drawn after 20 full moves (40 turns), used for the raffle. + // later we can add achievements: + // SuperFastAchievement // if a game is finished in less than N seconds. + // OnlyPawnsAchievement // winning with only pawns, etc. +} + +func (s playerStats) String() string { + return ufmt.Sprintf( + "addr:%s moves:%d started:%d won:%d lost:%d timedout:%d resigned:%d drawn:%d serious:%d", + s.Addr, s.Moves, s.StartedGames, s.WonGames, s.LostGames, s.TimedoutGames, + s.ResignedGames, s.DrawnGames, s.SeriousGames, + ) +} + +func getPlayerStats(addr std.Address) *playerStats { + addrStr := string(addr) + res, found := allPlayerStats.Get(addrStr) + if found { + return res.(*playerStats) + } + + newStats := playerStats{} + allPlayerStats.Set(addrStr, &newStats) + return &newStats +} + +func GetPlayerStats(addr std.Address) playerStats { + stats := getPlayerStats(addr) + cpy := *stats + cpy.Addr = addr + return cpy +} + +func AllPlayerStats() []playerStats { + ret := []playerStats{} + + allPlayerStats.Iterate("", "", func(addrString string, v interface{}) bool { + stats := *(v.(*playerStats)) + stats.Addr = std.Address(addrString) + ret = append(ret, stats) + return false + }) + + return ret +} + +type gameStats struct { + caller, opponent, white, black *playerStats +} + +func (g Game) getStats(caller std.Address) gameStats { + stats := gameStats{ + white: getPlayerStats(g.White), + black: getPlayerStats(g.Black), + } + + if caller != "" { // if caller is empty, we just fill "black" and "white". + if caller == g.White { + stats.caller = stats.white + stats.opponent = stats.black + } else { + stats.caller = stats.black + stats.opponent = stats.white + } + } + + return stats +} + +func (g Game) updateEndgameStats() { + stats := g.getStats("") + + // serious games + isSerious := false + switch { + case g.State == GameStateCheckmated: // checkmates + isSerious = true + case len(g.Position.Moves) >= 40: // long games + isSerious = true + } + if isSerious { + stats.black.SeriousGames++ + stats.white.SeriousGames++ + } + + // timeouts, aborted, etc + if g.State == GameStateTimeout || g.State == GameStateAborted { + stats.black.TimedoutGames++ + stats.white.TimedoutGames++ + } + + // winners, losers, draws + switch g.Winner { + case WinnerWhite: + stats.white.WonGames++ + stats.black.LostGames++ + if g.State == GameStateResigned { + stats.black.ResignedGames++ + } + case WinnerBlack: + stats.black.WonGames++ + stats.white.LostGames++ + if g.State == GameStateResigned { + stats.white.ResignedGames++ + } + case WinnerDraw: + stats.black.DrawnGames++ + stats.white.DrawnGames++ + } +}