Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}