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

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}