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}