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}