discovery.gno
9.54 Kb ยท 424 lines
1// this file concerns mostly with "discovery"; ie. finding information
2// about a user's chess playing and previous games
3
4package chess
5
6import (
7 "bytes"
8 "sort"
9 "std"
10 "strconv"
11 "strings"
12
13 "gno.land/p/demo/avl"
14 "gno.land/p/morgan/chess/glicko2"
15 "gno.land/r/sys/users"
16)
17
18type Category byte
19
20const (
21 // blanks are reserved for future use (bullet and classic)
22 _ Category = iota
23 Blitz
24 Rapid
25 _
26 Correspondence
27 CategoryMax
28)
29
30var categoryString = [CategoryMax]string{
31 Blitz: "blitz",
32 Rapid: "rapid",
33 Correspondence: "correspondence",
34}
35
36var categoryList = [...]Category{Blitz, Rapid, Correspondence}
37
38func (c Category) String() string {
39 if c > CategoryMax || categoryString[c] == "" {
40 panic("invalid category")
41 }
42 return categoryString[c]
43}
44
45func CategoryFromString(s string) Category {
46 for i, cs := range categoryString {
47 if s == cs {
48 return Category(i)
49 }
50 }
51 panic("invalid category")
52}
53
54func (tc *TimeControl) Category() Category {
55 // https://lichess.org/faq#time-controls
56 if tc == nil {
57 return Correspondence
58 }
59
60 totalTime := tc.Seconds + tc.Increment*40
61 switch {
62 case tc.Seconds <= 0 || tc.Increment < 0:
63 // should not happen
64 return Correspondence
65 case totalTime < 60*8:
66 return Blitz
67 default:
68 return Rapid
69 }
70}
71
72// realm state
73var (
74 playerStore avl.Tree // std.Address -> *Player
75 leaderboard [CategoryMax]leaderboardType
76 playerRatings [CategoryMax][]*glicko2.PlayerRating
77)
78
79func GetPlayer(player string) string {
80 addr := parsePlayer(player)
81 v, ok := playerStore.Get(addr.String())
82 if !ok {
83 panic("player not found")
84 }
85 b, err := v.(*Player).MarshalJSON()
86 checkErr(err)
87 return string(b)
88}
89
90// Player contains game-related player information.
91type Player struct {
92 Address std.Address
93 CategoryInfo [CategoryMax]CategoryInfo
94}
95
96type CategoryInfo struct {
97 Wins, Losses, Draws int
98 *glicko2.PlayerRating
99}
100
101// Score for determining leaderboards.
102func (p Player) Score(cat Category) float64 {
103 return p.CategoryInfo[cat].Rating
104}
105
106// Leaderboard position, 0 indexed.
107// Dynamically calculated to avoid having to shift positions when LB changes.
108func (p Player) LeaderboardPosition(cat Category) int {
109 pos, ok := leaderboard[cat].find(p.Score(cat), p.Address)
110 if !ok {
111 return -1
112 }
113 return pos
114}
115
116func (g *Game) saveResult() {
117 w, b := getPlayer(g.White), getPlayer(g.Black)
118
119 cat := g.Time.Category()
120
121 // Get numeric result for glicko2.
122 var result float64
123 switch g.Winner {
124 case WinnerWhite:
125 w.CategoryInfo[cat].Wins++
126 b.CategoryInfo[cat].Losses++
127 result = 1
128 case WinnerBlack:
129 w.CategoryInfo[cat].Losses++
130 b.CategoryInfo[cat].Wins++
131 result = 0
132 case WinnerDraw:
133 w.CategoryInfo[cat].Draws++
134 b.CategoryInfo[cat].Draws++
135 result = 0.5
136 default:
137 return // TODO: maybe panic
138 }
139
140 // Call glicko 2 rating calculator.
141 owr, obr := w.CategoryInfo[cat].Rating, b.CategoryInfo[cat].Rating
142 glicko2.UpdateRatings(playerRatings[cat], []glicko2.RatingScore{{
143 White: g.White,
144 Black: g.Black,
145 Score: result,
146 }})
147
148 // Save in playerStore.
149 playerStore.Set(w.Address.String(), w)
150 playerStore.Set(b.Address.String(), b)
151 leaderboard[cat], _ = leaderboard[cat].push(g.White, owr, w.CategoryInfo[cat].Rating)
152 leaderboard[cat], _ = leaderboard[cat].push(g.Black, obr, b.CategoryInfo[cat].Rating)
153}
154
155func getPlayer(addr std.Address) *Player {
156 praw, ok := playerStore.Get(addr.String())
157 if ok {
158 return praw.(*Player)
159 }
160 p := new(Player)
161 p.Address = addr
162 for _, cat := range categoryList {
163 pr := glicko2.NewPlayerRating(addr)
164 p.CategoryInfo[cat] = CategoryInfo{
165 PlayerRating: pr,
166 }
167 playerRatings[cat] = append(playerRatings[cat], pr)
168 }
169 playerStore.Set(addr.String(), p)
170 return p
171}
172
173type lbEntry struct {
174 addr std.Address
175 score float64
176}
177
178type leaderboardType []lbEntry
179
180// find performs binary search on leaderboard to find the first
181// position where score appears, or anything lesser than it.
182// Additionally, if addr is given, it finds the position where the given address appears.
183// The second return parameter returns whether the address was found.
184//
185// The index will be 0 if the score is higher than any other on the leaderboard,
186// and len(leaderboards) if it is lower than any other.
187func (lb leaderboardType) find(score float64, addr std.Address) (int, bool) {
188 i := sort.Search(len(lb), func(i int) bool {
189 return lb[i].score <= score
190 })
191
192 if addr == "" || i == len(lb) {
193 return i, false
194 }
195
196 for j := 0; i+j < len(lb) && lb[i+j].score == score; j++ {
197 if lb[i+j].addr == addr {
198 return i + j, true
199 }
200 }
201
202 return i, false
203}
204
205// push adds or modifies the player's position in the leaderboard.
206// the new leaderboard, and the new position of the player in the leaderboard is returned (0-indexed)
207func (lb leaderboardType) push(player std.Address, oldScore, newScore float64) (leaderboardType, int) {
208 // determine where the player is, currently
209 oldPos, found := lb.find(oldScore, player)
210 if found && (oldScore == newScore) {
211 return lb, oldPos
212 }
213
214 // determine where to place the player next.
215 newPos, _ := lb.find(newScore, "")
216
217 var n leaderboardType
218 switch {
219 case !found:
220 n = append(leaderboardType{}, lb[:newPos]...)
221 n = append(n, lbEntry{player, newScore})
222 n = append(n, lb[newPos:]...)
223
224 case oldPos == newPos:
225 n = lb
226 n[newPos] = lbEntry{player, newScore}
227 case oldPos > newPos:
228 n = append(leaderboardType{}, lb[:newPos]...)
229 n = append(n, lbEntry{player, newScore})
230 n = append(n, lb[newPos:oldPos]...)
231 n = append(n, lb[oldPos+1:]...)
232 default: // oldPos < newPos
233 n = append(leaderboardType{}, lb[:oldPos]...)
234 n = append(n, lb[oldPos+1:newPos]...)
235 n = append(n, lbEntry{player, newScore})
236 n = append(n, lb[newPos:]...)
237 }
238 return n, newPos
239}
240
241// Leaderboard returns a list of all users, ordered by their position in the leaderboard.
242// category is one of blitz, rapid or correspondence.
243func Leaderboard(category string) string {
244 cat := CategoryFromString(category)
245 var buf bytes.Buffer
246 buf.WriteByte('[')
247 for idx, entry := range leaderboard[cat] {
248 p, _ := playerStore.Get(entry.addr.String())
249 d, err := p.(*Player).MarshalJSON()
250 checkErr(err)
251 buf.Write(d)
252 if idx != len(leaderboard[cat])-1 {
253 buf.WriteByte(',')
254 }
255 }
256 buf.WriteByte(']')
257 return buf.String()
258}
259
260// ListGames provides game listing functionality, with filter-based search functionality.
261//
262// available filters:
263//
264// player:<player> white:<player> black:<player> finished:bool
265// limit:int id<cmp>int sort:asc/desc
266// <cmp>: '<' or '>'
267// <player>: either a bech32 address, "@user" (r/demo/users), or "caller"
268func ListGames(filters string) string {
269 ft := parseFilters(filters)
270 results := make([]*Game, 0, ft.limit)
271 cb := func(g *Game) (stop bool) {
272 if !ft.valid(g) {
273 return false
274 }
275 results = append(results, g)
276 return len(results) >= ft.limit
277 }
278
279 // iterate over user2games array if we have one;
280 // if we don't, iterate over games.
281 if ft.u2gAddr != "" {
282 v, ok := user2Games.Get(ft.u2gAddr.String())
283 if !ok {
284 return "[]"
285 }
286 games := v.([]*Game)
287 if ft.reverse {
288 for i := len(games) - 1; i >= 0; i-- {
289 if cb(games[i]) {
290 break
291 }
292 }
293 } else {
294 for _, game := range games {
295 if cb(game) {
296 break
297 }
298 }
299 }
300 } else {
301 fn := gameStore.Iterate
302 if ft.reverse {
303 fn = gameStore.ReverseIterate
304 }
305 fn(ft.minID, ft.maxID, func(_ string, v interface{}) bool {
306 return cb(v.(*Game))
307 })
308 }
309
310 // fast path: no results
311 if len(results) == 0 {
312 return "[]"
313 }
314
315 // encode json
316 var buf bytes.Buffer
317 buf.WriteByte('[')
318 for idx, g := range results {
319 buf.WriteString(g.json())
320 if idx != len(results)-1 {
321 buf.WriteByte(',')
322 }
323 }
324 buf.WriteByte(']')
325
326 return buf.String()
327}
328
329type listGamesFilters struct {
330 filters []func(*Game) bool
331 u2gAddr std.Address
332 maxID string
333 minID string
334 limit int
335 reverse bool
336}
337
338func (l *listGamesFilters) valid(game *Game) bool {
339 for _, filt := range l.filters {
340 if !filt(game) {
341 return false
342 }
343 }
344 return true
345}
346
347func parseFilters(filters string) (r listGamesFilters) {
348 // default to desc order
349 r.reverse = true
350
351 parts := strings.Fields(filters)
352 for _, part := range parts {
353 idx := strings.IndexAny(part, ":<>")
354 if idx < 0 {
355 panic("invalid filter: " + part)
356 }
357 filt, pred := part[:idx+1], part[idx+1:]
358 switch filt {
359 case "player:":
360 a := parsePlayer(pred)
361 r.filters = append(r.filters, func(g *Game) bool { return g.White == a || g.Black == a })
362 if r.u2gAddr == "" {
363 r.u2gAddr = a
364 }
365 case "white:":
366 a := parsePlayer(pred)
367 r.filters = append(r.filters, func(g *Game) bool { return g.White == a })
368 if r.u2gAddr == "" {
369 r.u2gAddr = a
370 }
371 case "black:":
372 a := parsePlayer(pred)
373 r.filters = append(r.filters, func(g *Game) bool { return g.Black == a })
374 if r.u2gAddr == "" {
375 r.u2gAddr = a
376 }
377 case "finished:":
378 b := parseBool(pred)
379 r.filters = append(r.filters, func(g *Game) bool { return g.State.IsFinished() == b })
380 case "id<":
381 r.maxID = pred
382 case "id>":
383 r.minID = pred
384 case "limit:":
385 n, err := strconv.Atoi(pred)
386 checkErr(err)
387 r.limit = n
388 case "sort:":
389 r.reverse = pred == "desc"
390 default:
391 panic("invalid filter: " + filt)
392 }
393 }
394 return
395}
396
397func parseBool(s string) bool {
398 switch s {
399 case "true", "True", "TRUE", "1":
400 return true
401 case "false", "False", "FALSE", "0":
402 return false
403 }
404 panic("invalid bool " + s)
405}
406
407func parsePlayer(s string) std.Address {
408 switch {
409 case s == "":
410 panic("invalid address/user")
411 case s == "caller":
412 return std.PreviousRealm().Address()
413 case s[0] == '@':
414 u, _ := users.ResolveName(s[1:])
415 if u == nil {
416 panic("user not found: " + s[1:])
417 }
418 return u.Addr()
419 case s[0] == 'g':
420 return std.Address(s)
421 default:
422 panic("invalid address/user: " + s)
423 }
424}