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}