Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}