render.gno
8.19 Kb · 228 lines
1package arena
2
3import (
4 "strings"
5 "time"
6
7 "gno.land/p/g1t2kg2vtr3fukg43eujkn6x53gfdyakhngt4sfd/gnofly/arena/payout/v0"
8)
9
10// Render returns the state of the arenas as markdown, for gnoweb:
11//
12// "" the open arenas and the last games played
13// "arena/<id>" one arena: its rules, lobby and game, or its result
14// "earnings" the top winners
15//
16// Everything printed here was validated against a narrow charset when it was
17// written (see spec.gno and validate.gno), or is an address the chain gave
18// the realm. The only caller-supplied value, the id of an arena page, is
19// printed only if it is a valid id. Nothing needs markdown escaping.
20func Render(path string) string {
21 switch {
22 case path == "":
23 return renderHome()
24 case path == "earnings":
25 return renderEarnings()
26 case strings.HasPrefix(path, "arena/"):
27 id := strings.TrimPrefix(path, "arena/")
28 if a := getArena(id); a != nil {
29 return renderArena(id, a)
30 }
31 if r, ok := resultOf(id); ok {
32 return renderResult(id, r)
33 }
34 }
35 return "# gnofly arenas\n\nPage not found. Go back to the [overview](" + link("") + ").\n"
36}
37
38// link returns the gnoweb URL of a Render path of this realm.
39func link(path string) string {
40 if path == "" {
41 return webPath
42 }
43 return webPath + ":" + path
44}
45
46func renderHome() string {
47 var b strings.Builder
48 b.WriteString("# gnofly arenas\n\n")
49 b.WriteString("Paid games of gnofly, a multiplayer plane game, created by players at [gnofly.xyz/arena](https://gnofly.xyz/arena).\n")
50 b.WriteString("A pilot pays the entry price to join, the realm keeps the stakes until the game is over, then credits them by the rule of its mode. Winners claim what they are owed with Claim.\n")
51 b.WriteString("The house takes " + percent(feeBps) + " of what a settled game pays out.\n\n")
52
53 b.WriteString("## Open arenas\n\n")
54 if arenas.Size() == 0 {
55 b.WriteString("No arena has players right now.\n\n")
56 } else {
57 b.WriteString("| Arena | Mode | Entry | Seats | Lives | Time | Kill target | Pilots |\n|---|---|---|---|---|---|---|---|\n")
58 arenas.Iterate("", "", func(id string, v any) bool {
59 a := v.(*arena)
60 pilots := itoa(int64(len(a.lobby))) + " waiting"
61 if a.round != nil {
62 pilots = itoa(int64(len(a.round.players))) + " playing"
63 }
64 b.WriteString("| [" + id[:8] + "](" + link("arena/"+id) + ") | " + a.mode + " | " + gnot(a.stake) +
65 " | " + itoa(a.minSeats) + " to " + itoa(a.maxSeats) + " | " + itoa(a.lives) +
66 " | " + itoa(a.seconds/60) + " min | " + targetCell(a.killTarget) + " | " + pilots + " |\n")
67 return false
68 })
69 b.WriteByte('\n')
70 }
71
72 b.WriteString("## Last games\n\n")
73 if len(closed) == 0 {
74 b.WriteString("No game is over yet.\n\n")
75 } else {
76 b.WriteString("| Arena | Mode | Outcome | Pilots | Pot | Fee |\n|---|---|---|---|---|---|\n")
77 for i := len(closed) - 1; i >= 0 && i >= len(closed)-10; i-- {
78 r, _ := resultOf(closed[i])
79 b.WriteString("| [" + closed[i][:8] + "](" + link("arena/"+closed[i]) + ") | " + r.mode + " | " + r.outcome +
80 " | " + itoa(r.players) + " | " + gnot(r.pot) + " | " + gnot(r.fee) + " |\n")
81 }
82 b.WriteByte('\n')
83 }
84
85 b.WriteString("| | |\n|---|---|\n")
86 b.WriteString("| In escrow | " + gnot(escrowed) + " |\n")
87 b.WriteString("| Refund delay | " + itoa(refundDelay) + " s |\n\n")
88 b.WriteString("- [Earnings](" + link("earnings") + "): " + itoa(int64(len(winners.addrs))) + " ranked pilots\n")
89 return b.String()
90}
91
92func renderArena(id string, a *arena) string {
93 var b strings.Builder
94 b.WriteString("# Arena " + id[:8] + "\n\n")
95 b.WriteString("Mode `" + a.mode + "`: " + modeDoc(a.mode) + "\n\n")
96
97 b.WriteString("| | |\n|---|---|\n")
98 b.WriteString("| Id | `" + id + "` |\n")
99 b.WriteString("| Entry | " + gnot(a.stake) + " |\n")
100 b.WriteString("| Seats | " + itoa(a.minSeats) + " to " + itoa(a.maxSeats) + " |\n")
101 b.WriteString("| Lives | " + itoa(a.lives) + " |\n")
102 b.WriteString("| Game time | " + itoa(a.seconds/60) + " min |\n")
103 b.WriteString("| Kill target | " + targetCell(a.killTarget) + " |\n")
104 b.WriteString("| Created by | " + a.creator.String() + " |\n")
105 b.WriteString("| Takes entries until | " + clock(a.created+entryWindow) + " |\n\n")
106
107 if a.round == nil {
108 b.WriteString("## Lobby\n\n")
109 b.WriteString("Pilots who paid and wait for the game. They can leave with `Leave` until it starts.\n\n")
110 b.WriteString("| Seat | Pilot |\n|---|---|\n")
111 for i, player := range a.lobby {
112 b.WriteString("| " + itoa(int64(i+1)) + " | " + player.String() + " |\n")
113 }
114 } else {
115 r := a.round
116 b.WriteString("## Game\n\n")
117 b.WriteString("Started at " + clock(r.startedAt) + " with " + itoa(int64(len(r.players))) +
118 " pilots and a pot of " + gnot(a.stake*int64(len(r.players))) + ".\n")
119 b.WriteString("If the referee does not settle it, anyone can send the stakes back with `RefundRound` from " +
120 clock(r.startedAt+a.seconds+refundDelay) + ".\n\n")
121 b.WriteString("| Pilot |\n|---|\n")
122 for _, player := range r.players {
123 b.WriteString("| " + player.String() + " |\n")
124 }
125 }
126 b.WriteString("\n[Earnings](" + link("earnings") + ") | [Overview](" + link("") + ")\n")
127 return b.String()
128}
129
130func renderResult(id string, r result) string {
131 var b strings.Builder
132 b.WriteString("# Arena " + id[:8] + "\n\n")
133 b.WriteString("Mode `" + r.mode + "`, " + itoa(r.players) + " pilots, closed at " + clock(r.closedAt) + ": " + r.outcome + ".\n\n")
134 b.WriteString("| | |\n|---|---|\n")
135 b.WriteString("| Entry | " + gnot(r.stake) + " |\n")
136 b.WriteString("| Pot | " + gnot(r.pot) + " |\n")
137 b.WriteString("| Fee | " + gnot(r.fee) + " |\n")
138 b.WriteString("| Paid | " + paidCell(r) + " |\n")
139 b.WriteString("\n[Overview](" + link("") + ")\n")
140 return b.String()
141}
142
143// targetCell formats a kill target: most arenas end on the clock only.
144func targetCell(n int64) string {
145 if n == 0 {
146 return "none"
147 }
148 return itoa(n) + " kills"
149}
150
151// paidCell lists who was paid what in a round, for a table cell.
152func paidCell(r result) string {
153 if r.outcome != outcomeSettled {
154 return "every stake sent back"
155 }
156 if len(r.payouts) == 0 {
157 return "nobody"
158 }
159 parts := make([]string, len(r.payouts))
160 for i, p := range r.payouts {
161 parts[i] = p.Address.String() + " " + gnot(p.Amount)
162 }
163 return strings.Join(parts, ", ")
164}
165
166func renderEarnings() string {
167 var b strings.Builder
168 b.WriteString("# gnofly arena earnings\n\n")
169 b.WriteString("The top " + itoa(topSize) + " pilots by what they won above their own stakes, over every settled round.\n\n")
170 if len(winners.addrs) == 0 {
171 b.WriteString("Nobody won anything yet.\n")
172 } else {
173 b.WriteString("| Rank | Pilot | Won | Rounds |\n|---|---|---|---|\n")
174 for i, addr := range winners.addrs {
175 t := totalsOf(addr)
176 b.WriteString("| " + itoa(int64(i+1)) + " | " + addr.String() +
177 " | " + gnot(t.won) + " | " + itoa(t.rounds) + " |\n")
178 }
179 }
180 b.WriteString("\n[Back to the overview](" + link("") + ")\n")
181 return b.String()
182}
183
184// modeDoc is one sentence about the payout rule of a mode.
185func modeDoc(mode string) string {
186 switch mode {
187 case payout.ModeBattleRoyale:
188 return "the pot, less the fee, goes to the last pilot flying; when the clock runs out, to the pilot still in with the most kills."
189 case payout.ModeDeathmatch:
190 return "the pot, less the fee, goes to the top three by kills: 60%, 30% and 10%."
191 }
192 return "see the payout package."
193}
194
195// gnot formats an amount of ugnot in GNOT, without trailing zeros:
196// 10000000 gives "10 GNOT", 9500000 gives "9.5 GNOT", 1 gives "0.000001 GNOT".
197func gnot(ugnot int64) string {
198 whole, frac := ugnot/1000000, ugnot%1000000
199 if frac == 0 {
200 return itoa(whole) + " GNOT"
201 }
202 decimals := itoa(1000000 + frac)[1:] // all six of them, zeros included
203 end := len(decimals)
204 for decimals[end-1] == '0' {
205 end--
206 }
207 return itoa(whole) + "." + decimals[:end] + " GNOT"
208}
209
210// percent formats basis points as a percentage, without trailing zeros:
211// 500 gives "5%", 250 gives "2.5%", 1 gives "0.01%".
212func percent(bps int64) string {
213 whole, frac := bps/100, bps%100
214 switch {
215 case frac == 0:
216 return itoa(whole) + "%"
217 case frac%10 == 0:
218 return itoa(whole) + "." + itoa(frac/10) + "%"
219 case frac < 10:
220 return itoa(whole) + ".0" + itoa(frac) + "%"
221 }
222 return itoa(whole) + "." + itoa(frac) + "%"
223}
224
225// clock formats a block time in Unix seconds, in UTC.
226func clock(unix int64) string {
227 return time.Unix(unix, 0).UTC().Format("2006-01-02 15:04:05") + " UTC"
228}