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

posting.gno

14.35 Kb · 430 lines
  1package kourtv3
  2import (
  3	"chain"
  4	"strconv"
  5	"strings"
  6	sanitize "gno.land/p/nt/markdown/sanitize/v0"
  7)
  8const (
  9	ladderT1BpsInit = int64(5)
 10	ladderT2BpsInit = int64(25)
 11	ladderT3BpsInit = int64(100)
 12	postsEntryInit = int64(3)
 13	postsT2Init    = int64(10)
 14	postsT3Init    = int64(30)
 15	ladderTopRatio = int64(10)
 16	passPriceMinCC = int64(100_000)
 17	passPriceBps   = int64(1)
 18	passPriceOfT3 = int64(100)
 19)
 20func mustPostingInvariants() {
 21	if postsT3Init != postsEntryInit*ladderTopRatio {
 22		panic("kourtv3: the top rung must be exactly ladderTopRatio x entry — the ratio is the bound, not a suggestion")
 23	}
 24	if !(0 < ladderT1BpsInit && ladderT1BpsInit < ladderT2BpsInit && ladderT2BpsInit < ladderT3BpsInit) {
 25		panic("kourtv3: the ladder must be strictly increasing")
 26	}
 27	if !(0 < postsEntryInit && postsEntryInit < postsT2Init && postsT2Init < postsT3Init) {
 28		panic("kourtv3: the rates must be strictly increasing")
 29	}
 30	if ladderT3BpsInit < passPriceOfT3*passPriceBps {
 31		panic("kourtv3: t3 must be at least 100x the pass price, or a unit of standing is worth more than the flag lane's burn can cover")
 32	}
 33}
 34func init() { mustPostingInvariants() }
 35type ladder struct {
 36	t1Bps, t2Bps, t3Bps int64
 37	postsEntry, postsT2 int64
 38}
 39var ladderDefault = ladder{
 40	t1Bps: ladderT1BpsInit, t2Bps: ladderT2BpsInit, t3Bps: ladderT3BpsInit,
 41	postsEntry: postsEntryInit, postsT2: postsT2Init,
 42}
 43func (l ladder) postsT3() int64 { return l.postsEntry * ladderTopRatio }
 44func ladderFor(c *Court) ladder {
 45	if c.ladder != nil {
 46		return *c.ladder
 47	}
 48	return ladderDefault
 49}
 50func mustLadder(l ladder) {
 51	if !(0 < l.t1Bps && l.t1Bps < l.t2Bps && l.t2Bps < l.t3Bps && l.t3Bps <= 10_000) {
 52		panic("kourtv3: the ladder must be strictly increasing within 0..10000 bps")
 53	}
 54	if !(0 < l.postsEntry && l.postsEntry < l.postsT2 && l.postsT2 < l.postsT3()) {
 55		panic("kourtv3: the posting rates must be strictly increasing")
 56	}
 57	if l.t3Bps < passPriceOfT3*passPriceBps {
 58		panic("kourtv3: t3 must stay at least 100x the pass price — lowering it alone would make a unit of standing worth more than the flag lane's burn can cover")
 59	}
 60}
 61func SetLadderDefault(cur realm, t1Bps, t2Bps, t3Bps, postsEntry, postsT2 int64) {
 62	if !cur.IsCurrent() {
 63		panic(errStaleRealm)
 64	}
 65	d := ensureGlobalDAO()
 66	if cur.Previous().Address() != d.admin {
 67		panic("kourtv3: only the global DAO admin sets the default ladder")
 68	}
 69	l := ladder{t1Bps, t2Bps, t3Bps, postsEntry, postsT2}
 70	mustLadder(l)
 71	ladderDefault = l
 72	chain.Emit(globalActEvent,
 73		"court", "*",
 74		"claim", "0",
 75		"act", "set-ladder-default:"+ladderKey(l),
 76		"by", cur.Previous().Address().String(),
 77		"height", eventHeight(),
 78	)
 79}
 80func SetCourtLadder(cur realm, courtSlug string, t1Bps, t2Bps, t3Bps, postsEntry, postsT2 int64) {
 81	if !cur.IsCurrent() {
 82		panic(errStaleRealm)
 83	}
 84	who := cur.Previous().Address()
 85	c := mustCourt(courtSlug)
 86	cm := ensureMod(c)
 87	requireActiveMod(cm, who)
 88	l := ladder{t1Bps, t2Bps, t3Bps, postsEntry, postsT2}
 89	mustLadder(l)
 90	if fire, _ := approveAction(cm.pending, "setladder:"+ladderKey(l), who, "",
 91		speechM(cm)); !fire {
 92		return
 93	}
 94	c.ladder = &l
 95	cm.appendCourtLog(who, "setladder:"+ladderKey(l), "")
 96	emitModAct(c.id, 0, "setladder", who)
 97}
 98func ladderKey(l ladder) string {
 99	return strconv.FormatInt(l.t1Bps, 10) + ":" + strconv.FormatInt(l.t2Bps, 10) +
100		":" + strconv.FormatInt(l.t3Bps, 10) + ":" +
101		strconv.FormatInt(l.postsEntry, 10) + ":" + strconv.FormatInt(l.postsT2, 10)
102}
103func ClearCourtLadder(cur realm, courtSlug string) {
104	if !cur.IsCurrent() {
105		panic(errStaleRealm)
106	}
107	who := cur.Previous().Address()
108	c := mustCourt(courtSlug)
109	cm := ensureMod(c)
110	requireMod(cm, who)
111	if c.ladder == nil {
112		panic("kourtv3: this court has no ladder override")
113	}
114	if fire, _ := approveAction(cm.pending, "clearladder:"+c.id, who, "",
115		speechM(cm)); !fire {
116		return
117	}
118	c.ladder = nil
119	cm.appendCourtLog(who, "clearladder", "")
120	emitModAct(c.id, 0, "clearladder", who)
121}
122func boardSupply(c *Court) (int64, bool) {
123	if c.coin.Epoch() < 2 {
124		return 0, false
125	}
126	return c.coin.PastTotal(c.coin.Epoch() - 1), true
127}
128func passPriceFor(c *Court) int64 {
129	sup, sealed := boardSupply(c)
130	if !sealed {
131		return 0
132	}
133	p := sup * passPriceBps / 10_000
134	if p < passPriceMinCC {
135		p = passPriceMinCC
136	}
137	if lid := sup * ladderFor(c).t3Bps / 10_000 / passPriceOfT3; p > lid {
138		p = lid
139	}
140	if p < 0 {
141		p = 0
142	}
143	return p
144}
145func PassPrice(courtSlug string) int64 { return passPriceFor(mustCourt(courtSlug)) }
146func Ladder(courtSlug string) string {
147	c := mustCourt(courtSlug)
148	l := ladderFor(c)
149	inherited := "1"
150	if c.ladder != nil {
151		inherited = "0"
152	}
153	return "t1:" + strconv.FormatInt(l.t1Bps, 10) +
154		";t2:" + strconv.FormatInt(l.t2Bps, 10) +
155		";t3:" + strconv.FormatInt(l.t3Bps, 10) +
156		";entry:" + strconv.FormatInt(l.postsEntry, 10) +
157		";l2:" + strconv.FormatInt(l.postsT2, 10) +
158		";l3:" + strconv.FormatInt(l.postsT3(), 10) +
159		";inherited:" + inherited
160}
161func postLevel(c *Court, who address) int64 {
162	l := ladderFor(c)
163	sup, sealed := boardSupply(c)
164	if !sealed {
165		return 0
166	}
167	var s int64
168	if r := lookupStanding(c, who); r != nil {
169		s = r.score
170	}
171	switch {
172	case s >= sup*l.t3Bps/10_000:
173		return 3
174	case s >= sup*l.t2Bps/10_000:
175		return 2
176	case s >= sup*l.t1Bps/10_000:
177		return 1
178	}
179	if r := lookupStanding(c, who); r != nil && r.passHeld {
180		return 1
181	}
182	return 0
183}
184func postsPerDayAt(l ladder, level int64) int64 {
185	switch level {
186	case 3:
187		return l.postsT3()
188	case 2:
189		return l.postsT2
190	case 1:
191		return l.postsEntry
192	}
193	return 0
194}
195func PostLevel(courtSlug string, who address) int64 {
196	return postLevel(mustCourt(courtSlug), who)
197}
198func PostsPerDay(courtSlug string, who address) int64 {
199	c := mustCourt(courtSlug)
200	return postsPerDayAt(ladderFor(c), postLevel(c, who))
201}
202func BuyCommentPass(cur realm, courtSlug string) int64 {
203	if !cur.IsCurrent() {
204		panic(errStaleRealm)
205	}
206	who := cur.Previous().Address()
207	c := mustCourt(courtSlug)
208	touch(c)
209	if r := lookupStanding(c, who); r != nil && r.passHeld {
210		panic("kourtv3: this address already holds a pass in this court")
211	}
212	price := passPriceFor(c)
213	if price <= 0 {
214		if _, sealed := boardSupply(c); !sealed {
215			panic("kourtv3: this court has not completed a sealed epoch yet; its board is not open")
216		}
217		panic("kourtv3: this court cannot price a pass yet — it has no supply")
218	}
219	mustSpendable(c, who, price)
220	c.coin.Burn(who, price)
221	getStanding(c, who).passHeld = true
222	return price
223}
224func HoldsPass(courtSlug string, who address) bool {
225	r := lookupStanding(mustCourt(courtSlug), who)
226	return r != nil && r.passHeld
227}
228const secsPerDay = int64(86_400)
229func bucketAt(tokens, lastRefill, rate, now int64) (avail, stamp int64) {
230	if rate <= 0 {
231		return 0, lastRefill
232	}
233	if lastRefill == 0 {
234		return rate, now
235	}
236	stamp = lastRefill
237	if gain := (now - lastRefill) * rate / secsPerDay; gain > 0 {
238		tokens = satAdd(tokens, gain)
239		stamp += gain * secsPerDay / rate
240	}
241	if tokens > rate {
242		tokens = rate
243	}
244	if tokens < 0 {
245		tokens = 0
246	}
247	return tokens, stamp
248}
249func refill(c *Court, who address, r *standingRow) int64 {
250	rate := postsPerDayAt(ladderFor(c), postLevel(c, who))
251	r.tokens, r.lastRefill = bucketAt(r.tokens, r.lastRefill, rate, nowTime())
252	return r.tokens
253}
254func mustSpendPost(c *Court, who address) {
255	lvl := postLevel(c, who)
256	if lvl == 0 {
257		if _, sealed := boardSupply(c); !sealed {
258			panic("kourtv3: this court has not completed a sealed epoch yet; its board is not open")
259		}
260		panic("kourtv3: you cannot post in this court yet — earn standing, or buy an entry pass")
261	}
262	r := getStanding(c, who)
263	if refill(c, who, r) < 1 {
264		panic("kourtv3: your posting allowance for now is spent — level " +
265			strconv.FormatInt(lvl, 10) + " allows " +
266			strconv.FormatInt(postsPerDayAt(ladderFor(c), lvl), 10) + " a day")
267	}
268	r.tokens--
269}
270func PostsAvailable(courtSlug string, who address) int64 {
271	c := mustCourt(courtSlug)
272	r := lookupStanding(c, who)
273	if r == nil {
274		return 0
275	}
276	avail, _ := bucketAt(r.tokens, r.lastRefill, postsPerDayAt(ladderFor(c), postLevel(c, who)), nowTime())
277	return avail
278}
279func renderStanding(slug, addrArg string) string {
280	c := mustCourt(slug)
281	who := address(addrArg)
282	if !who.IsValid() {
283		return "## Not found\nThat is not a valid address."
284	}
285	l := ladderFor(c)
286	sup, sealed := boardSupply(c)
287	lvl := postLevel(c, who)
288	var b strings.Builder
289	b.WriteString("# Standing in " + courtNameFor(c) + "\n\n")
290	b.WriteString("`" + sanitize.InlineText(who.String()) + "`\n\n")
291	if !sealed {
292		b.WriteString("This court is too new to have a board. Its thresholds are quoted\n" +
293			"against a sealed epoch and it has not completed one yet; comments open\n" +
294			"as soon as it has.\n\n")
295		b.WriteString(helpLink + "\n")
296		return b.String()
297	}
298	var score, hw int64
299	if r := lookupStanding(c, who); r != nil {
300		score, hw = r.score, r.highWater
301	}
302	frozenAt, frozenUntil := boardFrozenAt(c, who)
303	if frozenUntil != 0 {
304		when := "block " + strconv.FormatInt(frozenUntil, 10)
305		if frozenAt != 0 {
306			when = strconv.FormatInt(frozenAt, 10) + " (" + when + ")"
307		}
308		b.WriteString("> **This court's moderators have frozen you out of its boards**\n" +
309			"> until " + when + ".\n>\n" +
310			"> It reaches comments and upvotes and nothing else: you may still stake,\n" +
311			"> vote, answer, dispute, flag, open claims, withdraw, draw emission, and\n" +
312			"> take every election action. An entry pass will not lift it, and buying\n" +
313			"> one now would burn coin for nothing.\n>\n" +
314			"> The court's [moderation log](/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + slug +
315			"/mod) records who acted and why.\n\n")
316	}
317	b.WriteString("## What you may do\n\n")
318	b.WriteString("- level: **" + strconv.FormatInt(lvl, 10) + "** of 3\n")
319	b.WriteString("- comments: **" + strconv.FormatInt(postsPerDayAt(l, lvl), 10) +
320		" a day**, " + strconv.FormatInt(PostsAvailable(slug, who), 10) + " available now\n")
321	b.WriteString("- per claim: at most " + strconv.Itoa(maxBoardRowsPerAuthor) + " comments\n")
322	if lvl == 0 {
323		if hw > score {
324			b.WriteString("\nA moderator slash took your standing to zero, and with it the\n" +
325				"entry pass if you held one — the burn is not refunded. A successor\n" +
326				"set, or the platform, can restore up to the high-water mark below.\n")
327		}
328		if frozenUntil == 0 {
329			if hw > score {
330				b.WriteString("You can also earn back from zero, or buy a new pass for " +
331					strconv.FormatInt(passPriceFor(c), 10) + " " + qualifiedSymbol(c) + ".\n")
332			} else {
333				b.WriteString("\nYou cannot comment here yet. Earn standing below, or buy an entry\n" +
334					"pass for " + strconv.FormatInt(passPriceFor(c), 10) + " " +
335					qualifiedSymbol(c) + " (burned, one-off).\n")
336			}
337		}
338	}
339	b.WriteString("\n## Standing\n\n")
340	b.WriteString("- earned: **" + strconv.FormatInt(score, 10) + "**\n")
341	b.WriteString("- high-water mark: " + strconv.FormatInt(hw, 10) +
342		" — what a moderator slash can be restored toward\n")
343	b.WriteString("- entry pass: " + yesNo(HoldsPass(slug, who)) + "\n")
344	writeStandingFrom(&b, c, who)
345	b.WriteString("\n## The rungs, in this court\n\n")
346	for _, k := range []struct {
347		name string
348		bps  int64
349		rate int64
350	}{{"1", l.t1Bps, l.postsEntry}, {"2", l.t2Bps, l.postsT2}, {"3", l.t3Bps, l.postsT3()}} {
351		b.WriteString("- level " + k.name + ": " + strconv.FormatInt(sup*k.bps/10_000, 10) +
352			" standing (" + strconv.FormatInt(k.bps, 10) + " bps of supply) → " +
353			strconv.FormatInt(k.rate, 10) + " a day\n")
354	}
355	b.WriteString("\nEach rung is a **share of this court's supply**, not a fixed " +
356		"number, so the bar rises as the court grows. Standing itself is never " +
357		"taken except by a logged moderator slash — but a score that does not " +
358		"move can still fall below a rung it used to clear.\n")
359	b.WriteString("\n## How standing is earned\n\n")
360	b.WriteString("Only where the court adjudicated something, and always in proportion\n" +
361		"to your own capital that the act committed or destroyed:\n\n")
362	b.WriteString("- a quality flag whose slash **settled** — the full bar, two thirds low\n")
363	b.WriteString("- **prevailing in a dispute** — an uphold against real opposition, or an overturn\n")
364	b.WriteString("- authoring a claim the court rated **HIGH** — seeded claims earn nothing\n")
365	b.WriteString("- **winning conviction** on a resolved claim; half where nobody disputed it\n\n")
366	b.WriteString("Never earned: commenting, being upvoted, buying " +
367		qualifiedSymbol(c) + ", or buying the pass.\n")
368	b.WriteString("Standing does not decay. A moderator may slash it for abuse; the\n")
369	b.WriteString("high-water mark above is what a successor set can restore.\n")
370	b.WriteString("\n## The answer record\n\n")
371	b.WriteString("- contested-and-upheld answers: **" +
372		strconv.FormatInt(int64(AnswerRecord(slug, who)), 10) + "**\n")
373	b.WriteString("\n_Adjudicated — moderators cannot change this._\n")
374	return b.String()
375}
376func renderStandingIndex(slug string) string {
377	c := mustCourt(slug)
378	l := ladderFor(c)
379	var b strings.Builder
380	b.WriteString("# Standing in " + courtNameFor(c) + "\n\n")
381	b.WriteString("To see an address's standing, put it on the end of this " +
382		"page's path: `/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + sanitize.InlineText(slug) +
383		"/me/<address>`\n\n")
384	b.WriteString("An entry pass costs " + strconv.FormatInt(passPriceFor(c), 10) +
385		" " + qualifiedSymbol(c) + ", burned, once. Standing is earned and " +
386		"cannot be bought.\n\n")
387	b.WriteString("Standing is measured against this court's coin supply, in " +
388		"basis points — one bp is a hundredth of a percent.\n\n")
389	b.WriteString("- level 1 at " + strconv.FormatInt(l.t1Bps, 10) + " bps of supply → " +
390		strconv.FormatInt(l.postsEntry, 10) + " comments a day\n")
391	b.WriteString("- level 2 at " + strconv.FormatInt(l.t2Bps, 10) + " bps → " +
392		strconv.FormatInt(l.postsT2, 10) + " a day\n")
393	b.WriteString("- level 3 at " + strconv.FormatInt(l.t3Bps, 10) + " bps → " +
394		strconv.FormatInt(l.postsT3(), 10) + " a day\n")
395	return b.String()
396}
397func writeStandingFrom(b *strings.Builder, c *Court, who address) {
398	r := lookupStanding(c, who)
399	if r == nil {
400		return
401	}
402	labels := [standingCatN]string{
403		"quality flags whose slash settled",
404		"disputes you prevailed in",
405		"claims of yours the court rated HIGH",
406		"conviction on resolved claims",
407	}
408	listed := false
409	for i := 0; i < standingCatN; i++ {
410		if r.from[i] == 0 {
411			continue
412		}
413		if !listed {
414			b.WriteString("- from: ")
415			listed = true
416		} else {
417			b.WriteString("; ")
418		}
419		b.WriteString(strconv.FormatInt(r.from[i], 10) + " from " + labels[i])
420	}
421	if listed {
422		b.WriteString("\n")
423	}
424}
425func yesNo(b bool) string {
426	if b {
427		return "held"
428	}
429	return "none"
430}