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_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