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

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