sponsor.gno
10.28 Kb · 257 lines
1package radio
2
3import (
4 "chain"
5
6 "gno.land/p/nym-alexiscolin000/gnoradio/store/v0"
7 "gno.land/p/nym-alexiscolin000/gnoradio/text/v0"
8 "gno.land/r/nym-alexiscolin000/gnoradio/catalog/v1"
9 "gno.land/r/nym-alexiscolin000/gnoradio/data"
10)
11
12// Sponsored picks: an artist's promo budget (catalog/sponsor.gno) refunds a
13// listener's pick of their track: by default what the pick and collecting
14// its refund cost (0.2 GNOT, catalog/sponsor.gno). The listener
15// chooses it (QueueSponsored) instead of a normal pick; the artist's refund is
16// reserved at once in the data realm's vault, and the listener collects it
17// (ClaimPickPayout, one signature, their gas) once the slot has aired in
18// full. A slot dropped or cut before its end gives the reservation back; one
19// not collected within 7 days lapses back to the budget by itself.
20//
21// Farming with throwaway wallets earns at most the pick cost back, and these
22// rules keep it rare: a wallet needs a first pick 7 days old and 3 normal
23// picks in the last two 15-day periods (two counters in the curator record: the
24// current period and the one before, so the window is 15 to 30 days, never
25// more);
26// one sponsored pick per artist per station and hour; sponsored airtime at
27// most a quarter of the 2 hours listeners may program; and per wallet one per
28// artist and three a day (the catalog). They earn no curator point, do not
29// join Listeners' choice and carry no dedication.
30
31const (
32 repPeriod = 15 * 24 * 3600 // reputation counters roll over every 15 days
33 repAge = 7 * 24 * 3600 // a wallet's first pick must be this old
34 repPicks = 3 // normal picks this period and the one before
35 sponsorShare = maxAheadAir / 4
36 sponsorWindow = 3600
37)
38
39// A sponsored pick not collected yet is radio/sponsor address/P(station) ->
40// Rec(start, track, end, ugnot, held, reserved, gen): one open per
41// wallet and station; held is the end the catalog reserved for, reserved
42// when (unix), gen the budget's funding generation then.
43//
44// The vault pools an artist's holds per day, and a new owner's funding
45// refunds the previous funder everything, holds included, and starts a new
46// funding generation (catalog/sponsor.gno): a reservation made before is
47// cancelled, never paid from (nor released into) a later listener's hold,
48// even when the budget went back to the same funder.
49
50func sponsorKey(stationID int, addr address) string {
51 return addr.String() + "/" + store.Pad(stationID)
52}
53
54// QueueSponsored is Queue paid by the artist: once the pick has aired in
55// full, ClaimPickPayout sends the picker the artist's refund (catalog
56// PromoOffer). No dedication. A wallet has one sponsored pick open per
57// station: it must be collected (or have lapsed) before the next.
58func QueueSponsored(cur realm, stationID, trackID int) {
59 queueSponsored(cur, stationID, trackID, 0)
60}
61
62// QueueSponsoredAt is QueueSponsored for a chosen time (see QueueAt; 0: now).
63// The refund lapses 7 days after the booked time plus the track's length.
64func QueueSponsoredAt(cur realm, stationID, trackID int, at int64) {
65 queueSponsored(cur, stationID, trackID, at)
66}
67
68func queueSponsored(cur realm, stationID, trackID int, at int64) {
69 caller := userCaller(cur)
70 t := rd()
71 if why := sponsorBlock(t, caller); why != "" {
72 panic("radio: " + why)
73 }
74 mustStation(stationID)
75 // One open per station until it lapses: collected, or nothing left to
76 // pay (the catalog lapses it at the end of a UTC day, so a record past
77 // end+7d may still pay).
78 if rec, ok := data.Get(cSponsor, sponsorKey(stationID, caller)); ok {
79 if payoutStatus(store.Fields(rec)) != "lapsed" {
80 panic("radio: collect your last sponsored pick on this station first")
81 }
82 }
83 queue(cur, caller, stationID, trackID, false, true, "", at)
84}
85
86// ClaimPickPayout sends the caller the refund of their sponsored pick that
87// started at start on stationID, once it has aired in full.
88func ClaimPickPayout(cur realm, stationID int, start int64) {
89 caller := userCaller(cur)
90 noPayment()
91 mustStation(stationID)
92 t := rd()
93 k := sponsorKey(stationID, caller)
94 rec, ok := t.get(cSponsor, k)
95 if !ok || text.MustAtoi64(store.Field(rec, 0)) != start {
96 panic("radio: no sponsored pick of yours to collect at this time")
97 }
98 f := store.Fields(rec)
99 track, amt, held := text.MustAtoi(f[1]), text.MustAtoi64(f[3]), text.MustAtoi64(f[4])
100 status := payoutStatus(f)
101 if status == "airing" {
102 panic("radio: your pick has not finished airing yet")
103 }
104 t.del(cSponsor, k)
105 // A removed track, a cancelled sponsorship or a lapsed refund leaves
106 // nothing to pay: the record goes, so the next sponsored pick is open.
107 if status == "lapsed" {
108 save(cur, t)
109 chain.Emit("PickPayoutVoid", "station", text.Itoa(stationID), "start", text.Itoa64(start), "track", text.Itoa(track), "to", caller.String())
110 return
111 }
112 catalog.PromoClaim(cross(cur), track, caller, held, amt, text.MustAtoi(f[6]))
113 c, sts := curRec(t, caller.String())
114 c[3] += amt
115 putCur(t, caller.String(), c, sts)
116 save(cur, t)
117 chain.Emit("PickPaid", "station", text.Itoa(stationID), "start", text.Itoa64(start), "track", text.Itoa(track), "to", caller.String(), "amount", text.Itoa64(amt))
118}
119
120// sponsorBlock says why a wallet may not take sponsored picks yet ("" when it may).
121func sponsorBlock(t *tx, addr address) string {
122 c, _ := curRec(t, addr.String())
123 ts := now()
124 if c[4] == 0 || ts-c[4] < repAge {
125 return "sponsored picks open 7 days after your first pick"
126 }
127 recent := int64(0)
128 switch ts / repPeriod {
129 case c[5]:
130 recent = c[6] + c[7]
131 case c[5] + 1:
132 recent = c[6]
133 }
134 if recent < repPicks {
135 return "sponsored picks need 3 normal picks in the last two 15-day periods"
136 }
137 return ""
138}
139
140// sponsorQuota: one sponsored pick per artist per station and hour, and
141// sponsored airtime at most a quarter of the listener window, both around
142// airs (now, or the time booked).
143// The share counts the listener window from airs; the per-artist hour is
144// checked around plays, when the pick really airs (review R31).
145func sponsorQuota(st *station, artistID int, dur, airs, plays int64) {
146 air := dur
147 for _, s := range st.slots() {
148 if s.Pay == 0 {
149 continue
150 }
151 if end := s.Start + s.Dur; end > airs && s.Start < airs+maxAheadAir {
152 air += end - max64(s.Start, airs)
153 }
154 if s.Start+s.Dur > plays-sponsorWindow && s.Start < plays+sponsorWindow && s.artist == artistID {
155 panic("radio: this artist already has a sponsored pick this hour here, pick it normally")
156 }
157 }
158 if air > sponsorShare {
159 panic("radio: sponsored picks are full on this station for now, pick normally")
160 }
161}
162
163// reserve books the artist's refund for a sponsored slot, lapsing after held.
164func reserve(cur realm, t *tx, picker address, stationID, trackID int, start, dur, held int64) int64 {
165 amt, gen := catalog.PromoReserve(cross(cur), trackID, picker, held)
166 t.set(cSponsor, sponsorKey(stationID, picker), store.Rec(text.Itoa64(start), text.Itoa(trackID), text.Itoa64(start+dur), text.Itoa64(amt), text.Itoa64(held), text.Itoa64(now()), text.Itoa(gen)))
167 return amt
168}
169
170// held is the end the catalog reserves a sponsored slot's refund for: a
171// booked slot keeps the one of its time even if it moves to a boundary.
172func (s Slot) held() int64 {
173 if s.At > 0 {
174 return s.At + s.Dur
175 }
176 return s.Start + s.Dur
177}
178
179// hold is a sponsored slot's reservation in the catalog: filed under the end
180// it was reserved for (the slot may have moved since). reserved is when it
181// was taken, 0 once the slot started airing: only a pick that never aired
182// gives its daily counts back.
183type hold struct {
184 track int
185 picker address
186 held, paid, reserved int64
187 gen int
188}
189
190// unsponsor forgets the open record of a sponsored slot cut at ts before it
191// started, and returns its reservation; ok is false when there was none. A
192// slot already on air keeps its record: whoever cut it (a hide, a genre edit,
193// a drop), the picker earned the refund and collects it once its end passes.
194func unsponsor(t *tx, stationID int, s Slot, ts int64) (hold, bool) {
195 k := sponsorKey(stationID, s.By)
196 rec, ok := t.get(cSponsor, k)
197 if !ok || s.Start < ts || text.MustAtoi64(store.Field(rec, 0)) != s.Start {
198 return hold{}, false
199 }
200 t.del(cSponsor, k)
201 f := store.Fields(rec)
202 return hold{s.Track, s.By, text.MustAtoi64(f[4]), s.Pay, text.MustAtoi64(f[5]), text.MustAtoi(f[6])}, true
203}
204
205// release gives the reservations of cut sponsored slots back to their
206// artists. While the catalog is paused it gives nothing back, so the radio
207// can still drop slots: each hold lapses back to its budget by itself.
208func release(cur realm, cut []hold) {
209 if data.Paused("catalog") {
210 return
211 }
212 for _, h := range cut {
213 catalog.PromoRelease(cross(cur), h.track, h.picker, h.held, h.paid, h.reserved, h.gen)
214 }
215}
216
217// PickPayoutStatus says what ClaimPickPayout would do for addr's sponsored
218// pick that started at start on stationID: "ok" (it pays), "airing" (not
219// finished yet), "lapsed" (nothing to pay: lapsed or cancelled; collecting only clears it) or
220// "none" (no such open pick).
221func PickPayoutStatus(addr address, stationID int, start int64) string {
222 rec, ok := data.Get(cSponsor, sponsorKey(stationID, addr))
223 if !ok || text.Atoi64(store.Field(rec, 0)) != start {
224 return "none"
225 }
226 return payoutStatus(store.Fields(rec))
227}
228
229func payoutStatus(f []string) string {
230 track, end, amt, held := text.MustAtoi(f[1]), text.MustAtoi64(f[2]), text.MustAtoi64(f[3]), text.MustAtoi64(f[4])
231 if now() < end {
232 return "airing"
233 }
234 // An aired pick pays whatever happened to its track since: hiding it
235 // must not let its artist take back a refund the picker earned.
236 if !catalog.PromoClaimable(track, held, amt, text.MustAtoi(f[6])) {
237 return "lapsed"
238 }
239 return "ok"
240}
241
242// SponsoredJSON lists a wallet's sponsored picks not collected yet, with
243// whether it may take new ones and each one's PickPayoutStatus:
244// {"block":"why not, or empty","open":[{"station","start","track","end","amount","status"}]}.
245func SponsoredJSON(addr address) string {
246 out := `{"block":` + text.Str(sponsorBlock(rd(), addr)) + `,"open":[`
247 p := addr.String() + "/"
248 keys, recs := store.Rows(data.Page(cSponsor, p, addr.String()+"0", numStations, false))
249 for i, rec := range recs {
250 f := store.Fields(rec)
251 if i > 0 {
252 out += ","
253 }
254 out += `{"station":` + text.Itoa(text.Atoi(keys[i][len(p):])) + `,"start":` + f[0] + `,"track":` + f[1] + `,"end":` + f[2] + `,"amount":` + f[3] + `,"status":"` + payoutStatus(f) + `"}`
255 }
256 return out + `]}`
257}