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}