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}