Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}