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