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

render.gno

37.26 Kb · 1108 lines
   1package golf
   2
   3import (
   4	"encoding/hex"
   5	"math"
   6	"strconv"
   7	"strings"
   8	"time"
   9
  10	"gno.land/p/nym-alexiscolin000/gnogolf/course"
  11	"gno.land/p/nym-alexiscolin000/gnogolf/physics"
  12	"gno.land/p/nt/ufmt/v0"
  13	"gno.land/r/nym-alexiscolin000/gnogolf/store"
  14)
  15
  16// wearRunes draw a cell's wear: none, then up to wearLight, wearWorn and
  17// wearGroove balls come to rest there, and past.
  18const (
  19	wearRunes  = " :;x%" // pristine grass blank: a shot's dots stand out on it
  20	wearLight  = 2
  21	wearWorn   = 6
  22	wearGroove = 14
  23)
  24
  25// maxTunnels is how many tunnels the board numbers, 1 to 9.
  26const maxTunnels = 9
  27
  28const (
  29	markWall = '#'
  30	markPost = '0'
  31	markCup  = 'U' // not O: in gnoweb's font O and the post's 0 look alike
  32	markTee  = '*'
  33)
  34
  35// Render is the game as text: gnoweb draws the board and submits the shot.
  36//
  37// Paths: "" the hub; "<world>" a cup's holes; "<address>" the holes an
  38// address published; "<hole>" a hole's page, "<hole>/<address>" the same
  39// with that player's ball, "<hole>/data" a version's provenance and data.
  40// <hole> is a version's id or an alias. A query string ("?…") is ignored.
  41func Render(path string) string {
  42	if i := strings.IndexByte(path, '?'); i >= 0 {
  43		path = path[:i]
  44	}
  45	if path == "" {
  46		return renderHub()
  47	}
  48	if e := lookup(path); e != nil {
  49		return renderHole(e, e.hole(), "")
  50	}
  51	// the segments of a hole's path, which gnoweb links one by one
  52	if course.IsWorld(path) {
  53		return renderCup(path)
  54	}
  55	if address(path).IsValid() {
  56		return renderAuthor(address(path))
  57	}
  58	// the id has slashes of its own, so what trails it is looked at last
  59	if i := strings.LastIndex(path, "/"); i >= 0 {
  60		head, tail := path[:i], path[i+1:]
  61		if e := lookup(head); e != nil {
  62			if address(tail).IsValid() {
  63				return renderHole(e, e.hole(), address(tail))
  64			}
  65			if tail == "data" {
  66				return renderData(e)
  67			}
  68		}
  69	}
  70	return noSuch(path)
  71}
  72
  73// noSuch is the page for a path that is no hole, cup or address.
  74func noSuch(path string) string {
  75	what := "That page"
  76	if c := cleanText(path, maxName); c != "" {
  77		what = "`" + c + "`"
  78	}
  79	return "# No such hole\n\n> [!WARNING]\n> " + what + " is not a hole here. " + md("See every hole", hub) + ".\n"
  80}
  81
  82// lookup is readHole that answers nil for no hole.
  83func lookup(id string) *entry {
  84	if e := find(id); e != nil {
  85		return e
  86	}
  87	return find(current(id))
  88}
  89
  90// playLink is the 3D client's link to this hole: a current course hole by cup
  91// and order, any other by its id, which no newer version can take.
  92func playLink(e *entry) string {
  93	if !e.official || e.next != "" {
  94		return playURL + "?hole=" + e.id
  95	}
  96	return playURL + "?cup=" + e.world + "&hole=" + strconv.Itoa(int(e.order))
  97}
  98
  99// cupName is how a world is called on the page.
 100func cupName(w string) string {
 101	if r := worldRank(w); r < len(worlds) {
 102		return worlds[r].name
 103	}
 104	if w == "" {
 105		return "Holes"
 106	}
 107	return strings.ToUpper(w[:1]) + w[1:]
 108}
 109
 110// who is a player as a link to their gno.land profile.
 111func who(addr string) string { return md(short(addr), "/u/"+addr) }
 112
 113func renderHub() string {
 114	var sb strings.Builder
 115	sb.WriteString("# Gnogolf — mini-golf on-chain\n\n" + moved() +
 116		"The course is data its owner published, anyone can add holes of their own, and the chain computes every shot: nobody can fake a score.\n\n" +
 117		"> [!TIP] How to play\n" +
 118		"> **In 3D:** " + md("open the game", playURL) + ". Aim with a free preview, then record your round in a transaction (a long one in a few).\n>\n" +
 119		"> **Here, one shot at a time:** open a hole, fill its Launch form (an angle and a power) and sign it with your gno.land wallet (" + md("Adena", "https://adena.app") + "), then add `/<your address>` to the hole's URL to see your ball.\n>\n" +
 120		"> Only players with a gno.land name are ranked.\n\n")
 121	// what to race first: a few records worth beating, not the podium
 122	beat := ""
 123	for m := 0; m < modes; m++ {
 124		if rows := featuredRows(m); rows != "" {
 125			beat += "**" + modeTitles[m] + "**\n\n" + rows + "\n"
 126		}
 127	}
 128	if beat != "" {
 129		sb.WriteString("## Records to beat today\n\nA few players worth racing today, each for a reason (never the ranking's top three; drawn again each day). Race their ghosts in the game: their best rounds, replayed stroke for stroke.\n\n" + beat)
 130	}
 131	var archived, others []*entry
 132	type cup struct {
 133		world      string
 134		holes, par int
 135		top        *entry
 136	}
 137	var cups []*cup
 138	for _, e := range listed() {
 139		switch {
 140		case e.official && e.next == "":
 141			// one column per cup: nothing here calls a hole
 142			if len(cups) == 0 || cups[len(cups)-1].world != e.world {
 143				cups = append(cups, &cup{world: e.world})
 144			}
 145			c := cups[len(cups)-1]
 146			c.holes++
 147			c.par += e.par
 148			if e.plays > 0 && (c.top == nil || e.plays > c.top.plays) {
 149				c.top = e
 150			}
 151		case e.official:
 152			archived = append(archived, e)
 153		default:
 154			others = append(others, e)
 155		}
 156	}
 157	if len(cups) == 0 {
 158		sb.WriteString("_No course hole yet._\n\n")
 159	} else {
 160		// a column a cup, so they all fit one row (gnoweb's columns wrap past four)
 161		head, rule, sizes, tops := "| ", "|---", "| holes · par", "| most played"
 162		for _, c := range cups {
 163			top := "—"
 164			if c.top != nil {
 165				top = c.top.name
 166			}
 167			head += " | " + md(cupName(c.world), hub+":"+c.world)
 168			rule += "|---"
 169			sizes += " | " + strconv.Itoa(c.holes) + " · " + strconv.Itoa(c.par)
 170			tops += " | " + top
 171		}
 172		sb.WriteString(head + " |\n" + rule + "|\n" + sizes + " |\n" + tops + " |\n\n")
 173	}
 174
 175	sb.WriteString("## Community holes\n\nAnyone can publish a hole of their own with PublishMine while the owner keeps publishing open. These are not part of the course: they have no cup and do not count in its ranking.\n\n")
 176	switch {
 177	case len(others) > 0:
 178	case publishing:
 179		sb.WriteString("_None yet. Publish yours with " + md("PublishMine", hub+"$help&func=PublishMine") + "._\n")
 180	default:
 181		sb.WriteString("_None yet: publishing opens with the game's hole builder._\n")
 182	}
 183	names := map[string]string{} // the record holders' names, read once each (bestCell)
 184	if len(others) > 0 {
 185		sb.WriteString("| hole | by | par | best | shots played |\n|---|---|---|---|---|\n")
 186		for _, e := range others {
 187			sb.WriteString("| " + md(e.name, hub+":"+e.slot) + " | " + md(short(e.by.String()), hub+":"+e.by.String()) + " | " + strconv.Itoa(e.par) + " | " + bestCell(e, names) + " | " +
 188				strconv.Itoa(e.plays) + " |\n")
 189		}
 190	}
 191	if len(archived) > 0 {
 192		sb.WriteString("\n## Archived course holes\n\nA newer version took each one's place in its cup. They can still be played, and keep their records, but no longer count in the course-wide ranking. Every version of a slot is on its data page.\n\n" +
 193			"| hole | version | par | best | shots played | now |\n|---|---|---|---|---|---|\n")
 194		for _, e := range archived {
 195			sb.WriteString("| " + md(e.name, hub+":"+e.id) + " | " + e.id + " | " + strconv.Itoa(e.par) + " | " + bestCell(e, names) + " | " +
 196				strconv.Itoa(e.plays) + " | " + md(e.next, hub+":"+e.next) + " |\n")
 197		}
 198	}
 199	sb.WriteString("\n")
 200	sb.WriteString("## The boards\n\nEach named player's best finish on each current hole of the course, added up: most holes first, then the best score against par (each hole's strokes less its par, added up: a hole at par counts the same whatever its par). " +
 201		"Assisted rounds (played with the 3D game's aim line) and pro rounds (without it, on the player's word; every round played on this page is pro) are ranked apart.\n\n")
 202	for m := 0; m < modes; m++ {
 203		sb.WriteString("### " + modeTitles[m] + "\n\n")
 204		rows, i := "", 0
 205		eachRanked(m, func(r *row, name string) {
 206			i++
 207			rows += "| " + strconv.Itoa(i) + " | " + md("@"+name, "/u/"+name) + " | " + strconv.Itoa(r.holes) + " | " + vsPar(r.strokes-r.par) + " |\n"
 208		})
 209		if rows == "" {
 210			sb.WriteString("_Nobody has finished a hole of the course yet._\n\n")
 211			continue
 212		}
 213		sb.WriteString("| rank | player | holes | vs par |\n|---|---|---|---|\n" + rows + "\n")
 214	}
 215
 216	sb.WriteString(fairPlay())
 217	sb.WriteString(noAdmin())
 218	return sb.String()
 219}
 220
 221// fairPlay says what keeps a score honest, and where the rules stand.
 222func fairPlay() string {
 223	return "## Fair play\n\n" +
 224		"- **The chain plays every shot.** A round is its shots (an angle, a power, the moment of the release), replayed here: nobody can send a score.\n" +
 225		"- **Shots go in the game's steps of 0.01**, angle and power: a finer one (a program's precision) is refused.\n" +
 226		"- **A record sent again is not ranked.** A round that sends one of the hole's top records again, shot for shot within a hair, is kept (the player's round and ghost) but ranks nowhere.\n" +
 227		"- **The site's boards hide suspected bots**, and say why; the chain and this page keep every record.\n" +
 228		"- **The rules and the data are apart:** the records are kept in " + md("Gnogolf's data", strings.TrimPrefix(dataRealm, "gno.land")) + ", which takes writes from these rules alone. New rules take over three days after their code is on chain, so anyone can read them first; nothing is reset.\n\n"
 229}
 230
 231// moved is the banner every page shows once these rules no longer save (the
 232// data's writer is another, or saves are paused) or the course has moved.
 233func moved() string {
 234	switch w := store.Writer(); {
 235	case w == "":
 236		return "> [!WARNING] Saves are paused\n> " + md("Gnogolf's data", strings.TrimPrefix(dataRealm, "gno.land")) + " takes no save until these rules are resumed or new ones named: rounds here are refused, every record can still be read.\n\n"
 237	case w != self:
 238		return "> [!NOTE] New rules play this course\n> " + md(w, strings.TrimPrefix(w, "gno.land")) + " keeps its records now: play there; a round sent here is refused.\n\n"
 239	case successor != "":
 240		return "> [!NOTE] This course has moved\n> Its successor is " + md(successor, strings.TrimPrefix(successor, "gno.land")) +
 241			". Everything here still plays and keeps its records.\n\n"
 242	}
 243	return ""
 244}
 245
 246// renderCup is a cup's page: its current holes, in order.
 247func renderCup(world string) string {
 248	var sb strings.Builder
 249	sb.WriteString("# " + cupName(world) + "\n\n" + moved())
 250	k := slotKey(world, 0)
 251	k = k[:len(k)-3] // the world's slots are the keys "<rank><world>/…"
 252	n, names := 0, map[string]string{}
 253	slots.Iterate(k, k[:len(k)-1]+"0", func(_, v string) bool {
 254		e := find(v)
 255		if n == 0 {
 256			sb.WriteString("| # | hole | par | best | shots played |\n|---|---|---|---|---|\n")
 257		}
 258		n++
 259		sb.WriteString("| " + strconv.Itoa(int(e.order)) + " | " + md(e.name, hub+":"+e.slot) + " | " + strconv.Itoa(e.par) + " | " +
 260			bestCell(e, names) + " | " + strconv.Itoa(e.plays) + " |\n")
 261		return n >= maxListed
 262	})
 263	if n == 0 {
 264		return noSuch(world)
 265	}
 266	return sb.String() + "\n" + md("Every cup", hub) + "\n"
 267}
 268
 269// renderAuthor is the holes an address published, as their current versions.
 270func renderAuthor(a address) string {
 271	var sb strings.Builder
 272	sb.WriteString("# Community holes by " + who(a.String()) + "\n\n" + moved())
 273	n, names := 0, map[string]string{}
 274	aliases.Iterate(a.String()+"/", a.String()+"0", func(k, v string) bool {
 275		if hidden.Has(k) {
 276			return false // off the lists, as Hide says: this page is one
 277		}
 278		e := find(v)
 279		if n == 0 {
 280			sb.WriteString("| hole | versions | par | best | shots played | data |\n|---|---|---|---|---|---|\n")
 281		}
 282		n++
 283		sb.WriteString("| " + md(e.name, hub+":"+e.slot) + " | " + strconv.Itoa(e.version) + " | " + strconv.Itoa(e.par) + " | " +
 284			bestCell(e, names) + " | " + strconv.Itoa(e.plays) + " | " + source(e) + " |\n")
 285		return n >= pageMax
 286	})
 287	if n == 0 {
 288		sb.WriteString("_This address has published no community hole._\n")
 289	}
 290	return sb.String() + "\n" + md("Every hole", hub) + "\n"
 291}
 292
 293// bestCell is a hole's records, assisted then pro, for a table cell. names
 294// caches holders: r/sys/users is read once a player, not once a record.
 295func bestCell(e *entry, names map[string]string) string {
 296	best := ""
 297	for m := 0; m < modes; m++ {
 298		if r := e.rec(m); r.best > 0 {
 299			if best != "" {
 300				best += " · "
 301			}
 302			by := r.by.String()
 303			name, ok := names[by]
 304			if !ok {
 305				name = byName(by)
 306				names[by] = name
 307			}
 308			best += ufmt.Sprintf("%s %d, %s", modeNames[m], r.best, name)
 309		}
 310	}
 311	if best == "" {
 312		return "—"
 313	}
 314	return best
 315}
 316
 317// weatherNote is the hole's weather now (f, the forecast of this period) and
 318// how long it lasts, on a hole's own page.
 319func weatherNote(f course.Forecast) string {
 320	left := PeriodSeconds - time.Now().Unix()%PeriodSeconds
 321	what := "Clear"
 322	switch f.Kind {
 323	case course.Wind:
 324		a, _ := aim(physics.Vec2{}, f.Wind)
 325		what = ufmt.Sprintf("Wind toward **%.0f°**: it pushes every roll that way", a)
 326	case course.Rain:
 327		what = "Rain: the green runs fast, and puddles slow the ball"
 328	case course.Fog:
 329		what = "Fog: it hides nothing here, and does not touch the ball"
 330	case course.Storm:
 331		what = "Storm: rain, puddles and wind"
 332	case course.Snow:
 333		what = "Snow: the green runs slow"
 334	}
 335	// ufmt has no width: the seconds padded by hand
 336	return ufmt.Sprintf("> [!NOTE] Weather now\n> %s — for %d min %s s more. A round keeps the weather it started in, and can be played on until the next weather is over: after that it must be Reset.\n\n",
 337		what, left/60, pad(int(left%60), 2))
 338}
 339
 340// noAdmin states who can change what, the owner's own powers included.
 341func noAdmin() string {
 342	holder := who(owner.String())
 343	if owner == "" {
 344		holder = "nobody: the role was given up, and the course is frozen for good"
 345	}
 346	return "\n> [!INFO]- Who can change what\n" +
 347		"> Anyone can play (Launch, PlayRound, PlayRoundAt, PlayRoundPro), Reset their own round, " +
 348		"publish holes of their own (PublishMine) while publishing is open, and settle the ranking sooner (Drain). These rules never edit or delete a round, a record, a best, a standing or a published version, " +
 349		"and nobody can change this realm's code. Its rules can be replaced, its records kept: the owner of " + md("Gnogolf's data", strings.TrimPrefix(dataRealm, "gno.land")) + " may propose new rules, which take over three days after their code is on chain (anyone can read it first: what they do to the records is in it), and may stop every save at once for a bug (the reads go on) and start these same rules again; that page says which rules are in force. Every hole is data, played by golf's own physics: no hole runs code of its own. Check it yourself: " + md("the source", hub+"$source") + ".\n>\n" +
 350		"> One party holds a real power: the course's owner, now " + holder + ". The owner publishes the course holes and their new versions (Publish). " +
 351		"A new version takes its slot: the old one is archived, still playable, its records kept, but its bests leave the course-wide ranking, which the new version starts afresh. " +
 352		"So the owner shapes the course, and through it the ranking, and a stolen owner key could archive every slot, for good. " +
 353		"A version identical to the current one is refused, but a replacement can still be unplayable: a hole can be replaced, never taken down. " +
 354		"When a version is published picks its id, and its id seeds its weather. " +
 355		"The owner also sets where the pages link the 3D game (SetPlayURL), and can name, once and for good, a successor this course has moved to (SetSuccessor): a banner, which blocks nothing. " +
 356		"Whoever holds `" + strings.TrimPrefix(officialPrefix, "gno.land/") + "` can deploy lookalike realms under it. " +
 357		"The owner can take a community hole off the lists (Hide: a scam or abuse in its name or note), and put it back; it stays playable, its page and data kept. " +
 358		"The owner opens community publishing and can close it again (SetPublishing): closed, nobody adds a hole or a version, and every hole published plays on. " +
 359		"The owner cannot edit or remove a version, change a community hole, the weather, the physics or this code, " +
 360		"and can only hand the role on (Transfer, then Accept) or give it up for good (Renounce).\n"
 361}
 362
 363// source links a hole's data page: read it before trusting it with a round.
 364func source(e *entry) string { return md("data", hub+":"+e.id+"/data") }
 365
 366func md(label, href string) string { return "[" + label + "](" + href + ")" }
 367
 368// vsPar is a score against par: "even", "+3", "-2" (ufmt has no %+d).
 369func vsPar(d int) string {
 370	switch {
 371	case d > 0:
 372		return "+" + strconv.Itoa(d)
 373	case d < 0:
 374		return strconv.Itoa(d)
 375	}
 376	return "even"
 377}
 378
 379// flatRolls is how far a shot rolls on flat grass (a tee shot's Kick, the
 380// usual friction).
 381const flatRolls = "On flat grass a power of 10 rolls about 40, a 5 about 15."
 382
 383func renderHole(e *entry, h *course.Simple, focus address) string {
 384	f := h.Field()
 385	var mine *round // the focused player's round, looked up once
 386	if v, ok := e.roundTree().Get(focus.String()); ok {
 387		mine = decodeRound(v)
 388	}
 389	stroke := 0
 390	if mine != nil {
 391		stroke = mine.strokes
 392	}
 393	// a timed hole is drawn as it stands for the focused player's next stroke
 394	ws, ps := h.Extras(stroke)
 395	f = course.WithZones(course.WithExtras(f, ws, ps), h.ExtraZones(stroke))
 396	// a community hole's world is its author's word: only the course has cups
 397	where := md(cupName(e.world), hub+":"+e.world)
 398	if !e.official {
 399		where = "Community hole by " + md(short(e.by.String()), hub+":"+e.by.String())
 400	}
 401	s := ufmt.Sprintf("# %s\n\n**Par %d** · %s · %s\n\n", e.name, e.par, where, md("Play in 3D", playLink(e))) + moved()
 402	if e.next != "" {
 403		next := e.next
 404		if n := find(e.next); n != nil {
 405			next = n.name
 406		}
 407		s += "> [!NOTE]\n> Archived: " + md(next, hub+":"+e.next) + " (" + e.next + ") took its place. It can still be played, and your best here still shows on this page"
 408		if e.official {
 409			s += ", but it no longer counts in the course-wide ranking"
 410		}
 411		s += ".\n\n"
 412	}
 413	if focus != "" {
 414		s += roundSummary(e, focus, mine)
 415	}
 416	// the page's forecast, drawn once: its note, and the last shot of a round
 417	// played in it
 418	now := weatherOf(e, h, Period())
 419	s += weatherNote(now)
 420	if h.Varies() {
 421		if focus == "" {
 422			s += "_This hole changes with every stroke. It is drawn for a first shot; add `/<your address>` to the URL to see it for your next one._\n\n"
 423		} else {
 424			s += ufmt.Sprintf("_This hole changes with every stroke. Drawn for %s's stroke %d._\n\n", short(focus.String()), stroke+1)
 425		}
 426	}
 427	if boardWork(f, h) > maxBoardWork {
 428		s += "_This hole is too detailed to draw here: " + md("play it in 3D", playLink(e)) + "._\n\n"
 429	} else {
 430		b := board(e, h, f, mine, now)
 431		leg, walls := legend(f)
 432		s += "```\n" + b + "```\n\n"
 433		s += key(b, walls) + leg + "\n\n"
 434	}
 435
 436	s += "## Play\n\n"
 437	// the one number a text player cannot read off the board: where the cup is
 438	from, who := h.Start(), "the tee"
 439	if mine != nil {
 440		from, who = mine.ball(), "the ball"
 441	}
 442	a, d := aim(from, h.Cup())
 443	s += ufmt.Sprintf("From %s the cup is **%.0f°** away, **%.1f** units in a straight line (0° = right, 90° = down). %s\n\n", who, a, d, flatRolls)
 444	s += "_Rounds played here have no aim preview, so they go on the **Pro** board. A shot needs a gno.land wallet (Adena); the 3D game previews shots for free._\n\n"
 445	s += "<gno-form exec=\"Launch\">\n"
 446	s += ufmt.Sprintf("<gno-input name=\"hole\" value=\"%s\" readonly=\"true\" description=\"this hole\" required=\"true\" />\n", e.id)
 447	s += "<gno-input name=\"angle\" description=\"angle in degrees: 0 = right, 90 = down\" placeholder=\"e.g. 12.5\" required=\"true\" />\n"
 448	s += "<gno-input name=\"power\" description=\"power: above 0, up to 10\" placeholder=\"e.g. 6\" required=\"true\" />\n"
 449	s += "</gno-form>\n\n"
 450	s += ufmt.Sprintf("_After your shot, see your ball at `%s:%s/<your address>`._\n\n", hub, e.id)
 451	s += "A round under way you would rather start afresh? Put your ball back on the tee (a holed round needs nothing: the next shot starts a new one):\n\n"
 452	s += "<gno-form exec=\"Reset\">\n"
 453	s += ufmt.Sprintf("<gno-input name=\"hole\" value=\"%s\" readonly=\"true\" description=\"this hole\" required=\"true\" />\n", e.id)
 454	s += "</gno-form>\n\n"
 455	if e.official && e.next == "" {
 456		s += "Took a gno.land name after saving rounds? " + md("Claim", hub+"$help&func=Claim") + " puts them on the boards now.\n\n"
 457	}
 458
 459	s += ufmt.Sprintf("## Best rounds\n\n%d rounds under way here; finished: %d assisted, %d pro.", e.roundTree().Size(), e.bests(assisted).Size(), e.bests(pro).Size())
 460	if e.board(assisted).Size()+e.board(pro).Size() == 0 {
 461		s += " No named player has finished it yet.\n\n"
 462	} else {
 463		s += " The named players' bests: each keeps its round, which the 3D game replays as a ghost to race.\n\n"
 464	}
 465	for m := 0; m < modes; m++ {
 466		rows := ""
 467		n, walked := 0, 0
 468		e.board(m).Iterate("", "", func(k, v string) bool {
 469			walked++
 470			p := v
 471			if name := nameOf(p); name != "" { // a name deleted since is skipped
 472				n++
 473				rows += ufmt.Sprintf("| %d | [@%s](%s:%s/%s) | %d | %s |\n", n, name, hub, e.id, p, boardStrokes(k), race(e, p, "race"))
 474			}
 475			return n >= topSize || walked >= maxWalked
 476		})
 477		if rows != "" {
 478			s += "### " + modeTitles[m] + "\n\n| # | player | score | ghost |\n|---|---|---|---|\n" + rows + "\n"
 479		}
 480	}
 481	return s + "\n_This hole is " + source(e) + " — read it before you trust it with a round._\n"
 482}
 483
 484// roundSummary is how a player stands on the hole: their round under way, if
 485// any, and their best in each mode, with where it puts them on the hole's
 486// board, or why nowhere. A holed round is not kept: its best is.
 487func roundSummary(e *entry, p address, r *round) string {
 488	who := short(p.String())
 489	name := nameOf(p.String())
 490	if name != "" {
 491		who = "@" + name
 492	}
 493	s := ""
 494	if r != nil {
 495		over := ""
 496		if r.period < Period()-1 {
 497			over = " Its weather is over: Reset (below) before the next shot."
 498		}
 499		s = ufmt.Sprintf("> %s's round: %s so far, not holed yet.%s\n>\n> %s\n>\n", who, count(r.strokes, "stroke"), over, shotsOf(r.shots))
 500	}
 501	for m := 0; m < modes; m++ {
 502		v := bestOfPlayer(e, m, p.String())
 503		if v == "" {
 504			continue
 505		}
 506		best := bestStrokes(v)
 507		s += ufmt.Sprintf("> **%s's best here: %d** (par %d, %s), %s. ", who, best, e.par, vsPar(best-e.par), modeNames[m])
 508		b := e.board(m)
 509		switch k := boardKey(v, p.String()); {
 510		case b.Has(k):
 511			s += ufmt.Sprintf("**#%d of %d** on the %s board. %s.\n>\n", b.index(k)+1, b.Size(), modeNames[m], race(e, p.String(), "Race this ghost in 3D"))
 512		case e.copies(m).Has(p.String()):
 513			s += "Not ranked: it is a record of the board sent again (Fair play, on the course's page).\n>\n"
 514		case name == "":
 515			s += "Not on the board: only players with a gno.land name are ranked.\n>\n"
 516		case e.official && e.next == "":
 517			s += "Not on the board yet: the name came after this round. Claim (below, or the game's \"Rank them\") ranks it now.\n>\n"
 518		default: // Claim seats the course's current holes only
 519			s += "Not on the board yet: the name came after this round. Finish it again: a named player's every finish puts their best on the board.\n>\n"
 520		}
 521		_, _, shots := bestFields(v)
 522		s += "> " + shotsOf(shots) + "\n>\n"
 523	}
 524	if s == "" {
 525		return "> [!NOTE]\n> " + who + " has no round on this hole yet.\n\n"
 526	}
 527	return "> [!NOTE]\n" + strings.TrimSuffix(s, ">\n") + "\n"
 528}
 529
 530// shotsOf is a round's shots for a page: golf's own "%.4f,%.4f,%d", digits
 531// and punctuation only.
 532func shotsOf(shots string) string {
 533	return "Shots: `" + strings.ReplaceAll(shots, ";", "; ") + "`"
 534}
 535
 536// renderData is a data version's page: where it came from, its versions,
 537// and its data, which is all the hole there is.
 538func renderData(e *entry) string {
 539	pkgs := strings.Replace(officialPrefix, "gno.land/r/", "/p/", 1) // the course's packages, as "/p/<ns>/"
 540	s := "# " + e.name + ": its data\n\n**" + e.id + "**, version " + strconv.Itoa(e.version) + " of " + md(e.slot, hub+":"+e.slot) +
 541		", published at height " + strconv.FormatInt(e.height, 10) + " by " + who(e.by.String()) + ". " + md("Play it", hub+":"+e.id) + ".\n\n"
 542	if e.note != "" {
 543		s += "> " + e.note + "\n\n"
 544	}
 545	if e.next != "" {
 546		s += "_Archived: " + md(e.next, hub+":"+e.next) + " took its place._\n\n"
 547	}
 548	s += "## Versions\n\n| version | height | by | sha256 | note |\n|---|---|---|---|---|\n"
 549	now := find(current(e.slot))
 550	for n := firstVersion(now); n <= now.version; n++ {
 551		v := find(e.slot + "/v" + strconv.Itoa(n))
 552		if v == nil {
 553			continue // (one before this course's first here: imported from v1, with v1's number)
 554		}
 555		s += "| " + md(v.id, hub+":"+v.id+"/data") + " | " + strconv.FormatInt(v.height, 10) + " | " + who(v.by.String()) + " | `" + v.sha[:16] + "…` | " + v.note + " |\n"
 556	}
 557	data, _ := holeData.Get(e.id)
 558	s += "\n## The data\n\n" + strconv.Itoa(len(data)) + " bytes of GG1, sha256 `" + e.sha + "`. " +
 559		"Every call decodes it with " + md("course", pkgs+"course$source") + " and plays it with " + md("physics", pkgs+"physics$source") +
 560		"; " + md("golf", hub+"$source") + " checked it once, when it was published. HoleData gives it back as hex:\n\n"
 561	return s + "```\n" + hex.EncodeToString([]byte(data)) + "\n```\n"
 562}
 563
 564// board rasterises the hole: wear, then zones, posts, walls, cup, and mine,
 565// the focused player's round (nil for none); now is this period's forecast.
 566func board(e *entry, h *course.Simple, f *physics.Field, mine *round, now course.Forecast) string {
 567	bw, bh := h.W, h.H
 568	g := make([][]rune, bh)
 569	wear := e.wearGrid()
 570	for y := 0; y < bh; y++ {
 571		row := make([]rune, bw)
 572		for x := 0; x < bw; x++ {
 573			row[x] = wearRune(wear, x*course.WearW/bw, y*course.WearH/bh)
 574		}
 575		g[y] = row
 576	}
 577	// the walls, traced once: drawn, and what the flood fill stops at
 578	wall := make([][]rune, bh)
 579	for y := range wall {
 580		wall[y] = make([]rune, bw)
 581	}
 582	for _, w := range f.Walls {
 583		trace(wall, w.Seg, mark(w.Mark, markWall))
 584	}
 585
 586	// the last flight first, so the pieces drawn over it stay visible
 587	if mine != nil {
 588		fc := now
 589		if mine.period != now.Period { // a round begun in the period before
 590			fc = weatherOf(e, h, mine.period)
 591		}
 592		for _, p := range lastShot(h, mine, fc).Path {
 593			put(g, p, '·')
 594		}
 595	}
 596
 597	tunnel := 0
 598	for i := range f.Zones {
 599		z := &f.Zones[i]
 600		m := mark(z.Mark, '?')
 601		if z.Kind == physics.Tunnel && tunnel < maxTunnels {
 602			// a tunnel's mouth and exit share a digit
 603			tunnel++
 604			m = rune('0' + tunnel)
 605		}
 606		drawZone(g, z, m)
 607	}
 608	for y := range wall {
 609		for x, c := range wall[y] {
 610			if c != 0 {
 611				g[y][x] = c
 612			}
 613		}
 614	}
 615	for _, p := range f.Posts {
 616		fill(g, p.Circle, mark(p.Mark, markPost))
 617		put(g, p.C, mark(p.Mark, markPost)) // a small post still shows
 618	}
 619	tunnel = 0
 620	for _, z := range f.Zones {
 621		if z.Kind == physics.Tunnel && tunnel < maxTunnels {
 622			tunnel++
 623			put(g, z.Vec, rune('0'+tunnel))
 624		}
 625	}
 626	put(g, h.Start(), markTee)
 627	put(g, h.Cup(), markCup)
 628
 629	// ground no ball can reach is not green: blank it, as the 3D client does
 630	blankUnreachable(g, wall, f, h.Start(), h.Cup())
 631
 632	// only your own ball: everyone's at once would bury the board
 633	if mine != nil {
 634		put(g, mine.ball(), '@')
 635	}
 636
 637	var sb strings.Builder
 638	for _, row := range g {
 639		sb.WriteString(string(row) + "\n")
 640	}
 641	return sb.String()
 642}
 643
 644// drawZone marks the cells whose centre the zone holds, as the physics sees it
 645// (physics.Zone.Contains): a polygon's row from where the centre line crosses
 646// its edges, an ellipse's as the run the physics' test finds from its middle.
 647func drawZone(g [][]rune, z *physics.Zone, m rune) {
 648	bh := len(g)
 649	if bh == 0 {
 650		return
 651	}
 652	bw := len(g[0])
 653	x0, x1 := boxCells(z.Min.X, z.Max.X, bw)
 654	y0, y1 := boxCells(z.Min.Y, z.Max.Y, bh)
 655	if x0 >= x1 {
 656		return
 657	}
 658	poly := len(z.Poly) >= 3
 659	var flip []bool
 660	if poly {
 661		flip = make([]bool, x1+1)
 662	}
 663	for y := y0; y < y1; y++ {
 664		row, py := g[y], float64(y)+0.5
 665		switch {
 666		case poly:
 667			// even-odd, as inPoly: a sweep from the right flips at each
 668			// crossing's cell. No sort: a row costs its edges and cells.
 669			for x := x0; x <= x1; x++ {
 670				flip[x] = false
 671			}
 672			a := z.Poly[len(z.Poly)-1]
 673			for _, b := range z.Poly { // the edge from a to b
 674				if (a.Y > py) != (b.Y > py) {
 675					c := cellAt(a.X+(py-a.Y)*(b.X-a.X)/(b.Y-a.Y), x0, x1)
 676					flip[c] = !flip[c]
 677				}
 678				a = b
 679			}
 680			in := false
 681			for x := x1 - 1; x >= x0; x-- {
 682				if flip[x+1] {
 683					in = !in
 684				}
 685				if in != z.Outside {
 686					row[x] = m
 687				}
 688			}
 689		case z.Round:
 690			hx, hy := (z.Max.X-z.Min.X)/2, (z.Max.Y-z.Min.Y)/2
 691			dy := (py - z.Min.Y - hy) / hy
 692			in := func(x int) bool {
 693				dx := (float64(x) + 0.5 - z.Min.X - hx) / hx
 694				return dx*dx+dy*dy < 1
 695			}
 696			// the cells nearest the middle: if neither is in, none is
 697			mid := int(math.Floor(z.Min.X + hx - 0.5))
 698			c := -1
 699			for _, x := range [2]int{mid, mid + 1} {
 700				if x < x0 {
 701					x = x0
 702				}
 703				if x >= x1 {
 704					x = x1 - 1
 705				}
 706				if c < 0 && in(x) {
 707					c = x
 708				}
 709			}
 710			if c >= 0 {
 711				from := firstCell(x0, c, in)
 712				to := firstCell(c, x1, func(x int) bool { return !in(x) })
 713				fillRow(row, from, to, m)
 714			}
 715		default:
 716			fillRow(row, x0, x1, m)
 717		}
 718	}
 719}
 720
 721// boxCells is the cells from int(lo) to int(hi), within 0..n, whose centre
 722// lies in [lo, hi): the cells the physics' box test takes.
 723func boxCells(lo, hi float64, n int) (int, int) {
 724	a, b := span(lo, hi, n)
 725	if a >= b {
 726		return a, a
 727	}
 728	a = firstCell(a, b, func(x int) bool { return float64(x)+0.5 >= lo })
 729	return a, firstCell(a, b, func(x int) bool { return !(float64(x)+0.5 < hi) })
 730}
 731
 732// cellAt is the first cell in [lo, hi), lo >= 0, whose centre is at or past c
 733// (!(x+0.5 < c), as the physics compares), hi if none: ceil(c-0.5).
 734func cellAt(c float64, lo, hi int) int {
 735	if !(float64(lo)+0.5 < c) { // NaN too, as a search would find
 736		return lo
 737	}
 738	if c >= float64(hi) {
 739		return hi
 740	}
 741	x := int(c) // c > 0.5: its floor
 742	if float64(x)+0.5 < c {
 743		x++
 744	}
 745	if x > hi {
 746		x = hi
 747	}
 748	return x
 749}
 750
 751// firstCell is the first x in [lo, hi) where ok turns true, hi if it never
 752// does: ok is false up to some x, and true from there.
 753func firstCell(lo, hi int, ok func(x int) bool) int {
 754	for lo < hi {
 755		mid := (lo + hi) / 2
 756		if ok(mid) {
 757			hi = mid
 758		} else {
 759			lo = mid + 1
 760		}
 761	}
 762	return lo
 763}
 764
 765func fillRow(row []rune, from, to int, m rune) {
 766	for x := from; x < to; x++ {
 767		row[x] = m
 768	}
 769}
 770
 771// span is the cells from int(lo) to int(hi), within 0..n.
 772func span(lo, hi float64, n int) (int, int) {
 773	a, b := int(lo), int(hi)
 774	if a < 0 {
 775		a = 0
 776	}
 777	if b > n {
 778		b = n
 779	}
 780	return a, b
 781}
 782
 783// maxBoardWork bounds a board's drawing, in thousands of gas (boardWork):
 784// past it the page does not draw, far under the 3e9 a query may use.
 785const maxBoardWork = 600000
 786
 787// boardWork estimates board's cost in thousands of gas, with measured weights:
 788// a board cell; a cell and a polygon edge per row of a zone's box (every edge
 789// counted as crossing, as a zigzag's do); a step along a wall; a cell of a
 790// post's box.
 791func boardWork(f *physics.Field, h *course.Simple) int {
 792	bw, bh := h.W, h.H
 793	n := 32 * bw * bh
 794	for i := range f.Zones {
 795		z := &f.Zones[i]
 796		x0, x1 := span(z.Min.X, z.Max.X, bw)
 797		y0, y1 := span(z.Min.Y, z.Max.Y, bh)
 798		if x1 > x0 && y1 > y0 {
 799			n += (y1 - y0) * (5*(x1-x0) + 16*len(z.Poly))
 800		}
 801	}
 802	for i := range f.Walls {
 803		n += 10 * (int(f.Walls[i].Seg.B.Sub(f.Walls[i].Seg.A).Len()*2) + 2)
 804	}
 805	for i := range f.Posts {
 806		d := int(2*f.Posts[i].R) + 2
 807		n += 20 * d * d
 808	}
 809	return n
 810}
 811
 812// key names the board's fixed marks it shows (tee and cup always), with the
 813// skins of the walls drawn `#`.
 814func key(b, walls string) string {
 815	// what the board shows, read once: a big board is thousands of runes
 816	var on [128]bool
 817	shot := false // '·', the one mark past ASCII
 818	for _, c := range b {
 819		if c < 128 {
 820			on[c] = true
 821		} else if c == '·' {
 822			shot = true
 823		}
 824	}
 825	s := "`*` tee · `U` cup"
 826	for _, k := range []struct{ marks, what string }{
 827		{"@", "the ball"}, {"·", "its last shot"}, {"#", "wall"},
 828		{":;x%", "wear (worn by past shots)"},
 829	} {
 830		shown := k.marks == "·" && shot
 831		for i := 0; i < len(k.marks) && k.marks[i] < 128; i++ {
 832			shown = shown || on[k.marks[i]]
 833		}
 834		if shown {
 835			s += " · `" + k.marks + "` " + k.what
 836			if k.marks == "#" && walls != "" {
 837				s += " (" + walls + ")"
 838			}
 839		}
 840	}
 841	// tunnels are numbered from 1: name the digits the board has
 842	digits := ""
 843	for c := '1'; c <= '9'; c++ {
 844		if on[c] {
 845			digits += string(c)
 846		}
 847	}
 848	switch len(digits) {
 849	case 0:
 850	case 1:
 851		s += " · `" + digits + "` a tunnel and its exit"
 852	default:
 853		s += " · `" + digits + "` tunnels, each with its exit"
 854	}
 855	return s
 856}
 857
 858// legend names the pieces the hole gave a skin (a slope with the way it
 859// falls), and the skins of its `#` walls.
 860func legend(f *physics.Field) (string, string) {
 861	s, walls := "", ""
 862	add := func(m rune, def rune, skin string) {
 863		if skin == "" {
 864			return
 865		}
 866		r := mark(m, def)
 867		if r == markWall {
 868			// the key's own wall: name its skins there, once each
 869			if !strings.Contains(", "+walls+", ", ", "+skin+", ") {
 870				if walls != "" {
 871					walls += ", "
 872				}
 873				walls += skin
 874			}
 875			return
 876		}
 877		// once each: a mark with two meanings (two slopes' ways) says both
 878		item := ufmt.Sprintf(" · `%s` %s", string(r), skin)
 879		if strings.Contains(s+" · ", item+" · ") {
 880			return
 881		}
 882		s += item
 883	}
 884	for _, z := range f.Zones {
 885		if z.Kind == physics.Tunnel {
 886			continue // numbered on the board
 887		}
 888		name := cleanSkin(z.Skin)
 889		if name == "" || name == z.Kind.String() {
 890			name = z.Kind.String()
 891		} else {
 892			name += " (" + z.Kind.String() + ")"
 893		}
 894		// a slope says which way it takes the ball, in the shot's degrees (the wind's, the cup's)
 895		if z.Kind == physics.Slope && (z.Vec.X != 0 || z.Vec.Y != 0) {
 896			a, _ := aim(physics.Vec2{}, z.Vec)
 897			if z.Air {
 898				name += ufmt.Sprintf(", pushes toward %.0f°", a)
 899			} else {
 900				name += ufmt.Sprintf(", falls toward %.0f°", a)
 901			}
 902		}
 903		add(z.Mark, '?', name)
 904	}
 905	for _, p := range f.Posts {
 906		add(p.Mark, markPost, cleanSkin(p.Skin))
 907	}
 908	for _, w := range f.Walls {
 909		add(w.Mark, markWall, cleanSkin(w.Skin))
 910	}
 911	return s, walls
 912}
 913
 914// mark is what a piece shows on the text board. Marks come from the hole's
 915// author, and the board sits in a code block: only a printable ASCII
 916// character that cannot close the block or start markdown is kept.
 917func mark(m, def rune) rune {
 918	if m <= ' ' || m > '~' || strings.ContainsRune("`[]()<>*_#|\\!@oU0123456789", m) {
 919		return def
 920	}
 921	return m
 922}
 923
 924// wearRune takes wear-cell coordinates.
 925func wearRune(wear []int, x, y int) rune {
 926	i := y*course.WearW + x
 927	if i < 0 || i >= len(wear) {
 928		return rune(wearRunes[0])
 929	}
 930	switch v := wear[i]; {
 931	case v == 0:
 932		return rune(wearRunes[0])
 933	case v <= wearLight:
 934		return rune(wearRunes[1])
 935	case v <= wearWorn:
 936		return rune(wearRunes[2])
 937	case v <= wearGroove:
 938		return rune(wearRunes[3])
 939	default:
 940		return rune(wearRunes[4])
 941	}
 942}
 943
 944// trace draws a wall as points every half unit, put inlined: a board has
 945// thousands.
 946func trace(g [][]rune, s physics.Segment, c rune) {
 947	bh := len(g)
 948	if bh == 0 {
 949		return
 950	}
 951	bw := len(g[0])
 952	d := s.B.Sub(s.A)
 953	steps := int(d.Len()*2) + 1
 954	for i := 0; i <= steps; i++ {
 955		k := float64(i) / float64(steps)
 956		x, y := int(s.A.X+d.X*k), int(s.A.Y+d.Y*k)
 957		if x == bw {
 958			x = bw - 1
 959		}
 960		if y == bh {
 961			y = bh - 1
 962		}
 963		if x >= 0 && x < bw && y >= 0 && y < bh {
 964			g[y][x] = c
 965		}
 966	}
 967}
 968
 969// fill draws a post: the cells whose centre is within its radius. LenCmp
 970// skips Len's software square root.
 971func fill(g [][]rune, c physics.Circle, r rune) {
 972	bh := len(g)
 973	if bh == 0 {
 974		return
 975	}
 976	bw := len(g[0])
 977	for y := int(c.C.Y - c.R); y <= int(c.C.Y+c.R); y++ {
 978		gy := y
 979		if gy == bh {
 980			gy = bh - 1
 981		}
 982		if gy < 0 || gy >= bh {
 983			continue
 984		}
 985		for x := int(c.C.X - c.R); x <= int(c.C.X+c.R); x++ {
 986			gx := x
 987			if gx == bw {
 988				gx = bw - 1
 989			}
 990			if gx >= 0 && gx < bw && (physics.Vec2{X: float64(x) + 0.5, Y: float64(y) + 0.5}).Sub(c.C).LenCmp(c.R) <= 0 {
 991				g[gy][gx] = r
 992			}
 993		}
 994	}
 995}
 996
 997func put(g [][]rune, p physics.Vec2, c rune) {
 998	x, y := int(p.X), int(p.Y)
 999	bh := len(g)
1000	if bh == 0 {
1001		return
1002	}
1003	bw := len(g[0])
1004	// the far edge belongs to the last cell, not off the board
1005	if x == bw {
1006		x = bw - 1
1007	}
1008	if y == bh {
1009		y = bh - 1
1010	}
1011	if x < 0 || x >= bw || y < 0 || y >= bh {
1012		return
1013	}
1014	g[y][x] = c
1015}
1016
1017func short(addr string) string {
1018	if len(addr) <= 12 {
1019		return addr
1020	}
1021	return addr[:8] + "…" + addr[len(addr)-4:]
1022}
1023
1024// blankUnreachable clears the cells no ball can reach: a flood fill from the
1025// tee, the cup and where tunnels and hazards put the ball, stopped by walls.
1026func blankUnreachable(g, wall [][]rune, f *physics.Field, seeds ...physics.Vec2) {
1027	bh := len(g)
1028	if bh == 0 {
1029		return
1030	}
1031	bw := len(g[0])
1032	for _, z := range f.Zones {
1033		if z.Kind == physics.Tunnel || z.Kind == physics.Hazard {
1034			seeds = append(seeds, z.Vec)
1035		}
1036	}
1037	// a row's run at a time, no closure: a board has thousands of cells
1038	seen := make([][]bool, bh)
1039	for y := range seen {
1040		seen[y] = make([]bool, bw)
1041	}
1042	var stack []int // y*bw + x: a cell whose run is yet to fill
1043	for _, p := range seeds {
1044		if x, y := int(p.X), int(p.Y); x >= 0 && y >= 0 && x < bw && y < bh {
1045			stack = append(stack, y*bw+x)
1046		}
1047	}
1048	for len(stack) > 0 {
1049		c := stack[len(stack)-1]
1050		stack = stack[:len(stack)-1]
1051		x, y := c%bw, c/bw
1052		s, w := seen[y], wall[y]
1053		if s[x] || w[x] != 0 {
1054			continue
1055		}
1056		l, r := x, x+1
1057		for l > 0 && !s[l-1] && w[l-1] == 0 {
1058			l--
1059		}
1060		for r < bw && !s[r] && w[r] == 0 {
1061			r++
1062		}
1063		for i := l; i < r; i++ {
1064			s[i] = true
1065		}
1066		// the rows above and below: a cell for each open run under this one
1067		for ny := y - 1; ny <= y+1; ny += 2 {
1068			if ny < 0 || ny >= bh {
1069				continue
1070			}
1071			ns, nw := seen[ny], wall[ny]
1072			open := false
1073			for i := l; i < r; i++ {
1074				o := !ns[i] && nw[i] == 0
1075				if o && !open {
1076					stack = append(stack, ny*bw+i)
1077				}
1078				open = o
1079			}
1080		}
1081	}
1082	for y := 0; y < bh; y++ {
1083		for x := 0; x < bw; x++ {
1084			if !seen[y][x] && wall[y][x] == 0 {
1085				g[y][x] = ' '
1086			}
1087		}
1088	}
1089}
1090
1091// race is the 3D game's dare link (by=): this hole against p's best's ghost.
1092func race(e *entry, p, label string) string { return md(label, playLink(e)+"&by="+p) }
1093
1094// byName is a player by their gno.land name when they have one.
1095func byName(addr string) string {
1096	if n := nameOf(addr); n != "" {
1097		return md("@"+n, "/u/"+n)
1098	}
1099	return who(addr)
1100}
1101
1102// count is n things, said right for one.
1103func count(n int, what string) string {
1104	if n == 1 {
1105		return "1 " + what
1106	}
1107	return strconv.Itoa(n) + " " + what + "s"
1108}