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}