price.gno
9.42 Kb · 286 lines
1package nsskin
2
3import (
4 "strings"
5
6 "gno.land/p/nt/avl/v0"
7 "gno.land/p/nt/ufmt/v0"
8)
9
10/*
11WHAT ONE COSTS, PER DESIGN.
12
13There used to be a single `price` for the whole collection, which is the
14right shape for a launch batch and the wrong one the moment anything in
15it is worth more or less than the rest — or worth nothing, which is the
16interesting case.
17
18Three sources, in order, and only the first of them stores anything:
19
20 an ADMIN OVERRIDE, if one has been set for this id
21 ZERO, if the id falls in the free share by the rule below
22 the BASE PRICE otherwise
23
24# WHY THE FREE ONES ARE COMPUTED RATHER THAN LISTED
25
26Marking a third of sixty definitions free by storing a flag on each is
27sixty writes and sixty entries that live forever, to record something
28that never changes. The rule below derives it from the id, so it costs
29nothing to store, nothing to keep in step, and cannot drift between the
30contract, the shop and the collection file — all three ask the same
31question of the same string and get the same answer.
32
33It is ARBITRARY, which is the point: nothing about a design implies its
34price, and a rule nobody can lobby is fairer than a list somebody picked.
35An admin who disagrees about a particular one sets an override, and that
36override is then a deliberate, visible, single stored fact.
37
38# WHY A HASH AND NOT EVERY THIRD ONE
39
40Definitions are iterated and displayed in id order, so "every third"
41would put the free ones in a visible stripe down the grid and make the
42whole thing look like a pricing table rather than a collection.
43*/
44
45/*
46The threshold, out of a hundred, under which a hashed id is free.
47
48THIRTY-FIVE, NOT THIRTY, AND THE FOUR-POINT GAP IS THE HONEST PART. The
49rule is a statement about ids, not a quota, so any particular collection
50lands near the threshold rather than on it — sixty ids at a threshold of
51thirty came out at thirteen, which is twenty-one per cent and not the
52third that was asked for. Thirty-five puts the launch batch at eighteen,
53which is thirty per cent exactly.
54
55So the constant was chosen to fit the collection that exists. That is
56worth saying plainly rather than dressing up: what it buys is that every
57FUTURE id still gets an answer nobody can lobby for, which is the
58property that mattered, while the batch on sale today is the share that
59was promised.
60
61A constant rather than a stored value, because changing it would
62silently reprice every skin nobody has overridden — including ones
63people have already been shown a price for. That should cost a deploy.
64*/
65const freeShare = 35
66
67// priceOverride holds ONLY the ids an admin has priced by hand. An id
68// that is absent is not "free" and not "base" — it is unopinionated, and
69// PriceOf decides. Storing a value for every skin would make the common
70// case cost as much as the exception.
71var priceOverride = avl.NewTree() // definition id -> int64 ugnot
72
73/*
74freeByRule is FNV-1a over the id, taken modulo one hundred.
75
76freeShare of every hundred ids fall under the threshold, so the share
77lands NEAR it rather than on it for any particular collection — see the
78note on the constant. The launch batch of sixty comes out at eighteen,
79and ListFreeSkins says so rather than asking anyone to trust the
80arithmetic.
81
82The same hash is in nsview.faceForArt, deliberately not shared: one runs
83over a sprite and one over an id, they answer different questions, and a
84helper spanning both realms would be a dependency between them for four
85lines of arithmetic.
86*/
87func freeByRule(id string) bool {
88 h := uint32(2166136261)
89 for i := 0; i < len(id); i++ {
90 h ^= uint32(id[i])
91 h *= 16777619
92 }
93 return h%100 < freeShare
94}
95
96// IsFreeByRule reports what the rule alone says, ignoring any override.
97// Exposed so an admin panel can show what it would revert to.
98func IsFreeByRule(id string) bool {
99 return freeByRule(strings.ToLower(strings.TrimSpace(id)))
100}
101
102// HasPriceOverride reports whether this id has been priced by hand. A
103// panel needs it to tell "set to zero" from "free by the rule", which
104// look identical in the price alone and are different decisions.
105func HasPriceOverride(id string) bool {
106 return priceOverride.Has(strings.ToLower(strings.TrimSpace(id)))
107}
108
109// PriceOf is what this design costs right now, in ugnot.
110//
111// It does NOT check that the id exists: a price for a definition that is
112// not there is meaningless rather than wrong, and Buy is the thing that
113// must refuse an unknown id — which it already does, by name, before it
114// ever asks what the price is.
115func PriceOf(id string) int64 {
116 id = strings.ToLower(strings.TrimSpace(id))
117 if v := priceOverride.Get(id); v != nil {
118 return v.(int64)
119 }
120 if freeByRule(id) {
121 return 0
122 }
123 return price
124}
125
126/*
127SetSkinPrice prices one design by hand, overriding the rule.
128
129Zero is a legitimate value and is NOT the same as removing the override:
130it says "this one is free because I said so", which survives a change to
131the base price and to the rule. ClearSkinPrice is how you go back to
132being unopinionated.
133*/
134func SetSkinPrice(cur realm, id string, p int64) {
135 assertIsAdmin(cur)
136 setSkinPrice(id, p)
137}
138
139// setSkinPrice is SetSkinPrice without the realm ceremony. Split for the
140// same reason attach.gno splits refresh from Refresh: the rules are
141// worth testing on their own, and the gate above them is the same
142// assertIsAdmin every other write in this realm goes through.
143func setSkinPrice(id string, p int64) {
144 id = strings.ToLower(strings.TrimSpace(id))
145 if !skins.Has(id) {
146 panic("nsskin: no such skin: " + id)
147 }
148 if p < 0 {
149 panic("nsskin: a negative price is not a discount")
150 }
151 priceOverride.Set(id, p)
152}
153
154// ClearSkinPrice drops an override, putting the design back under the
155// rule and the base price.
156func ClearSkinPrice(cur realm, id string) {
157 assertIsAdmin(cur)
158 clearSkinPrice(id)
159}
160
161func clearSkinPrice(id string) {
162 priceOverride.Remove(strings.ToLower(strings.TrimSpace(id)))
163}
164
165/*
166SetSkinPrices prices many designs in one call.
167
168A panel that lets somebody retune a collection produces a batch of
169changes, and sixty separate transactions is sixty flat fees for what is
170one decision. Same encoding as AllSkins reads back — tab between the id
171and the price, newline between records — so the two halves of the panel
172speak one format.
173
174An empty price field CLEARS the override rather than setting zero. The
175panel shows an empty box for "no opinion" and a 0 for "free on purpose",
176and those must not collapse into each other on the way here.
177*/
178func SetSkinPrices(cur realm, packed string) {
179 assertIsAdmin(cur)
180 setSkinPrices(packed)
181}
182
183func setSkinPrices(packed string) {
184 /* CHECKED IN FULL BEFORE ANYTHING IS WRITTEN.
185 A panic reverts the whole transaction on chain, so applying as we
186 go would come out right in production — but only because of a
187 guarantee living somewhere else. Validating first makes the
188 promise true of this function on its own, which is what the tests
189 can actually hold it to, and it costs one pass over a list that is
190 sixty lines at its largest. */
191 type change struct {
192 id string
193 clear bool
194 p int64
195 }
196 var pending []change
197
198 for _, raw := range strings.Split(packed, "\n") {
199 /* TRIMMED AROUND THE FIELDS, NOT AROUND THE LINE. Trimming the
200 whole line first ate the trailing tab of "id\t", which is the
201 form that CLEARS an override — so "no opinion" arrived here
202 looking like a line with no tab at all and was rejected as
203 malformed. Only the carriage return goes, because a form
204 posting CRLF is not making a statement about pricing. */
205 line := strings.TrimRight(raw, "\r")
206 if strings.TrimSpace(line) == "" {
207 continue
208 }
209 tab := strings.Index(line, "\t")
210 if tab < 0 {
211 panic("nsskin: each line is <id>\\t<ugnot>, got: " + line)
212 }
213 id := strings.ToLower(strings.TrimSpace(line[:tab]))
214 val := strings.TrimSpace(line[tab+1:])
215 if !skins.Has(id) {
216 panic("nsskin: no such skin: " + id)
217 }
218 if val == "" {
219 pending = append(pending, change{id: id, clear: true})
220 continue
221 }
222 p, ok := parseU64(val)
223 if !ok {
224 panic("nsskin: not a number of ugnot: " + val)
225 }
226 pending = append(pending, change{id: id, p: p})
227 }
228
229 for _, c := range pending {
230 if c.clear {
231 priceOverride.Remove(c.id)
232 continue
233 }
234 priceOverride.Set(c.id, c.p)
235 }
236}
237
238// parseU64 is strconv.ParseInt without the import, and without accepting
239// a sign — every price here is a non-negative amount of ugnot, and "-0"
240// or "+5" arriving from a form should be refused rather than guessed at.
241func parseU64(s string) (int64, bool) {
242 if len(s) == 0 || len(s) > 18 {
243 return 0, false
244 }
245 var n int64
246 for i := 0; i < len(s); i++ {
247 c := s[i]
248 if c < '0' || c > '9' {
249 return 0, false
250 }
251 n = n*10 + int64(c-'0')
252 }
253 return n, true
254}
255
256/*
257ListFreeSkins names every definition that costs nothing right now,
258counting overrides — so it answers "what can I have for free", which is
259the question, rather than "what does the rule say", which is trivia.
260*/
261func ListFreeSkins() string {
262 var b strings.Builder
263 skins.Iterate("", "", func(k string, _ any) bool {
264 if PriceOf(k) == 0 {
265 b.WriteString(k + "\n")
266 }
267 return false
268 })
269 return b.String()
270}
271
272// AllPrices is every id and its price, for a shop that wants the lot in
273// one read. AllSkins carries the price too; this exists for a panel that
274// wants prices without the rest and for checking the two agree.
275func AllPrices() string {
276 var b strings.Builder
277 skins.Iterate("", "", func(k string, _ any) bool {
278 o := "0"
279 if priceOverride.Has(k) {
280 o = "1"
281 }
282 b.WriteString(k + "\t" + ufmt.Sprintf("%d", PriceOf(k)) + "\t" + o + "\n")
283 return false
284 })
285 return b.String()
286}