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

bubblerumble2.gno

18.74 Kb · 664 lines
  1// Package bubblerumble2 is the second Bubble Rumble realm: the same last-bidder-
  2// wins clock as v1, with the money rearranged so that players want company.
  3//
  4// Of every bid: 55% goes to the pot, 30% is paid at once to the earlier bidders
  5// of that pool pro-rata to what they have paid in, 10% goes to whoever first
  6// invited the bidder (forever, on every bid they make), 5% to the pool's creator.
  7// When the clock runs out, 80% of the pot goes to the last bidder and 20% is
  8// shared among everyone who played under the winner's flag — or all of it to the
  9// winner if nobody else flew that flag.
 10//
 11// So the player on top wants more bids (each one pays them), everyone wants
 12// their invitees to bid (10% of everything), and countries are teams.
 13package bubblerumble2
 14
 15import (
 16	"chain"
 17	"chain/banker"
 18	"chain/runtime/unsafe"
 19	"math"
 20	"strconv"
 21	"strings"
 22	"time"
 23)
 24
 25const (
 26	MinWindow    int64 = 10                // seconds
 27	MaxWindow    int64 = 30 * 24 * 3600    // 30 days
 28	MinBasePrice int64 = 1_000             // ugnot, 0.001 GNOT
 29	MaxBasePrice int64 = 1_000_000_000_000 // ugnot, 1,000,000 GNOT
 30	MaxNameLen         = 40
 31	HistoryLen         = 50  // bids kept per pool for the feed
 32	RosterMax          = 200 // distinct bidders a pool pays dividends and team shares to
 33	MaxBoost     int64 = 10  // the shortest clock a bid can buy is window/MaxBoost
 34
 35	// where every bid goes, in percent
 36	PotShare     int64 = 55
 37	DivShare     int64 = 30 // to earlier bidders of the pool, pro-rata by paid-in
 38	RefShare     int64 = 10 // to the bidder's inviter
 39	CreatorShare int64 = 5
 40	// of the pot at the close: the rest goes to the winner
 41	TeamShare int64 = 20
 42)
 43
 44// pkgPath must match gnomod.toml: the realm's own address derives from it.
 45const pkgPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble2"
 46
 47type Entry struct {
 48	N      int64
 49	Bidder address
 50	Flag   string
 51	Price  int64 // what was paid; at least the price at the time
 52	Clock  int64 // seconds this bid had to stand
 53	Pot    int64 // pot right after this bid
 54	At     time.Time
 55}
 56
 57// Member is a distinct bidder of a pool: the roster dividends and team shares go to.
 58type Member struct {
 59	Addr   address
 60	Flag   string
 61	Paid   int64 // gross paid into this pool; the dividend weight
 62	Earned int64 // dividends received from this pool
 63}
 64
 65type Pool struct {
 66	ID          int64
 67	Name        string
 68	Creator     address
 69	Window      int64 // seconds the last bid must stand to win
 70	BasePrice   int64 // ugnot; the Nth bid costs BasePrice·√N
 71	Seed        int64
 72	Pot         int64
 73	Bids        int64
 74	LastBidder  address
 75	LastBidAt   time.Time
 76	Clock       int64 // seconds the current top bid has to stand
 77	CreatedAt   time.Time
 78	Paid        bool // the pot went out, or the seed back to the creator
 79	Cancelled   bool
 80	DivPaid     int64 // dividends paid out of this pool so far
 81	RefPaid     int64
 82	CreatorPaid int64
 83	WinnerFlag  string
 84	TeamPaid    int64 // what the winner's team received at the close
 85	History     []Entry
 86	Roster      []*Member
 87}
 88
 89// Player is what the realm remembers about an address across pools.
 90type Player struct {
 91	Referrer  address // set by the first bid, permanent
 92	Recruits  int64
 93	Flag      string
 94	Bids      int64
 95	Paid      int64
 96	DivEarned int64
 97	RefEarned int64
 98	Won       int64 // pots and team shares received
 99}
100
101var (
102	pools     []*Pool
103	players   = map[address]*Player{}
104	teamPaid  = map[string]int64{} // flag -> gross bid volume under it
105	teamWon   = map[string]int64{} // flag -> pots closed under it
106	teamSize  = map[string]int64{} // flag -> players flying it
107	realmAddr = chain.PackageAddress(pkgPath)
108)
109
110// Price of the next bid in a pool with the given base price and n bids so far.
111func Price(base, n int64) int64 {
112	return int64(math.Ceil(float64(base) * math.Sqrt(float64(n+1))))
113}
114
115func (p *Pool) Deadline() time.Time { return p.LastBidAt.Add(time.Duration(p.Clock) * time.Second) }
116func (p *Pool) Over() bool           { return p.Bids > 0 && !time.Now().Before(p.Deadline()) }
117func (p *Pool) NextPrice() int64     { return Price(p.BasePrice, p.Bids) }
118
119// ClockFor is the clock a bid buys by paying sent against a price: the window
120// divided by the multiple paid, never below window/MaxBoost.
121func ClockFor(window, price, sent int64) int64 {
122	c := int64(float64(window) * float64(price) / float64(sent))
123	if c < window/MaxBoost {
124		c = window / MaxBoost
125	}
126	if c < 1 {
127		c = 1
128	}
129	return c
130}
131
132// CreatePool opens a pool. Any ugnot sent with the call seeds its pot. The
133// creator earns CreatorShare of every bid.
134func CreatePool(cur realm, name string, window int64, basePrice int64) int64 {
135	creator := userCaller(cur)
136	name = cleanName(name)
137	if window < MinWindow || window > MaxWindow {
138		panic("window must be between 10s and 30d")
139	}
140	if basePrice < MinBasePrice || basePrice > MaxBasePrice {
141		panic("base price must be between 0.001 and 1,000,000 GNOT")
142	}
143	seed := ugnotSent()
144	p := &Pool{
145		ID: int64(len(pools)) + 1, Name: name, Creator: creator, Window: window,
146		BasePrice: basePrice, Seed: seed, Pot: seed, CreatedAt: time.Now(),
147	}
148	pools = append(pools, p)
149	chain.Emit("PoolCreated", "id", itoa(p.ID), "creator", creator.String(), "seed", itoa(seed))
150	return p.ID
151}
152
153// Bid pays at least the current price and resets the clock. ref is the address
154// that invited the bidder ("" for none; only the first bid ever sets it), flag
155// a two-letter country to play under ("" keeps the last one).
156func Bid(cur realm, id int64, ref string, flag string) {
157	bidder := userCaller(cur)
158	p := pool(id)
159	if p.Cancelled {
160		panic("pool was cancelled")
161	}
162	if p.Over() {
163		panic("pool is closed")
164	}
165	if p.LastBidder == bidder {
166		panic("you are already the last bidder")
167	}
168	price := p.NextPrice()
169	sent := ugnotSent()
170	if sent < price {
171		panic("send at least " + itoa(price) + "ugnot, got " + itoa(sent) + "ugnot")
172	}
173	pl := player(bidder)
174	if f := cleanFlag(flag); f != "" && f != pl.Flag {
175		if pl.Flag != "" {
176			teamSize[pl.Flag]--
177		}
178		pl.Flag = f
179		teamSize[f]++
180	}
181	if pl.Referrer == "" && ref != "" {
182		r := address(ref)
183		if r.IsValid() && r != bidder {
184			pl.Referrer = r
185			player(r).Recruits++
186		}
187	}
188
189	// where the money goes
190	div, refCut, creatorCut := sent*DivShare/100, sent*RefShare/100, sent*CreatorShare/100
191	toPot := sent - div - refCut - creatorCut
192	if creatorCut > 0 {
193		send(cur, p.Creator, creatorCut)
194		p.CreatorPaid += creatorCut
195	}
196	if pl.Referrer != "" && refCut > 0 {
197		send(cur, pl.Referrer, refCut)
198		p.RefPaid += refCut
199		player(pl.Referrer).RefEarned += refCut
200	} else {
201		toPot += refCut
202	}
203	toPot += p.payDividends(cur, bidder, div)
204	p.Pot += toPot
205
206	m := p.member(bidder)
207	m.Paid += sent
208	m.Flag = pl.Flag
209	pl.Bids++
210	pl.Paid += sent
211	if pl.Flag != "" {
212		teamPaid[pl.Flag] += sent
213	}
214	p.Bids++
215	p.LastBidder = bidder
216	p.LastBidAt = time.Now()
217	p.Clock = ClockFor(p.Window, price, sent)
218	p.History = append(p.History, Entry{N: p.Bids, Bidder: bidder, Flag: pl.Flag, Price: sent, Clock: p.Clock, Pot: p.Pot, At: p.LastBidAt})
219	if len(p.History) > HistoryLen {
220		p.History = p.History[len(p.History)-HistoryLen:]
221	}
222	chain.Emit("Bid", "id", itoa(id), "n", itoa(p.Bids), "bidder", bidder.String(), "paid", itoa(sent), "pot", itoa(p.Pot), "div", itoa(div-(toPot-(sent-div-refCut-creatorCut))))
223}
224
225// payDividends splits div among the pool's earlier bidders, pro-rata to what
226// each has paid in, skipping the bidder; whatever cannot be placed (no earlier
227// bidders, rounding) is returned to go into the pot.
228func (p *Pool) payDividends(cur realm, bidder address, div int64) int64 {
229	var total int64
230	for _, m := range p.Roster {
231		if m.Addr != bidder {
232			total += m.Paid
233		}
234	}
235	if total == 0 || div == 0 {
236		return div
237	}
238	var paid int64
239	for _, m := range p.Roster {
240		if m.Addr == bidder {
241			continue
242		}
243		amt := div * m.Paid / total
244		if amt > 0 {
245			send(cur, m.Addr, amt)
246			m.Earned += amt
247			player(m.Addr).DivEarned += amt
248			paid += amt
249		}
250	}
251	p.DivPaid += paid
252	return div - paid
253}
254
255// member finds or adds a bidder on the roster; a full roster drops its oldest.
256func (p *Pool) member(a address) *Member {
257	for _, m := range p.Roster {
258		if m.Addr == a {
259			return m
260		}
261	}
262	if len(p.Roster) >= RosterMax {
263		p.Roster = p.Roster[1:]
264	}
265	m := &Member{Addr: a}
266	p.Roster = append(p.Roster, m)
267	return m
268}
269
270// Claim pays out a finished pool: 80% to the winner, 20% shared among the
271// roster members flying the winner's flag (pro-rata by paid-in, the winner
272// included) — or everything to the winner when nobody else flew it. Anyone may call it.
273func Claim(cur realm, id int64) {
274	p := pool(id)
275	if p.Cancelled {
276		panic("pool was cancelled")
277	}
278	if !p.Over() {
279		panic("clock still running")
280	}
281	if p.Paid {
282		panic("already paid out")
283	}
284	p.Paid = true
285	winner := p.LastBidder
286	flag := ""
287	if m := p.find(winner); m != nil {
288		flag = m.Flag
289	}
290	p.WinnerFlag = flag
291	var team int64
292	var mates int
293	if flag != "" {
294		for _, m := range p.Roster {
295			if m.Flag == flag {
296				team += m.Paid
297				mates++
298			}
299		}
300	}
301	rest := p.Pot
302	if mates > 1 && team > 0 {
303		cut := p.Pot * TeamShare / 100
304		for _, m := range p.Roster {
305			if m.Flag != flag {
306				continue
307			}
308			amt := cut * m.Paid / team
309			if amt > 0 {
310				send(cur, m.Addr, amt)
311				player(m.Addr).Won += amt
312				rest -= amt
313				p.TeamPaid += amt
314			}
315		}
316	}
317	send(cur, winner, rest)
318	player(winner).Won += rest
319	if flag != "" {
320		teamWon[flag] += p.Pot
321	}
322	chain.Emit("Paid", "id", itoa(id), "winner", winner.String(), "pot", itoa(p.Pot), "team", itoa(p.TeamPaid))
323}
324
325// Cancel lets the creator take the seed back from a pool nobody has bid on.
326func Cancel(cur realm, id int64) {
327	caller := userCaller(cur)
328	p := pool(id)
329	if p.Creator != caller {
330		panic("only the creator can cancel")
331	}
332	if p.Bids > 0 {
333		panic("pool already has bids")
334	}
335	if p.Cancelled {
336		panic("already cancelled")
337	}
338	p.Cancelled = true
339	p.Paid = true
340	if p.Pot > 0 {
341		send(cur, caller, p.Pot)
342	}
343	chain.Emit("Cancelled", "id", itoa(id))
344}
345
346func (p *Pool) find(a address) *Member {
347	for _, m := range p.Roster {
348		if m.Addr == a {
349			return m
350		}
351	}
352	return nil
353}
354
355func player(a address) *Player {
356	pl := players[a]
357	if pl == nil {
358		pl = &Player{}
359		players[a] = pl
360	}
361	return pl
362}
363
364func send(cur realm, to address, ugnot int64) {
365	banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(realmAddr, to, chain.Coins{{"ugnot", ugnot}})
366}
367
368func pool(id int64) *Pool {
369	if id < 1 || id > int64(len(pools)) {
370		panic("no such pool")
371	}
372	return pools[id-1]
373}
374
375// userCaller is the signer of the transaction, refusing calls relayed through
376// another realm so nobody can bid on someone else's behalf.
377func userCaller(cur realm) address {
378	prev := cur.Previous()
379	if !prev.IsUserCall() {
380		panic("must be a direct user call")
381	}
382	return prev.Address()
383}
384
385func ugnotSent() int64 {
386	sent := unsafe.OriginSend()
387	for _, c := range sent {
388		if c.Denom != "ugnot" {
389			panic("only ugnot is accepted")
390		}
391	}
392	return sent.AmountOf("ugnot")
393}
394
395func cleanName(s string) string {
396	s = strings.TrimSpace(s)
397	if s == "" || len(s) > MaxNameLen {
398		panic("name must be 1-40 characters")
399	}
400	for _, c := range s {
401		if c < 0x20 || c == 0x7f {
402			panic("name has control characters")
403		}
404	}
405	return s
406}
407
408// cleanFlag accepts a two-letter country code, upper-cased; anything else is "".
409func cleanFlag(s string) string {
410	s = strings.ToUpper(strings.TrimSpace(s))
411	if len(s) != 2 || s[0] < 'A' || s[0] > 'Z' || s[1] < 'A' || s[1] > 'Z' {
412		return ""
413	}
414	return s
415}
416
417func itoa(n int64) string { return strconv.FormatInt(n, 10) }
418
419func parseID(s string) int64 {
420	n, err := strconv.ParseInt(s, 10, 64)
421	if err != nil {
422		panic("bad pool id")
423	}
424	return n
425}
426
427// GNOT formats ugnot as GNOT with the trailing zeros trimmed.
428func GNOT(ugnot int64) string {
429	whole, frac := ugnot/1_000_000, ugnot%1_000_000
430	if frac == 0 {
431		return itoa(whole) + " GNOT"
432	}
433	f := strconv.FormatInt(1_000_000+frac, 10)[1:]
434	return itoa(whole) + "." + strings.TrimRight(f, "0") + " GNOT"
435}
436
437func short(a address) string {
438	s := a.String()
439	if len(s) < 12 {
440		return s
441	}
442	return s[:8] + "…" + s[len(s)-4:]
443}
444
445func dur(sec int64) string {
446	if sec <= 0 {
447		return "0s"
448	}
449	d, h, m, s := sec/86400, sec%86400/3600, sec%3600/60, sec%60
450	out := ""
451	if d > 0 {
452		out += itoa(d) + "d "
453	}
454	if d > 0 || h > 0 {
455		out += itoa(h) + "h "
456	}
457	if d > 0 || h > 0 || m > 0 {
458		out += itoa(m) + "m "
459	}
460	return out + itoa(s) + "s"
461}
462
463// --- rendering ---------------------------------------------------------------
464
465// Render serves gnoweb ("" and "<id>") and the page ("json", "json/<id>",
466// "me/<address>", "teams").
467func Render(path string) string {
468	path = strings.Trim(path, "/")
469	switch {
470	case path == "json":
471		return jsonAll()
472	case strings.HasPrefix(path, "json/"):
473		return pool(parseID(path[5:])).json(true)
474	case strings.HasPrefix(path, "me/"):
475		return jsonMe(address(path[3:]))
476	case path == "teams":
477		return jsonTeams()
478	case path == "":
479		return markdownAll()
480	default:
481		return pool(parseID(path)).markdown()
482	}
483}
484
485func unix(t time.Time) string {
486	if t.IsZero() {
487		return "0"
488	}
489	return itoa(t.Unix())
490}
491
492func jbool(b bool) string {
493	if b {
494		return "true"
495	}
496	return "false"
497}
498
499func (p *Pool) json(detail bool) string {
500	var b strings.Builder
501	b.WriteString("{\"id\":" + itoa(p.ID))
502	b.WriteString(",\"name\":" + strconv.Quote(p.Name))
503	b.WriteString(",\"creator\":\"" + p.Creator.String() + "\"")
504	b.WriteString(",\"window\":" + itoa(p.Window))
505	b.WriteString(",\"basePrice\":" + itoa(p.BasePrice))
506	b.WriteString(",\"seed\":" + itoa(p.Seed))
507	b.WriteString(",\"pot\":" + itoa(p.Pot))
508	b.WriteString(",\"bids\":" + itoa(p.Bids))
509	b.WriteString(",\"nextPrice\":" + itoa(p.NextPrice()))
510	b.WriteString(",\"lastBidder\":\"" + p.LastBidder.String() + "\"")
511	b.WriteString(",\"lastBidAt\":" + unix(p.LastBidAt))
512	b.WriteString(",\"clock\":" + itoa(p.Clock))
513	b.WriteString(",\"createdAt\":" + unix(p.CreatedAt))
514	b.WriteString(",\"over\":" + jbool(p.Over()))
515	b.WriteString(",\"paid\":" + jbool(p.Paid))
516	b.WriteString(",\"cancelled\":" + jbool(p.Cancelled))
517	b.WriteString(",\"bidders\":" + itoa(int64(len(p.Roster))))
518	b.WriteString(",\"divPaid\":" + itoa(p.DivPaid))
519	b.WriteString(",\"refPaid\":" + itoa(p.RefPaid))
520	b.WriteString(",\"creatorPaid\":" + itoa(p.CreatorPaid))
521	b.WriteString(",\"teamPaid\":" + itoa(p.TeamPaid))
522	flag := p.WinnerFlag
523	if m := p.find(p.LastBidder); m != nil && flag == "" {
524		flag = m.Flag
525	}
526	b.WriteString(",\"flag\":\"" + flag + "\"")
527	// what each flag has paid into this pool
528	b.WriteString(",\"teams\":{")
529	seen := map[string]int64{}
530	order := []string{}
531	for _, m := range p.Roster {
532		if m.Flag == "" {
533			continue
534		}
535		if _, ok := seen[m.Flag]; !ok {
536			order = append(order, m.Flag)
537		}
538		seen[m.Flag] += m.Paid
539	}
540	for i, f := range order {
541		if i > 0 {
542			b.WriteString(",")
543		}
544		b.WriteString("\"" + f + "\":" + itoa(seen[f]))
545	}
546	b.WriteString("}")
547	if detail {
548		b.WriteString(",\"history\":[")
549		for i := len(p.History) - 1; i >= 0; i-- { // newest first
550			h := p.History[i]
551			if i < len(p.History)-1 {
552				b.WriteString(",")
553			}
554			b.WriteString("{\"n\":" + itoa(h.N) + ",\"bidder\":\"" + h.Bidder.String() + "\",\"flag\":\"" + h.Flag + "\",\"price\":" + itoa(h.Price) + ",\"clock\":" + itoa(h.Clock) + ",\"pot\":" + itoa(h.Pot) + ",\"at\":" + unix(h.At) + "}")
555		}
556		b.WriteString("],\"roster\":[")
557		for i, m := range p.Roster {
558			if i > 0 {
559				b.WriteString(",")
560			}
561			b.WriteString("{\"addr\":\"" + m.Addr.String() + "\",\"flag\":\"" + m.Flag + "\",\"paid\":" + itoa(m.Paid) + ",\"earned\":" + itoa(m.Earned) + "}")
562		}
563		b.WriteString("]")
564	}
565	b.WriteString("}")
566	return b.String()
567}
568
569func jsonAll() string {
570	var b strings.Builder
571	b.WriteString("{\"now\":" + itoa(time.Now().Unix()) + ",\"realm\":\"" + pkgPath + "\"")
572	b.WriteString(",\"split\":{\"pot\":" + itoa(PotShare) + ",\"div\":" + itoa(DivShare) + ",\"ref\":" + itoa(RefShare) + ",\"creator\":" + itoa(CreatorShare) + ",\"team\":" + itoa(TeamShare) + ",\"roster\":" + itoa(int64(RosterMax)) + "}")
573	b.WriteString(",\"pools\":[")
574	for i, p := range pools {
575		if i > 0 {
576			b.WriteString(",")
577		}
578		b.WriteString(p.json(false))
579	}
580	b.WriteString("],\"teams\":" + jsonTeams() + "}")
581	return b.String()
582}
583
584// jsonTeams is the country leaderboard: paid in, won, and players per flag.
585func jsonTeams() string {
586	var b strings.Builder
587	b.WriteString("{")
588	i := 0
589	for f, paid := range teamPaid {
590		if i > 0 {
591			b.WriteString(",")
592		}
593		i++
594		b.WriteString("\"" + f + "\":{\"paid\":" + itoa(paid) + ",\"won\":" + itoa(teamWon[f]) + ",\"players\":" + itoa(teamSize[f]) + "}")
595	}
596	b.WriteString("}")
597	return b.String()
598}
599
600// jsonMe is what the page shows a connected wallet about itself.
601func jsonMe(a address) string {
602	pl := players[a]
603	if pl == nil {
604		pl = &Player{}
605	}
606	return "{\"addr\":\"" + a.String() + "\",\"referrer\":\"" + pl.Referrer.String() + "\",\"recruits\":" + itoa(pl.Recruits) +
607		",\"flag\":\"" + pl.Flag + "\",\"bids\":" + itoa(pl.Bids) + ",\"paid\":" + itoa(pl.Paid) +
608		",\"divEarned\":" + itoa(pl.DivEarned) + ",\"refEarned\":" + itoa(pl.RefEarned) + ",\"won\":" + itoa(pl.Won) + "}"
609}
610
611func (p *Pool) status() string {
612	switch {
613	case p.Cancelled:
614		return "cancelled"
615	case p.Over():
616		s := "won by " + short(p.LastBidder)
617		if !p.Paid {
618			s += " (unclaimed)"
619		}
620		return s
621	case p.Bids == 0:
622		return "no bids yet · " + dur(p.Window) + " window · next " + GNOT(p.NextPrice())
623	default:
624		left := p.Deadline().Unix() - time.Now().Unix()
625		s := dur(left) + " left"
626		if p.Clock < p.Window {
627			s += " of a " + dur(p.Clock) + " clock"
628		}
629		return s + " · " + short(p.LastBidder) + " on top · next " + GNOT(p.NextPrice())
630	}
631}
632
633func markdownAll() string {
634	var b strings.Builder
635	b.WriteString("# Bubble Rumble\n\n")
636	b.WriteString("Every bid goes into a pool's pot and resets its clock; the Nth bid costs base·√N. ")
637	b.WriteString("Of every bid, 55% is pot, 30% is paid at once to the pool's earlier bidders, 10% to whoever invited you, 5% to the pool's creator. ")
638	b.WriteString("When the clock runs out the last bidder takes 80% and the players under the winner's flag share 20%. Play at [bubblerumble.net](https://bubblerumble.net).\n\n")
639	if len(pools) == 0 {
640		b.WriteString("_No pools yet._\n")
641		return b.String()
642	}
643	b.WriteString("## Pools\n\n")
644	for i := len(pools) - 1; i >= 0; i-- {
645		p := pools[i]
646		b.WriteString("- [" + p.Name + "](?" + itoa(p.ID) + ") — **" + GNOT(p.Pot) + "** · " + itoa(p.Bids) + " bids · " + p.status() + "\n")
647	}
648	return b.String()
649}
650
651func (p *Pool) markdown() string {
652	var b strings.Builder
653	b.WriteString("# " + p.Name + "\n\n")
654	b.WriteString("**" + GNOT(p.Pot) + "** in the pot · " + p.status() + "\n\n")
655	b.WriteString("Opened by " + short(p.Creator) + " · " + dur(p.Window) + " window · base " + GNOT(p.BasePrice) + " · seed " + GNOT(p.Seed) + " · " + GNOT(p.DivPaid) + " paid to earlier bidders so far\n\n")
656	if len(p.History) > 0 {
657		b.WriteString("## Bids\n\n")
658		for i := len(p.History) - 1; i >= 0; i-- {
659			h := p.History[i]
660			b.WriteString("- #" + itoa(h.N) + " " + short(h.Bidder) + " " + h.Flag + " paid " + GNOT(h.Price) + " → pot " + GNOT(h.Pot) + "\n")
661		}
662	}
663	return b.String()
664}