Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

state.gno

28.70 Kb · 845 lines
  1package golf
  2
  3import (
  4	"math"
  5	"strconv"
  6	"strings"
  7
  8	"gno.land/p/nym-alexiscolin000/gnogolf/course"
  9	"gno.land/p/nym-alexiscolin000/gnogolf/physics"
 10	bptree "gno.land/p/nt/bptree/v0"
 11	"gno.land/p/nt/ufmt/v0"
 12)
 13
 14// HoleState is the hole as JSON for a client: geometry, skins (names the
 15// client looks up), weather and wear, all it needs to draw the hole and aim.
 16// hole is a version's id or an alias; "hole" in the answer is always the
 17// version's id, the one to write with, with its "slot" and "v", and an
 18// archived one's "next".
 19//
 20// A shot's path (Round, Simulate*) is the flight the chain computed: replay
 21// it, never re-simulate. Points far apart are a tunnel: do not interpolate.
 22func HoleState(hole string) string {
 23	e := readHole(hole)
 24	h := e.hole()
 25	f := h.Field()
 26	var sb strings.Builder
 27	sb.WriteString("{" + versionJSON +
 28		`,"hole":` + jstr(e.id) +
 29		`,"name":` + jstr(e.name) +
 30		`,"official":` + strconv.FormatBool(e.official))
 31	if e.slot != "" {
 32		sb.WriteString(`,"slot":` + jstr(e.slot) + `,"v":` + strconv.Itoa(e.version))
 33	}
 34	if e.next != "" {
 35		sb.WriteString(`,"next":` + jstr(e.next))
 36	}
 37	p := Period()
 38	sb.WriteString(boardJSON(h) +
 39		`,"par":` + strconv.Itoa(e.par) +
 40		`,"world":` + jstr(e.world) + `,"order":` + jnum(e.order) +
 41		`,"timed":` + strconv.FormatBool(h.Varies()) +
 42		`,"period":` + strconv.FormatInt(p, 10) +
 43		`,"weather":` + forecastJSON("", weatherOf(e, h, p)) +
 44		`,"start":` + jvec(h.Start()) +
 45		`,"cup":` + jvec(h.Cup()) +
 46		`,"cupR":` + jnum(h.CupRadius) + `,"ballR":` + jnum(f.Radius))
 47	// the hole's work before the weather (newWork): the client splits a save
 48	// and sets its gas by it
 49	w := newWork(h, nil)
 50	sb.WriteString(ufmt.Sprintf(`,"work":{"walls":%d,"pieces":%d,"setup":%d}`, w.walls, w.pieces, w.setup))
 51	sb.WriteString(`,"walls":` + wallsJSON(f.Walls) + `,"posts":` + postsJSON(f.Posts))
 52	sb.WriteString(`,"zones":` + zonesJSON(f.Zones, true))
 53	sb.WriteString(`,"wear":{"w":` + strconv.Itoa(course.WearW) + `,"h":` + strconv.Itoa(course.WearH) + `,"cells":[`)
 54	for i, c := range e.wearGrid() {
 55		sb.WriteString(sep(i))
 56		sb.WriteString(strconv.Itoa(c))
 57	}
 58	sb.WriteString("]}}")
 59	return sb.String()
 60}
 61
 62// roundJSON is one player's round: ball rounded, rest exact (for SimulateFrom)
 63// and the last stroke replayed: rounds store shots, not paths.
 64func roundJSON(e *entry, h *course.Simple, player string, r *round) string {
 65	var sb strings.Builder
 66	sb.WriteString("{" + versionJSON + `,"player":` + jstr(player) +
 67		`,"ball":` + jvec(r.ball()) + `,"rest":` + jexact(r.ball()) +
 68		ufmt.Sprintf(`,"strokes":%d,"period":%d`, r.strokes, r.period) +
 69		`,"mode":` + jstr(modeNames[r.mode]) +
 70		`,"shots":` + jstr(r.shots))
 71	shot := lastShot(h, r, weatherOf(e, h, r.period))
 72	sb.WriteString(`,"path":[` + pathJSON(shot.Path) + `],"air":` + jstr(airOf(shot)) + `,"cause":` + jstr(string(shot.Cause)))
 73	sb.WriteString("}")
 74	return sb.String()
 75}
 76
 77// lastShot replays a round's last stroke, read-only, with its stroke number,
 78// tick and weather (fc, its period's forecast): the same flight, less the
 79// wear. Empty before a first one.
 80func lastShot(h *course.Simple, r *round, fc course.Forecast) physics.Shot {
 81	if r.strokes == 0 || r.shots == "" {
 82		return physics.Shot{}
 83	}
 84	s := r.shots
 85	if i := strings.LastIndex(s, ";"); i >= 0 {
 86		s = s[i+1:]
 87	}
 88	angle, power, tick := parseShot(s)
 89	from := physics.Vec2{X: r.fx, Y: r.fy}
 90	shot, holed := h.PreviewWith(from, angle*math.Pi/180, power, r.strokes-1, tick, fc.Zones)
 91	return back(h, from, shot, holed)
 92}
 93
 94// pathJSON is a shot's path points, comma separated.
 95func pathJSON(ps []physics.Vec2) string {
 96	var sb strings.Builder
 97	for i, p := range ps {
 98		sb.WriteString(sep(i))
 99		sb.WriteString(jvec(p))
100	}
101	return sb.String()
102}
103
104// airOf is a shot's air flags, one character per path point: "0001100…".
105func airOf(s physics.Shot) string {
106	b := make([]byte, len(s.Path))
107	for i := range b {
108		b[i] = '0'
109		if i < len(s.Air) && s.Air[i] {
110			b[i] = '1'
111		}
112	}
113	return string(b)
114}
115
116// Round is one player's round under way on a hole with its last stroke's
117// path, or null if they have none: never started, holed (its best is kept:
118// Ghost) or Reset. It costs one stroke of gas.
119func Round(hole string, player address) string {
120	e := readHole(hole)
121	v := e.roundTree().Get(player.String())
122	if v == nil {
123		return "null"
124	}
125	return roundJSON(e, e.hole(), player.String(), v.(*round))
126}
127
128func sep(i int) string {
129	if i == 0 {
130		return ""
131	}
132	return ","
133}
134
135// jnum is a number with 3 decimals, the bytes of ufmt's "%.3f" at a fraction
136// of its gas. NaN and Inf, not JSON, print 0. Below 1e12, v*1000 is within
137// 1.2e-4 of the exact decimal, so its integer part decides the rounding
138// unless the fraction is within 1e-3 of a half: those, and huge numbers, go
139// to strconv, which is exact.
140func jnum(v float64) string {
141	if math.IsNaN(v) || math.IsInf(v, 0) {
142		return "0"
143	}
144	a := v * 1000
145	if a < 0 {
146		a = -a
147	}
148	if a >= 1e12 {
149		return strconv.FormatFloat(v, 'f', 3, 64)
150	}
151	k := int64(a)
152	switch f := a - float64(k); {
153	case f > 0.499 && f < 0.501:
154		return strconv.FormatFloat(v, 'f', 3, 64)
155	case f > 0.5:
156		k++
157	}
158	frac := k % 1000
159	s := strconv.FormatInt(k/1000, 10) + "."
160	if frac < 100 {
161		s += "0"
162	}
163	if frac < 10 {
164		s += "0"
165	}
166	s += strconv.FormatInt(frac, 10)
167	if math.Signbit(v) { // "%.3f" keeps the sign of a negative that rounds to 0
168		s = "-" + s
169	}
170	return s
171}
172
173// jexact is a point exactly, as the shortest decimals that read back as the
174// same float64: what a client hands back to SimulateFrom.
175func jexact(v physics.Vec2) string {
176	return "[" + strconv.FormatFloat(v.X, 'g', -1, 64) + "," + strconv.FormatFloat(v.Y, 'g', -1, 64) + "]"
177}
178
179// versionJSON opens every object a read returns.
180const versionJSON = `"version":1`
181
182func jvec(v physics.Vec2) string { return "[" + jnum(v.X) + "," + jnum(v.Y) + "]" }
183
184// jstr quotes an author's string so it cannot break out of its JSON. Every
185// byte below 0x20 is escaped: one in one hole's name would make Holes()
186// invalid JSON for every client.
187func jstr(s string) string {
188	const hex = "0123456789abcdef"
189	b := make([]byte, 0, len(s)+2)
190	b = append(b, '"')
191	for i := 0; i < len(s); i++ {
192		c := s[i]
193		switch {
194		case c == '"' || c == '\\':
195			b = append(b, '\\', c)
196		case c < 0x20:
197			b = append(b, '\\', 'u', '0', '0', hex[c>>4], hex[c&15])
198		default:
199			b = append(b, c)
200		}
201	}
202	return string(append(b, '"'))
203}
204
205// Holes lists at most maxListed holes for a client's menu, as listed orders
206// them: the course's current holes, its latest archived versions, then
207// community holes, newest first, perAuthor an address, none hidden (the rest
208// are in Community). official marks the course's holes, the only ones in a
209// cup and the course ranking; next is the version that replaced a hole, slot
210// its alias, play the 3D client's link, successor the realm that took the
211// course over ("" if none). It decodes nothing.
212func Holes() string {
213	var sb strings.Builder
214	sb.WriteString("{" + versionJSON + `,"play":` + jstr(playURL) + `,"successor":` + jstr(successor) + `,"holes":[`)
215	for i, e := range listed() {
216		sb.WriteString(sep(i) + holeRow(e))
217	}
218	sb.WriteString("]}")
219	return sb.String()
220}
221
222// holeRow is one hole as Holes and Community list it.
223func holeRow(e *entry) string {
224	s := `{"id":` + jstr(e.id) +
225		`,"name":` + jstr(e.name) +
226		`,"official":` + strconv.FormatBool(e.official) +
227		`,"plays":` + strconv.Itoa(e.plays) +
228		`,"best":` + strconv.Itoa(e.rec(assisted).best) +
229		`,"proBest":` + strconv.Itoa(e.rec(pro).best) +
230		`,"par":` + strconv.Itoa(e.par) +
231		`,"world":` + jstr(e.world) + `,"order":` + jnum(e.order) + `,"next":` + jstr(e.next)
232	if e.slot != "" {
233		s += `,"slot":` + jstr(e.slot)
234	}
235	return s + "}"
236}
237
238// Community is a page of every community hole, every version of each, by id:
239// all that Holes leaves out. It is paged like Records.
240func Community(after string, limit int) string {
241	var sb strings.Builder
242	sb.WriteString("{" + versionJSON + `,"rows":[`)
243	next := page(bpWalk(community), after, limit, func(i int, _ string, v any) {
244		sb.WriteString(sep(i) + holeRow(v.(*entry)))
245	})
246	sb.WriteString(`],"next":` + jstr(next) + "}")
247	return sb.String()
248}
249
250// perAuthor is the most published holes of one address Holes and the hub
251// list: one address publishing a hundred cannot push everyone else's out.
252const perAuthor = 3
253
254// listed is the holes in list order, at most maxListed: every current course
255// hole in course order; then, if minCommunity places stay free, up to
256// maxArchived archived course versions, latest archived first; then, in the
257// room left, the newest shown community holes (current, not hidden) among
258// each author's perAuthor newest, by block height then alias, last first. An
259// author fills perAuthor rows at most, however many holes they publish.
260//
261// It reads about what it lists: authors latest first, perAuthor keys each,
262// until an author's latest is older than every row kept (room+1 authors at
263// most). Hidden holes have no byAuthor row: nothing hidden is walked.
264func listed() []*entry {
265	list := make([]*entry, 0, maxListed)
266	full := func() bool { return len(list) >= maxListed }
267	slots.Iterate("", "", func(_ string, v any) bool {
268		list = append(list, find(v.(string)))
269		return full()
270	})
271	if room := maxListed - minCommunity - len(list); room > 0 {
272		if room > maxArchived {
273			room = maxArchived
274		}
275		archived.ReverseIterate("", "", func(_ string, v any) bool {
276			list = append(list, find(v.(string)))
277			room--
278			return room == 0
279		})
280	}
281	room := maxListed - len(list)
282	if room <= 0 {
283		return list
284	}
285	// the newest room of the rows seen, newest first, by recentKey(height,
286	// alias): an author walked later has only older holes than the one
287	// before's latest, so a row past room never comes back
288	type seen struct{ key, id string }
289	top := make([]seen, 0, room)
290	byAuthor.ReverseIterate("", authorRows, func(ak string, _ any) bool {
291		if len(top) == room && ak < top[room-1].key {
292			return true // their latest, and so all theirs, are older than all kept
293		}
294		a := ak[13:] + " "
295		n := 0
296		byAuthor.ReverseIterate(a, a[:len(a)-1]+"!", func(k string, v any) bool {
297			// "<address> <height> <slug>" -> "<height> <address>/<slug>"
298			key := k[len(a):len(a)+12] + " " + a[:len(a)-1] + "/" + k[len(a)+13:]
299			i := len(top)
300			for i > 0 && top[i-1].key < key {
301				i--
302			}
303			if i < room {
304				if len(top) < room {
305					top = append(top, seen{})
306				}
307				copy(top[i+1:], top[i:len(top)-1])
308				top[i] = seen{key, v.(string)}
309			}
310			n++
311			return n == perAuthor
312		})
313		return false
314	})
315	for _, s := range top {
316		list = append(list, find(s.id))
317	}
318	return list
319}
320
321// authorRows is past the last of byAuthor's author rows ("<height> <address>")
322// and before its first hole ("g1…"): no key is ":".
323const authorRows = ":"
324
325// maxArchived bounds the archived course versions Holes and the hub list,
326// and minCommunity is the room they leave for community holes.
327const (
328	maxArchived  = 20
329	minCommunity = 20
330)
331
332// SimulateFrom previews one shot, read-only: from an exact ball (a "rest"
333// answered before), shot "angle,power,tick", its stroke number (0 is the
334// first; timed and pulse holes change with it) and any period not ahead.
335// It is what PlayRoundAt would play, for one shot's gas. As a vm/qeval query
336// it needs no transaction or wallet: a client animates a shot the instant it
337// is released.
338func SimulateFrom(hole string, ballX, ballY float64, shot string, stroke int, period int64) string {
339	notAhead(period)
340	validStroke(stroke)
341	angle, power, tick := parseShot(shot)
342	e := readHole(hole)
343	h := e.hole()
344	ball := onBoard(h, ballX, ballY)
345	angle, power = validShot(angle, power)
346	sh, holed := resolve(h.PreviewWith(ball, angle*math.Pi/180, power, stroke, tick, weatherOf(e, h, period).Zones))
347	sh = back(h, ball, sh, holed)
348	return "{" + versionJSON + `,"holed":` + strconv.FormatBool(holed) + `,"bounces":` + strconv.Itoa(sh.Bounces) + shotJSON(sh) + "}"
349}
350
351func validStroke(stroke int) {
352	if stroke < 0 || stroke >= maxRoundStrokes {
353		panic("golf: a stroke is between 0 and 59")
354	}
355}
356
357// onBoard refuses a ball off the board (or NaN): no round leaves one there.
358func onBoard(h *course.Simple, x, y float64) physics.Vec2 {
359	w, hh := h.W, h.H
360	if !(x >= 0 && x <= float64(w) && y >= 0 && y <= float64(hh)) {
361		panic("golf: the ball must be on the board")
362	}
363	return physics.Vec2{X: x, Y: y}
364}
365
366// shotJSON is a shot's path, air, cause, work (Shot.Work, which a client adds
367// up as a commit does) and exact resting point.
368func shotJSON(shot physics.Shot) string {
369	return `,"path":[` + pathJSON(shot.Path) + `],"air":` + jstr(airOf(shot)) + `,"cause":` + jstr(string(shot.Cause)) +
370		`,"work":` + strconv.Itoa(shot.Work) + `,"rest":` + jexact(shot.Rest())
371}
372
373// SimulateRound replays a shot list from the tee, read-only, and returns its
374// last shot and stroke count: what PlayRound would record, refusals included.
375// Continue from an exact "rest" (SimulateFrom): rounded points are for drawing.
376func SimulateRound(hole string, shots string) string {
377	return simulateRound(hole, nil, 0, shots, Period())
378}
379
380// SimulateRoundAt is SimulateRound in a period's weather, the current one or
381// the one before, as a round's first stroke: it agrees with PlayRoundAt.
382func SimulateRoundAt(hole string, shots string, period int64) string {
383	playablePeriod(period)
384	return simulateRound(hole, nil, 0, shots, period)
385}
386
387// SimulateRoundIn is SimulateRound in any period gone by, to check a recorded
388// round as the chain played it. Weather still to come is refused.
389func SimulateRoundIn(hole string, shots string, period int64) string {
390	notAhead(period)
391	return simulateRound(hole, nil, 0, shots, period)
392}
393
394// SimulateCommit previews the next PlayRoundAt (or PlayRoundPro) commit,
395// read-only: shots from the exact ball ("rest") at stroke number stroke (0 is
396// the first) in period's weather. It refuses what that commit would (too much
397// work, too many strokes, a weather over), so a client checks every commit
398// before it signs any. JSON as SimulateRound; strokes counts after the commit.
399func SimulateCommit(hole string, ballX, ballY float64, stroke int, shots string, period int64) string {
400	if stroke == 0 {
401		playablePeriod(period)
402	} else {
403		notAhead(period)
404		notOver(period)
405	}
406	validStroke(stroke)
407	return simulateRound(hole, &physics.Vec2{X: ballX, Y: ballY}, stroke, shots, period)
408}
409
410// simulateRound is the SimulateRound* and SimulateCommit replay: from nil is
411// the tee, and the first shot is stroke number strokes.
412func simulateRound(hole string, from *physics.Vec2, strokes int, shots string, period int64) string {
413	e := readHole(hole)
414	h := e.hole()
415	ball := h.Start()
416	if from != nil {
417		ball = onBoard(h, from.X, from.Y)
418	}
419	fc := weatherOf(e, h, period)
420	weather := fc.Zones
421	list := shotList(shots)
422	w := newWork(h, weather)
423	w.fixed = fixedGas(e, fc)
424	holed := false
425	var last physics.Shot
426	for _, s := range list {
427		if strokes >= maxRoundStrokes {
428			panic(errStrokeLimit)
429		}
430		w.next(h)
431		angle, power, tick := parseShot(s)
432		angle, power = validShot(angle, power)
433		last, holed = resolve(h.PreviewWith(ball, angle*math.Pi/180, power, strokes, tick, weather))
434		last = back(h, ball, last, holed)
435		w.add(last)
436		ball = last.Rest()
437		strokes++
438		if holed {
439			break
440		}
441	}
442	return "{" + versionJSON + `,"holed":` + strconv.FormatBool(holed) + `,"strokes":` + strconv.Itoa(strokes) +
443		`,"bounces":` + strconv.Itoa(last.Bounces) + `,"period":` + strconv.FormatInt(period, 10) +
444		shotJSON(last) + "}"
445}
446
447func boardJSON(h *course.Simple) string {
448	w, hh := h.W, h.H
449	return `,"board":{"w":` + strconv.Itoa(w) + `,"h":` + strconv.Itoa(hh) + "}"
450}
451
452func wallsJSON(ws []physics.Wall) string {
453	var sb strings.Builder
454	sb.WriteString("[")
455	for i := range ws {
456		w := &ws[i]
457		sb.WriteString(sep(i) + "{" + `"a":` + jvec(w.Seg.A) + `,"b":` + jvec(w.Seg.B) +
458			`,"skin":` + jstr(cleanSkin(w.Skin)) + timingJSON(w.Every, w.On, w.Phase) + "}")
459	}
460	sb.WriteString("]")
461	return sb.String()
462}
463
464func postsJSON(ps []physics.Post) string {
465	var sb strings.Builder
466	sb.WriteString("[")
467	for i := range ps {
468		p := &ps[i]
469		sb.WriteString(sep(i) + "{" + `"c":` + jvec(p.C) + `,"r":` + jnum(p.R) +
470			`,"skin":` + jstr(cleanSkin(p.Skin)) + "}")
471	}
472	sb.WriteString("]")
473	return sb.String()
474}
475
476// zonesJSON is a hole's zones, or the weather's (forecastJSON: no flags).
477// With flags, "air" and "capped" are printed only when true.
478func zonesJSON(zs []physics.Zone, flags bool) string {
479	var sb strings.Builder
480	sb.WriteString("[")
481	for i := range zs {
482		z := &zs[i]
483		sb.WriteString(sep(i) + "{" + `"kind":` + jstr(z.Kind.String()) +
484			`,"min":` + jvec(z.Min) + `,"max":` + jvec(z.Max) +
485			`,"vec":` + jvec(z.Vec) + `,"scale":` + jnum(z.Scale) +
486			`,"round":` + strconv.FormatBool(z.Round) + polyJSON(z) + timingJSON(z.Every, z.On, z.Phase))
487		if flags && z.Air {
488			sb.WriteString(`,"air":true`)
489		}
490		if flags && z.Capped {
491			sb.WriteString(`,"capped":true`)
492		}
493		sb.WriteString(`,"skin":` + jstr(cleanSkin(z.Skin)) + "}")
494	}
495	sb.WriteString("]")
496	return sb.String()
497}
498
499// timingJSON is a timed piece's clock (a tram, a storm's gusts); nothing for
500// an untimed one.
501func timingJSON(every, on, phase int) string {
502	if every <= 0 {
503		return ""
504	}
505	return `,"every":` + strconv.Itoa(every) + `,"on":` + strconv.Itoa(on) + `,"phase":` + strconv.Itoa(phase)
506}
507
508// polyJSON is a polygon zone's outline, and whether it is the outside of it;
509// nothing for the usual rectangle or ellipse.
510func polyJSON(z *physics.Zone) string {
511	if len(z.Poly) < 3 {
512		return ""
513	}
514	return `,"poly":[` + pathJSON(z.Poly) + `],"outside":` + strconv.FormatBool(z.Outside)
515}
516
517// Extras is what a timed hole adds at one stroke of a round (0 is the first),
518// to draw over HoleState's field. Empty for other holes.
519func Extras(hole string, stroke int) string {
520	h := readHole(hole).hole()
521	if stroke < 0 {
522		return "{" + versionJSON + `,"walls":[],"posts":[],"zones":[]}`
523	}
524	ws, ps := h.Extras(stroke)
525	return "{" + versionJSON + `,"walls":` + wallsJSON(ws) + `,"posts":` + postsJSON(ps) + `,"zones":` + zonesJSON(h.ExtraZones(stroke), true) + "}"
526}
527
528// Leaderboard is a mode's course-wide top ten ("assisted" or "pro"): named
529// players' bests on the current course holes, summed; most holes, then the
530// best score against par (strokes - par), then the first there. Its cost does
531// not grow with players.
532func Leaderboard(mode string) string {
533	m := modeOf(mode)
534	var sb strings.Builder
535	// holes: the course's current ones, the only ones that count
536	sb.WriteString("{" + versionJSON + `,"mode":` + jstr(modeNames[m]) + `,"holes":` + strconv.Itoa(slots.Size()) + `,"rows":[`)
537	n := 0
538	eachRanked(m, func(r *row, name string) {
539		sb.WriteString(sep(n) + standingRow(r.player, name, r))
540		n++
541	})
542	sb.WriteString("]}")
543	return sb.String()
544}
545
546// eachRanked walks a mode's top ten: the first named players of its ranking,
547// with their names. A name deleted since is skipped, not shown.
548func eachRanked(m int, fn func(r *row, name string)) {
549	n, walked := 0, 0
550	ranks[m].Iterate("", "", func(_ string, v any) bool {
551		walked++ // skipped names count: the walk stays bounded
552		p := v.(string)
553		if name := nameOf(p); name != "" {
554			fn(totals[m].Get(p).(*row), name)
555			n++
556		}
557		return n >= topSize || walked >= maxWalked
558	})
559}
560
561// maxWalked is the most rows a top ten walks, skipped names included.
562const maxWalked = 4 * topSize
563
564// strokesRow and standingRow are a player's best or standing row; name is ""
565// on the reads that resolve no names. A standing's par is its holes' pars
566// summed: strokes - par is its score against par.
567func strokesRow(p, name string, strokes int) string {
568	return `{"player":` + jstr(p) + nameField(name) + `,"strokes":` + strconv.Itoa(strokes) + "}"
569}
570
571func standingRow(p, name string, r *row) string {
572	return `{"player":` + jstr(p) + nameField(name) + standingFields(r) + "}"
573}
574
575// standingFields is a standing's numbers: holes, strokes and par.
576func standingFields(r *row) string {
577	return `,"holes":` + strconv.Itoa(r.holes) + `,"strokes":` + strconv.Itoa(r.strokes) + `,"par":` + strconv.Itoa(r.par)
578}
579
580func nameField(name string) string {
581	if name == "" {
582		return ""
583	}
584	return `,"name":` + jstr(name)
585}
586
587// Rank is a player's place in a mode's course ranking, with their standing:
588// "rank" 1 is the top, out of "of"; 0 if unnamed or no current course hole
589// finished. A name deleted since keeps its place until its next change, so a
590// rank may be that many too low.
591func Rank(mode string, player address) string {
592	m := modeOf(mode)
593	p := player.String()
594	rank, r := 0, &row{}
595	if v := totals[m].Get(p); v != nil {
596		r = v.(*row)
597		if key := rankKey(r); ranks[m].Has(key) {
598			rank = ranks[m].index(key) + 1
599		}
600	}
601	return "{" + versionJSON + `,"mode":` + jstr(modeNames[m]) + `,"player":` + jstr(p) +
602		`,"rank":` + strconv.Itoa(rank) + `,"of":` + strconv.Itoa(ranks[m].Size()) + standingFields(r) + "}"
603}
604
605// CourseLeaderboard is a page of a mode's course ranking, paged like
606// HoleLeaderboard. holes is the course's current holes, players how many the
607// ranking holds.
608func CourseLeaderboard(mode string, offset, limit int) string {
609	m := modeOf(mode)
610	limit = clampLimit(limit)
611	size := ranks[m].Size()
612	if offset < 0 {
613		offset = 0
614	}
615	if offset > size {
616		offset = size // and offset+limit cannot overflow
617	}
618	var sb strings.Builder
619	sb.WriteString("{" + versionJSON + `,"mode":` + jstr(modeNames[m]) + `,"holes":` + strconv.Itoa(slots.Size()) +
620		`,"players":` + strconv.Itoa(size) + `,"offset":` + strconv.Itoa(offset) + `,"rows":[`)
621	n := 0
622	ranks[m].IterateByOffset(offset, limit, func(_ string, v any) bool {
623		p := v.(string)
624		name := nameOf(p)
625		if name == "" {
626			return false // a name deleted since
627		}
628		sb.WriteString(sep(n) + standingRow(p, name, totals[m].Get(p).(*row)))
629		n++
630		return false
631	})
632	next := 0
633	if offset+limit < size {
634		next = offset + limit
635	}
636	sb.WriteString(`],"next":` + strconv.Itoa(next) + "}")
637	return sb.String()
638}
639
640// Ghost is a player's best round on a hole in a mode, named or not, with its
641// height (the first to a score ranks first), period and shots, or null: what a
642// duel races. It replays in its own weather (SimulateRoundIn for the first
643// stroke, then SimulateFrom from each rest).
644func Ghost(hole, mode string, player address) string {
645	e, m := readHole(hole), modeOf(mode)
646	v := e.bests(m).Get(player.String())
647	if v == nil {
648		return "null"
649	}
650	strokes := bestStrokes(v)
651	at, period, shots := bestFields(v)
652	return "{" + versionJSON + `,"hole":` + jstr(e.id) + `,"mode":` + jstr(modeNames[m]) + `,"player":` + jstr(player.String()) +
653		`,"height":` + strconv.FormatInt(at, 10) + `,"strokes":` + strconv.Itoa(strokes) + `,"period":` + strconv.FormatInt(period, 10) + `,"shots":` + jstr(shots) + "}"
654}
655
656// HoleRank is a player's place on a hole's board in a mode, with their best,
657// as Rank: 0 if unnamed or no finish there.
658func HoleRank(hole, mode string, player address) string {
659	e, m := readHole(hole), modeOf(mode)
660	p := player.String()
661	v := e.bests(m).Get(p)
662	rank, strokes := 0, bestStrokes(v)
663	if strokes > 0 {
664		if k := boardKey(v, p); e.board(m).Has(k) {
665			rank = e.board(m).index(k) + 1
666		}
667	}
668	return "{" + versionJSON + `,"hole":` + jstr(e.id) + `,"mode":` + jstr(modeNames[m]) + `,"player":` + jstr(p) +
669		`,"rank":` + strconv.Itoa(rank) + `,"of":` + strconv.Itoa(e.board(m).Size()) + `,"strokes":` + strconv.Itoa(strokes) + "}"
670}
671
672// maxListed bounds Holes() and the hub: anyone can add a hole.
673const maxListed = 120
674
675// HoleLeaderboard is a page of a hole's board in a mode: named players' bests,
676// fewest strokes then first there, from rank offset+1 (clamped to
677// 0..players), at most limit rows (1..100). players is the named players,
678// finished anyone who finished; next is the next offset, 0 at the end. A name
679// deleted since is skipped, so a page may hold fewer rows. A page reads its
680// own rows only.
681func HoleLeaderboard(hole, mode string, offset, limit int) string {
682	e, m := readHole(hole), modeOf(mode)
683	limit = clampLimit(limit)
684	board := e.board(m)
685	size := board.Size()
686	if offset < 0 {
687		offset = 0
688	}
689	if offset > size {
690		offset = size // and offset+limit cannot overflow
691	}
692	var sb strings.Builder
693	sb.WriteString("{" + versionJSON + `,"hole":` + jstr(e.id) + `,"mode":` + jstr(modeNames[m]) +
694		`,"par":` + strconv.Itoa(e.par) + `,"players":` + strconv.Itoa(size) + `,"finished":` + strconv.Itoa(e.bests(m).Size()) +
695		`,"offset":` + strconv.Itoa(offset) + `,"rows":[`)
696	n := 0
697	board.IterateByOffset(offset, limit, func(k string, v any) bool {
698		p := v.(string)
699		name := nameOf(p)
700		if name == "" {
701			return false // a name deleted since
702		}
703		sb.WriteString(sep(n) + strokesRow(p, name, boardStrokes(k)))
704		n++
705		return false
706	})
707	next := 0
708	if offset+limit < size {
709		next = offset + limit
710	}
711	sb.WriteString(`],"next":` + strconv.Itoa(next) + "}")
712	return sb.String()
713}
714
715// pageMax is the most rows one page of a read returns.
716const pageMax = 100
717
718// clampLimit keeps a page's size to 1..pageMax.
719func clampLimit(n int) int {
720	if n < 1 {
721		return 1
722	}
723	if n > pageMax {
724		return pageMax
725	}
726	return n
727}
728
729// maxFriends bounds the addresses one Bests or Standings read takes.
730const maxFriends = 50
731
732// friends splits a comma-separated list of addresses: each valid one once,
733// at most maxFriends. Junk and repeats are skipped and use up nothing.
734func friends(list string) []string {
735	var out []string
736	seen := map[string]bool{}
737	for _, p := range strings.Split(list, ",") {
738		if len(out) == maxFriends {
739			break
740		}
741		if p = strings.TrimSpace(p); p != "" && !seen[p] && address(p).IsValid() {
742			seen[p] = true
743			out = append(out, p)
744		}
745	}
746	return out
747}
748
749// Bests is the best finished round on one hole, in a mode, of each of the
750// given players (comma-separated addresses, named or not): a board of
751// friends, where no stranger and no bot can push anyone off.
752func Bests(hole, mode, players string) string {
753	e, m := readHole(hole), modeOf(mode)
754	var sb strings.Builder
755	sb.WriteString("{" + versionJSON + `,"hole":` + jstr(e.id) + `,"mode":` + jstr(modeNames[m]) + `,"par":` + strconv.Itoa(e.par) + `,"rows":[`)
756	n := 0
757	for _, p := range friends(players) {
758		if v := e.bests(m).Get(p); v != nil {
759			sb.WriteString(sep(n) + strokesRow(p, "", bestStrokes(v)))
760			n++
761		}
762	}
763	sb.WriteString("]}")
764	return sb.String()
765}
766
767// Standings is each given player's course-wide standing in a mode: how many
768// of the course's current holes they finished, in how many strokes, and
769// those holes' pars summed.
770func Standings(mode, players string) string {
771	m := modeOf(mode)
772	var sb strings.Builder
773	sb.WriteString("{" + versionJSON + `,"mode":` + jstr(modeNames[m]) + `,"holes":` + strconv.Itoa(slots.Size()) + `,"rows":[`)
774	n := 0
775	for _, p := range friends(players) {
776		if v := totals[m].Get(p); v != nil {
777			sb.WriteString(sep(n) + standingRow(p, "", v.(*row)))
778			n++
779		}
780	}
781	sb.WriteString("]}")
782	return sb.String()
783}
784
785// Records is a page of every player's best on a hole in a mode, named or not,
786// with the height that ranks it before a later tie: all a successor needs to
787// carry the boards over in their order.
788// The paged reads (Records, Players, Community) walk everything by
789// key: after is the last key of the page before ("" for the first), limit is
790// clamped to 1..100, next is the after of the next page ("" at the end).
791func Records(hole, mode, after string, limit int) string {
792	e, m := readHole(hole), modeOf(mode)
793	var sb strings.Builder
794	sb.WriteString("{" + versionJSON + `,"hole":` + jstr(e.id) + `,"mode":` + jstr(modeNames[m]) + `,"rows":[`)
795	next := page(e.bests(m).Iterate, after, limit, func(i int, p string, v any) {
796		at, _, _ := bestFields(v)
797		sb.WriteString(sep(i) + `{"player":` + jstr(p) + `,"strokes":` + strconv.Itoa(bestStrokes(v)) + `,"height":` + strconv.FormatInt(at, 10) + "}")
798	})
799	sb.WriteString(`],"next":` + jstr(next) + "}")
800	return sb.String()
801}
802
803// Players is a page of every course standing in a mode, named or not: its
804// holes, strokes and par (all the ranking's key reads) and the height of the
805// finish that last improved it (the tie-break), paged like Records.
806func Players(mode, after string, limit int) string {
807	m := modeOf(mode)
808	var sb strings.Builder
809	sb.WriteString("{" + versionJSON + `,"mode":` + jstr(modeNames[m]) + `,"rows":[`)
810	next := page(totals[m].Iterate, after, limit, func(i int, p string, v any) {
811		r := v.(*row)
812		sb.WriteString(sep(i) + `{"player":` + jstr(p) + standingFields(r) + `,"height":` + strconv.FormatInt(r.height, 10) + "}")
813	})
814	sb.WriteString(`],"next":` + jstr(next) + "}")
815	return sb.String()
816}
817
818// walk is a tree's Iterate: what page reads.
819type walk func(start, end string, cb func(key string, v any) bool) bool
820
821func bpWalk(t *bptree.BPTree) walk {
822	return func(start, end string, cb func(string, any) bool) bool { return t.Iterate(start, end, cb) }
823}
824
825// page walks up to limit entries of t after the key after, and returns the
826// key to continue from, "" at the end.
827func page(t walk, after string, limit int, fn func(i int, key string, v any)) string {
828	limit = clampLimit(limit)
829	start := ""
830	if after != "" {
831		start = after + "\x00" // the first key past after
832	}
833	n, next, last := 0, "", ""
834	t(start, "", func(k string, v any) bool {
835		if n == limit {
836			next = last
837			return true
838		}
839		fn(n, k, v)
840		last = k
841		n++
842		return false
843	})
844	return next
845}