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.

game_admin.gno

15.43 Kb · 545 lines
  1package game31
  2
  3import (
  4	"encoding/hex"
  5	"strconv"
  6	"strings"
  7	"time"
  8
  9	"chain"
 10	"chain/banker"
 11)
 12
 13func init(cur realm) {
 14	// The deployer (tx signer of addpkg) becomes admin and default operator.
 15	admin = address(cur.Previous().Address())
 16	operator = admin
 17}
 18
 19// ── Roles ────────────────────────────────────────────────────────────────────
 20
 21func isAdmin(cur realm) bool {
 22	return admin != address("") && cur.Previous().Address() == admin
 23}
 24
 25func requireAdmin(cur realm) {
 26	if !isAdmin(cur) {
 27		panic("unauthorized: admin only")
 28	}
 29}
 30
 31
 32// TransferAdmin hands the admin role to another address.
 33func TransferAdmin(cur realm, newAdmin string) {
 34	requireAdmin(cur)
 35	transferAdmin(newAdmin)
 36}
 37
 38func transferAdmin(newAdmin string) {
 39	a := address(newAdmin)
 40	if !a.IsValid() {
 41		panic("invalid address")
 42	}
 43	admin = a
 44}
 45
 46// UpdateConfig is kept for game27/28 API compatibility (= TransferAdmin).
 47func UpdateConfig(cur realm, newAdmin string) {
 48	requireAdmin(cur)
 49	transferAdmin(newAdmin)
 50}
 51
 52
 53// SetClaimSigner registers the ed25519 public key (64 hex chars) of the
 54// server's claim signer. Pass "" to disable all vouchered calls (kill switch).
 55func SetClaimSigner(cur realm, pubKeyHex string) {
 56	requireAdmin(cur)
 57	if pubKeyHex == "" {
 58		claimSignerPubKey = nil
 59		return
 60	}
 61	b, err := hex.DecodeString(pubKeyHex)
 62	if err != nil || len(b) != 32 {
 63		panic("claim signer must be a 32-byte ed25519 public key in hex")
 64	}
 65	claimSignerPubKey = b
 66}
 67
 68func ClaimSigner() string { return hex.EncodeToString(claimSignerPubKey) }
 69func Admin() string       { return admin.String() }
 70
 71func SetPaused(cur realm, p bool) {
 72	requireAdmin(cur)
 73	paused = p
 74}
 75
 76func IsPaused() bool { return paused }
 77
 78func requireNotPaused() {
 79	if paused {
 80		panic("game is paused")
 81	}
 82}
 83
 84// ── Blacklist ─────────────────────────────────────────────────────────────────
 85
 86// BanAddress bans a player address. durationSecs=0 means permanent.
 87func BanAddress(cur realm, targetAddr string, reason string, durationSecs int64) {
 88	requireAdmin(cur)
 89	if !address(targetAddr).IsValid() {
 90		panic("invalid address")
 91	}
 92	expiry := int64(0)
 93	if durationSecs > 0 {
 94		expiry = time.Now().Unix() + durationSecs
 95	}
 96	blacklist.Set(targetAddr, strings.ReplaceAll(reason, ":", " ")+":"+strconv.FormatInt(time.Now().Unix(), 10))
 97	banExpiries.Set(targetAddr, strconv.FormatInt(expiry, 10))
 98}
 99
100func UnbanAddress(cur realm, targetAddr string) {
101	requireAdmin(cur)
102	blacklist.Remove(targetAddr)
103	banExpiries.Remove(targetAddr)
104}
105
106func BlacklistStatus(addr string) string {
107	if !blacklist.Has(addr) {
108		return `{"banned":false}`
109	}
110	entry := blacklist.Get(addr).(string)
111	expiry, _ := strconv.ParseInt(banExpiries.Get(addr).(string), 10, 64)
112	now := time.Now().Unix()
113	if expiry > 0 && now >= expiry {
114		return `{"banned":false}`
115	}
116	reason := strings.SplitN(entry, ":", 2)[0]
117	if expiry == 0 {
118		return `{"banned":true,"reason":` + jsonStr(reason) + `,"permanent":true}`
119	}
120	return `{"banned":true,"reason":` + jsonStr(reason) + `,"permanent":false,"remainingSecs":` + strconv.FormatInt(expiry-now, 10) + `}`
121}
122
123func isBlacklisted(addr string) bool {
124	if !blacklist.Has(addr) {
125		return false
126	}
127	expiry, _ := strconv.ParseInt(banExpiries.Get(addr).(string), 10, 64)
128	if expiry == 0 {
129		return true
130	}
131	return time.Now().Unix() < expiry
132}
133
134func requirePlayer(cur realm) string {
135	requireNotPaused()
136	caller := cur.Previous().Address().String()
137	if isBlacklisted(caller) {
138		panic("address banned")
139	}
140	return caller
141}
142
143// ── Veins ────────────────────────────────────────────────────────────────────
144
145func veinMax(coinId string) uint64 {
146	// FIX 2B: skip AVL read when no overrides have ever been set.
147	if veinOverrideActive && veinMaxOverride.Has(coinId) {
148		v, _ := strconv.ParseUint(veinMaxOverride.Get(coinId).(string), 10, 64)
149		return v
150	}
151	r, ok := coinRarity[coinId]
152	if !ok {
153		return 0
154	}
155	if v, ok := veinCoinDefault[coinId]; ok {
156		return v
157	}
158	return veinDefaultMax[r]
159}
160
161func veinMintedOf(coinId string) uint64 {
162	if !veinMinted.Has(coinId) {
163		return 0
164	}
165	v, _ := strconv.ParseUint(veinMinted.Get(coinId).(string), 10, 64)
166	return v
167}
168
169// takeFromVein returns how much of amt can still be minted for coinId and
170// records it as minted.
171func takeFromVein(coinId string, amt uint64) uint64 {
172	mx := veinMax(coinId)
173	mi := veinMintedOf(coinId)
174	if mi >= mx {
175		return 0
176	}
177	if amt > mx-mi {
178		amt = mx - mi
179	}
180	veinMinted.Set(coinId, strconv.FormatUint(mi+amt, 10))
181	if half := mx / 2; mi < half && mi+amt >= half {
182		chain.Emit("VeinHalfMinted", "coin", coinId,
183			"minted", strconv.FormatUint(mi+amt, 10), "max", strconv.FormatUint(mx, 10))
184	}
185	if mi+amt == mx {
186		chain.Emit("VeinExhausted", "coin", coinId, "max", strconv.FormatUint(mx, 10))
187	}
188	return amt
189}
190
191// SetVeinMax overrides a coin's vein size. It can't go below what was minted.
192func SetVeinMax(cur realm, coinId string, maxSupply string) {
193	requireAdmin(cur)
194	setVeinMax(coinId, maxSupply)
195}
196
197func setVeinMax(coinId string, maxSupply string) {
198	if _, ok := coinRarity[coinId]; !ok {
199		panic("unknown coin id: " + coinId)
200	}
201	mx, err := strconv.ParseUint(maxSupply, 10, 64)
202	if err != nil {
203		panic("invalid max supply")
204	}
205	if mx < veinMintedOf(coinId) {
206		panic("vein max below already-minted supply")
207	}
208	veinMaxOverride.Set(coinId, strconv.FormatUint(mx, 10))
209	veinOverrideActive = true
210}
211
212// RegisterVein is the game28 name for SetVeinMax.
213func RegisterVein(cur realm, coinId string, maxSupply string) {
214	requireAdmin(cur)
215	setVeinMax(coinId, maxSupply)
216}
217
218func VeinSupply(coinId string) string {
219	if _, ok := coinRarity[coinId]; !ok {
220		return `{"remaining":"0","max":"0"}`
221	}
222	mx := veinMax(coinId)
223	mi := veinMintedOf(coinId)
224	rem := uint64(0)
225	if mx > mi {
226		rem = mx - mi
227	}
228	return `{"remaining":"` + strconv.FormatUint(rem, 10) + `","max":"` + strconv.FormatUint(mx, 10) + `","minted":"` + strconv.FormatUint(mi, 10) + `","halfMinted":` + strconv.FormatBool(mx > 0 && mi >= mx/2) + `,"exhausted":` + strconv.FormatBool(mx > 0 && mi >= mx) + `}`
229}
230
231// ── Gnome Pass (paid) ────────────────────────────────────────────────────────
232
233// SetPassPrice sets the ugnot price per 30-day period.
234func SetPassPrice(cur realm, ugnotPer30Days int64) {
235	requireAdmin(cur)
236	if ugnotPer30Days <= 0 {
237		panic("price must be > 0")
238	}
239	passPriceUgnot = ugnotPer30Days
240}
241
242func PassPrice() int64 { return passPriceUgnot }
243
244// ActivateGnomePass buys durationDays (30..360, multiple of 30) of Gnome Pass.
245// The tx must attach exactly passPrice × durationDays/30 ugnot (MsgCall send).
246func ActivateGnomePass(cur realm, durationDays int64) {
247	caller := requirePlayer(cur)
248	// Payment verification must only trust the origin envelope when the
249	// caller is the signer itself (no intermediate realm can consume it).
250	if cur.Previous().PkgPath() != "" {
251		panic("Gnome Pass must be bought directly by the player")
252	}
253	if durationDays <= 0 || durationDays%30 != 0 || durationDays > 360 {
254		panic("durationDays must be 30, 60, ... 360")
255	}
256	periods := durationDays / 30
257	required := passPriceUgnot * periods
258	sent := banker.NewBanker(banker.BankerTypeOriginSend, cur).GetCoins(cur.Address())
259	if len(sent) != 1 || sent[0].Denom != "ugnot" || sent[0].Amount != required {
260		panic("Gnome Pass costs " + strconv.FormatInt(required, 10) + "ugnot for " + strconv.FormatInt(durationDays, 10) + " days — attach exactly that amount")
261	}
262	passRevenueUgnot += required
263
264	now := time.Now().Unix()
265	base := now
266	if gnomePasses.Has(caller) {
267		existing, _ := strconv.ParseInt(gnomePasses.Get(caller).(string), 10, 64)
268		if existing > now {
269			base = existing
270		}
271	}
272	gnomePasses.Set(caller, strconv.FormatInt(base+durationDays*86400, 10))
273}
274
275func GnomePassExpiry(addr string) int64 {
276	if !gnomePasses.Has(addr) {
277		return 0
278	}
279	expiry, _ := strconv.ParseInt(gnomePasses.Get(addr).(string), 10, 64)
280	return expiry
281}
282
283func HasActivePass(addr string) bool { return GnomePassExpiry(addr) > time.Now().Unix() }
284
285func GnomePassCount() int {
286	count := 0
287	now := time.Now().Unix()
288	gnomePasses.Iterate("", "", func(_ string, val any) bool {
289		e, _ := strconv.ParseInt(val.(string), 10, 64)
290		if e > now {
291			count++
292		}
293		return false
294	})
295	return count
296}
297
298func PassRevenue() int64 { return passRevenueUgnot }
299
300// TreasuryBalance returns the ugnot held by this realm (pass revenue not yet withdrawn).
301func TreasuryBalance(cur realm) int64 {
302	realmAddr := chain.PackageAddress("gno.land/r/g138gdmwj6heduu3st796lw6qqdwylgaxf7ty5gz/game31")
303	coins := banker.NewBanker(banker.BankerTypeReadonly, cur).GetCoins(realmAddr)
304	for _, c := range coins {
305		if c.Denom == "ugnot" {
306			return c.Amount
307		}
308	}
309	return 0
310}
311
312// WithdrawFunds sends GNOT the admin is OWED — Gnome Pass revenue not yet
313// withdrawn — to `to`. It can never touch other ugnot the realm holds
314// (donations, anything a future feature would hold for players).
315func WithdrawFunds(cur realm, to string, amountUgnot int64) {
316	requireAdmin(cur)
317	dst := address(to)
318	if !dst.IsValid() {
319		panic("invalid address")
320	}
321	if amountUgnot <= 0 {
322		panic("amount must be > 0")
323	}
324	if amountUgnot > passRevenueUgnot-adminGnotWithdrawn {
325		panic("can only withdraw unwithdrawn pass revenue: " + strconv.FormatInt(passRevenueUgnot-adminGnotWithdrawn, 10) + "ugnot")
326	}
327	adminGnotWithdrawn += amountUgnot
328	b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
329	b.SendCoins(cur.Address(), dst, chain.Coins{chain.NewCoin("ugnot", amountUgnot)})
330	chain.Emit("FundsWithdrawn", "to", to, "ugnot", strconv.FormatInt(amountUgnot, 10))
331}
332
333// WithdrawableFunds = pass revenue the admin may still withdraw (ugnot).
334func WithdrawableFunds() int64 { return passRevenueUgnot - adminGnotWithdrawn }
335
336// ── Migration (game27 / game28 → game29) ────────────────────────────────────
337//
338// MigrateBatch(batchId, kind, rowsJson) imports one batch. rowsJson is a JSON
339// array of string arrays. Kinds and row shapes:
340//   balance  [addr, coinId, amount]       → balances (SET, not add)
341//   lifetime [addr, coinId, amount]       → lifetimeMined (SET) + miners total
342//   tools    [addr, "t1,t2,..."]          → ownedTools (SET, unknown ids dropped)
343//   toollife [addr, toolId, life]         → toolLife (SET, clamped to max)
344//   skill    [addr, skillId, rank]        → skillRanks (SET, clamped to max)
345//   medal    [addr, achievementId]        → achievements (allowlisted ids only)
346//   pass     [addr, expiryUnix]           → gnomePasses (SET)
347//   guild    [guildId, name, leader, "m1,m2,..."] → guilds + members
348//   ban      [addr, reason, expiryUnix]   → blacklist (expiry 0 = permanent)
349// A batchId that was already applied is skipped, and every write is a SET, so
350// re-running a whole migration is a no-op. FinalizeMigration locks it forever.
351func MigrateBatch(cur realm, batchId string, kind string, rowsJson string) int {
352	requireAdmin(cur)
353	if migrationFinalized {
354		panic("migration is finalized")
355	}
356	if batchId == "" {
357		panic("batchId required")
358	}
359	if migrationBatches.Has(batchId) {
360		return 0
361	}
362	rows := parseStringRows(rowsJson)
363	n := 0
364	for _, r := range rows {
365		if migrateRow(kind, r) {
366			n++
367		}
368	}
369	migrationBatches.Set(batchId, kind+":"+strconv.Itoa(n))
370	return n
371}
372
373func FinalizeMigration(cur realm) {
374	requireAdmin(cur)
375	migrationFinalized = true
376}
377
378func MigrationStatus() string {
379	return `{"finalized":` + strconv.FormatBool(migrationFinalized) + `,"batches":` + strconv.Itoa(migrationBatches.Size()) + `}`
380}
381
382func MigrationBatch(batchId string) string {
383	if !migrationBatches.Has(batchId) {
384		return ""
385	}
386	return migrationBatches.Get(batchId).(string)
387}
388
389func validAddr(s string) bool { return address(s).IsValid() }
390
391func migrateRow(kind string, r []string) bool {
392	switch kind {
393	case "balance", "lifetime":
394		if len(r) != 3 || !validAddr(r[0]) {
395			return false
396		}
397		if _, ok := coinRarity[r[1]]; !ok {
398			return false
399		}
400		amt, err := strconv.ParseUint(r[2], 10, 64)
401		if err != nil {
402			return false
403		}
404		key := r[0] + ":" + r[1]
405		if kind == "balance" {
406			old := getU(&balances, key)
407			// Imported balances count against the vein (delta on re-run).
408			if amt > old {
409				mi := veinMintedOf(r[1])
410				veinMinted.Set(r[1], strconv.FormatUint(mi+(amt-old), 10))
411			}
412			setU(&balances, key, amt)
413		} else {
414			old := getU(&lifetimeMined, key)
415			setU(&lifetimeMined, key, amt)
416			tot := getU(&miners, r[0])
417			if tot >= old {
418				tot -= old
419			} else {
420				tot = 0
421			}
422			setU(&miners, r[0], tot+amt)
423		}
424		return true
425	case "tools":
426		if len(r) != 2 || !validAddr(r[0]) {
427			return false
428		}
429		var keep []string
430		seen := map[string]bool{}
431		for _, t := range strings.Split(r[1], ",") {
432			t = strings.TrimSpace(t)
433			if t == "" || t == "hands" || seen[t] {
434				continue
435			}
436			if _, ok := toolCosts[t]; !ok {
437				continue
438			}
439			seen[t] = true
440			keep = append(keep, t)
441		}
442		if len(keep) == 0 {
443			ownedTools.Remove(r[0])
444		} else {
445			ownedTools.Set(r[0], strings.Join(keep, ","))
446		}
447		return true
448	case "toollife":
449		if len(r) != 3 || !validAddr(r[0]) {
450			return false
451		}
452		mx, ok := toolMaxLife[r[1]]
453		if !ok || mx == 0 {
454			return false
455		}
456		life, err := strconv.ParseUint(r[2], 10, 64)
457		if err != nil {
458			return false
459		}
460		if em := effectiveMaxLife(r[0], r[1]); life > em {
461			life = em
462		}
463		setU(&toolLife, r[0]+":"+r[1], life)
464		return true
465	case "skill":
466		if len(r) != 3 || !validAddr(r[0]) {
467			return false
468		}
469		mx, ok := skillMaxRanks[r[1]]
470		if !ok {
471			return false
472		}
473		rank, err := strconv.ParseUint(r[2], 10, 64)
474		if err != nil {
475			return false
476		}
477		if rank > mx {
478			rank = mx
479		}
480		setU(&skillRanks, r[0]+":"+r[1], rank)
481		return true
482	case "medal":
483		if len(r) != 2 || !validAddr(r[0]) || !isValidAchievementId(r[1]) {
484			return false
485		}
486		achievements.Set(r[0]+":"+r[1], "1")
487		return true
488	case "pass":
489		if len(r) != 2 || !validAddr(r[0]) {
490			return false
491		}
492		e, err := strconv.ParseInt(r[1], 10, 64)
493		if err != nil || e <= 0 {
494			return false
495		}
496		gnomePasses.Set(r[0], strconv.FormatInt(e, 10))
497		return true
498	case "guild":
499		if len(r) != 4 || r[0] == "" || !validAddr(r[2]) {
500			return false
501		}
502		name := sanitizeName(r[1])
503		if len(name) < 3 {
504			return false
505		}
506		members := []string{}
507		for _, m := range strings.Split(r[3], ",") {
508			m = strings.TrimSpace(m)
509			if validAddr(m) && len(members) < maxGuildMembers {
510				members = append(members, m)
511			}
512		}
513		if len(members) == 0 {
514			return false
515		}
516		// Drop stale membership rows of this guild, then write fresh ones.
517		var stale []string
518		guildMembers.Iterate(r[0]+":", r[0]+";", func(k string, _ any) bool {
519			stale = append(stale, k)
520			return false
521		})
522		for _, k := range stale {
523			guildMembers.Remove(k)
524		}
525		for _, m := range members {
526			guildMembers.Set(r[0]+":"+m, "1")
527			playerGuild.Set(m, r[0])
528		}
529		guildMetas[r[0]] = guildMeta{name: name, leader: r[2], createdAt: time.Now().Unix()}
530		rewriteGuild(r[0])
531		return true
532	case "ban":
533		if len(r) != 3 || !validAddr(r[0]) {
534			return false
535		}
536		e, err := strconv.ParseInt(r[2], 10, 64)
537		if err != nil || e < 0 {
538			return false
539		}
540		blacklist.Set(r[0], strings.ReplaceAll(r[1], ":", " ")+":"+strconv.FormatInt(time.Now().Unix(), 10))
541		banExpiries.Set(r[0], strconv.FormatInt(e, 10))
542		return true
543	}
544	panic("unknown migration kind: " + kind)
545}