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

16.23 Kb · 559 lines
  1package game33
  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/g1algwpj0dch4stpy7pd87n7a3cwx3pjymlpexn7/game33")
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 (game31 → game32) ──────────────────────────────────────────────
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.
351// Every unique player address in a batch is recorded in migrationAddrs so that
352// MigrationStatus can report how many distinct players were migrated.
353func MigrateBatch(cur realm, batchId string, kind string, rowsJson string) int {
354	requireAdmin(cur)
355	if migrationFinalized {
356		panic("migration is finalized")
357	}
358	if batchId == "" {
359		panic("batchId required")
360	}
361	if migrationBatches.Has(batchId) {
362		return 0
363	}
364	rows := parseStringRows(rowsJson)
365	n := 0
366	for _, r := range rows {
367		if migrateRow(kind, r) {
368			n++
369			// Track the player address for the migrated-count in MigrationStatus.
370			// For player-scoped kinds the address is always r[0]; guild rows use a
371			// guild ID, not a player address, so we skip them.
372			if kind != "guild" && len(r) > 0 && validAddr(r[0]) {
373				migrationAddrs.Set(r[0], "1")
374			}
375		}
376	}
377	migrationBatches.Set(batchId, kind+":"+strconv.Itoa(n))
378	return n
379}
380
381func FinalizeMigration(cur realm) {
382	requireAdmin(cur)
383	migrationFinalized = true
384}
385
386// MigrationStatus returns a JSON object with:
387//   - finalized: bool   — true once FinalizeMigration has been called
388//   - batches:   int    — number of distinct batch IDs applied
389//   - addresses: int    — number of distinct player addresses migrated across all batches
390func MigrationStatus() string {
391	return `{"finalized":` + strconv.FormatBool(migrationFinalized) +
392		`,"batches":` + strconv.Itoa(migrationBatches.Size()) +
393		`,"addresses":` + strconv.Itoa(migrationAddrs.Size()) + `}`
394}
395
396func MigrationBatch(batchId string) string {
397	if !migrationBatches.Has(batchId) {
398		return ""
399	}
400	return migrationBatches.Get(batchId).(string)
401}
402
403func validAddr(s string) bool { return address(s).IsValid() }
404
405func migrateRow(kind string, r []string) bool {
406	switch kind {
407	case "balance", "lifetime":
408		if len(r) != 3 || !validAddr(r[0]) {
409			return false
410		}
411		if _, ok := coinRarity[r[1]]; !ok {
412			return false
413		}
414		amt, err := strconv.ParseUint(r[2], 10, 64)
415		if err != nil {
416			return false
417		}
418		key := r[0] + ":" + r[1]
419		if kind == "balance" {
420			old := getU(&balances, key)
421			// Imported balances count against the vein (delta on re-run).
422			if amt > old {
423				mi := veinMintedOf(r[1])
424				veinMinted.Set(r[1], strconv.FormatUint(mi+(amt-old), 10))
425			}
426			setU(&balances, key, amt)
427		} else {
428			old := getU(&lifetimeMined, key)
429			setU(&lifetimeMined, key, amt)
430			tot := getU(&miners, r[0])
431			if tot >= old {
432				tot -= old
433			} else {
434				tot = 0
435			}
436			setU(&miners, r[0], tot+amt)
437		}
438		return true
439	case "tools":
440		if len(r) != 2 || !validAddr(r[0]) {
441			return false
442		}
443		var keep []string
444		seen := map[string]bool{}
445		for _, t := range strings.Split(r[1], ",") {
446			t = strings.TrimSpace(t)
447			if t == "" || t == "hands" || seen[t] {
448				continue
449			}
450			if _, ok := toolCosts[t]; !ok {
451				continue
452			}
453			seen[t] = true
454			keep = append(keep, t)
455		}
456		if len(keep) == 0 {
457			ownedTools.Remove(r[0])
458		} else {
459			ownedTools.Set(r[0], strings.Join(keep, ","))
460		}
461		return true
462	case "toollife":
463		if len(r) != 3 || !validAddr(r[0]) {
464			return false
465		}
466		mx, ok := toolMaxLife[r[1]]
467		if !ok || mx == 0 {
468			return false
469		}
470		life, err := strconv.ParseUint(r[2], 10, 64)
471		if err != nil {
472			return false
473		}
474		if em := effectiveMaxLife(r[0], r[1]); life > em {
475			life = em
476		}
477		setU(&toolLife, r[0]+":"+r[1], life)
478		return true
479	case "skill":
480		if len(r) != 3 || !validAddr(r[0]) {
481			return false
482		}
483		mx, ok := skillMaxRanks[r[1]]
484		if !ok {
485			return false
486		}
487		rank, err := strconv.ParseUint(r[2], 10, 64)
488		if err != nil {
489			return false
490		}
491		if rank > mx {
492			rank = mx
493		}
494		setU(&skillRanks, r[0]+":"+r[1], rank)
495		return true
496	case "medal":
497		if len(r) != 2 || !validAddr(r[0]) || !isValidAchievementId(r[1]) {
498			return false
499		}
500		achievements.Set(r[0]+":"+r[1], "1")
501		return true
502	case "pass":
503		if len(r) != 2 || !validAddr(r[0]) {
504			return false
505		}
506		e, err := strconv.ParseInt(r[1], 10, 64)
507		if err != nil || e <= 0 {
508			return false
509		}
510		gnomePasses.Set(r[0], strconv.FormatInt(e, 10))
511		return true
512	case "guild":
513		if len(r) != 4 || r[0] == "" || !validAddr(r[2]) {
514			return false
515		}
516		name := sanitizeName(r[1])
517		if len(name) < 3 {
518			return false
519		}
520		members := []string{}
521		for _, m := range strings.Split(r[3], ",") {
522			m = strings.TrimSpace(m)
523			if validAddr(m) && len(members) < maxGuildMembers {
524				members = append(members, m)
525			}
526		}
527		if len(members) == 0 {
528			return false
529		}
530		// Drop stale membership rows of this guild, then write fresh ones.
531		var stale []string
532		guildMembers.Iterate(r[0]+":", r[0]+";", func(k string, _ any) bool {
533			stale = append(stale, k)
534			return false
535		})
536		for _, k := range stale {
537			guildMembers.Remove(k)
538		}
539		for _, m := range members {
540			guildMembers.Set(r[0]+":"+m, "1")
541			playerGuild.Set(m, r[0])
542		}
543		guildMetas[r[0]] = guildMeta{name: name, leader: r[2], createdAt: time.Now().Unix()}
544		rewriteGuild(r[0])
545		return true
546	case "ban":
547		if len(r) != 3 || !validAddr(r[0]) {
548			return false
549		}
550		e, err := strconv.ParseInt(r[2], 10, 64)
551		if err != nil || e < 0 {
552			return false
553		}
554		blacklist.Set(r[0], strings.ReplaceAll(r[1], ":", " ")+":"+strconv.FormatInt(time.Now().Unix(), 10))
555		banExpiries.Set(r[0], strconv.FormatInt(e, 10))
556		return true
557	}
558	panic("unknown migration kind: " + kind)
559}