v1.gno
9.76 Kb · 309 lines
1// Package gems6v2 (deployed at .../gems6v2/v5, wired to ../stablev5; v4 with the gemsart3d2 art) is the mint-mechanics
2// half of the gems6v2 candidate design: trait assembly and chain-
3// roster management live here, same as the real gems6/v1. The one
4// real difference is what MintAssembled does with the assembled
5// traits: it never renders anything -- no gemsart.RenderTokenSVG call
6// at all -- it just hands the raw trait fields to ../stable's
7// RecordMint, which writes a cheap sealed placeholder. Rendering
8// happens later, lazily, on a read (see ../stable's currentMetadata).
9// This realm still imports gemsartfab, but only for its roster helper
10// functions (StoneForChain, ColorPaletteSize, ShapeNames, ...) used to
11// pick a valid random combination -- never for the expensive render
12// itself.
13package gems
14
15import (
16 "chain"
17 "chain/banker"
18 "chain/runtime"
19 unsaferealm "chain/runtime/unsafe"
20 "crypto/sha256"
21 "strings"
22
23 gemsart "gno.land/p/g17cjym5e9hhws46lt6329pv2gtx2ay0503hgems/gemsart3d2"
24 "gno.land/p/nt/grc721/v0"
25 stable "gno.land/r/g17cjym5e9hhws46lt6329pv2gtx2ay0503hgems/gems/stable"
26 "gno.land/p/nt/seqid/v0"
27 "gno.land/p/nt/ufmt/v0"
28)
29
30var (
31 collectionOwnerHere address = "g17cjym5e9hhws46lt6329pv2gtx2ay0503hgems"
32
33 mintingOpen = true
34
35 maxMintsPerWallet int64 = 0
36
37 nextID seqid.ID
38)
39
40/* -------------------------------- Minting ---------------------------------- */
41
42// MintAssembled: assembles a fresh random (chain, shape, color, metal,
43// active) combination, verifies payment against ../stable's discount-
44// adjusted price, then hands the RAW trait fields (not a rendered
45// image) to ../stable.RecordMint -- the render happens later, lazily,
46// on a read. This is the whole fix: nothing shape-dependent runs
47// inside this transaction, so its real gas cost stays small and
48// uniform regardless of which random trait combination gets drawn
49// (confirmed on the throwaway revealpoc spike before this real
50// split-realm version was built).
51func MintAssembled(cur realm, to address) grc721.TokenID {
52 if !mintingOpen {
53 panic("minting is closed")
54 }
55 if maxMintsPerWallet > 0 && callerAddress() != collectionOwnerHere && stable.MintsByWallet(to) >= maxMintsPerWallet {
56 panic(ufmt.Sprintf("wallet %s has already reached the %d-mint limit for this collection", to.String(), maxMintsPerWallet))
57 }
58
59 caller := callerAddress()
60 requiredPrice := stable.EffectivePrice(caller)
61 if requiredPrice != 0 {
62 runtime.AssertOriginCall()
63 if !unsaferealm.PreviousRealm().IsUserCall() {
64 panic("payment verification requires a direct user call")
65 }
66 got := unsaferealm.OriginSend().AmountOf("ugnot")
67 if got != requiredPrice {
68 panic(ufmt.Sprintf("payment required: exactly %d ugnot, got %d", requiredPrice, got))
69 }
70 b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
71 b.SendCoins(cur.Address(), stable.Owner(), chain.Coins{{Denom: "ugnot", Amount: got}})
72 }
73
74 tp, comboKey := assembleUniqueTraits()
75
76 // plain numbers from 1 (#1, #2, ...), not seqid's padded base-32
77 // string ("000003q"): what collectors and marketplaces expect to read
78 tid := grc721.TokenID(ufmt.Sprintf("%d", uint64(nextID)+1))
79 stable.RecordMint(cross(cur), to, caller, tid, tp.Chain, tp.ShapeIdx, tp.ColorIdx, tp.Metal, tp.Active, comboKey, rarityTier(tp))
80
81 nextID.Next()
82
83 return tid
84}
85
86/* --------------------------- trait assembly --------------------------- */
87
88type traitPreset struct {
89 Chain string
90 ShapeIdx int
91 ColorIdx int
92 Metal string
93 Active bool
94}
95
96func traitComboKey(chainName string, shapeIdx, colorIdx int, metal string, active bool) string {
97 return ufmt.Sprintf("%s|%d|%d|%s|%t", chainName, shapeIdx, colorIdx, metal, active)
98}
99
100const activeProbability = 0.2
101
102type prng struct {
103 seed string
104 n int
105}
106
107func newPRNG(seed string) *prng { return &prng{seed: seed} }
108
109func (p *prng) next() uint64 {
110 p.n++
111 material := ufmt.Sprintf("%s:%d", p.seed, p.n)
112 sum := sha256.Sum256([]byte(material))
113 var v uint64
114 for i := 0; i < 8; i++ {
115 v = v<<8 | uint64(sum[i])
116 }
117 return v
118}
119
120func (p *prng) floatIn(lo, hi float64) float64 {
121 frac := float64(p.next()%1000000) / 1000000.0
122 return lo + frac*(hi-lo)
123}
124
125func (p *prng) intn(n int) int {
126 if n <= 1 {
127 return 0
128 }
129 return int(p.next() % uint64(n))
130}
131
132func randomTraitPreset(attempt int) traitPreset {
133 material := ufmt.Sprintf("assemble:%d:%d:%s:%d",
134 runtime.ChainHeight(), uint64(nextID), callerAddress().String(), attempt)
135 rnd := newPRNG(material)
136
137 chainName := chainRoster[rnd.intn(len(chainRoster))]
138 stone := gemsart.StoneForChain(chainName)
139 // colour by the stone's real-world odds (gemsart.colorWeights)
140 colorIdx := gemsart.PickColorIndex(stone, rnd.next())
141 // only cuts the stone is really sold in; pearls have their own shapes
142 var shapeIdx int
143 if stone == "Pearl" {
144 shapeIdx = rnd.intn(len(gemsart.PearlShapeNames))
145 } else {
146 cuts := gemsart.CutsForStone(stone)
147 shapeIdx = cuts[rnd.intn(len(cuts))]
148 }
149 metal := gemsart.MetalNames[rnd.intn(len(gemsart.MetalNames))]
150 // mainnet never sunsets: always Active, so metadata matches the image
151 active := rnd.floatIn(0, 1) < activeProbability || gemsart.ChainAlwaysActive(chainName)
152
153 return traitPreset{Chain: chainName, ShapeIdx: shapeIdx, ColorIdx: colorIdx, Metal: metal, Active: active}
154}
155
156const maxAssembleAttempts = 200
157
158func assembleUniqueTraits() (traitPreset, string) {
159 for attempt := 0; attempt < maxAssembleAttempts; attempt++ {
160 tp := randomTraitPreset(attempt)
161 key := traitComboKey(tp.Chain, tp.ShapeIdx, tp.ColorIdx, tp.Metal, tp.Active)
162 if stable.IsTraitComboAvailable(key) {
163 return tp, key
164 }
165 }
166 panic("could not find an unused trait combination after many attempts -- the collection may be fully minted")
167}
168
169func IsTraitComboAvailable(chainName string, shapeIdx, colorIdx int, metal string, active bool) bool {
170 key := traitComboKey(chainName, shapeIdx, colorIdx, metal, active)
171 return stable.IsTraitComboAvailable(key)
172}
173
174func MaxUniqueTraitCombos() int64 {
175 var total int64
176 for _, chainName := range chainRoster {
177 stone := gemsart.StoneForChain(chainName)
178 shapeCount := len(gemsart.CutsForStone(stone))
179 if stone == "Pearl" {
180 shapeCount = len(gemsart.PearlShapeNames)
181 }
182 states := int64(2) // active or sunset
183 if gemsart.ChainAlwaysActive(chainName) {
184 states = 1
185 }
186 total += int64(gemsart.ColorPaletteSize(stone)) * int64(shapeCount) * states
187 }
188 return total * int64(len(gemsart.MetalNames))
189}
190
191/* -------------------------------- rarity -------------------------------- */
192
193// ComboProbability is the chance (0..1) that one mint draws this piece's
194// look: its stone (the share of roster chains that carry it), its cut
195// (even among the stone's cuts), its colour (the stone's colour odds) and
196// its status (20% active; always active on mainnet). Ring metal and the
197// chain's own name are equally likely for every piece, so they leave the
198// ranking unchanged and are left out.
199func ComboProbability(chainName string, shapeIdx, colorIdx int, active bool) float64 {
200 stone := gemsart.StoneForChain(chainName)
201 same := 0
202 for _, c := range chainRoster {
203 if gemsart.StoneForChain(c) == stone {
204 same++
205 }
206 }
207 p := float64(same) / float64(len(chainRoster))
208 if stone == "Pearl" {
209 p /= float64(len(gemsart.PearlShapeNames))
210 } else {
211 p /= float64(len(gemsart.CutsForStone(stone)))
212 }
213 p *= gemsart.ColorProbability(stone, colorIdx)
214 if !gemsart.ChainAlwaysActive(chainName) {
215 if active {
216 p *= activeProbability
217 } else {
218 p *= 1 - activeProbability
219 }
220 }
221 return p
222}
223
224// rarityTiers are upper bounds on ComboProbability, rarest first. Set
225// (2026-09-25) from the full distribution of all 691 looks on the
226// 18-chain roster, each bound the midpoint between two neighbouring
227// probability values, so no two equally likely looks fall on different
228// sides and float rounding cannot move one. Shares of mints:
229// Legendary 1.3%, Epic 3.5%, Rare 11.1%, Uncommon 25.0%, Common 59.1%.
230// A tier is fixed at mint (stored with the token); adding a chain later
231// only changes the odds of new mints, never an existing token's tier.
232var rarityTiers = []struct {
233 name string
234 maxP float64
235}{
236 {"Legendary", 0.000127233877},
237 {"Epic", 0.000423465423},
238 {"Rare", 0.000754985755},
239 {"Uncommon", 0.00364957265},
240}
241
242func rarityTier(tp traitPreset) string {
243 p := ComboProbability(tp.Chain, tp.ShapeIdx, tp.ColorIdx, tp.Active)
244 for _, t := range rarityTiers {
245 if p <= t.maxP {
246 return t.name
247 }
248 }
249 return "Common"
250}
251
252/* ----------------------------- Chain roster ---------------------------- */
253
254var chainRoster = []string{
255 "TEST-1", "TEST-2", "TEST-3", "TEST-4", "TEST-5", "TEST-6", "TEST-7",
256 "TEST-8", "TEST-9", "TEST-10", "TEST-11", "TEST-12", "TEST-13",
257 "TOPAZ-1", "SAPPHIRE-1", "PEARL-1", "BETANET", "GNOLAND-1",
258}
259
260func AddChain(cur realm, chainName string) {
261 assertOwner()
262 if chainName == "" {
263 panic("chain name must not be empty")
264 }
265 for _, c := range chainRoster {
266 if c == chainName {
267 panic("chain already in the roster")
268 }
269 }
270 chainRoster = append(chainRoster, chainName)
271}
272
273func ChainRosterSize() int64 { return int64(len(chainRoster)) }
274func ChainRosterCSV() string { return strings.Join(chainRoster, ";") }
275
276/* ----------------------------- Owner controls -------------------------------- */
277
278func callerAddress() address {
279 return unsaferealm.PreviousRealm().Address()
280}
281
282func assertOwner() {
283 if callerAddress() != collectionOwnerHere {
284 panic("unauthorized: caller is not the collection owner")
285 }
286}
287
288func MintingOpen() bool { return mintingOpen }
289
290func SetMintingOpen(cur realm, open bool) {
291 assertOwner()
292 mintingOpen = open
293 stable.ReportMintingOpen(cross(cur), open)
294}
295
296func MaxMintsPerWallet() int64 { return maxMintsPerWallet }
297
298func SetMaxMintsPerWallet(cur realm, max int64) {
299 assertOwner()
300 if max < 0 {
301 panic("max must be non-negative (0 = unlimited)")
302 }
303 maxMintsPerWallet = max
304 stable.ReportMaxMintsPerWallet(cross(cur), max)
305}
306
307func MintsByWallet(owner address) int64 {
308 return stable.MintsByWallet(owner)
309}