game_queries.gno
10.34 Kb · 336 lines
1package game29
2
3import (
4 "strconv"
5 "strings"
6 "time"
7
8 "chain"
9)
10
11// ── Tool-life helpers ─────────────────────────────────────────────────────────
12
13// effectiveMaxLife = toolMaxLife × (1000 + Σ rank × bonus‰) / 1000.
14// FIX 1C: fast-path for players with no tool-life skills trained (the common
15// case), avoiding 22 AVL reads (11 skills × Has+Get) that all return 0.
16func effectiveMaxLife(callerAddr string, toolId string) uint64 {
17 base, ok := toolMaxLife[toolId]
18 if !ok || base == 0 {
19 return 0
20 }
21 // Fast path: check the first skill only. Skills must be trained in order
22 // (toolLifeSkillIds[0] = "tempered_grip" is always the entry-level skill).
23 // If the player has no rank in it, they have no rank in any of the 11.
24 if !skillRanks.Has(callerAddr + ":" + toolLifeSkillIds[0]) {
25 return base
26 }
27 bonus := uint64(0)
28 for _, skillId := range toolLifeSkillIds {
29 if rank := getU(&skillRanks, callerAddr+":"+skillId); rank > 0 {
30 bonus += rank * toolLifeSkillBonus[skillId]
31 }
32 }
33 return base * (1000 + bonus) / 1000
34}
35
36func ownsTool(addr, toolId string) bool {
37 if toolId == "hands" {
38 return true
39 }
40 if !ownedTools.Has(addr) {
41 return false
42 }
43 for _, t := range strings.Split(ownedTools.Get(addr).(string), ",") {
44 if strings.TrimSpace(t) == toolId {
45 return true
46 }
47 }
48 return false
49}
50
51// ── Coin Transfer ────────────────────────────────────────────────────────────
52
53// Transfer sends coins to another player. It is a player's own money, so the
54// admin can't stop it: it works while the game is paused and for banned
55// addresses (bans only stop earning). It needs a finalized migration
56// (MigrateBatch SETs balances). There is no receive cap: any coin can be sent
57// to any player, whatever tools they own.
58func Transfer(cur realm, coinId string, toAddr string, amountStr string) {
59 caller := cur.Previous().Address().String()
60 if !migrationFinalized {
61 panic("transfers open after FinalizeMigration")
62 }
63 if !address(toAddr).IsValid() {
64 panic("invalid destination address")
65 }
66 if caller == toAddr {
67 panic("cannot transfer to yourself")
68 }
69 if _, ok := coinRarity[coinId]; !ok {
70 panic("unknown coin id: " + coinId)
71 }
72 amount, err := strconv.ParseUint(amountStr, 10, 64)
73 if err != nil || amount == 0 || amount > maxAmount {
74 panic("invalid amount")
75 }
76 debitBal(caller, coinId, amount)
77 creditBal(toAddr, coinId, amount)
78 chain.Emit("Transferred", "coin", coinId, "from", caller, "to", toAddr, "amount", u(amount))
79}
80
81// ── Tools ────────────────────────────────────────────────────────────────────
82
83func BuyTool(cur realm, toolId string) {
84 caller := requirePlayer(cur)
85 costs, hasCost := toolCosts[toolId]
86 if !hasCost {
87 panic("unknown tool: " + toolId)
88 }
89 if ownsTool(caller, toolId) {
90 panic("already own " + toolId + " — use RepairTool to restore durability")
91 }
92 if prereq, ok := toolRequires[toolId]; ok && !ownsTool(caller, prereq) {
93 panic("must own " + prereq + " before buying " + toolId)
94 }
95 deductBalances(caller, costs)
96
97 if !ownedTools.Has(caller) || ownedTools.Get(caller).(string) == "" {
98 ownedTools.Set(caller, toolId)
99 } else {
100 ownedTools.Set(caller, ownedTools.Get(caller).(string)+","+toolId)
101 }
102 // Always initialise durability at purchase (skill-boosted max).
103 if mx, ok := toolMaxLife[toolId]; ok && mx > 0 {
104 setU(&toolLife, caller+":"+toolId, effectiveMaxLife(caller, toolId))
105 }
106}
107
108func RepairTool(cur realm, toolId string) {
109 caller := requirePlayer(cur)
110 maxLife, ok := toolMaxLife[toolId]
111 if !ok || maxLife == 0 {
112 panic("tool cannot be repaired: " + toolId)
113 }
114 if !ownsTool(caller, toolId) {
115 panic("you don't own " + toolId)
116 }
117 var repairCosts [][2]string
118 for _, pair := range toolCosts[toolId] {
119 baseAmt, _ := strconv.ParseUint(pair[1], 10, 64)
120 if amt := (baseAmt*30 + 99) / 100; amt > 0 {
121 repairCosts = append(repairCosts, [2]string{pair[0], strconv.FormatUint(amt, 10)})
122 }
123 }
124 deductBalances(caller, repairCosts)
125 setU(&toolLife, caller+":"+toolId, effectiveMaxLife(caller, toolId))
126}
127
128// ── Progression ──────────────────────────────────────────────────────────────
129
130func TrainSkill(cur realm, skillId string) {
131 caller := requirePlayer(cur)
132 key := caller + ":" + skillId
133 rank := getU(&skillRanks, key)
134 maxRank, ok := skillMaxRanks[skillId]
135 if !ok {
136 panic("unknown skill: " + skillId)
137 }
138 if rank >= maxRank {
139 panic("skill already at maximum rank")
140 }
141 baseCosts, ok := skillBaseCosts[skillId]
142 if !ok {
143 panic("unknown skill: " + skillId)
144 }
145 mult := uint64(1) << rank
146 var scaled [][2]string
147 for _, pair := range baseCosts {
148 baseAmt, _ := strconv.ParseUint(pair[1], 10, 64)
149 if s := baseAmt * mult; s > 0 {
150 scaled = append(scaled, [2]string{pair[0], strconv.FormatUint(s, 10)})
151 }
152 }
153 deductBalances(caller, scaled)
154 setU(&skillRanks, key, rank+1)
155}
156
157// ── Achievements (allowlisted, fixed rewards, vouchered) ─────────────────────
158
159func parseGrindId(id string) (string, uint64, bool) {
160 if !strings.HasPrefix(id, "g.") {
161 return "", 0, false
162 }
163 rest := id[2:]
164 dot := strings.LastIndex(rest, ".")
165 if dot <= 0 {
166 return "", 0, false
167 }
168 coin := rest[:dot]
169 need, err := strconv.ParseUint(rest[dot+1:], 10, 64)
170 if err != nil {
171 return "", 0, false
172 }
173 if _, ok := coinRarity[coin]; !ok {
174 return "", 0, false
175 }
176 for _, r := range grindRungs {
177 if r == need {
178 return coin, need, true
179 }
180 }
181 return "", 0, false
182}
183
184func isValidAchievementId(id string) bool {
185 if _, ok := achievementRewards[id]; ok {
186 return true
187 }
188 _, _, ok := parseGrindId(id)
189 return ok
190}
191
192// achievementReward returns the fixed reward for id, checking any on-chain
193// predicate (grind: lifetimeMined ≥ need; "<tool>_own": tool owned).
194// ok=false means the id is unknown or not earned on chain.
195func achievementReward(addr, id string) ([]coinAmt, string) {
196 if coin, need, isGrind := parseGrindId(id); isGrind {
197 if getU(&lifetimeMined, addr+":"+coin) < need {
198 return nil, "not earned: mined " + strconv.FormatUint(getU(&lifetimeMined, addr+":"+coin), 10) + "/" + strconv.FormatUint(need, 10) + " " + coin
199 }
200 return []coinAmt{{coin, grindPay(need)}}, ""
201 }
202 rw, ok := achievementRewards[id]
203 if !ok {
204 return nil, "unknown achievement: " + id
205 }
206 if strings.HasSuffix(id, "_own") {
207 tool := strings.TrimSuffix(id, "_own")
208 if _, isTool := toolCosts[tool]; isTool && !ownsTool(addr, tool) {
209 return nil, "not earned: you don't own " + tool
210 }
211 }
212 return rw, ""
213}
214
215// claimAchievementsFor claims ids for addr. strict=true panics on any bad id;
216// otherwise bad/claimed ids are skipped. Returns the number claimed.
217func claimAchievementsFor(addr string, ids []string, strict bool) int {
218 if len(ids) > 200 {
219 panic("too many achievements in one call (max 200)")
220 }
221 n := 0
222 for _, id := range ids {
223 key := addr + ":" + id
224 if achievements.Has(key) {
225 if strict {
226 panic("already claimed")
227 }
228 continue
229 }
230 rw, why := achievementReward(addr, id)
231 if why != "" {
232 if strict {
233 panic(why)
234 }
235 continue
236 }
237 achievements.Set(key, "1")
238 for _, ca := range rw {
239 creditReward(addr, ca.coin, ca.amt)
240 }
241 n++
242 }
243 return n
244}
245
246// ClaimAchievement claims one allowlisted achievement and credits its fixed
247// reward. Needs a server voucher of kind "ach" with payload = `["<id>"]`.
248func ClaimAchievement(cur realm, achievementId string, nonce int64, expiry int64, sigHex string) {
249 caller := requirePlayer(cur)
250 payload := `[` + jsonStr(achievementId) + `]`
251 useVoucher("ach", caller, nonce, expiry, payload, sigHex)
252 claimAchievementsFor(caller, []string{achievementId}, true)
253}
254
255// BatchClaimAchievements claims several ids (already-claimed / not-earned ids
256// are skipped). idsJson must be exactly the voucher payload.
257func BatchClaimAchievements(cur realm, idsJson string, nonce int64, expiry int64, sigHex string) int {
258 caller := requirePlayer(cur)
259 useVoucher("ach", caller, nonce, expiry, idsJson, sigHex)
260 return claimAchievementsFor(caller, parseStringList(idsJson), false)
261}
262
263func HasAchievement(addr, id string) bool { return achievements.Has(addr + ":" + id) }
264
265// parseDailyCheckin reads the stored value for an address and returns
266// (lastDay, streak). It handles both the new "<lastDay>:<streak>" format
267// and the legacy "<unix seconds>" format (which stores raw epoch seconds;
268// treated as lastDay = seconds/86400, streak = 1).
269func parseDailyCheckin(raw string) (int64, int64) {
270 for i := 0; i < len(raw); i++ {
271 if raw[i] == ':' {
272 lastDay, errD := strconv.ParseInt(raw[:i], 10, 64)
273 streak, errS := strconv.ParseInt(raw[i+1:], 10, 64)
274 if errD == nil && errS == nil {
275 return lastDay, streak
276 }
277 break
278 }
279 }
280 // Legacy format: raw is a plain unix-seconds timestamp
281 seconds, err := strconv.ParseInt(raw, 10, 64)
282 if err != nil {
283 return 0, 0
284 }
285 return seconds / 86400, 1
286}
287
288func DailyCheckin(cur realm) {
289 caller := requirePlayer(cur)
290 today := time.Now().Unix() / 86400
291
292 var lastDay, streak int64
293 if dailyCheckins.Has(caller) {
294 raw := dailyCheckins.Get(caller).(string)
295 lastDay, streak = parseDailyCheckin(raw)
296 }
297
298 if lastDay == today {
299 panic("already checked in today")
300 }
301 if lastDay == today-1 {
302 streak = streak + 1
303 } else {
304 streak = 1
305 }
306
307 val := strconv.FormatInt(today, 10) + ":" + strconv.FormatInt(streak, 10)
308 dailyCheckins.Set(caller, val)
309}
310
311// Tasks: game28 let anyone mark any task done. In game29 tasks are created
312// and completed for a player only by the operator (server) — no free medals.
313func CreateTask(cur realm, taskId string, desc string) {
314 if !isOperator(cur) {
315 panic("unauthorized: operator only")
316 }
317 if !isSafeId(taskId, 64) {
318 panic("invalid task id")
319 }
320 achievements.Set("task:"+taskId+":desc", sanitizeName(desc))
321}
322
323func CompleteTask(cur realm, taskId string) {
324 panic("CompleteTask is operator-only in game29 — use CompleteTaskFor")
325}
326
327func CompleteTaskFor(cur realm, player string, taskId string) {
328 if !isOperator(cur) {
329 panic("unauthorized: operator only")
330 }
331 if !achievements.Has("task:" + taskId + ":desc") {
332 panic("unknown task")
333 }
334 achievements.Set(player+":task."+taskId, "1")
335}
336