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}