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

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