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

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