render.gno
19.34 Kb · 541 lines
1package gnoslinger
2
3import (
4 "strconv"
5 "strings"
6 "time"
7
8 "gno.land/p/g1th0masp67zfs9lg6d34qhghldhg6kjcqjuv7c2/gnoslinger/cards/v0"
9 "gno.land/p/g1th0masp67zfs9lg6d34qhghldhg6kjcqjuv7c2/gnoslinger/engine/v0"
10 "gno.land/p/g1th0masp67zfs9lg6d34qhghldhg6kjcqjuv7c2/gnoslinger/hex/v0"
11 "gno.land/p/moul/md/v0"
12 "gno.land/p/moul/txlink/v0"
13 "gno.land/p/nt/markdown/sanitize/v0"
14 "gno.land/p/nt/mux/v0"
15 profiles "gno.land/r/g1th0masp67zfs9lg6d34qhghldhg6kjcqjuv7c2/gnoslinger/profiles/v0"
16)
17
18const recentLimit = 20
19
20var router = mux.NewRouter()
21
22func init() {
23 router.HandleFunc("", renderHome)
24 router.HandleFunc("help", renderHelp)
25 router.HandleFunc("game/{id}", renderGame)
26 router.HandleFunc("json/game/{id}", func(res *mux.ResponseWriter, req *mux.Request) {
27 res.Write(GameJSON(req.GetVar("id")))
28 })
29 router.HandleFunc("json/games/{addr}", func(res *mux.ResponseWriter, req *mux.Request) {
30 res.Write(GamesJSON(req.GetVar("addr")))
31 })
32 router.HandleFunc("json/history/{id}/{turn}", func(res *mux.ResponseWriter, req *mux.Request) {
33 turn, err := strconv.Atoi(req.GetVar("turn"))
34 if err != nil {
35 panic("gnoslinger: invalid turn")
36 }
37 res.Write(TurnJSON(req.GetVar("id"), turn))
38 })
39 router.HandleFunc("json/plan/{id}/{addr}", func(res *mux.ResponseWriter, req *mux.Request) {
40 res.Write(PlanJSON(req.GetVar("id"), req.GetVar("addr")))
41 })
42}
43
44// Render serves the gnoweb pages ("", "help", "game/{id}") and the raw JSON
45// views the web client reads ("json/game/{id}", "json/games/{addr}",
46// "json/history/{id}/{turn}", "json/plan/{id}/{addr}"); the players' profiles and
47// ranking are the profiles realm's (gno.land/r/g1th0masp67zfs9lg6d34qhghldhg6kjcqjuv7c2/gnoslinger/profiles/v0).
48func Render(path string) string {
49 return router.Render(path)
50}
51
52// link is a gnoweb link to a sub-path of this realm.
53func link(text, path string) string {
54 return md.Link(text, strings.TrimPrefix(realmPath, "gno.land")+":"+path)
55}
56
57func renderHome(res *mux.ResponseWriter, _ *mux.Request) {
58 res.Write(md.H1("Gnoslinger"))
59 res.Write(md.Paragraph("Showdowns of two to four gunfighters on board A, the Corral, each on their own. " +
60 "Create a game, wait for the seats to fill up, then submit a plan every turn. " +
61 link("How to play", "help") + "."))
62 res.Write(md.Paragraph(createLink("Create a game of two as the marshal", 2) + " · " + createLink("of three", 3) + " · " + createLink("of four", 4)))
63
64 res.Write(md.H2("Open games"))
65 n := 0
66 var b strings.Builder
67 b.WriteString("| Game | Creator | Seats | Stake | Created | |\n|---|---|---|---|---|---|\n")
68 openGames.ReverseIterate("", "", func(_ string, v any) bool {
69 g := v.(*Game)
70 b.WriteString("| " + link(g.ID, "game/"+g.ID) +
71 " | " + addr(g.Players[0].Addr) +
72 " | " + strconv.Itoa(g.seated()) + "/" + strconv.Itoa(g.seats()) +
73 " | " + stakeLabel(g) +
74 " | " + when(g.CreatedAt) +
75 " | " + joinLink(g) + " |\n")
76 n++
77 return n >= recentLimit
78 })
79 if n == 0 {
80 res.Write(md.Paragraph("No game is waiting for players."))
81 } else {
82 res.Write(b.String() + "\n")
83 }
84
85 res.Write(md.H2("Recent games"))
86 n = 0
87 b.Reset()
88 b.WriteString("| Game | Players | Turn | Phase |\n|---|---|---|---|\n")
89 games.ReverseIterate("", "", func(_ string, v any) bool {
90 g := v.(*Game)
91 b.WriteString("| " + link(g.ID, "game/"+g.ID) +
92 " | " + seatLabels(g) +
93 " | " + strconv.Itoa(g.Turn) + "/" + strconv.Itoa(g.MaxTurns) +
94 " | " + phaseLabel(g) + " |\n")
95 n++
96 return n >= recentLimit
97 })
98 if n == 0 {
99 res.Write(md.Paragraph("No game yet."))
100 } else {
101 res.Write(b.String() + "\n")
102 }
103 if house > 0 {
104 res.Write(md.Paragraph("The house holds " + gnot(house) + " for events."))
105 }
106}
107
108func renderGame(res *mux.ResponseWriter, req *mux.Request) {
109 g := getGame(req.GetVar("id"))
110 res.Write(md.H1("Game " + g.ID))
111 res.Write(md.Paragraph("Turn " + strconv.Itoa(g.Turn) + " of " + strconv.Itoa(g.MaxTurns) + " · " + phaseLabel(g)))
112 if g.Stake > 0 {
113 line := "Stake " + stakeLabel(g) + " · pot " + gnot(g.Pot)
114 for i, v := range g.Payouts {
115 if v > 0 {
116 line += " · " + addr(g.Players[i].Addr) + " got " + gnot(v)
117 }
118 }
119 if g.HouseCut > 0 {
120 line += " · the house kept " + gnot(g.HouseCut)
121 }
122 res.Write(md.Paragraph(line))
123 }
124
125 var b strings.Builder
126 b.WriteString("| Seat | Player | Character | Hex | Facing | Down | Delay | Status | Plan |\n")
127 b.WriteString("|---|---|---|---|---|---|---|---|---|\n")
128 for i, p := range g.Players {
129 c := g.State.Chars[i]
130 plan := "-"
131 if g.Phase == PhasePlanning || g.Phase == PhaseRevealing {
132 switch {
133 case g.Revealed[i]:
134 plan = "revealed"
135 case g.committed(i):
136 plan = "committed"
137 default:
138 plan = "pending"
139 }
140 }
141 b.WriteString("| " + strconv.Itoa(i) +
142 " | " + addr(p.Addr) +
143 " | " + sanitize.InlineText(p.Char) +
144 " | " + c.Hex +
145 " | " + c.Facing.String() +
146 " | " + yesNo(c.Down) +
147 " | " + strconv.Itoa(c.Delay) +
148 " | " + c.Status.String() +
149 " | " + plan + " |\n")
150 }
151 res.Write(b.String() + "\n")
152
153 for _, r := range g.Turns {
154 res.Write(md.H3("Turn " + strconv.Itoa(r.Turn)))
155 plans := make([]string, len(r.Plans))
156 for i, p := range r.Plans {
157 plans[i] = md.InlineCode(orPass(p))
158 }
159 res.Write(md.Paragraph("Plans: " + strings.Join(plans, " vs ")))
160 if drawn := r.Cards(); len(drawn) > 0 {
161 cards := make([]string, len(drawn))
162 for i, n := range drawn {
163 cards[i] = strconv.Itoa(n)
164 }
165 res.Write(md.Paragraph("Result cards drawn: " + strings.Join(cards, ", ")))
166 }
167 evs := r.Events()
168 items := make([]string, 0, len(evs))
169 for _, e := range evs {
170 items = append(items, describeEvent(g, e))
171 }
172 if len(items) == 0 {
173 items = append(items, "Nothing happened.")
174 }
175 res.Write(md.BulletList(items) + "\n")
176 }
177
178 switch g.Phase {
179 case PhaseWaiting:
180 res.Write(md.Paragraph(joinLink(g) + " · " + md.Link("Cancel", txlink.Call("CancelGame", "id", g.ID))))
181 case PhasePlanning:
182 res.Write(md.Paragraph("Plans are hidden: commit " + md.InlineCode("PlanHash(id, turn, seat, salt, plan)") +
183 ", reveal once every plan is committed. " +
184 md.Link("Commit a plan", txlink.Call("Commit", "id", g.ID, "turn", strconv.Itoa(g.Turn), "hash", "", "locked", "")) +
185 " · " + md.Link("Surrender", txlink.Call("Surrender", "id", g.ID)) +
186 " · " + md.Link("Claim timeout", txlink.Call("ClaimTimeout", "id", g.ID)) +
187 " (after " + when(g.timeoutAt()) + ")"))
188 case PhaseRevealing:
189 res.Write(md.Paragraph(md.Link("Reveal a plan", txlink.Call("Reveal", "id", g.ID, "turn", strconv.Itoa(g.Turn), "seat", "", "salt", "", "plan", "")) +
190 " · " + md.Link("Claim timeout", txlink.Call("ClaimTimeout", "id", g.ID)) +
191 " (after " + when(g.timeoutAt()) + ")"))
192 }
193 res.Write(md.Paragraph(link("Back to the lobby", "")))
194}
195
196func renderHelp(res *mux.ResponseWriter, _ *mux.Request) {
197 res.Write(md.H1("How to play"))
198 res.Write(md.Paragraph("Two to four gunfighters, each on their own. Each turn has five segments. Every player secretly picks up to five time points " +
199 "of action cards, in the order they will do them, then the turn resolves: an action executes on the " +
200 "segment equal to the time points used so far plus its own cost plus the delay points on your sheet. " +
201 "Delay you gain during a segment counts from the next one; actions left after segment 5 are cancelled. " +
202 "At the end of the turn half of your delay (rounded up) goes away."))
203 res.Write(md.H2("Setting up"))
204 res.Write(md.Paragraph("The creator picks how many play, two to four, and is the marshal; the others take the next characters " +
205 "in the order they join: the kid, then the reb, then the yankee. Each player picks where to stand when calling or joining a showdown, " +
206 "among the hexes assigned to its character, inside the corral: " + setupList() + "; each faces any direction (" +
207 md.InlineCode("0") + " N, " + md.InlineCode("1") + " NE, " + md.InlineCode("2") + " SE, " + md.InlineCode("3") + " S, " +
208 md.InlineCode("4") + " SW, " + md.InlineCode("5") + " NW; the marshal looks south-east, the kid north-west, the reb north and the yankee south unless told otherwise). " +
209 "All start upright with a loaded Colt 45 in the holster. Aims and shots name a character by seat (" + md.InlineCode("@1") +
210 ") or a hex. A character killed or passed out is out of the fight; one may surrender while the plans are collected, and the others fight on."))
211 res.Write(md.H2("Plan string"))
212 res.Write(md.Paragraph("A plan is a comma-separated list of card sides in play order, each " +
213 md.InlineCode("<card><f|b>[:<direction>]") + ". Directions are relative to your facing: " +
214 md.InlineCode("ahead_left") + ", " + md.InlineCode("ahead") + ", " + md.InlineCode("ahead_right") + " for forward moves and turns, " +
215 md.InlineCode("back_left") + ", " + md.InlineCode("back") + ", " + md.InlineCode("back_right") + " for backward ones. " +
216 "An empty plan passes."))
217 res.Write(md.Paragraph("Example: " + md.InlineCode("1f:ahead_left,2f:ahead,3f") + " advances ahead-left, runs ahead, then sprints straight."))
218 res.Write(md.Paragraph("Rules: one side per card per turn; the total cost must fit in 5 minus your delay; " +
219 "Run needs Advance in the same plan; Sprint needs Run."))
220 res.Write(md.Paragraph("Aiming and shooting need the target in your aim zone and in sight (rule 31). " +
221 "On board A only the horse trough (" + md.InlineCode("A-I11*") + ") gets in the way: it hides two characters " +
222 "lying DOWN from each other, and a DOWN character next to it from anyone across it; standing characters see " +
223 "over it. An aim at a target out of sight is cancelled and AIM points on it are lost at the end of the " +
224 "segment; a shot at it is held, nothing spent, like a shot at a hex nobody stands in when it comes up."))
225 res.Write(md.H2("Cards"))
226 var b strings.Builder
227 b.WriteString("| Card | Front | Back |\n|---|---|---|\n")
228 for _, c := range cards.Cards {
229 b.WriteString("| " + strconv.Itoa(c.Number) + " | " + sideLabel(c.Front) + " | " + sideLabel(c.Back) + " |\n")
230 }
231 res.Write(b.String() + "\n")
232 res.Write(md.Paragraph("Only foot actions are available for now; the others come with guns and brawling."))
233}
234
235// cancelReason is the reason of a cancel event as the log shows it: the
236// reason code, except a target out of sight, spelled out with the obstacle
237// in the way.
238func cancelReason(e engine.Event) string {
239 switch e.Reason {
240 case engine.ReasonNoLOS:
241 return noSight(e)
242 case engine.ReasonNotInHand:
243 return "the gun is not in hand"
244 case engine.ReasonNotBoth:
245 return "the gun is not in both hands"
246 }
247 return e.Reason
248}
249
250// noSight words a no_los reason: the event's To names the obstacle in the way.
251func noSight(e engine.Event) string {
252 if e.To == "" {
253 return "no line of sight"
254 }
255 return "no line of sight, " + e.To + " in the way"
256}
257
258func sideLabel(s cards.Side) string {
259 l := s.Name + " (" + strconv.Itoa(s.Cost) + ")"
260 if !cards.Enabled(s.Code) {
261 l += " *"
262 }
263 return l
264}
265
266func describeEvent(g *Game, e engine.Event) string {
267 who := sanitize.InlineText(g.Players[e.Player].Char)
268 seg := "turn start"
269 switch {
270 case e.Seg > engine.Segments:
271 seg = "end of turn"
272 case e.Seg > 0:
273 seg = "seg " + strconv.Itoa(e.Seg)
274 }
275 action := strings.ReplaceAll(string(e.Action), "_", " ")
276 var what string
277 switch e.Kind {
278 case engine.EvMove:
279 what = who + " " + action + ": " + e.From + " → " + e.To
280 if e.Down {
281 what += " (crawling)"
282 }
283 case engine.EvTurn:
284 what = who + " " + action + ": now facing " + hex.Dir(e.Facing).String()
285 case engine.EvFlip:
286 if e.Down {
287 what = who + " goes down"
288 } else {
289 what = who + " gets up"
290 }
291 case engine.EvDelay:
292 what = who + " gains " + strconv.Itoa(e.N) + " delay (" + e.Reason + "), now " + strconv.Itoa(e.Delay)
293 if e.Card > 0 {
294 // Every card drawn reads the same: who draws which card and why, then what it does.
295 drawn := who + " draws card " + strconv.Itoa(e.Card) + " (" + e.Reason + "): "
296 if e.Result != "" {
297 what = drawn + strings.ToUpper(strings.ReplaceAll(e.Result, "_", " "))
298 } else {
299 gain := "no delay"
300 if e.N > 0 {
301 gain = "gains " + strconv.Itoa(e.N) + " delay"
302 }
303 what = drawn + gain + ", now " + strconv.Itoa(e.Delay)
304 if e.Endurance > 0 {
305 what += ", loses " + strconv.Itoa(e.Endurance) + " endurance"
306 }
307 }
308 }
309 case engine.EvWildShot:
310 what = who + "'s " + e.Gun.Spec().Name + " goes off (wild shot)"
311 case engine.EvCock:
312 what = who + " cocks the " + e.Gun.Spec().Name
313 case engine.EvUncock:
314 what = who + " uncocks the " + e.Gun.Spec().Name
315 case engine.EvAim:
316 what = who + " aims at " + e.To
317 if e.Target >= 0 {
318 what += " (" + seatLabel(g, e.Target) + ")"
319 }
320 what += ": " + strconv.Itoa(e.N) + " AIM points"
321 case engine.EvLoseAim:
322 if e.Reason == engine.ReasonNoLOS {
323 what = who + " loses the aim (" + noSight(e) + ")"
324 } else if e.Reason == engine.ReasonOwnHex {
325 what = who + " loses the aim (the markers cannot move into or out of its own hex)"
326 } else {
327 what = who + " loses the aim (" + strings.ReplaceAll(e.Reason, "_", " ") + ")"
328 }
329 case engine.EvNothing:
330 hold := " holds fire: "
331 if e.Action == cards.Throw {
332 hold = " holds the throw: "
333 }
334 switch e.Reason {
335 case engine.ReasonEmptyHex:
336 what = who + hold + "nobody in " + e.To
337 case engine.ReasonNoLOS:
338 what = who + hold + seatLabel(g, e.Target) + " in " + e.To + " is out of sight"
339 case engine.ReasonOutOfFight:
340 what = who + hold + seatLabel(g, e.Target) + " is out of the fight"
341 default:
342 what = who + " does nothing (" + action + ")"
343 }
344 case engine.EvShot:
345 at := "at " + e.To
346 if e.Target >= 0 {
347 at = seatLabel(g, e.Target) + " in " + e.To
348 }
349 what = who + " shoots " + at + " (card " + strconv.Itoa(e.Card) + ", aim time " + strconv.Itoa(e.N) + ", range " + strconv.Itoa(e.Range) + "): "
350 switch {
351 case e.Reason == engine.ReasonMisfire:
352 what += "misfire"
353 case e.Hit == string(engine.Miss):
354 what += "miss"
355 if e.Reason != "" {
356 what += " (" + strings.ReplaceAll(e.Reason, "_", " ") + ")"
357 }
358 default:
359 what += e.Hit + " hit"
360 }
361 case engine.EvThrow:
362 at := "at " + e.To
363 if e.Target >= 0 {
364 at = "at " + seatLabel(g, e.Target) + " in " + e.To
365 }
366 what = who + " throws the " + e.Gun.Spec().Name + " " + at + " (card " + strconv.Itoa(e.Card) + ", aim time " + strconv.Itoa(e.N) + ", range " + strconv.Itoa(e.Range) + "): "
367 if e.Hit == string(engine.Miss) {
368 what += "miss"
369 if e.Reason != "" {
370 what += " (" + strings.ReplaceAll(e.Reason, "_", " ") + ")"
371 }
372 } else {
373 what += e.Hit + " hit"
374 }
375 case engine.EvMalfunction:
376 what = who + " draws card " + strconv.Itoa(e.Card) + ": MALFUNCTION, " + strings.ReplaceAll(e.Result, "_", " ")
377 case engine.EvWound:
378 what = who + " suffers " + strings.ToUpper(strings.ReplaceAll(e.Result, "_", " "))
379 if e.N > 0 {
380 what += " " + strconv.Itoa(e.N)
381 }
382 case engine.EvDirection:
383 what = who + " draws card " + strconv.Itoa(e.Card) + " as a direction: " + strings.ReplaceAll(e.To, "_", " ")
384 case engine.EvDropGun:
385 what = who + " drops the " + e.Gun.Spec().Name + " in " + e.To
386 if e.Reason == engine.ReasonThrow {
387 what = who + "'s " + e.Gun.Spec().Name + " lands in " + e.To
388 }
389 case engine.EvMoveGun:
390 what = who + " moves the " + e.Gun.Spec().Name + " from " + strings.ReplaceAll(e.From, "_", " ") + " to the " + strings.ReplaceAll(e.To, "_", " ")
391 case engine.EvPassOut:
392 what = who + " passes out"
393 case engine.EvRunAway:
394 what = who + " runs away off the map from " + e.From
395 case engine.EvSeriousCheck:
396 what = who + " draws card " + strconv.Itoa(e.Card) + " for the SERIOUS wounds: " + strconv.Itoa(e.N) + " points"
397 case engine.EvVP:
398 pts := strconv.Itoa(e.N)
399 if e.N > 0 {
400 pts = "+" + pts
401 }
402 what = who + ": " + pts + " VP, " + strings.ReplaceAll(e.Reason, "_", " ")
403 case engine.EvDraw:
404 to := "to the " + strings.ReplaceAll(e.To, "_", " ")
405 if e.To == engine.BothHands.String() {
406 to = "into both hands"
407 }
408 cocks := " and cocks it"
409 if e.Result == engine.DrawJammed {
410 cocks = " (jammed: not cocked)"
411 }
412 switch e.From {
413 case "ground":
414 what = who + " picks up the " + e.Gun.Spec().Name + " from the ground " + to + cocks
415 case engine.Holstered.String():
416 what = who + " draws the " + e.Gun.Spec().Name + " " + to + cocks
417 default:
418 what = who + " moves the " + e.Gun.Spec().Name + " " + to + cocks
419 }
420 case engine.EvLoad:
421 what = who + " loads a shell in the " + e.Gun.Spec().Name + " (" + strconv.Itoa(e.N) + " now)"
422 if e.Result == engine.LoadUnjammed {
423 what += ": no longer jammed"
424 }
425 case engine.EvCancel:
426 what = who + ": " + action + " is cancelled (" + cancelReason(e) + ")"
427 default:
428 what = who + " " + e.Kind
429 }
430 return seg + ": " + what
431}
432
433func seatLabel(g *Game, i int) string {
434 if i < 0 || i >= g.seats() || g.Players[i].Addr == "" {
435 return "?"
436 }
437 return sanitize.InlineText(g.Players[i].Char)
438}
439
440// seatLabels names every seat, "marshal vs the_kid vs ?".
441func seatLabels(g *Game) string {
442 labels := make([]string, g.seats())
443 for i := range g.Players {
444 labels[i] = seatLabel(g, i)
445 }
446 return strings.Join(labels, " vs ")
447}
448
449// createLink is a transaction link to call a showdown of seats players,
450// standing in the middle of the marshal's hexes, facing its way.
451func createLink(text string, seats int) string {
452 setup := engine.Setups[0]
453 return md.Link(text, txlink.Call("CreateGame", "seats", strconv.Itoa(seats), "maxTurns", "20", "timeoutMinutes", "0",
454 "startHex", setup.Hexes[len(setup.Hexes)/2], "facing", strconv.Itoa(int(setup.Facing))))
455}
456
457// joinLink is a transaction link to take the next free seat of a waiting
458// game, standing in the middle of its character's hexes.
459func joinLink(g *Game) string {
460 seat := g.freeSeat()
461 if seat < 0 {
462 return ""
463 }
464 setup := engine.SetupOf(engine.SeatChar(seat))
465 tx := txlink.NewLink("JoinGame").AddArgs("id", g.ID, "startHex", setup.Hexes[len(setup.Hexes)/2], "facing", strconv.Itoa(int(setup.Facing)))
466 if g.Stake > 0 {
467 tx = tx.SetSend(strconv.FormatInt(g.Stake, 10) + Denom)
468 }
469 return md.Link("Join as "+strings.ReplaceAll(setup.Char, "_", " "), tx.URL())
470}
471
472// gnot words an amount of ugnot in GNOT: "5 GNOT", "9.5 GNOT".
473func gnot(ugnot int64) string {
474 s := strconv.FormatInt(ugnot/1_000_000, 10)
475 if frac := ugnot % 1_000_000; frac != 0 {
476 f := strconv.FormatInt(1_000_000+frac, 10)[1:]
477 s += "." + strings.TrimRight(f, "0")
478 }
479 return s + " GNOT"
480}
481
482// stakeLabel words a showdown's stake: "free", or "5 GNOT each".
483func stakeLabel(g *Game) string {
484 if g.Stake == 0 {
485 return "free"
486 }
487 return gnot(g.Stake) + " each"
488}
489
490// setupList words where each character sets up: "the marshal in A-G5 to A-G9, ...".
491func setupList() string {
492 parts := make([]string, len(engine.Setups))
493 for i, s := range engine.Setups {
494 parts[i] = strings.ReplaceAll(s.Char, "_", " ") + " in " + md.InlineCode(s.Hexes[0]) + " to " + md.InlineCode(s.Hexes[len(s.Hexes)-1])
495 }
496 return strings.Join(parts, ", ")
497}
498
499func phaseLabel(g *Game) string {
500 if g.Phase != PhaseFinished {
501 return g.Phase.String()
502 }
503 l := "finished (" + g.EndReason + ")"
504 if len(g.Awards) > 0 {
505 vp := make([]string, len(g.VP))
506 for i, v := range g.VP {
507 vp[i] = strconv.Itoa(v)
508 }
509 l += ", " + strings.Join(vp, " to ") + " VP"
510 }
511 if g.Winner >= 0 {
512 l += ", winner: " + seatLabel(g, g.Winner)
513 }
514 return l
515}
516
517// addr shows an address by its registered name when it has one.
518func addr(a address) string {
519 if name := profiles.Name(a); name != "" {
520 return md.Bold(sanitize.InlineText(name))
521 }
522 return md.InlineCode(sanitize.InlineText(a.String()))
523}
524
525func when(t time.Time) string {
526 return t.UTC().Format("2006-01-02 15:04") + " UTC"
527}
528
529func yesNo(b bool) string {
530 if b {
531 return "yes"
532 }
533 return "no"
534}
535
536func orPass(plan string) string {
537 if plan == "" {
538 return "(pass)"
539 }
540 return plan
541}