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}