golf.gno
40.80 Kb · 1181 lines
1// Package golf is the game realm: it keeps the holes, the players' rounds,
2// the records and the rankings. Every hole is data (a GG1 string, p/…/course),
3// never code: golf decodes it and plays it with p/…/physics. The owner
4// publishes the course's holes into slots of a cup ("garden/7"), each publish
5// a new version ("garden/7/v2"); once the owner opens publishing, anyone can
6// publish a community hole ("<address>/<slug>/v1", PublishMine), playable and
7// recorded but outside the course and its ranking.
8//
9// Reads take a version's id or its alias ("garden/7", "<address>/<slug>"),
10// its current version. Writes take the exact id only, as HoleState or Holes gave
11// it: a round cannot be replayed on a version it was not played on.
12//
13// Who can change what (in full on the hub page): the owner holds one power,
14// the course itself: its holes (Publish), what the lists show (Hide), and
15// whether anyone can publish (SetPublishing).
16package golf
17
18import (
19 "chain"
20 "chain/runtime"
21 "math"
22 "strconv"
23 "strings"
24 "unicode"
25
26 "gno.land/p/nym-alexiscolin000/gnogolf/course"
27 "gno.land/p/nym-alexiscolin000/gnogolf/physics"
28 "gno.land/p/nt/avl/v0"
29 bptree "gno.land/p/nt/bptree/v0"
30 "gno.land/p/nt/ufmt/v0"
31 "gno.land/r/sys/users"
32)
33
34const (
35 maxPower = 10.0
36 // maxShots bounds a commit; its work (workBudget) may bound it lower.
37 maxShots = 12
38)
39
40// The refusals said in more than one place.
41const (
42 errShot = "golf: a shot is an angle and a power between 0 and 10"
43 errStrokeLimit = "golf: this round is over its stroke limit, Reset to play again"
44 errShotTooHeavy = "golf: one shot on this hole could cost more than a transaction can replay"
45 errTooStormy = "golf: in its dearest weather, a commit on this hole would leave too little room for a shot"
46 errUnknownHole = "golf: unknown hole: "
47)
48
49type entry struct {
50 id string // "<alias>/v<n>"
51 name string // the hole's name, cleaned; frozen when it was published
52 par int // frozen like the name
53 world string // "garden", "island", "town": frozen too
54 order float64
55 // official: a version the owner published into a slot; only these rank
56 official bool
57 // its provenance, set at publish (the hole itself is never stored: hole())
58 slot string // the alias it answers to: "garden/7", "<address>/<slug>"
59 version int // its number under that alias, from 1
60 by address // who published it
61 height int64
62 sha string // sha256 of its data, hex
63 note string
64 wear []byte // WearW*WearH little-endian u32 counters, made at its first play
65 // Its rounds, bests and board are rows of the shared indexes (views), not
66 // trees of its own, whose first leaf (a few KB) its first player would pay.
67 plays int
68 // the records per mode (rec), flat: a nested object costs a few hundred
69 // bytes more
70 best, proBest int
71 bestBy, proBestBy address
72 next string // the version that took this one's place, "" while it is current
73 // its drain once archived (drain); nil if never archived
74 out *draining
75}
76
77// record is the hole's record in one mode: who holds it, in how many
78// strokes, 0 if never completed.
79type record struct {
80 by address
81 best int
82}
83
84// rec is the entry's record in mode m.
85func (e *entry) rec(m int) record {
86 if m == pro {
87 return record{e.proBestBy, e.proBest}
88 }
89 return record{e.bestBy, e.best}
90}
91
92// roundTree is the version's rounds under way: player address -> *round. A
93// round is stored from its first commit that does not finish it, and removed
94// as it is holed (stroke) or Reset: a finish is kept as a best, not a round.
95func (e *entry) roundTree() view { return view{rounds, e.id + " "} }
96
97// bests is the version's results in mode m: player address -> their best
98// finished round there, kept across replays, as bestOf writes it.
99func (e *entry) bests(m int) view { return view{bests, e.id + " " + modeNames[m] + " "} }
100
101// bestOf is how a best is kept: "<strokes> <height> <period> <shots>". The
102// height ranks it before a later tie (boardKey); the period and shots let
103// Ghost replay it in its own weather. The mode is in the key.
104func bestOf(r *round) string {
105 return strconv.Itoa(r.strokes) + " " + strconv.FormatInt(runtime.ChainHeight(), 10) + " " +
106 strconv.FormatInt(r.period, 10) + " " + r.shots
107}
108
109// errBest is a best not as bestOf writes it: never written, refused if read.
110const errBest = "golf: a best not as bestOf writes it: "
111
112// bestStrokes is a kept best's strokes, 0 for none (a tree's nil).
113func bestStrokes(v any) int {
114 if v == nil {
115 return 0
116 }
117 s := v.(string)
118 i := strings.Index(s, " ")
119 if i < 0 {
120 panic(errBest + s)
121 }
122 n, err := strconv.Atoi(s[:i])
123 if err != nil {
124 panic(errBest + s)
125 }
126 return n
127}
128
129// bestFields is a kept best's height, period and shots.
130func bestFields(v any) (int64, int64, string) {
131 s := v.(string)
132 f := strings.SplitN(s, " ", 4)
133 if len(f) < 4 {
134 panic(errBest + s)
135 }
136 at, err := strconv.ParseInt(f[1], 10, 64)
137 period, err2 := strconv.ParseInt(f[2], 10, 64)
138 if err != nil || err2 != nil {
139 panic(errBest + s)
140 }
141 return at, period, f[3]
142}
143
144// board is the version's board in mode m: boardKey(best, player) ->
145// player, the named players' bests, fewest strokes first.
146func (e *entry) board(m int) view { return view{boards, e.id + " " + modeNames[m] + " "} }
147
148// view is the rows of a shared index under prefix p, read and written
149// without it. p ends with a space, which no id holds, so its rows are exactly
150// the keys from p to p with the space made a "!".
151type view struct {
152 t *bptree.BPTree
153 p string
154}
155
156func (v view) end() string { return v.p[:len(v.p)-1] + "!" }
157func (v view) Get(k string) any { return v.t.Get(v.p + k) }
158func (v view) Has(k string) bool { return v.t.Has(v.p + k) }
159func (v view) Set(k string, x any) bool { return v.t.Set(v.p+k, x) }
160func (v view) Remove(k string) (any, bool) { return v.t.Remove(v.p + k) }
161func (v view) Size() int { return before(v.t, v.end()) - before(v.t, v.p) }
162func (v view) index(k string) int { return before(v.t, v.p+k) - before(v.t, v.p) }
163func (v view) strip(cb func(string, any) bool) func(string, any) bool {
164 return func(k string, x any) bool { return cb(k[len(v.p):], x) }
165}
166
167// Iterate is the rows in [start, end) ascending, end "" for no bound.
168func (v view) Iterate(start, end string, cb func(key string, x any) bool) bool {
169 hi := v.end()
170 if end != "" {
171 hi = v.p + end
172 }
173 return v.t.Iterate(v.p+start, hi, v.strip(cb))
174}
175
176// IterateByOffset is count rows from the offset-th, fewer at the end.
177func (v view) IterateByOffset(offset, count int, cb func(key string, x any) bool) bool {
178 end := v.end()
179 return v.t.IterateByOffset(before(v.t, v.p)+offset, count, func(k string, x any) bool {
180 return k >= end || v.strip(cb)(k, x)
181 })
182}
183
184// before is how many keys of t sort before k: a binary search by index.
185func before(t *bptree.BPTree, k string) int {
186 lo, hi := 0, t.Size()
187 for lo < hi {
188 mid := (lo + hi) / 2
189 if key, _ := t.GetByIndex(mid); key < k {
190 lo = mid + 1
191 } else {
192 hi = mid
193 }
194 }
195 return lo
196}
197
198// seeded is a shared index whose first leaf the deployer pays at init, not
199// its first player. Its key "" sorts before every view's rows: no view sees it.
200func seeded() *bptree.BPTree {
201 t := bptree.NewBPTree32()
202 t.Set("", nil)
203 return t
204}
205
206func newEntry(id, name string, par int, world string, order float64) *entry {
207 return &entry{id: id, name: name, par: par, world: world, order: order}
208}
209
210// hole is the entry's hole, decoded from its data for this call only: a
211// public call decodes it once and passes it down, and it is never stored.
212func (e *entry) hole() *course.Simple {
213 s, err := course.Decode(holeData.Get(e.id).(string))
214 if err != nil {
215 panic("golf: the data of " + e.id + " does not decode: " + err.Error()) // checked at publish: cannot happen
216 }
217 return s
218}
219
220// draining is an archived hole's drain, per mode: the next player to take
221// out, whether all are out, and the players whose first finish came after it
222// was archived: never counted, so never taken out.
223type draining struct {
224 from [modes]string
225 done [modes]bool
226 fresh [modes]*avl.Tree // player -> true
227}
228
229type round struct {
230 bx, by float64 // the ball, exactly: flat, not a nested Vec2 object
231 fx, fy float64 // where the last stroke started: its path is replayed from there
232 strokes int
233 period int64 // the weather it is played in: set by its first stroke
234 shots string // the decisions so far, "angle,power,tick;…": the round, replayable
235 mode int // assisted or pro: set by its first stroke, like the weather
236}
237
238func (r *round) ball() physics.Vec2 { return physics.Vec2{X: r.bx, Y: r.by} }
239
240// A round is played in one of two modes, each with its own records and
241// rankings: assisted (the full aim preview) or pro (the preview cut short).
242// The chain cannot see a screen: the mode is the player's word.
243const (
244 assisted = iota
245 pro
246 modes
247)
248
249// modeOf reads a mode's name: "assisted" (or "") or "pro"; anything else
250// panics rather than answer for another mode.
251func modeOf(name string) int {
252 switch name {
253 case "", "assisted":
254 return assisted
255 case "pro":
256 return pro
257 }
258 panic(`golf: a mode is "assisted" or "pro", got: ` + name)
259}
260
261var modeNames = [modes]string{"assisted", "pro"}
262
263// modeTitles is how the pages head a mode's board.
264var modeTitles = [modes]string{"Assisted", "Pro"}
265
266// The indexes are B+ trees (32 entries a leaf: far cheaper an entry than an
267// AVL node). What players add goes to five of them, shared by every version
268// and mode and seeded at init (views): a first player pays for their own rows
269// only.
270//
271// A B+ tree must not change while it is iterated: no callback below writes
272// to the tree it walks (drain walks a hole's bests and writes the standings).
273var (
274 // the course's versions, current and archived: id -> *entry
275 courseHoles = bptree.NewBPTree32()
276 // everyone else's published data: id -> *entry
277 community = bptree.NewBPTree32()
278 // id -> a version's GG1 string, apart from its entry so that listing
279 // holes never loads it
280 holeData = bptree.NewBPTree32()
281 // slotKey(world, order) -> id of the current version there, in course order
282 slots = bptree.NewBPTree32()
283 // "<address>/<slug>" -> id of the current version of a community hole
284 aliases = bptree.NewBPTree32()
285 // the listed community holes, two kinds of row in one tree (one first
286 // leaf to pay, not two):
287 // - "<address> " + recentKey(publish height, slug) -> id: an author's
288 // holes not hidden, newest last;
289 // - recentKey(their newest row's height, address) -> nil: one per
290 // author (moveAuthor), the latest to publish last.
291 // A height is digits and an address "g1…": the two never mix.
292 byAuthor = bptree.NewBPTree32()
293 // "<address>/<slug>" -> true: community holes hidden (Hide), every
294 // version, out of byAuthor; still playable
295 hidden = avl.NewTree()
296 // recentKey(archive height, id) -> id of an archived course version
297 archived = bptree.NewBPTree32()
298 // every version's rounds, bests and boards, under its id (views)
299 rounds, bests, boards = seeded(), seeded(), seeded()
300 // per mode: player -> *row, their standing over the official holes
301 allTotals = seeded()
302 totals = [modes]view{{allTotals, "assisted "}, {allTotals, "pro "}}
303 // per mode: rankKey(row) -> player, the named players with a hole, best
304 // first
305 allRanks = seeded()
306 ranks = [modes]view{{allRanks, "assisted "}, {allRanks, "pro "}}
307 // per mode: rankKey(row) -> player, standings with no hole left (setRow)
308 idle = [modes]view{{allRanks, "idle assisted "}, {allRanks, "idle pro "}}
309 // archived holes still in the standings, oldest first (drain)
310 archiving []string
311)
312
313// row is one player's standing across the course: their best on each hole
314// counted, summed, and those holes' pars, summed. Its score against par,
315// strokes - par, is what ranks it after its holes.
316type row struct {
317 player string
318 holes, strokes, par int
319 height int64 // of the finish that last improved it: the first there ranks first
320}
321
322const topSize = 10
323
324// named reports whether a player has a gno.land name (r/sys/users). Only
325// named players rank: a name takes a registration, so a script cannot fill
326// the boards with free addresses. Every finish is still kept.
327func named(player string) bool {
328 return !users.ResolveAddress(address(player)).IsDeleted()
329}
330
331// nameOf is a named player's name, "" for anyone else.
332func nameOf(player string) string {
333 if u := users.ResolveAddress(address(player)); !u.IsDeleted() && nameish(u.Name()) {
334 return u.Name()
335 }
336 return ""
337}
338
339// nameish is a name as r/sys/users registers it (a-z, then a-z, 0-9, "_" and
340// "-", at most 64), checked again because the pages print names in links.
341func nameish(n string) bool {
342 if n == "" || len(n) > 64 || n[0] < 'a' || n[0] > 'z' {
343 return false
344 }
345 for i := 0; i < len(n); i++ {
346 if c := n[i]; !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '_' || c == '-') {
347 return false
348 }
349 }
350 return true
351}
352
353// find is the hole with this exact id, nil if none.
354func find(id string) *entry {
355 if v := courseHoles.Get(id); v != nil {
356 return v.(*entry)
357 }
358 if v := community.Get(id); v != nil {
359 return v.(*entry)
360 }
361 return nil
362}
363
364// current is the id an alias points at now: a course slot's or a community
365// hole's current version; "" if it is neither.
366func current(alias string) string {
367 if k, ok := slotKeyOf(alias); ok {
368 if v := slots.Get(k); v != nil {
369 return v.(string)
370 }
371 return ""
372 }
373 if v := aliases.Get(alias); v != nil {
374 return v.(string)
375 }
376 return ""
377}
378
379// mustHole is the hole a write names: its exact id. An alias is refused: a
380// new version may have taken its place since the round's shots were chosen.
381func mustHole(id string) *entry {
382 if e := find(id); e != nil {
383 return e
384 }
385 if now := current(id); now != "" {
386 panic("golf: " + id + " is an alias: play its version by id, " + now)
387 }
388 panic(errUnknownHole + id)
389}
390
391// readHole is the hole a read names: an exact id, or an alias for its
392// current version.
393func readHole(id string) *entry {
394 if e := find(id); e != nil {
395 return e
396 }
397 if now := current(id); now != "" {
398 return find(now)
399 }
400 panic(errUnknownHole + id)
401}
402
403// recentKey orders by height, then by what is at that height.
404func recentKey(height int64, what string) string { return pad(int(height), 12) + " " + what }
405
406// pad writes n in width digits, zeros first: keys that sort as numbers.
407func pad(n, width int) string {
408 s := strconv.Itoa(n)
409 for len(s) < width {
410 s = "0" + s
411 }
412 return s
413}
414
415// rankKey orders the course ranking: most holes first, then the best score
416// against par (a hole at par counts the same whatever its par), then the
417// first there, then the address. The score is signed: vsParBias puts it in
418// seven digits. The holes take five: a standing can count more than the
419// course's slots while a republished hole drains (its old best still counts,
420// its new one adds a hole), so they get room well past maxCourse.
421func rankKey(r *row) string {
422 return pad(99999-r.holes, 5) + "/" + pad(vsParBias+r.strokes-r.par, 7) + "/" + pad(int(r.height), 12) + "/" + r.player
423}
424
425// maxCourse bounds the course's slots (Publish); a standing's holes are at
426// most those plus the archived versions drain has not taken it out of yet.
427// vsParBias is more than a standing can be under or over par: a hole is -18
428// to +59 (1 to 60 strokes, par 1 to 19), so a full course at most 9999 * 59 =
429// 589,941 over, 179,982 under, and the seven digits hold 55,555 holes.
430const (
431 maxCourse = 9999
432 vsParBias = 1000000
433)
434
435// boardKey orders a hole's board by each player's best v: fewest strokes,
436// then the first to make them (a best's shots are public: whoever replays
437// them ties it and ranks after), then the address.
438func boardKey(v any, player string) string {
439 at, _, _ := bestFields(v)
440 return pad(bestStrokes(v), 3) + "/" + pad(int(at), 12) + "/" + player
441}
442
443// boardStrokes is the strokes of a board's key: always its three digits.
444func boardStrokes(k string) int {
445 n, err := strconv.Atoi(k[:3])
446 if err != nil {
447 panic("golf: bad board key: " + k) // pad writes three digits: cannot happen
448 }
449 return n
450}
451
452// setRow moves a player's standing, as of height at, and keeps the ranking
453// in step. A standing with no hole left is not freed, nor its key, which
454// moves to idle: its deposit is the player's, and freeing it would refund
455// whoever drained it. It moves back at the player's next hole.
456func setRow(m int, r *row, holes, strokes, par int, at int64) {
457 k := rankKey(r)
458 _, had := ranks[m].Remove(k)
459 if _, ok := idle[m].Remove(k); ok {
460 had = true
461 }
462 r.holes, r.strokes, r.par, r.height = holes, strokes, par, at
463 switch {
464 case r.holes > 0 && named(r.player):
465 ranks[m].Set(rankKey(r), r.player)
466 case had:
467 idle[m].Set(rankKey(r), r.player)
468 }
469}
470
471// improve moves a player's standing by a new best on an official hole of par
472// par (was: their old best there, 0 if none): only those count, or anyone's
473// own hole would be a way to the top. The standing is kept, not recomputed,
474// so a finish costs the same however many play.
475func improve(m int, player string, par, was, best int) {
476 r := &row{player: player}
477 if v := totals[m].Get(player); v != nil {
478 r = v.(*row)
479 } else {
480 totals[m].Set(player, r)
481 }
482 if was == 0 {
483 setRow(m, r, r.holes+1, r.strokes+best, r.par+par, runtime.ChainHeight())
484 } else {
485 setRow(m, r, r.holes, r.strokes+best-was, r.par, runtime.ChainHeight())
486 }
487}
488
489// retire archives an official hole replaced by next. Its bests stay there as
490// its records but leave the course standings, in batches (drain): the Publish
491// takes retireBatch, each course finish finishBatch, a Drain up to drainMax.
492func retire(old *entry, next string) {
493 old.next, old.out = next, &draining{}
494 archived.Set(recentKey(runtime.ChainHeight(), old.id), old.id)
495 archiving = append(archiving, old.id)
496 chain.Emit(EventHoleRetired, "hole", old.id, "next", next)
497 drain(retireBatch)
498}
499
500// How many archived players a Publish, a course finish and a Drain take out
501// of the standings: sized so none costs more however many played.
502const (
503 retireBatch = 150
504 finishBatch = 4
505 drainMax = 400
506)
507
508// drain takes up to n archived players out of the standings, oldest
509// archived hole first.
510func drain(n int) {
511 for n > 0 && len(archiving) > 0 {
512 e := find(archiving[0])
513 d := e.out
514 for m := 0; m < modes && n > 0; m++ {
515 if d.done[m] {
516 continue
517 }
518 stopped := e.bests(m).Iterate(d.from[m], "", func(p string, v any) bool {
519 if n == 0 {
520 d.from[m] = p
521 return true
522 }
523 if d.fresh[m] == nil || !d.fresh[m].Has(p) {
524 if t := totals[m].Get(p); t != nil {
525 r := t.(*row)
526 setRow(m, r, r.holes-1, r.strokes-bestStrokes(v), r.par-e.par, r.height)
527 }
528 }
529 n--
530 return false
531 })
532 if !stopped {
533 d.done[m] = true
534 }
535 }
536 if d.done[assisted] && d.done[pro] {
537 if len(archiving) == 1 {
538 archiving = nil // the backing array goes with it
539 } else {
540 archiving = archiving[1:]
541 }
542 }
543 }
544}
545
546// Drain takes up to n players (1 to 400) of archived holes out of the course
547// standings, sooner than the finishes would, and returns how many archived
548// holes still have players in them. Anyone can call it. A standing it
549// empties is kept at 0 holes (setRow): nothing is refunded to the caller.
550func Drain(cur realm, n int) int {
551 if n < 1 {
552 n = 1
553 }
554 if n > drainMax {
555 n = drainMax
556 }
557 drain(n)
558 return len(archiving)
559}
560
561// counts reports whether a best on e by player (was: their previous best, 0
562// if none) moves their standing: e is a current official hole, or an archived
563// one whose drain has not reached them yet and where they already counted. A
564// first finish on an archived hole never counts.
565func counts(e *entry, m int, player string, was int) bool {
566 if !e.official {
567 return false
568 }
569 if e.next == "" {
570 return true
571 }
572 if was == 0 || e.out.done[m] || player < e.out.from[m] {
573 return false
574 }
575 return e.out.fresh[m] == nil || !e.out.fresh[m].Has(player)
576}
577
578// Launch plays one shot of the caller's round on hole (an exact id): angle in
579// degrees (0 = right, 90 = down), power in (0, 10]. It is how gnoweb plays,
580// with no aim preview, so its rounds are pro rounds. It answers with where
581// the ball stopped, or the score if holed; PlayRoundAt commits many shots.
582func Launch(cur realm, hole string, angle, power float64) string {
583 e := mustHole(hole)
584 h := e.hole()
585 player := cur.Previous().Address()
586 r := roundOf(e, h, player)
587 fresh := r.strokes == 0
588 if fresh {
589 r.period = Period() // a round starts in the weather of the moment, and keeps it
590 r.mode = pro
591 } else if r.mode != pro {
592 panic(otherMode(r))
593 }
594
595 fc := weatherOf(e, h, r.period)
596 w := newWork(h, fc.Zones)
597 w.fixed = fixedGas(e, fc)
598 w.next(h)
599 shot, holed := stroke(e, h, r, player, angle, power, 0, fc.Zones)
600 w.add(shot)
601 if holed {
602 return ufmt.Sprintf("Holed in %d on %s! Your best there: %s:%s/%s", r.strokes, e.id, hub, e.id, player.String())
603 }
604 if fresh {
605 e.roundTree().Set(player.String(), r)
606 }
607 // what a gnoweb player needs for the next shot
608 a, d := aim(r.ball(), h.Cup())
609 return ufmt.Sprintf("Stroke %d on %s: the ball stopped %.1f from the cup, which is at %.0f° from it. Your round: %s:%s/%s",
610 r.strokes, e.id, d, a, hub, e.id, player.String())
611}
612
613// aim is the angle, in the shot's degrees, and the straight-line distance
614// from a ball to the cup: walls and slopes are the player's to allow for.
615func aim(from, cup physics.Vec2) (float64, float64) {
616 d := cup.Sub(from)
617 a := math.Atan2(d.Y, d.X) * 180 / math.Pi
618 if a < 0 {
619 a += 360
620 }
621 return a, d.Len()
622}
623
624// PlayRound is PlayRoundAt in the current period, for a caller with none to
625// give (a form, a script); a round under way keeps its own. Prefer
626// PlayRoundAt: a commit included after the weather turned is then played in
627// the weather its shots were chosen in.
628func PlayRound(cur realm, hole string, shots string) string {
629 e, p := mustHole(hole), cur.Previous().Address()
630 period := Period()
631 if v := e.roundTree().Get(p.String()); v != nil { // (a round is kept from its first stroke on)
632 period = v.(*round).period // stroke() still refuses a stale one
633 }
634 return playRound(e, p, shots, period, assisted)
635}
636
637// PlayRoundAt replays shots into the caller's assisted round on hole (an
638// exact id), continuing it, in the weather of period: the current one or the
639// one before, and a round under way keeps its own. The client sends
640// decisions, never outcomes: the chain replays them.
641//
642// shots is "angle,power[,tick];…" (MsgCall carries no slice), at most 12,
643// fewer on a heavy hole: a commit past the work budget is refused with the
644// number of shots that fit, and the rest goes in the next commit.
645func PlayRoundAt(cur realm, hole string, shots string, period int64) string {
646 return playRound(mustHole(hole), cur.Previous().Address(), shots, period, assisted)
647}
648
649// PlayRoundPro is PlayRoundAt for a round played in pro mode: the aim
650// preview cut short. It counts on the pro records and rankings only.
651func PlayRoundPro(cur realm, hole string, shots string, period int64) string {
652 return playRound(mustHole(hole), cur.Previous().Address(), shots, period, pro)
653}
654
655func playRound(e *entry, player address, shots string, period int64, mode int) string {
656 h := e.hole()
657 r := roundOf(e, h, player)
658 fresh := r.strokes == 0
659 if fresh {
660 playablePeriod(period)
661 r.period, r.mode = period, mode
662 } else if r.period != period {
663 panic(ufmt.Sprintf("golf: this round is being played in the weather of period %d", r.period))
664 } else if r.mode != mode {
665 panic(otherMode(r))
666 }
667
668 list := shotList(shots)
669 fc := weatherOf(e, h, r.period) // once for the whole commit
670 weather := fc.Zones
671 w := newWork(h, weather)
672 w.fixed = fixedGas(e, fc)
673 for _, s := range list {
674 w.next(h)
675 angle, power, tick := parseShot(s)
676 shot, holed := stroke(e, h, r, player, angle, power, tick, weather)
677 w.add(shot)
678 if holed {
679 return ufmt.Sprintf("holed in %d strokes", r.strokes)
680 }
681 }
682 if fresh { // under way: stored from its first commit
683 e.roundTree().Set(player.String(), r)
684 }
685 return ufmt.Sprintf("%d shots replayed, ball at %.1f,%.1f after %d strokes",
686 w.played, r.bx, r.by, r.strokes)
687}
688
689// otherMode refuses to go on with a round in a mode it was not started in.
690func otherMode(r *round) string {
691 return "golf: this round is being played in " + modeNames[r.mode] + " mode"
692}
693
694// shotList is a commit's shots: the non-empty entries of "a,p;a,p;…", at
695// least one and at most maxShots.
696func shotList(shots string) []string {
697 var list []string
698 for _, s := range strings.Split(shots, ";") {
699 if s = strings.TrimSpace(s); s == "" {
700 continue
701 }
702 if len(list) == maxShots {
703 panic("golf: too many shots in one commit")
704 }
705 list = append(list, s)
706 }
707 if len(list) == 0 {
708 panic("golf: no shots")
709 }
710 return list
711}
712
713// work is a commit's estimated gas so far. A shot counts the larger of its
714// path's estimate and its Shot.Work's, plus its pulses' set-up, on top of
715// what the commit spent before its shots (fixed: decoding, forecast). A shot
716// that could pass workBudget is refused before anything is kept, and each is
717// played under a work cap (physics.Field.Cap), so no commit can pass it; the
718// client mirrors the model. An estimate, not a gas meter: see docs/golf.md,
719// "The work budget".
720type work struct {
721 walls, pieces int64 // on the board this commit: walls, and every piece and polygon edge
722 setup int64 // what each shot spends outside its Shot.Work: a pulse's pieces, set up afresh
723 bound int64 // shotBound, and the set-up
724 fixed int64 // spent before the shots: decoding, forecast
725 spent, most int64
726 played int
727 cap int // the work cap of the shot being played
728}
729
730// The budget: of the 2e9 gas a wallet lets a transaction simulate, 1.4e9 for
731// a commit, decoding and forecast included; the rest for package loads and
732// bookkeeping.
733const (
734 workBudget = 1400000000
735 workPerShot = 10000000
736 workPerWall = 150000
737 workPerPoint = 1200000
738 workPerPiece = 15000
739 workPerUnit = 1000
740 // a pulse's walls and posts, set up afresh for each shot outside its
741 // Shot.Work
742 workPerPulseWall = 700000
743 workPerPulsePost = 200000
744)
745
746// Decoding a hole's data costs at most decodeBase and decodePerByte a byte.
747const (
748 decodeBase = 6000000
749 decodePerByte = 4000
750)
751
752// fixedGas is what a commit on e spends before its shots, in the forecast
753// fc: decoding the hole, and drawing the forecast.
754func fixedGas(e *entry, fc course.Forecast) int64 {
755 return decodeGas(len(holeData.Get(e.id).(string))) + int64(fc.Work)*workPerUnit
756}
757
758// decodeGas bounds decoding size bytes of a hole's data.
759func decodeGas(size int) int64 { return decodeBase + decodePerByte*int64(size) }
760
761// maxShotGas bounds a hole's heaviest shot (shotBound and its pulses'
762// set-up): a hole past it is refused at publish.
763const maxShotGas = 1300000000
764
765// minShotWork is the work, in the physics' units, a commit's first shot may
766// always do whatever the weather: four fifths of MaxWork. A shot is capped at
767// what is left of the budget (work.next), so a hole whose dearest weather
768// would leave less is refused at publish (worstGas): on a published hole,
769// every shot of less than minShotWork plays out in any weather (as a commit's
770// first, if need be), and an author whose board is too heavy is told at
771// publish, not by a hole that misbehaves in the rain. A shot past it may be
772// cut short; none is known: the course's heaviest real shot does 441,737
773// over the full grid of tee shots (mountain/16, full power, in a storm), and
774// the heaviest found in an adversarial search 0.74 of MaxWork (docs/golf.md);
775// the course's tightest room in its dearest weather is 839,278 (mountain/7),
776// 39,278 over the floor.
777const minShotWork = 4 * physics.MaxWork / 5
778
779// worstGas is what a commit on h (size bytes of data) spends up to a first
780// shot of minShotWork in its dearest weather: decoding, the forecast at its
781// most (course.WorstForecastWork), and the shot, its walls and its pulses'
782// set-up, as work.next caps it.
783func worstGas(h *course.Simple, size int) int64 {
784 w := newWork(h, nil)
785 return decodeGas(size) + int64(course.WorstForecastWork(h))*workPerUnit +
786 workPerShot + w.walls*workPerWall + w.setup + (physics.MaxWorkStep+minShotWork)*workPerUnit
787}
788
789// newWork is a commit's work on a hole, before any shot: its walls and
790// pieces, a pulse's as if always there, and the weather's.
791func newWork(h *course.Simple, weather []physics.Zone) *work {
792 w := &work{}
793 if f := h.Field(); f != nil {
794 w.walls = int64(len(f.Walls))
795 w.pieces = pieces(f.Walls, f.Posts, f.Zones)
796 }
797 for _, p := range h.Pulses {
798 w.walls += int64(len(p.Walls))
799 w.pieces += pieces(p.Walls, p.Posts, p.Zones)
800 w.setup += int64(len(p.Walls))*workPerPulseWall + int64(len(p.Posts))*workPerPulsePost
801 }
802 w.pieces += pieces(nil, nil, weather)
803 w.bound = shotBound(w.walls) + w.setup
804 return w
805}
806
807// shotBound is the most one shot on a board of walls walls can cost: the
808// physics ends a stroke at MaxWork, passing it by MaxWorkStep at most.
809func shotBound(walls int64) int64 {
810 return workPerShot + walls*workPerWall + (physics.MaxWork+physics.MaxWorkStep)*workPerUnit
811}
812
813// pieces counts what a ball is tested against: each wall, post and zone, and
814// each edge of a zone's polygon.
815func pieces(ws []physics.Wall, ps []physics.Post, zs []physics.Zone) int64 {
816 n := len(ws) + len(ps) + len(zs)
817 for i := range zs {
818 n += len(zs[i].Poly)
819 }
820 return int64(n)
821}
822
823// next refuses the next shot if it could overrun the budget, and caps its
824// work at what is left of it.
825func (w *work) next(h *course.Simple) {
826 if w.played == 0 && w.bound > workBudget {
827 w.refuse()
828 }
829 if w.played > 0 && w.spent+w.most > workBudget {
830 w.refuse()
831 }
832 c := (workBudget-w.fixed-w.spent-workPerShot-w.walls*workPerWall)/workPerUnit - physics.MaxWorkStep
833 c -= w.setup / workPerUnit
834 if c < 1 {
835 w.refuse()
836 }
837 if c > physics.MaxWork {
838 c = physics.MaxWork
839 }
840 w.cap = int(c)
841 if f := h.Field(); f != nil {
842 f.Cap = w.cap // the decoded hole of this call only: never stored
843 }
844}
845
846// refuse refuses the shot being played: it does not fit the budget.
847func (w *work) refuse() {
848 if w.played == 0 {
849 panic(errShotTooHeavy)
850 }
851 panic(ufmt.Sprintf("golf: more shots than one transaction can replay on this hole: commit the first %d, then the rest", w.played))
852}
853
854func (w *work) add(s physics.Shot) {
855 if w.cap < physics.MaxWork && s.Work > w.cap {
856 w.refuse() // it reached its cap: it would have gone further
857 }
858 c := workPerShot + w.walls*workPerWall + int64(len(s.Path))*(workPerPoint+w.pieces*workPerPiece)
859 if u := workPerShot + w.walls*workPerWall + int64(s.Work)*workPerUnit; u > c {
860 c = u // a shot that did more than its path shows
861 }
862 c += w.setup
863 w.spent += c
864 if c > w.most {
865 w.most = c
866 }
867 w.played++
868}
869
870// Reset abandons the caller's round under way on hole (an exact id), freeing
871// its storage: the next stroke starts a new one from the tee. A finished round
872// is not kept (its best is), so a new one needs no Reset. With no round under
873// way it does nothing, and says nothing.
874func Reset(cur realm, hole string) {
875 e := mustHole(hole)
876 player := cur.Previous().Address().String()
877 if _, ok := e.roundTree().Remove(player); ok {
878 chain.Emit(EventRoundReset, "hole", e.id, "player", player)
879 }
880}
881
882// validShot refuses what would poison a round and returns the shot as
883// recorded. NaN and infinities pass every comparison, so they are refused by
884// name. The power is quantized before it is checked (0.00001 would round to
885// 0). The angle is brought into (-360, 360), so a huge one cannot overflow to
886// NaN in the physics, and again after rounding (-359.99996 is -0, not -360).
887func validShot(angle, power float64) (float64, float64) {
888 power = q4(power)
889 if math.IsNaN(angle) || math.IsInf(angle, 0) || !(power > 0 && power <= maxPower) {
890 panic(errShot)
891 }
892 angle = q4(math.Mod(angle, 360))
893 if angle <= -360 || angle >= 360 {
894 angle = math.Mod(angle, 360)
895 }
896 return angle, power
897}
898
899// resolve checks what the physics returned for a stroke: a path a read may
900// print, and a ball that is a number. A hole is anyone's data.
901func resolve(shot physics.Shot, holed bool) (physics.Shot, bool) {
902 if len(shot.Path) > maxPath {
903 panic("golf: the hole returned an oversized path")
904 }
905 rest := shot.Rest()
906 if math.IsNaN(rest.X) || math.IsNaN(rest.Y) || math.IsInf(rest.X, 0) || math.IsInf(rest.Y, 0) {
907 panic("golf: the hole returned a ball off the board")
908 }
909 return shot, holed
910}
911
912// back brings a ball that came to rest off the board (a leak in a hole's
913// walls) back to from, where the shot started; the stroke counts, with no
914// penalty. Every stroke golf plays or replays goes through it.
915func back(h *course.Simple, from physics.Vec2, shot physics.Shot, holed bool) physics.Shot {
916 rest := shot.Rest()
917 w, hh := h.W, h.H
918 if holed || len(shot.Path) == 0 || (rest.X >= 0 && rest.X <= float64(w) && rest.Y >= 0 && rest.Y <= float64(hh)) {
919 return shot
920 }
921 // copies: the hole's own slices are the hole's
922 shot.Path = append(append(make([]physics.Vec2, 0, len(shot.Path)+1), shot.Path...), from)
923 shot.Air = append(append(make([]bool, 0, len(shot.Air)+1), shot.Air...), false)
924 shot.Cause = append(append(make([]byte, 0, len(shot.Cause)+1), shot.Cause...), '-')
925 return shot
926}
927
928// stroke resolves one shot into the round and reports whether it was holed.
929func stroke(e *entry, h *course.Simple, r *round, player address, angle, power float64, tick int, weather []physics.Zone) (physics.Shot, bool) {
930 if r.strokes >= maxRoundStrokes {
931 panic(errStrokeLimit)
932 }
933 notOver(r.period) // every stroke, not only the first
934 // played exactly as it is recorded, so the record replays
935 angle, power = validShot(angle, power)
936
937 from := r.ball()
938 shot, holed := resolve(h.PreviewWith(from, angle*math.Pi/180, power, r.strokes, tick, weather))
939 shot = back(h, from, shot, holed)
940 w, hh := h.W, h.H
941 e.mark(course.WearIndexOn(shot.Rest(), w, hh)) // where the round's ball is: back's, for a leak
942 // the decisions, as played: what anyone needs to replay the round
943 if r.shots != "" {
944 r.shots += ";"
945 }
946 played := ufmt.Sprintf("%.4f,%.4f,%d", angle, power, tick)
947 r.shots += played
948 rest := shot.Rest()
949 r.fx, r.fy, r.bx, r.by = from.X, from.Y, rest.X, rest.Y
950 r.strokes++
951 e.plays++
952
953 if !holed {
954 chain.Emit(EventShot, "hole", e.id, "player", player.String(),
955 "strokes", strconv.Itoa(r.strokes), "mode", modeNames[r.mode], "shot", played)
956 return shot, false
957 }
958 m, p := r.mode, player.String()
959 // a player's best stays theirs: replaying the hole never erases it, and a
960 // tie keeps the older round, shots and all
961 best := e.bests(m).Get(p)
962 if was := bestStrokes(best); was == 0 || r.strokes < was {
963 if was > 0 {
964 e.board(m).Remove(boardKey(best, p))
965 }
966 best = bestOf(r)
967 e.bests(m).Set(p, best)
968 if counts(e, m, p, was) {
969 improve(m, p, e.par, was, r.strokes)
970 } else if was == 0 && e.official && e.next != "" && !e.out.done[m] && p >= e.out.from[m] {
971 // the drain has yet to reach this first finish: it was never
972 // counted, so it must not be taken out
973 if e.out.fresh[m] == nil {
974 e.out.fresh[m] = avl.NewTree()
975 }
976 e.out.fresh[m].Set(p, true)
977 }
978 }
979 if named(p) {
980 seat(e, m, p, best)
981 }
982 if e.official {
983 drain(finishBatch) // only the course's finishes pay for the course's drain
984 }
985 chain.Emit(EventHoled, "hole", e.id, "player", p,
986 "strokes", strconv.Itoa(r.strokes), "mode", modeNames[m], "shots", r.shots)
987 // the round is its best now, or no better than it: not kept (a round of
988 // this commit's alone was never stored)
989 e.roundTree().Remove(p)
990 return shot, true
991}
992
993// seat puts a named player where their best puts them: on the hole's board,
994// in its record if it tops it, and in the course ranking if they stand in it.
995// Every finish does it, improved or not, so a player named since ranks.
996func seat(e *entry, m int, p string, best any) {
997 k := boardKey(best, p)
998 if !e.board(m).Has(k) {
999 e.board(m).Set(k, p)
1000 }
1001 // the record is a named player's (a throwaway address could hold it for
1002 // good) and the board's top, so a tie goes to the first to make it
1003 if n, rec := bestStrokes(best), e.rec(m); rec.best == 0 || n < rec.best ||
1004 n == rec.best && address(p) != rec.by && k < boardKey(e.bests(m).Get(rec.by.String()), rec.by.String()) {
1005 if m == pro {
1006 e.proBest, e.proBestBy = n, address(p)
1007 } else {
1008 e.best, e.bestBy = n, address(p)
1009 }
1010 }
1011 if v := totals[m].Get(p); v != nil && v.(*row).holes > 0 {
1012 if rk := rankKey(v.(*row)); !ranks[m].Has(rk) {
1013 idle[m].Remove(rk) // a standing parked while unnamed leaves idle as it ranks
1014 ranks[m].Set(rk, p)
1015 }
1016 }
1017}
1018
1019// Claim ranks the caller's bests kept while they had no name: on the boards
1020// of the course's current holes, in both modes, and in the course ranking.
1021// It returns how many bests it seated, and refuses a caller with no gno.land
1022// name. It reads the course's holes only, so it costs the same for everyone.
1023func Claim(cur realm) int {
1024 p := cur.Previous().Address().String()
1025 if !named(p) {
1026 panic("golf: take a gno.land name first: only named players are ranked")
1027 }
1028 n := 0
1029 slots.Iterate("", "", func(_ string, v any) bool {
1030 e := find(v.(string))
1031 for m := 0; m < modes; m++ {
1032 if best := e.bests(m).Get(p); best != nil {
1033 seat(e, m, p, best)
1034 n++
1035 }
1036 }
1037 return false
1038 })
1039 return n
1040}
1041
1042// maxRoundStrokes bounds a round's history (Round shows it); maxPath the path
1043// a hole may return for a stroke (an honest one: a few hundred points).
1044const (
1045 maxRoundStrokes = 60
1046 maxPath = 512
1047)
1048
1049// q4 rounds to the precision a shot is recorded at.
1050func q4(x float64) float64 { return math.Round(x*1e4) / 1e4 }
1051
1052// maxTick bounds the third field of a shot: any timed piece turns in fewer.
1053const maxTick = 1023
1054
1055// parseShot reads "angle,power" or "angle,power,tick". The tick is where the
1056// timed pieces were when the player let go (0 if absent), clamped to
1057// 0..maxTick.
1058func parseShot(s string) (float64, float64, int) {
1059 parts := strings.Split(s, ",")
1060 if len(parts) != 2 && len(parts) != 3 {
1061 panic(`golf: a shot is "angle,power" or "angle,power,tick", got: ` + s)
1062 }
1063 angle, err := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
1064 if err != nil {
1065 panic("golf: bad angle: " + s)
1066 }
1067 power, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
1068 if err != nil {
1069 panic("golf: bad power: " + s)
1070 }
1071 tick := 0
1072 if len(parts) == 3 {
1073 tick = clampTick(strings.TrimSpace(parts[2]), s)
1074 }
1075 return angle, power, tick
1076}
1077
1078// clampTick reads a tick: an optional sign and digits, clamped to
1079// 0..maxTick, even past what an int holds.
1080func clampTick(t, shot string) int {
1081 digits := strings.TrimPrefix(strings.TrimPrefix(t, "-"), "+")
1082 if digits == "" {
1083 panic("golf: bad tick: " + shot)
1084 }
1085 for i := 0; i < len(digits); i++ {
1086 if digits[i] < '0' || digits[i] > '9' {
1087 panic("golf: bad tick: " + shot)
1088 }
1089 }
1090 if strings.HasPrefix(t, "-") {
1091 return 0
1092 }
1093 if digits = strings.TrimLeft(digits, "0"); len(digits) > 4 {
1094 return maxTick
1095 }
1096 n, err := strconv.Atoi("0" + digits)
1097 if err != nil {
1098 panic("golf: bad tick: " + shot) // digits only, at most 4: cannot happen
1099 }
1100 if n > maxTick {
1101 return maxTick
1102 }
1103 return n
1104}
1105
1106// roundOf is the player's round under way on e, or a new one from the tee,
1107// not stored: its commit stores it if it does not finish it.
1108func roundOf(e *entry, h *course.Simple, player address) *round {
1109 if v := e.roundTree().Get(player.String()); v != nil {
1110 return v.(*round)
1111 }
1112 s := h.Start()
1113 return &round{bx: s.X, by: s.Y}
1114}
1115
1116// maxName and maxNote bound a hole's name and a version's note.
1117const (
1118 maxName = 40
1119 maxNote = 140
1120)
1121
1122// cleanName keeps a hole's name to one short line of plain text
1123// (cleanText), with at least a letter or a digit in it.
1124func cleanName(n string) string {
1125 s := cleanText(n, maxName)
1126 for _, c := range s {
1127 if unicode.IsLetter(c) || unicode.IsDigit(c) {
1128 return s
1129 }
1130 }
1131 return "Untitled hole"
1132}
1133
1134// cleanText keeps an author's text to one line of plain text, at most max
1135// characters: no control, format (Cf: bidi, zero-width, tags) or nonspacing
1136// (Mn: stacked accents, variation selectors) characters, no blank-looking
1137// ones, nothing that ends a markdown table cell or opens a link, a tag, an
1138// HTML entity, a strikethrough, a mention or an email link.
1139func cleanText(n string, max int) string {
1140 b := make([]rune, 0, max)
1141 for _, c := range n {
1142 if len(b) >= max {
1143 break
1144 }
1145 switch {
1146 case c < 0x20 || c >= 0x7f && c < 0xa0:
1147 continue
1148 case c == 0xad, c == 0x34f, c == 0x61c, c == 0x115f, c == 0x1160, c == 0x17b4, c == 0x17b5, c == 0x180e,
1149 c >= 0x200b && c <= 0x200f, c >= 0x2028 && c <= 0x202e, c >= 0x2060 && c <= 0x206f,
1150 c == 0x2800, c == 0x3164, c >= 0xfe00 && c <= 0xfe0f, c == 0xfeff, c == 0xffa0,
1151 c >= 0xe0000 && c <= 0xe0fff:
1152 continue
1153 case unicode.In(c, unicode.Mn, unicode.Cf):
1154 // é typed as one (NFC) stays; combining marks (Devanagari, Thai) go
1155 continue
1156 case !unicode.IsPrint(c) && c != ' ':
1157 // what Go would print escaped (\U…), which JSON readers refuse
1158 continue
1159 case strings.ContainsRune("|[]<>`*_#\\&~@", c):
1160 continue
1161 }
1162 b = append(b, c)
1163 }
1164 // no bare link a page might make clickable (": /" keeps the length)
1165 return strings.TrimSpace(strings.ReplaceAll(string(b), "://", ": /"))
1166}
1167
1168// maxSkin bounds a skin's length.
1169const maxSkin = 24
1170
1171// cleanSkin keeps a skin to what it is meant to be: a short lower-case id.
1172func cleanSkin(s string) string {
1173 b := make([]byte, 0, maxSkin)
1174 for i := 0; i < len(s) && len(b) < maxSkin; i++ {
1175 c := s[i]
1176 if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == ' ' || c == '-' || c == '_' {
1177 b = append(b, c)
1178 }
1179 }
1180 return string(b)
1181}