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

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}