package golf
import (
"math"
"strconv"
"strings"
"gno.land/p/nym-alexiscolin000/gnogolf/course"
"gno.land/p/nym-alexiscolin000/gnogolf/physics"
bptree "gno.land/p/nt/bptree/v0"
"gno.land/p/nt/ufmt/v0"
)
// HoleState is the hole as JSON for a client: geometry, skins (names the
// client looks up), weather and wear, all it needs to draw the hole and aim.
// hole is a version's id or an alias; "hole" in the answer is always the
// version's id, the one to write with, with its "slot" and "v", and an
// archived one's "next".
//
// A shot's path (Round, Simulate*) is the flight the chain computed: replay
// it, never re-simulate. Points far apart are a tunnel: do not interpolate.
func HoleState(hole string) string {
e := readHole(hole)
h := e.hole()
f := h.Field()
var sb strings.Builder
sb.WriteString("{" + versionJSON +
`,"hole":` + jstr(e.id) +
`,"name":` + jstr(e.name) +
`,"official":` + strconv.FormatBool(e.official))
if e.slot != "" {
sb.WriteString(`,"slot":` + jstr(e.slot) + `,"v":` + strconv.Itoa(e.version))
}
if e.next != "" {
sb.WriteString(`,"next":` + jstr(e.next))
}
p := Period()
sb.WriteString(boardJSON(h) +
`,"par":` + strconv.Itoa(e.par) +
`,"world":` + jstr(e.world) + `,"order":` + jnum(e.order) +
`,"timed":` + strconv.FormatBool(h.Varies()) +
`,"period":` + strconv.FormatInt(p, 10) +
`,"weather":` + forecastJSON("", weatherOf(e, h, p)) +
`,"start":` + jvec(h.Start()) +
`,"cup":` + jvec(h.Cup()) +
`,"cupR":` + jnum(h.CupRadius) + `,"ballR":` + jnum(f.Radius))
// the hole's work before the weather (newWork): the client splits a save
// and sets its gas by it
w := newWork(h, nil)
sb.WriteString(ufmt.Sprintf(`,"work":{"walls":%d,"pieces":%d,"setup":%d}`, w.walls, w.pieces, w.setup))
sb.WriteString(`,"walls":` + wallsJSON(f.Walls) + `,"posts":` + postsJSON(f.Posts))
sb.WriteString(`,"zones":` + zonesJSON(f.Zones, true))
sb.WriteString(`,"wear":{"w":` + strconv.Itoa(course.WearW) + `,"h":` + strconv.Itoa(course.WearH) + `,"cells":[`)
for i, c := range e.wearGrid() {
sb.WriteString(sep(i))
sb.WriteString(strconv.Itoa(c))
}
sb.WriteString("]}}")
return sb.String()
}
// roundJSON is one player's round: ball rounded, rest exact (for SimulateFrom)
// and the last stroke replayed: rounds store shots, not paths.
func roundJSON(e *entry, h *course.Simple, player string, r *round) string {
var sb strings.Builder
sb.WriteString("{" + versionJSON + `,"player":` + jstr(player) +
`,"ball":` + jvec(r.ball()) + `,"rest":` + jexact(r.ball()) +
ufmt.Sprintf(`,"strokes":%d,"period":%d`, r.strokes, r.period) +
`,"mode":` + jstr(modeNames[r.mode]) +
`,"shots":` + jstr(r.shots))
shot := lastShot(h, r, weatherOf(e, h, r.period))
sb.WriteString(`,"path":[` + pathJSON(shot.Path) + `],"air":` + jstr(airOf(shot)) + `,"cause":` + jstr(string(shot.Cause)))
sb.WriteString("}")
return sb.String()
}
// lastShot replays a round's last stroke, read-only, with its stroke number,
// tick and weather (fc, its period's forecast): the same flight, less the
// wear. Empty before a first one.
func lastShot(h *course.Simple, r *round, fc course.Forecast) physics.Shot {
if r.strokes == 0 || r.shots == "" {
return physics.Shot{}
}
s := r.shots
if i := strings.LastIndex(s, ";"); i >= 0 {
s = s[i+1:]
}
angle, power, tick := parseShot(s)
from := physics.Vec2{X: r.fx, Y: r.fy}
shot, holed := h.PreviewWith(from, angle*math.Pi/180, power, r.strokes-1, tick, fc.Zones)
return back(h, from, shot, holed)
}
// pathJSON is a shot's path points, comma separated.
func pathJSON(ps []physics.Vec2) string {
var sb strings.Builder
for i, p := range ps {
sb.WriteString(sep(i))
sb.WriteString(jvec(p))
}
return sb.String()
}
// airOf is a shot's air flags, one character per path point: "0001100…".
func airOf(s physics.Shot) string {
b := make([]byte, len(s.Path))
for i := range b {
b[i] = '0'
if i < len(s.Air) && s.Air[i] {
b[i] = '1'
}
}
return string(b)
}
// Round is one player's round under way on a hole with its last stroke's
// path, or null if they have none: never started, holed (its best is kept:
// Ghost) or Reset. It costs one stroke of gas.
func Round(hole string, player address) string {
e := readHole(hole)
v := e.roundTree().Get(player.String())
if v == nil {
return "null"
}
return roundJSON(e, e.hole(), player.String(), v.(*round))
}
func sep(i int) string {
if i == 0 {
return ""
}
return ","
}
// jnum is a number with 3 decimals, the bytes of ufmt's "%.3f" at a fraction
// of its gas. NaN and Inf, not JSON, print 0. Below 1e12, v*1000 is within
// 1.2e-4 of the exact decimal, so its integer part decides the rounding
// unless the fraction is within 1e-3 of a half: those, and huge numbers, go
// to strconv, which is exact.
func jnum(v float64) string {
if math.IsNaN(v) || math.IsInf(v, 0) {
return "0"
}
a := v * 1000
if a < 0 {
a = -a
}
if a >= 1e12 {
return strconv.FormatFloat(v, 'f', 3, 64)
}
k := int64(a)
switch f := a - float64(k); {
case f > 0.499 && f < 0.501:
return strconv.FormatFloat(v, 'f', 3, 64)
case f > 0.5:
k++
}
frac := k % 1000
s := strconv.FormatInt(k/1000, 10) + "."
if frac < 100 {
s += "0"
}
if frac < 10 {
s += "0"
}
s += strconv.FormatInt(frac, 10)
if math.Signbit(v) { // "%.3f" keeps the sign of a negative that rounds to 0
s = "-" + s
}
return s
}
// jexact is a point exactly, as the shortest decimals that read back as the
// same float64: what a client hands back to SimulateFrom.
func jexact(v physics.Vec2) string {
return "[" + strconv.FormatFloat(v.X, 'g', -1, 64) + "," + strconv.FormatFloat(v.Y, 'g', -1, 64) + "]"
}
// versionJSON opens every object a read returns.
const versionJSON = `"version":1`
func jvec(v physics.Vec2) string { return "[" + jnum(v.X) + "," + jnum(v.Y) + "]" }
// jstr quotes an author's string so it cannot break out of its JSON. Every
// byte below 0x20 is escaped: one in one hole's name would make Holes()
// invalid JSON for every client.
func jstr(s string) string {
const hex = "0123456789abcdef"
b := make([]byte, 0, len(s)+2)
b = append(b, '"')
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '"' || c == '\\':
b = append(b, '\\', c)
case c < 0x20:
b = append(b, '\\', 'u', '0', '0', hex[c>>4], hex[c&15])
default:
b = append(b, c)
}
}
return string(append(b, '"'))
}
// Holes lists at most maxListed holes for a client's menu, as listed orders
// them: the course's current holes, its latest archived versions, then
// community holes, newest first, perAuthor an address, none hidden (the rest
// are in Community). official marks the course's holes, the only ones in a
// cup and the course ranking; next is the version that replaced a hole, slot
// its alias, play the 3D client's link, successor the realm that took the
// course over ("" if none). It decodes nothing.
func Holes() string {
var sb strings.Builder
sb.WriteString("{" + versionJSON + `,"play":` + jstr(playURL) + `,"successor":` + jstr(successor) + `,"holes":[`)
for i, e := range listed() {
sb.WriteString(sep(i) + holeRow(e))
}
sb.WriteString("]}")
return sb.String()
}
// holeRow is one hole as Holes and Community list it.
func holeRow(e *entry) string {
s := `{"id":` + jstr(e.id) +
`,"name":` + jstr(e.name) +
`,"official":` + strconv.FormatBool(e.official) +
`,"plays":` + strconv.Itoa(e.plays) +
`,"best":` + strconv.Itoa(e.rec(assisted).best) +
`,"proBest":` + strconv.Itoa(e.rec(pro).best) +
`,"par":` + strconv.Itoa(e.par) +
`,"world":` + jstr(e.world) + `,"order":` + jnum(e.order) + `,"next":` + jstr(e.next)
if e.slot != "" {
s += `,"slot":` + jstr(e.slot)
}
return s + "}"
}
// Community is a page of every community hole, every version of each, by id:
// all that Holes leaves out. It is paged like Records.
func Community(after string, limit int) string {
var sb strings.Builder
sb.WriteString("{" + versionJSON + `,"rows":[`)
next := page(bpWalk(community), after, limit, func(i int, _ string, v any) {
sb.WriteString(sep(i) + holeRow(v.(*entry)))
})
sb.WriteString(`],"next":` + jstr(next) + "}")
return sb.String()
}
// perAuthor is the most published holes of one address Holes and the hub
// list: one address publishing a hundred cannot push everyone else's out.
const perAuthor = 3
// listed is the holes in list order, at most maxListed: every current course
// hole in course order; then, if minCommunity places stay free, up to
// maxArchived archived course versions, latest archived first; then, in the
// room left, the newest shown community holes (current, not hidden) among
// each author's perAuthor newest, by block height then alias, last first. An
// author fills perAuthor rows at most, however many holes they publish.
//
// It reads about what it lists: authors latest first, perAuthor keys each,
// until an author's latest is older than every row kept (room+1 authors at
// most). Hidden holes have no byAuthor row: nothing hidden is walked.
func listed() []*entry {
list := make([]*entry, 0, maxListed)
full := func() bool { return len(list) >= maxListed }
slots.Iterate("", "", func(_ string, v any) bool {
list = append(list, find(v.(string)))
return full()
})
if room := maxListed - minCommunity - len(list); room > 0 {
if room > maxArchived {
room = maxArchived
}
archived.ReverseIterate("", "", func(_ string, v any) bool {
list = append(list, find(v.(string)))
room--
return room == 0
})
}
room := maxListed - len(list)
if room <= 0 {
return list
}
// the newest room of the rows seen, newest first, by recentKey(height,
// alias): an author walked later has only older holes than the one
// before's latest, so a row past room never comes back
type seen struct{ key, id string }
top := make([]seen, 0, room)
byAuthor.ReverseIterate("", authorRows, func(ak string, _ any) bool {
if len(top) == room && ak < top[room-1].key {
return true // their latest, and so all theirs, are older than all kept
}
a := ak[13:] + " "
n := 0
byAuthor.ReverseIterate(a, a[:len(a)-1]+"!", func(k string, v any) bool {
// "
" -> " /"
key := k[len(a):len(a)+12] + " " + a[:len(a)-1] + "/" + k[len(a)+13:]
i := len(top)
for i > 0 && top[i-1].key < key {
i--
}
if i < room {
if len(top) < room {
top = append(top, seen{})
}
copy(top[i+1:], top[i:len(top)-1])
top[i] = seen{key, v.(string)}
}
n++
return n == perAuthor
})
return false
})
for _, s := range top {
list = append(list, find(s.id))
}
return list
}
// authorRows is past the last of byAuthor's author rows (" ")
// and before its first hole ("g1…"): no key is ":".
const authorRows = ":"
// maxArchived bounds the archived course versions Holes and the hub list,
// and minCommunity is the room they leave for community holes.
const (
maxArchived = 20
minCommunity = 20
)
// SimulateFrom previews one shot, read-only: from an exact ball (a "rest"
// answered before), shot "angle,power,tick", its stroke number (0 is the
// first; timed and pulse holes change with it) and any period not ahead.
// It is what PlayRoundAt would play, for one shot's gas. As a vm/qeval query
// it needs no transaction or wallet: a client animates a shot the instant it
// is released.
func SimulateFrom(hole string, ballX, ballY float64, shot string, stroke int, period int64) string {
notAhead(period)
validStroke(stroke)
angle, power, tick := parseShot(shot)
e := readHole(hole)
h := e.hole()
ball := onBoard(h, ballX, ballY)
angle, power = validShot(angle, power)
sh, holed := resolve(h.PreviewWith(ball, angle*math.Pi/180, power, stroke, tick, weatherOf(e, h, period).Zones))
sh = back(h, ball, sh, holed)
return "{" + versionJSON + `,"holed":` + strconv.FormatBool(holed) + `,"bounces":` + strconv.Itoa(sh.Bounces) + shotJSON(sh) + "}"
}
func validStroke(stroke int) {
if stroke < 0 || stroke >= maxRoundStrokes {
panic("golf: a stroke is between 0 and 59")
}
}
// onBoard refuses a ball off the board (or NaN): no round leaves one there.
func onBoard(h *course.Simple, x, y float64) physics.Vec2 {
w, hh := h.W, h.H
if !(x >= 0 && x <= float64(w) && y >= 0 && y <= float64(hh)) {
panic("golf: the ball must be on the board")
}
return physics.Vec2{X: x, Y: y}
}
// shotJSON is a shot's path, air, cause, work (Shot.Work, which a client adds
// up as a commit does) and exact resting point.
func shotJSON(shot physics.Shot) string {
return `,"path":[` + pathJSON(shot.Path) + `],"air":` + jstr(airOf(shot)) + `,"cause":` + jstr(string(shot.Cause)) +
`,"work":` + strconv.Itoa(shot.Work) + `,"rest":` + jexact(shot.Rest())
}
// SimulateRound replays a shot list from the tee, read-only, and returns its
// last shot and stroke count: what PlayRound would record, refusals included.
// Continue from an exact "rest" (SimulateFrom): rounded points are for drawing.
func SimulateRound(hole string, shots string) string {
return simulateRound(hole, nil, 0, shots, Period())
}
// SimulateRoundAt is SimulateRound in a period's weather, the current one or
// the one before, as a round's first stroke: it agrees with PlayRoundAt.
func SimulateRoundAt(hole string, shots string, period int64) string {
playablePeriod(period)
return simulateRound(hole, nil, 0, shots, period)
}
// SimulateRoundIn is SimulateRound in any period gone by, to check a recorded
// round as the chain played it. Weather still to come is refused.
func SimulateRoundIn(hole string, shots string, period int64) string {
notAhead(period)
return simulateRound(hole, nil, 0, shots, period)
}
// SimulateCommit previews the next PlayRoundAt (or PlayRoundPro) commit,
// read-only: shots from the exact ball ("rest") at stroke number stroke (0 is
// the first) in period's weather. It refuses what that commit would (too much
// work, too many strokes, a weather over), so a client checks every commit
// before it signs any. JSON as SimulateRound; strokes counts after the commit.
func SimulateCommit(hole string, ballX, ballY float64, stroke int, shots string, period int64) string {
if stroke == 0 {
playablePeriod(period)
} else {
notAhead(period)
notOver(period)
}
validStroke(stroke)
return simulateRound(hole, &physics.Vec2{X: ballX, Y: ballY}, stroke, shots, period)
}
// simulateRound is the SimulateRound* and SimulateCommit replay: from nil is
// the tee, and the first shot is stroke number strokes.
func simulateRound(hole string, from *physics.Vec2, strokes int, shots string, period int64) string {
e := readHole(hole)
h := e.hole()
ball := h.Start()
if from != nil {
ball = onBoard(h, from.X, from.Y)
}
fc := weatherOf(e, h, period)
weather := fc.Zones
list := shotList(shots)
w := newWork(h, weather)
w.fixed = fixedGas(e, fc)
holed := false
var last physics.Shot
for _, s := range list {
if strokes >= maxRoundStrokes {
panic(errStrokeLimit)
}
w.next(h)
angle, power, tick := parseShot(s)
angle, power = validShot(angle, power)
last, holed = resolve(h.PreviewWith(ball, angle*math.Pi/180, power, strokes, tick, weather))
last = back(h, ball, last, holed)
w.add(last)
ball = last.Rest()
strokes++
if holed {
break
}
}
return "{" + versionJSON + `,"holed":` + strconv.FormatBool(holed) + `,"strokes":` + strconv.Itoa(strokes) +
`,"bounces":` + strconv.Itoa(last.Bounces) + `,"period":` + strconv.FormatInt(period, 10) +
shotJSON(last) + "}"
}
func boardJSON(h *course.Simple) string {
w, hh := h.W, h.H
return `,"board":{"w":` + strconv.Itoa(w) + `,"h":` + strconv.Itoa(hh) + "}"
}
func wallsJSON(ws []physics.Wall) string {
var sb strings.Builder
sb.WriteString("[")
for i := range ws {
w := &ws[i]
sb.WriteString(sep(i) + "{" + `"a":` + jvec(w.Seg.A) + `,"b":` + jvec(w.Seg.B) +
`,"skin":` + jstr(cleanSkin(w.Skin)) + timingJSON(w.Every, w.On, w.Phase) + "}")
}
sb.WriteString("]")
return sb.String()
}
func postsJSON(ps []physics.Post) string {
var sb strings.Builder
sb.WriteString("[")
for i := range ps {
p := &ps[i]
sb.WriteString(sep(i) + "{" + `"c":` + jvec(p.C) + `,"r":` + jnum(p.R) +
`,"skin":` + jstr(cleanSkin(p.Skin)) + "}")
}
sb.WriteString("]")
return sb.String()
}
// zonesJSON is a hole's zones, or the weather's (forecastJSON: no flags).
// With flags, "air" and "capped" are printed only when true.
func zonesJSON(zs []physics.Zone, flags bool) string {
var sb strings.Builder
sb.WriteString("[")
for i := range zs {
z := &zs[i]
sb.WriteString(sep(i) + "{" + `"kind":` + jstr(z.Kind.String()) +
`,"min":` + jvec(z.Min) + `,"max":` + jvec(z.Max) +
`,"vec":` + jvec(z.Vec) + `,"scale":` + jnum(z.Scale) +
`,"round":` + strconv.FormatBool(z.Round) + polyJSON(z) + timingJSON(z.Every, z.On, z.Phase))
if flags && z.Air {
sb.WriteString(`,"air":true`)
}
if flags && z.Capped {
sb.WriteString(`,"capped":true`)
}
sb.WriteString(`,"skin":` + jstr(cleanSkin(z.Skin)) + "}")
}
sb.WriteString("]")
return sb.String()
}
// timingJSON is a timed piece's clock (a tram, a storm's gusts); nothing for
// an untimed one.
func timingJSON(every, on, phase int) string {
if every <= 0 {
return ""
}
return `,"every":` + strconv.Itoa(every) + `,"on":` + strconv.Itoa(on) + `,"phase":` + strconv.Itoa(phase)
}
// polyJSON is a polygon zone's outline, and whether it is the outside of it;
// nothing for the usual rectangle or ellipse.
func polyJSON(z *physics.Zone) string {
if len(z.Poly) < 3 {
return ""
}
return `,"poly":[` + pathJSON(z.Poly) + `],"outside":` + strconv.FormatBool(z.Outside)
}
// Extras is what a timed hole adds at one stroke of a round (0 is the first),
// to draw over HoleState's field. Empty for other holes.
func Extras(hole string, stroke int) string {
h := readHole(hole).hole()
if stroke < 0 {
return "{" + versionJSON + `,"walls":[],"posts":[],"zones":[]}`
}
ws, ps := h.Extras(stroke)
return "{" + versionJSON + `,"walls":` + wallsJSON(ws) + `,"posts":` + postsJSON(ps) + `,"zones":` + zonesJSON(h.ExtraZones(stroke), true) + "}"
}
// Leaderboard is a mode's course-wide top ten ("assisted" or "pro"): named
// players' bests on the current course holes, summed; most holes, then the
// best score against par (strokes - par), then the first there. Its cost does
// not grow with players.
func Leaderboard(mode string) string {
m := modeOf(mode)
var sb strings.Builder
// holes: the course's current ones, the only ones that count
sb.WriteString("{" + versionJSON + `,"mode":` + jstr(modeNames[m]) + `,"holes":` + strconv.Itoa(slots.Size()) + `,"rows":[`)
n := 0
eachRanked(m, func(r *row, name string) {
sb.WriteString(sep(n) + standingRow(r.player, name, r))
n++
})
sb.WriteString("]}")
return sb.String()
}
// eachRanked walks a mode's top ten: the first named players of its ranking,
// with their names. A name deleted since is skipped, not shown.
func eachRanked(m int, fn func(r *row, name string)) {
n, walked := 0, 0
ranks[m].Iterate("", "", func(_ string, v any) bool {
walked++ // skipped names count: the walk stays bounded
p := v.(string)
if name := nameOf(p); name != "" {
fn(totals[m].Get(p).(*row), name)
n++
}
return n >= topSize || walked >= maxWalked
})
}
// maxWalked is the most rows a top ten walks, skipped names included.
const maxWalked = 4 * topSize
// strokesRow and standingRow are a player's best or standing row; name is ""
// on the reads that resolve no names. A standing's par is its holes' pars
// summed: strokes - par is its score against par.
func strokesRow(p, name string, strokes int) string {
return `{"player":` + jstr(p) + nameField(name) + `,"strokes":` + strconv.Itoa(strokes) + "}"
}
func standingRow(p, name string, r *row) string {
return `{"player":` + jstr(p) + nameField(name) + standingFields(r) + "}"
}
// standingFields is a standing's numbers: holes, strokes and par.
func standingFields(r *row) string {
return `,"holes":` + strconv.Itoa(r.holes) + `,"strokes":` + strconv.Itoa(r.strokes) + `,"par":` + strconv.Itoa(r.par)
}
func nameField(name string) string {
if name == "" {
return ""
}
return `,"name":` + jstr(name)
}
// Rank is a player's place in a mode's course ranking, with their standing:
// "rank" 1 is the top, out of "of"; 0 if unnamed or no current course hole
// finished. A name deleted since keeps its place until its next change, so a
// rank may be that many too low.
func Rank(mode string, player address) string {
m := modeOf(mode)
p := player.String()
rank, r := 0, &row{}
if v := totals[m].Get(p); v != nil {
r = v.(*row)
if key := rankKey(r); ranks[m].Has(key) {
rank = ranks[m].index(key) + 1
}
}
return "{" + versionJSON + `,"mode":` + jstr(modeNames[m]) + `,"player":` + jstr(p) +
`,"rank":` + strconv.Itoa(rank) + `,"of":` + strconv.Itoa(ranks[m].Size()) + standingFields(r) + "}"
}
// CourseLeaderboard is a page of a mode's course ranking, paged like
// HoleLeaderboard. holes is the course's current holes, players how many the
// ranking holds.
func CourseLeaderboard(mode string, offset, limit int) string {
m := modeOf(mode)
limit = clampLimit(limit)
size := ranks[m].Size()
if offset < 0 {
offset = 0
}
if offset > size {
offset = size // and offset+limit cannot overflow
}
var sb strings.Builder
sb.WriteString("{" + versionJSON + `,"mode":` + jstr(modeNames[m]) + `,"holes":` + strconv.Itoa(slots.Size()) +
`,"players":` + strconv.Itoa(size) + `,"offset":` + strconv.Itoa(offset) + `,"rows":[`)
n := 0
ranks[m].IterateByOffset(offset, limit, func(_ string, v any) bool {
p := v.(string)
name := nameOf(p)
if name == "" {
return false // a name deleted since
}
sb.WriteString(sep(n) + standingRow(p, name, totals[m].Get(p).(*row)))
n++
return false
})
next := 0
if offset+limit < size {
next = offset + limit
}
sb.WriteString(`],"next":` + strconv.Itoa(next) + "}")
return sb.String()
}
// Ghost is a player's best round on a hole in a mode, named or not, with its
// height (the first to a score ranks first), period and shots, or null: what a
// duel races. It replays in its own weather (SimulateRoundIn for the first
// stroke, then SimulateFrom from each rest).
func Ghost(hole, mode string, player address) string {
e, m := readHole(hole), modeOf(mode)
v := e.bests(m).Get(player.String())
if v == nil {
return "null"
}
strokes := bestStrokes(v)
at, period, shots := bestFields(v)
return "{" + versionJSON + `,"hole":` + jstr(e.id) + `,"mode":` + jstr(modeNames[m]) + `,"player":` + jstr(player.String()) +
`,"height":` + strconv.FormatInt(at, 10) + `,"strokes":` + strconv.Itoa(strokes) + `,"period":` + strconv.FormatInt(period, 10) + `,"shots":` + jstr(shots) + "}"
}
// HoleRank is a player's place on a hole's board in a mode, with their best,
// as Rank: 0 if unnamed or no finish there.
func HoleRank(hole, mode string, player address) string {
e, m := readHole(hole), modeOf(mode)
p := player.String()
v := e.bests(m).Get(p)
rank, strokes := 0, bestStrokes(v)
if strokes > 0 {
if k := boardKey(v, p); e.board(m).Has(k) {
rank = e.board(m).index(k) + 1
}
}
return "{" + versionJSON + `,"hole":` + jstr(e.id) + `,"mode":` + jstr(modeNames[m]) + `,"player":` + jstr(p) +
`,"rank":` + strconv.Itoa(rank) + `,"of":` + strconv.Itoa(e.board(m).Size()) + `,"strokes":` + strconv.Itoa(strokes) + "}"
}
// maxListed bounds Holes() and the hub: anyone can add a hole.
const maxListed = 120
// HoleLeaderboard is a page of a hole's board in a mode: named players' bests,
// fewest strokes then first there, from rank offset+1 (clamped to
// 0..players), at most limit rows (1..100). players is the named players,
// finished anyone who finished; next is the next offset, 0 at the end. A name
// deleted since is skipped, so a page may hold fewer rows. A page reads its
// own rows only.
func HoleLeaderboard(hole, mode string, offset, limit int) string {
e, m := readHole(hole), modeOf(mode)
limit = clampLimit(limit)
board := e.board(m)
size := board.Size()
if offset < 0 {
offset = 0
}
if offset > size {
offset = size // and offset+limit cannot overflow
}
var sb strings.Builder
sb.WriteString("{" + versionJSON + `,"hole":` + jstr(e.id) + `,"mode":` + jstr(modeNames[m]) +
`,"par":` + strconv.Itoa(e.par) + `,"players":` + strconv.Itoa(size) + `,"finished":` + strconv.Itoa(e.bests(m).Size()) +
`,"offset":` + strconv.Itoa(offset) + `,"rows":[`)
n := 0
board.IterateByOffset(offset, limit, func(k string, v any) bool {
p := v.(string)
name := nameOf(p)
if name == "" {
return false // a name deleted since
}
sb.WriteString(sep(n) + strokesRow(p, name, boardStrokes(k)))
n++
return false
})
next := 0
if offset+limit < size {
next = offset + limit
}
sb.WriteString(`],"next":` + strconv.Itoa(next) + "}")
return sb.String()
}
// pageMax is the most rows one page of a read returns.
const pageMax = 100
// clampLimit keeps a page's size to 1..pageMax.
func clampLimit(n int) int {
if n < 1 {
return 1
}
if n > pageMax {
return pageMax
}
return n
}
// maxFriends bounds the addresses one Bests or Standings read takes.
const maxFriends = 50
// friends splits a comma-separated list of addresses: each valid one once,
// at most maxFriends. Junk and repeats are skipped and use up nothing.
func friends(list string) []string {
var out []string
seen := map[string]bool{}
for _, p := range strings.Split(list, ",") {
if len(out) == maxFriends {
break
}
if p = strings.TrimSpace(p); p != "" && !seen[p] && address(p).IsValid() {
seen[p] = true
out = append(out, p)
}
}
return out
}
// Bests is the best finished round on one hole, in a mode, of each of the
// given players (comma-separated addresses, named or not): a board of
// friends, where no stranger and no bot can push anyone off.
func Bests(hole, mode, players string) string {
e, m := readHole(hole), modeOf(mode)
var sb strings.Builder
sb.WriteString("{" + versionJSON + `,"hole":` + jstr(e.id) + `,"mode":` + jstr(modeNames[m]) + `,"par":` + strconv.Itoa(e.par) + `,"rows":[`)
n := 0
for _, p := range friends(players) {
if v := e.bests(m).Get(p); v != nil {
sb.WriteString(sep(n) + strokesRow(p, "", bestStrokes(v)))
n++
}
}
sb.WriteString("]}")
return sb.String()
}
// Standings is each given player's course-wide standing in a mode: how many
// of the course's current holes they finished, in how many strokes, and
// those holes' pars summed.
func Standings(mode, players string) string {
m := modeOf(mode)
var sb strings.Builder
sb.WriteString("{" + versionJSON + `,"mode":` + jstr(modeNames[m]) + `,"holes":` + strconv.Itoa(slots.Size()) + `,"rows":[`)
n := 0
for _, p := range friends(players) {
if v := totals[m].Get(p); v != nil {
sb.WriteString(sep(n) + standingRow(p, "", v.(*row)))
n++
}
}
sb.WriteString("]}")
return sb.String()
}
// Records is a page of every player's best on a hole in a mode, named or not,
// with the height that ranks it before a later tie: all a successor needs to
// carry the boards over in their order.
// The paged reads (Records, Players, Community) walk everything by
// key: after is the last key of the page before ("" for the first), limit is
// clamped to 1..100, next is the after of the next page ("" at the end).
func Records(hole, mode, after string, limit int) string {
e, m := readHole(hole), modeOf(mode)
var sb strings.Builder
sb.WriteString("{" + versionJSON + `,"hole":` + jstr(e.id) + `,"mode":` + jstr(modeNames[m]) + `,"rows":[`)
next := page(e.bests(m).Iterate, after, limit, func(i int, p string, v any) {
at, _, _ := bestFields(v)
sb.WriteString(sep(i) + `{"player":` + jstr(p) + `,"strokes":` + strconv.Itoa(bestStrokes(v)) + `,"height":` + strconv.FormatInt(at, 10) + "}")
})
sb.WriteString(`],"next":` + jstr(next) + "}")
return sb.String()
}
// Players is a page of every course standing in a mode, named or not: its
// holes, strokes and par (all the ranking's key reads) and the height of the
// finish that last improved it (the tie-break), paged like Records.
func Players(mode, after string, limit int) string {
m := modeOf(mode)
var sb strings.Builder
sb.WriteString("{" + versionJSON + `,"mode":` + jstr(modeNames[m]) + `,"rows":[`)
next := page(totals[m].Iterate, after, limit, func(i int, p string, v any) {
r := v.(*row)
sb.WriteString(sep(i) + `{"player":` + jstr(p) + standingFields(r) + `,"height":` + strconv.FormatInt(r.height, 10) + "}")
})
sb.WriteString(`],"next":` + jstr(next) + "}")
return sb.String()
}
// walk is a tree's Iterate: what page reads.
type walk func(start, end string, cb func(key string, v any) bool) bool
func bpWalk(t *bptree.BPTree) walk {
return func(start, end string, cb func(string, any) bool) bool { return t.Iterate(start, end, cb) }
}
// page walks up to limit entries of t after the key after, and returns the
// key to continue from, "" at the end.
func page(t walk, after string, limit int, fn func(i int, key string, v any)) string {
limit = clampLimit(limit)
start := ""
if after != "" {
start = after + "\x00" // the first key past after
}
n, next, last := 0, "", ""
t(start, "", func(k string, v any) bool {
if n == limit {
next = last
return true
}
fn(n, k, v)
last = k
n++
return false
})
return next
}