curators.gno
11.28 Kb · 363 lines
1package radio
2
3import (
4 "chain"
5 "chain/banker"
6 "chain/runtime/unsafe"
7 "strings"
8
9 "gno.land/p/nym-alexiscolin000/gnoradio/role/v0"
10 "gno.land/p/nym-alexiscolin000/gnoradio/store/v0"
11 "gno.land/p/nym-alexiscolin000/gnoradio/text/v0"
12 "gno.land/r/nym-alexiscolin000/gnoradio/catalog/v1"
13 "gno.land/r/nym-alexiscolin000/gnoradio/data"
14)
15
16// Curators are the listeners who pick tracks. While a pick is on air, a tip to
17// its artist made with TipOnAir sends the artist's promo share to the picker
18// (catalog/promo.gno). Every pick and every shared tip updates the picker's
19// counters and the weekly top of the station and of all stations: one record
20// and a 10-entry list, whatever the number of curators.
21
22const (
23 allStations = -1
24 topCurators = 10
25 topMax = 5 + topCurators*46 // the longest station top: week, count, then score and key per curator
26 week = 7 * 24 * 3600
27 tipGrace = 120 // seconds after a pick ends during which a tip still credits its picker
28)
29
30// Curator is a picker's record, all time or for one week.
31type Curator struct {
32 Address address
33 Picks int
34 Tips int // tips received while their picks were on air
35 Earned int64 // ugnot from those tips
36 Promo int64 // ugnot refunded by artists for sponsored picks (sponsor.gno)
37}
38
39// A curator is one record: all time, picks, tips, earned, promo, first
40// pick, period, picks this period, picks the period before (sponsor.gno);
41// this week across stations, week, picks, tips, earned; each a fixed-width
42// number (cWidths). Then one entry per station picked on lately: the
43// station (1 digit), week, picks, tips, earned and the last pick time
44// (sWidths).
45const (
46 cWeek = 8
47 cFields = 12
48 cLen = 62 // the numbers
49 sLen = 27 // one station entry
50)
51
52var (
53 cWidths = [cFields]int{4, 4, 8, 8, 6, 4, 4, 4, 4, 4, 4, 8}
54 sWidths = [5]int{4, 4, 4, 8, 6}
55)
56
57// TipOnAir tips the artist of trackID: the attached ugnot go through the
58// catalog, which pays the artist and collaborators exactly as
59// catalog.TipWithSupport does. When trackID is a listener's pick on air on
60// stationID (or ended less than two minutes ago), the artist's promo share
61// goes to that listener; the radio reads the picker from its own schedule.
62// ref is who shared the link the tipper came from ("" for none): picker and
63// ref split the promo share, never more.
64func TipOnAir(cur realm, stationID, trackID, supportPct int, ref address) {
65 tipper := userCaller(cur)
66 if ref != "" && !role.Canonical(ref) {
67 panic("radio: ref is not an address (lower-case g1...)")
68 }
69 sent := unsafe.OriginSend()
70 if len(sent) != 1 || sent[0].Denom != "ugnot" || sent[0].Amount <= 0 {
71 panic("radio: attach an amount in ugnot")
72 }
73 t := rd()
74 st := t.station(stationID)
75 picker, home := st.picker(trackID, now(), adminAddr())
76 bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
77 bk.SendCoins(cur.Address(), chain.PackageAddress(data.Writer("catalog")), sent)
78 toPicker, _ := catalog.RadioTip(cross(cur), trackID, supportPct, tipper, picker, ref, sent[0].Amount)
79 if toPicker > 0 {
80 credit(t, picker, home, 0, 1, toPicker) // ranked where the pick lives
81 save(cur, t)
82 chain.Emit("CuratorTipped", "station", text.Itoa(st.id), "track", text.Itoa(trackID), "picker", picker.String(), "amount", text.Itoa64(toPicker))
83 }
84}
85
86// credit adds picks or a shared tip to a curator, all time and this week.
87// The station's entry also keeps the time of the last pick there, which the
88// queue cooldown reads (lastPickAt): no extra write per pick.
89func credit(t *tx, by address, stationID, picks, tips int, earned int64) {
90 k := by.String()
91 ts := now()
92 c, sts := curRec(t, k)
93 c[0], c[1], c[2] = c[0]+int64(picks), c[1]+int64(tips), c[2]+earned
94 if picks > 0 {
95 // The reputation sponsored picks need (sponsor.gno): the first pick,
96 // and picks counted per period, this one and the one before.
97 if c[4] == 0 {
98 c[4] = ts
99 }
100 switch p := ts / repPeriod; c[5] {
101 case p:
102 c[6]++
103 case p - 1:
104 c[5], c[6], c[7] = p, 1, c[6]
105 default:
106 c[5], c[6], c[7] = p, 1, 0
107 }
108 }
109 wk := weekOf(ts)
110 if c[cWeek] != wk {
111 c[cWeek], c[cWeek+1], c[cWeek+2], c[cWeek+3] = wk, 0, 0, 0
112 }
113 c[cWeek+1], c[cWeek+2], c[cWeek+3] = c[cWeek+1]+int64(picks), c[cWeek+2]+int64(tips), c[cWeek+3]+earned
114 w := stationOf(sts, stationID)
115 if w[0] != wk {
116 w = []int64{wk, 0, 0, 0, w[4]}
117 }
118 w[1], w[2], w[3] = w[1]+int64(picks), w[2]+int64(tips), w[3]+earned
119 if picks > 0 {
120 w[4] = ts
121 }
122 putCur(t, k, c, withStation(sts, stationID, w, ts))
123 setTop(t, 0, wk, k, c[cWeek+1]+c[cWeek+2])
124 setTop(t, stationID+1, wk, k, w[1]+w[2])
125}
126
127// curRec reads a curator record (zeros when absent) and its station entries.
128func curRec(t *tx, k string) ([]int64, string) {
129 c := make([]int64, cFields)
130 rec := t.val(cCurators, k)
131 if rec == "" {
132 return c, ""
133 }
134 at := 0
135 for i, w := range cWidths {
136 c[i] = store.Num(rec, at, w)
137 at += w
138 }
139 return c, rec[at:]
140}
141
142func putCur(t *tx, k string, c []int64, sts string) {
143 b := make([]byte, 0, cLen+len(sts))
144 for i, w := range cWidths {
145 b = store.AppendFixed(b, c[i], w)
146 }
147 t.set(cCurators, k, string(append(b, sts...)))
148}
149
150// stationOf returns a curator's entry for a station: week, picks, tips,
151// earned, last pick time (zeros when none).
152func stationOf(sts string, stationID int) []int64 {
153 w := make([]int64, 5)
154 for i := 0; i+sLen <= len(sts); i += sLen {
155 if int(dec[sts[i]]) == stationID {
156 at := i + 1
157 for j, n := range sWidths {
158 w[j] = store.Num(sts, at, n)
159 at += n
160 }
161 break
162 }
163 }
164 return w
165}
166
167// withStation sets a station's entry and drops the entries that no longer
168// count: an older week whose last pick is past the cooldown.
169func withStation(sts string, stationID int, w []int64, ts int64) string {
170 b := store.AppendFixed(make([]byte, 0, sLen+len(sts)), int64(stationID), 1)
171 for j, n := range sWidths {
172 b = store.AppendFixed(b, w[j], n)
173 }
174 wk := weekOf(ts)
175 for i := 0; i+sLen <= len(sts); i += sLen {
176 e := sts[i : i+sLen]
177 if int(dec[e[0]]) == stationID {
178 continue
179 }
180 if store.Num(e, 1, 4) == wk || ts-store.Num(e, sLen-6, 6) < queueCooldown {
181 b = append(b, e...)
182 }
183 }
184 return string(b)
185}
186
187// markPick keeps the time of a pick that earns no point (a sponsored one) for
188// the queue cooldown.
189func markPick(t *tx, by address, stationID int) {
190 k := by.String()
191 ts := now()
192 c, sts := curRec(t, k)
193 w := stationOf(sts, stationID)
194 if wk := weekOf(ts); w[0] != wk {
195 w = []int64{wk, 0, 0, 0, 0}
196 }
197 w[4] = ts
198 putCur(t, k, c, withStation(sts, stationID, w, ts))
199}
200
201// lastPickAt is when addr last picked on a station (0: never).
202func lastPickAt(t *tx, addr address, stationID int) int64 {
203 rec := t.val(cCurators, addr.String())
204 if rec == "" {
205 return 0
206 }
207 return stationOf(rec[cLen:], stationID)[4]
208}
209
210// weekOf numbers weeks from Monday 00:00 UTC.
211func weekOf(ts int64) int64 { return (ts + 3*24*3600) / week }
212
213// ---- weekly tops: radio/tops P(station+1), 0 all stations ----
214
215// A top is the week (4 digits), the number of entries n (1) and their
216// scores, best first (4 each), then their addresses, each after its length
217// (1).
218
219type rank struct {
220 key string // address
221 score int64 // picks + tips this week
222}
223
224// topRec is ranking i: 0 across stations, else station i-1's (in its
225// record, which a pick there writes anyway).
226func topRec(t *tx, i int) string {
227 if i == 0 {
228 return t.val(cTops, "all")
229 }
230 return t.station(i - 1).top
231}
232
233// top reads ranking i of week wk (nil when another week's).
234func top(t *tx, i int, wk int64) []rank {
235 rec := topRec(t, i)
236 if rec == "" || store.Num(rec, 0, 4) != wk {
237 return nil
238 }
239 n := int(dec[rec[4]])
240 out := make([]rank, 0, n)
241 for j, at := 0, 5+4*n; j < n; j++ {
242 l := int(dec[rec[at]])
243 out = append(out, rank{rec[at+1 : at+1+l], store.Num(rec, 5+4*j, 4)})
244 at += 1 + l
245 }
246 return out
247}
248
249// setTop raises k's score in a ranking, emptied when a new week starts.
250// Scores only grow, so a score below the last of a full top leaves it
251// untouched (and unsaved): k cannot be in it. That is read without
252// decoding the list.
253func setTop(t *tx, i int, wk int64, k string, v int64) {
254 if i == 0 {
255 if int64(t.flowAt(fTopWeek, 4)) == wk && int64(t.flowAt(fTopLast, 4)) > v {
256 return
257 }
258 } else if rec := topRec(t, i); rec != "" && store.Num(rec, 0, 4) == wk && int(dec[rec[4]]) == topCurators && v < store.Num(rec, 1+4*topCurators, 4) {
259 return
260 }
261 list := top(t, i, wk)
262 out := make([]rank, 0, topCurators+1)
263 for _, r := range list {
264 if r.key != k {
265 out = append(out, r)
266 }
267 }
268 j := len(out)
269 for j > 0 && out[j-1].score < v {
270 j--
271 }
272 if j >= topCurators {
273 return
274 }
275 out = append(out, rank{})
276 copy(out[j+1:], out[j:])
277 out[j] = rank{k, v}
278 if len(out) > topCurators {
279 out = out[:topCurators]
280 }
281 b := store.AppendFixed(store.AppendFixed(make([]byte, 0, topMax), wk, 4), int64(len(out)), 1)
282 for _, r := range out {
283 b = store.AppendFixed(b, r.score, 4)
284 }
285 for _, r := range out {
286 b = append(store.AppendFixed(b, int64(len(r.key)), 1), r.key...)
287 }
288 if i == 0 {
289 t.set(cTops, "all", string(b))
290 last := int64(0)
291 if len(out) == topCurators {
292 last = out[topCurators-1].score
293 }
294 t.setFlowAt(fTopWeek, 4, int(wk))
295 t.setFlowAt(fTopLast, 4, int(last))
296 return
297 }
298 st := t.station(i - 1)
299 st.top, st.dirty = string(b), true
300}
301
302// CuratorOf returns a listener's curator record, all time.
303func CuratorOf(addr address) Curator {
304 c, _ := curRec(rd(), addr.String())
305 return Curator{Address: addr, Picks: int(c[0]), Tips: int(c[1]), Earned: c[2], Promo: c[3]}
306}
307
308// TopCurators returns this week's top curators of a station (allStations:
309// every station), best first, with their counts for the week. Picks and tips
310// received on air score one point each.
311func TopCurators(stationID int) []Curator {
312 return weekTop(rd(), stationID)
313}
314
315func weekTop(t *tx, stationID int) []Curator {
316 if stationID != allStations {
317 mustStation(stationID)
318 }
319 list := top(t, stationID+1, weekOf(now()))
320 out := make([]Curator, 0, len(list))
321 for _, r := range list {
322 c, sts := curRec(t, r.key)
323 n := c[cWeek:]
324 if stationID != allStations {
325 n = stationOf(sts, stationID)
326 }
327 out = append(out, Curator{Address: address(r.key), Picks: int(n[1]), Tips: int(n[2]), Earned: n[3]})
328 }
329 return out
330}
331
332// CuratorJSON returns a listener's curator record: all time, this week, and
333// their place in this week's top across stations (0 when outside the top 10).
334func CuratorJSON(addr address) string {
335 t := rd()
336 r, _ := curRec(t, addr.String())
337 w := r[cWeek:]
338 if w[0] != weekOf(now()) {
339 w = []int64{0, 0, 0, 0}
340 }
341 place := 0
342 for i, c := range weekTop(t, allStations) {
343 if c.Address == addr {
344 place = i + 1
345 }
346 }
347 return `{"address":` + text.Str(addr.String()) + `,"picks":` + text.Itoa64(r[0]) + `,"tips":` + text.Itoa64(r[1]) + `,"earned":` + text.Itoa64(r[2]) + `,"promo":` + text.Itoa64(r[3]) +
348 `,"week":{"picks":` + text.Itoa64(w[1]) + `,"tips":` + text.Itoa64(w[2]) + `,"earned":` + text.Itoa64(w[3]) + `,"rank":` + text.Itoa(place) + `}}`
349}
350
351// TopCuratorsJSON returns TopCurators as JSON with the week's start (unix).
352func TopCuratorsJSON(stationID int) string {
353 var b strings.Builder
354 b.WriteString(`{"station":` + text.Itoa(stationID) + `,"since":` + text.Itoa64(weekOf(now())*week-3*24*3600) + `,"top":[`)
355 for i, c := range TopCurators(stationID) {
356 if i > 0 {
357 b.WriteString(",")
358 }
359 b.WriteString(`{"address":` + text.Str(c.Address.String()) + `,"picks":` + text.Itoa(c.Picks) + `,"tips":` + text.Itoa(c.Tips) + `,"earned":` + text.Itoa64(c.Earned) + `}`)
360 }
361 b.WriteString(`]}`)
362 return b.String()
363}