game_guilds.gno
9.52 Kb · 312 lines
1package game31
2
3import (
4 "sort"
5 "strconv"
6 "strings"
7 "time"
8
9 "gno.land/p/nt/avl/v0"
10 "gno.land/p/nt/ufmt/v0"
11)
12
13// ── Guilds ───────────────────────────────────────────────────────────────────
14
15const maxGuildMembers = 10
16const maxGuildNameLen = 24
17
18func guildJSON(guildId, name, leader string, members int, createdAt int64) string {
19 return `{"guildId":` + jsonStr(guildId) + `,"name":` + jsonStr(name) + `,"leader":` + jsonStr(leader) +
20 `,"memberCount":` + strconv.Itoa(members) + `,"createdAt":` + strconv.FormatInt(createdAt, 10) + `}`
21}
22
23func guildMemberCount(gid string) int {
24 n := 0
25 guildMembers.Iterate(gid+":", gid+";", func(string, any) bool {
26 n++
27 return false
28 })
29 return n
30}
31
32type guildMeta struct {
33 name, leader string
34 createdAt int64
35}
36
37// guild meta is stored alongside the JSON so it can be re-rendered safely.
38var guildMetas = map[string]guildMeta{}
39
40func rewriteGuild(gid string) {
41 m, ok := guildMetas[gid]
42 if !ok {
43 return
44 }
45 guilds.Set(gid, guildJSON(gid, m.name, m.leader, guildMemberCount(gid), m.createdAt))
46}
47
48func CreateGuild(cur realm, name string) string {
49 caller := requirePlayer(cur)
50 if playerGuild.Has(caller) {
51 panic("already in a guild")
52 }
53 name = sanitizeName(name)
54 if len(name) < 3 {
55 panic("guild name too short (min 3 chars)")
56 }
57 if len(name) > maxGuildNameLen {
58 panic("guild name too long (max 24 chars)")
59 }
60 guildCounter++
61 gid := "guild-" + zeroPad(guildCounter, 6)
62 now := time.Now().Unix()
63 guildMetas[gid] = guildMeta{name: name, leader: caller, createdAt: now}
64 guildMembers.Set(gid+":"+caller, "1")
65 playerGuild.Set(caller, gid)
66 rewriteGuild(gid)
67 return gid
68}
69
70func JoinGuild(cur realm, guildId string) {
71 caller := requirePlayer(cur)
72 if playerGuild.Has(caller) {
73 panic("already in a guild — leave current guild first")
74 }
75 if !guilds.Has(guildId) {
76 panic("guild not found")
77 }
78 if guildMemberCount(guildId) >= maxGuildMembers {
79 panic("guild is full (max 10 members)")
80 }
81 guildMembers.Set(guildId+":"+caller, "1")
82 playerGuild.Set(caller, guildId)
83 rewriteGuild(guildId)
84}
85
86func LeaveGuild(cur realm) {
87 requireNotPaused()
88 caller := cur.Previous().Address().String()
89 if !playerGuild.Has(caller) {
90 panic("not in a guild")
91 }
92 gid := playerGuild.Get(caller).(string)
93 guildMembers.Remove(gid + ":" + caller)
94 playerGuild.Remove(caller)
95 if guildMemberCount(gid) == 0 {
96 guilds.Remove(gid)
97 delete(guildMetas, gid)
98 return
99 }
100 if m, ok := guildMetas[gid]; ok && m.leader == caller {
101 // hand leadership to the first remaining member
102 guildMembers.Iterate(gid+":", gid+";", func(k string, _ any) bool {
103 m.leader = strings.TrimPrefix(k, gid+":")
104 return true
105 })
106 guildMetas[gid] = m
107 }
108 rewriteGuild(gid)
109}
110
111func PlayerGuild(addr string) string {
112 if !playerGuild.Has(addr) {
113 return ""
114 }
115 gid := playerGuild.Get(addr).(string)
116 if !guilds.Has(gid) {
117 return ""
118 }
119 return guilds.Get(gid).(string)
120}
121
122func GuildInfo(guildId string) string {
123 if !guilds.Has(guildId) {
124 return ""
125 }
126 return guilds.Get(guildId).(string)
127}
128
129func TopGuilds(limit int) string {
130 if limit <= 0 || limit > 50 {
131 limit = 20
132 }
133 var all guildRows
134 guilds.Iterate("", "", func(k string, v any) bool {
135 all = append(all, guildRow{v.(string), guildMemberCount(k)})
136 return false
137 })
138 sort.Stable(all)
139 var parts []string
140 for i := 0; i < len(all) && i < limit; i++ {
141 parts = append(parts, all[i].js)
142 }
143 return "[" + strings.Join(parts, ",") + "]"
144}
145
146// ── Queries ──────────────────────────────────────────────────────────────────
147
148func kvJSON(prefixAddr string, t *avl.Tree) string {
149 var parts []string
150 t.Iterate(prefixAddr+":", prefixAddr+";", func(key string, val any) bool {
151 parts = append(parts, jsonStr(strings.TrimPrefix(key, prefixAddr+":"))+":"+val.(string))
152 return false
153 })
154 return "{" + strings.Join(parts, ",") + "}"
155}
156
157func AllBalances(playerAddr string) string { return kvJSON(playerAddr, &balances) }
158func LifetimeMined(playerAddr string) string { return kvJSON(playerAddr, &lifetimeMined) }
159
160// PlayerXp returns the caller's cumulative rarity-weighted XP earned from
161// ClaimAndMint calls as a plain decimal string (e.g. "12345"). Returns "0"
162// for players who have not yet minted anything on this realm version.
163func PlayerXp(playerAddr string) string {
164 return strconv.FormatUint(getU(&playerXp, playerAddr), 10)
165}
166
167// Rewarded returns what rewards (medals etc.) credited, per coin. Separate
168// from LifetimeMined.
169func Rewarded(playerAddr string) string { return kvJSON(playerAddr, &rewarded) }
170func SkillRanks(playerAddr string) string { return kvJSON(playerAddr, &skillRanks) }
171func ToolLife(playerAddr string) string { return kvJSON(playerAddr, &toolLife) }
172
173func OwnedTools(playerAddr string) string {
174 if !ownedTools.Has(playerAddr) || ownedTools.Get(playerAddr).(string) == "" {
175 return `["hands"]`
176 }
177 var quoted []string
178 for _, id := range strings.Split(ownedTools.Get(playerAddr).(string), ",") {
179 quoted = append(quoted, jsonStr(id))
180 }
181 return "[" + strings.Join(quoted, ",") + "]"
182}
183
184func PlayerProfile(playerAddr string) string {
185 return `{"address":` + jsonStr(playerAddr) + `,"bestTool":` + jsonStr(bestOwnedTool(playerAddr)) +
186 `,"total":` + strconv.FormatUint(getU(&miners, playerAddr), 10) + `,"passExpiry":` + strconv.FormatInt(GnomePassExpiry(playerAddr), 10) + `}`
187}
188
189// AllMiners returns the leaderboard sorted by total mined (desc), paginated.
190// (game28 returned it in address order.)
191func AllMiners(limit int, offset int) string {
192 if limit <= 0 || limit > 200 {
193 limit = 100
194 }
195 if offset < 0 {
196 offset = 0
197 }
198 var rows minerRows
199 miners.Iterate("", "", func(k string, v any) bool {
200 t, _ := strconv.ParseUint(v.(string), 10, 64)
201 rows = append(rows, minerRow{k, t})
202 return false
203 })
204 sort.Sort(rows)
205 var parts []string
206 for i := offset; i < len(rows) && i < offset+limit; i++ {
207 d := rows[i].addr
208 if len(d) > 12 {
209 d = d[:8] + "..."
210 }
211 parts = append(parts, `{"address":`+jsonStr(rows[i].addr)+`,"display":`+jsonStr(d)+`,"total":`+strconv.FormatUint(rows[i].total, 10)+`,"rank":`+strconv.Itoa(i+1)+`}`)
212 }
213 return "[" + strings.Join(parts, ",") + "]"
214}
215
216func GetMedals(playerAddr string) string {
217 var claimed []string
218 achievements.Iterate(playerAddr+":", playerAddr+";", func(key string, _ any) bool {
219 claimed = append(claimed, jsonStr(strings.TrimPrefix(key, playerAddr+":")))
220 return false
221 })
222 return `{"medals":[` + strings.Join(claimed, ",") + `]}`
223}
224
225func DailyCheckinStatus(playerAddr string) string {
226 today := time.Now().Unix() / 86400
227 var lastDay, streak int64
228 if dailyCheckins.Has(playerAddr) {
229 raw := dailyCheckins.Get(playerAddr).(string)
230 lastDay, streak = parseDailyCheckin(raw)
231 }
232 done := lastDay == today
233 // Streak is live only if the player checked in today or yesterday.
234 // If they missed a day, report 0 so the client knows the streak is broken.
235 liveStreak := streak
236 if lastDay < today-1 {
237 liveStreak = 0
238 }
239 nextMidnight := (today + 1) * 86400
240 return ufmt.Sprintf(`{"ok":true,"done":%t,"streak":%d,"next":%d}`, done, liveStreak, nextMidnight)
241}
242
243func ListTasks(_ string) string {
244 var parts []string
245 achievements.Iterate("task:", "task;", func(k string, v any) bool {
246 if strings.HasSuffix(k, ":desc") {
247 id := strings.TrimSuffix(strings.TrimPrefix(k, "task:"), ":desc")
248 parts = append(parts, `{"id":`+jsonStr(id)+`,"desc":`+jsonStr(v.(string))+`}`)
249 }
250 return false
251 })
252 return `{"ok":true,"tasks":[` + strings.Join(parts, ",") + `]}`
253}
254
255// ── Render ───────────────────────────────────────────────────────────────────
256
257func Render(_ string) string {
258 return ufmt.Sprintf("# GnoMoji Game Realm — game30\n\nAdmin: `%s`\n\nPath: `%s`\n\nClaims: server-vouchered (%s)\n\nMigration finalized: %t\n\nMine emoji coins on Gno.land!\n",
259 admin.String(), realmPkgPath, func() string {
260 if len(claimSignerPubKey) == 32 {
261 return "signer set"
262 }
263 return "disabled — no signer"
264 }(), migrationFinalized)
265}
266
267// ── Helpers ──────────────────────────────────────────────────────────────────
268
269// deductBalances checks all balances, then deducts (no partial deductions).
270func deductBalances(caller string, costs [][2]string) {
271 for _, pair := range costs {
272 required, err := strconv.ParseUint(pair[1], 10, 64)
273 if err != nil || required == 0 {
274 continue
275 }
276 if have := balOf(caller, pair[0]); have < required {
277 panic("insufficient " + pair[0] + ": need " + pair[1] + " have " + strconv.FormatUint(have, 10))
278 }
279 }
280 for _, pair := range costs {
281 required, err := strconv.ParseUint(pair[1], 10, 64)
282 if err != nil || required == 0 {
283 continue
284 }
285 debitBal(caller, pair[0], required)
286 }
287}
288
289type guildRow struct {
290 js string
291 n int
292}
293type guildRows []guildRow
294
295func (g guildRows) Len() int { return len(g) }
296func (g guildRows) Less(i, j int) bool { return g[i].n > g[j].n }
297func (g guildRows) Swap(i, j int) { g[i], g[j] = g[j], g[i] }
298
299type minerRow struct {
300 addr string
301 total uint64
302}
303type minerRows []minerRow
304
305func (m minerRows) Len() int { return len(m) }
306func (m minerRows) Less(i, j int) bool {
307 if m[i].total != m[j].total {
308 return m[i].total > m[j].total
309 }
310 return m[i].addr < m[j].addr
311}
312func (m minerRows) Swap(i, j int) { m[i], m[j] = m[j], m[i] }