game_pvp.gno
12.69 Kb · 386 lines
1package game27
2
3import (
4 "strconv"
5 "strings"
6 "time"
7
8 "gno.land/p/nt/ufmt/v0"
9)
10
11// ── Casino ───────────────────────────────────────────────────────────────────
12
13func CommitCasinoSeed(cur realm, roundId string, seedHash string) {
14 if !isAdmin(cur) {
15 panic("unauthorized")
16 }
17 casinoRounds.Set(roundId, `{"roundId":"`+roundId+`","seedHash":"`+seedHash+`","status":"committed","bets":[]}`)
18}
19
20func PlaceCasinoBet(cur realm, roundId string, game string, coinId string, betAmount string, choice string, clientSeed string) {
21 requireNotPaused()
22 caller := cur.Previous().Address().String()
23 if isBlacklisted(caller) {
24 panic("address banned")
25 }
26 amt, err := strconv.ParseUint(betAmount, 10, 64)
27 if err != nil || amt == 0 {
28 panic("invalid bet amount")
29 }
30
31 balKey := caller + ":" + coinId
32 existing := uint64(0)
33 if balances.Has(balKey) {
34 existing, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
35 }
36 if existing < amt {
37 panic("insufficient balance")
38 }
39 balances.Set(balKey, strconv.FormatUint(existing-amt, 10))
40
41 if !casinoRounds.Has(roundId) {
42 panic("round not found")
43 }
44 bet := `{"addr":"` + caller + `","game":"` + game + `","coinId":"` + coinId + `","amount":` + betAmount + `,"choice":"` + choice + `","clientSeed":"` + clientSeed + `"}`
45 casinoRounds.Set(roundId+":bet:"+caller, bet)
46}
47
48func RevealCasinoSeed(cur realm, roundId string, serverSeed string) {
49 if !isAdmin(cur) {
50 panic("unauthorized")
51 }
52 casinoRounds.Set(roundId+":serverSeed", serverSeed)
53 casinoRounds.Set(roundId+":status", "revealed")
54}
55
56func RefundCasinoBet(cur realm, roundId string) {
57 requireNotPaused()
58 caller := cur.Previous().Address().String()
59 betKey := roundId + ":bet:" + caller
60 if !casinoRounds.Has(betKey) {
61 panic("no bet found for this round")
62 }
63
64 // Parse bet JSON to recover coinId and amount before removing the record.
65 // Bet JSON format: {"addr":"...","game":"...","coinId":"...","amount":N,...}
66 betJson := casinoRounds.Get(betKey).(string)
67
68 // Extract coinId (quoted string value)
69 coinId := ""
70 coinIdStart := strings.Index(betJson, `"coinId":"`) + len(`"coinId":"`)
71 if coinIdStart > len(`"coinId":"`) {
72 coinIdEnd := strings.Index(betJson[coinIdStart:], `"`) + coinIdStart
73 if coinIdEnd > coinIdStart {
74 coinId = betJson[coinIdStart:coinIdEnd]
75 }
76 }
77
78 // Extract amount (unquoted numeric value)
79 amountStr := ""
80 amountStart := strings.Index(betJson, `"amount":`) + len(`"amount":`)
81 if amountStart > len(`"amount":`) {
82 amountEnd := strings.IndexAny(betJson[amountStart:], `,}`) + amountStart
83 if amountEnd > amountStart {
84 amountStr = betJson[amountStart:amountEnd]
85 }
86 }
87
88 amount, _ := strconv.ParseUint(amountStr, 10, 64)
89 if amount > 0 && coinId != "" {
90 // Restore the deducted balance
91 balKey := caller + ":" + coinId
92 current := uint64(0)
93 if balances.Has(balKey) {
94 current, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
95 }
96 balances.Set(balKey, strconv.FormatUint(current+amount, 10))
97 }
98
99 casinoRounds.Remove(betKey)
100}
101
102// ── PvP Lobbies ──────────────────────────────────────────────────────────────
103
104// CreatePvpLobby creates a new PvP lobby and escrows the creator's wager from balances.
105// Phase 6: wager > 0 deducts from balances and records the escrow in pvpWagers.
106func CreatePvpLobby(cur realm, game string, coinId string, betAmount string) string {
107 requireNotPaused()
108 caller := cur.Previous().Address().String()
109 if isBlacklisted(caller) {
110 panic("address banned")
111 }
112
113 wager, err := strconv.ParseUint(betAmount, 10, 64)
114 if err != nil {
115 panic("invalid bet amount")
116 }
117
118 // Escrow creator's wager from balances (if wager > 0)
119 if wager > 0 {
120 balKey := caller + ":" + coinId
121 bal := uint64(0)
122 if balances.Has(balKey) {
123 bal, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
124 }
125 if bal < wager {
126 panic("insufficient balance for wager: need " + betAmount + " " + coinId)
127 }
128 balances.Set(balKey, strconv.FormatUint(bal-wager, 10))
129 }
130
131 prefix := caller
132 if len(prefix) > 8 {
133 prefix = caller[:8]
134 }
135 lobbyId := ufmt.Sprintf("pvp-%s-%d", prefix, time.Now().Unix())
136 lobby := `{"lobbyId":"` + lobbyId + `","creator":"` + caller + `","game":"` + game + `","coinId":"` + coinId + `","betAmount":` + betAmount + `,"status":"open","round":1,"createdAt":` + strconv.FormatInt(time.Now().Unix(), 10) + `}`
137 pvpLobbies.Set(lobbyId, lobby)
138
139 // Record wager escrow (challenger is empty until JoinPvpLobby)
140 if wager > 0 {
141 pvpWagers.Set(lobbyId, coinId+":"+betAmount+":"+caller+":")
142 }
143
144 return lobbyId
145}
146
147// JoinPvpLobby joins an existing PvP lobby and escrows the challenger's matching wager.
148// Phase 6: deducts challenger's matching wager from balances and updates pvpWagers.
149func JoinPvpLobby(cur realm, lobbyId string) {
150 requireNotPaused()
151 caller := cur.Previous().Address().String()
152 if isBlacklisted(caller) {
153 panic("address banned")
154 }
155
156 if !pvpLobbies.Has(lobbyId) {
157 panic("lobby not found")
158 }
159 if pvpLobbies.Has(lobbyId + ":challenger") {
160 panic("lobby already full")
161 }
162
163 // Parse coinId and betAmount from wager record
164 if pvpWagers.Has(lobbyId) {
165 wagerStr := pvpWagers.Get(lobbyId).(string)
166 parts := strings.SplitN(wagerStr, ":", 4)
167 if len(parts) == 4 {
168 coinId := parts[0]
169 betAmountStr := parts[1]
170 creator := parts[2]
171
172 if caller == creator {
173 panic("cannot join your own lobby")
174 }
175
176 wager, _ := strconv.ParseUint(betAmountStr, 10, 64)
177 if wager > 0 {
178 // Escrow challenger's matching wager
179 balKey := caller + ":" + coinId
180 bal := uint64(0)
181 if balances.Has(balKey) {
182 bal, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
183 }
184 if bal < wager {
185 panic("insufficient balance for wager: need " + betAmountStr + " " + coinId)
186 }
187 balances.Set(balKey, strconv.FormatUint(bal-wager, 10))
188 // Update wager record with challenger address
189 pvpWagers.Set(lobbyId, coinId+":"+betAmountStr+":"+creator+":"+caller)
190 }
191 }
192 }
193
194 pvpLobbies.Set(lobbyId+":challenger", caller)
195 pvpLobbies.Set(lobbyId+":status", "joined")
196}
197
198func CommitPvpMove(cur realm, lobbyId string, moveHash string) {
199 requireNotPaused()
200 caller := cur.Previous().Address().String()
201 pvpLobbies.Set(lobbyId+":commit:"+caller, moveHash)
202}
203
204// RevealPvpMove requires both players to have committed before either can reveal.
205// Phase 6: when both players have revealed, determines the RPS winner and pays out
206// 2× wager minus 2% rake to the winner. Draw = full refund to both players.
207// pvpWagers.Remove is called after payout to prevent double-payouts.
208func RevealPvpMove(cur realm, lobbyId string, choice string, salt string) {
209 requireNotPaused()
210 caller := cur.Previous().Address().String()
211
212 // Verify lobby exists and both players have committed
213 if !pvpLobbies.Has(lobbyId) {
214 panic("lobby not found")
215 }
216 lobbyJson := pvpLobbies.Get(lobbyId).(string)
217 // Extract creator address from JSON
218 creatorStart := strings.Index(lobbyJson, `"creator":"`) + len(`"creator":"`)
219 creatorEnd := strings.Index(lobbyJson[creatorStart:], `"`) + creatorStart
220 creator := lobbyJson[creatorStart:creatorEnd]
221
222 // Require challenger has joined
223 if !pvpLobbies.Has(lobbyId + ":challenger") {
224 panic("no opponent has joined yet")
225 }
226 challenger := pvpLobbies.Get(lobbyId + ":challenger").(string)
227
228 // Require both commits are present
229 if !pvpLobbies.Has(lobbyId+":commit:"+creator) || !pvpLobbies.Has(lobbyId+":commit:"+challenger) {
230 panic("both players must commit moves before revealing")
231 }
232
233 pvpLobbies.Set(lobbyId+":reveal:"+caller, choice+":"+salt)
234
235 // Check if both players have now revealed
236 creatorRevealed := pvpLobbies.Has(lobbyId + ":reveal:" + creator)
237 challengerRevealed := pvpLobbies.Has(lobbyId + ":reveal:" + challenger)
238
239 if creatorRevealed && challengerRevealed {
240 // Both revealed — determine winner and pay out
241 creatorReveal := pvpLobbies.Get(lobbyId + ":reveal:" + creator).(string)
242 challengerReveal := pvpLobbies.Get(lobbyId + ":reveal:" + challenger).(string)
243
244 creatorChoice := strings.SplitN(creatorReveal, ":", 2)[0]
245 challengerChoice := strings.SplitN(challengerReveal, ":", 2)[0]
246
247 winner := ""
248 // Rock-Paper-Scissors logic: rock beats scissors, scissors beats paper, paper beats rock
249 if creatorChoice == challengerChoice {
250 winner = "draw"
251 } else if (creatorChoice == "rock" && challengerChoice == "scissors") ||
252 (creatorChoice == "scissors" && challengerChoice == "paper") ||
253 (creatorChoice == "paper" && challengerChoice == "rock") {
254 winner = creator
255 } else {
256 winner = challenger
257 }
258
259 pvpLobbies.Set(lobbyId+":winner", winner)
260 pvpLobbies.Set(lobbyId+":status", "finished")
261
262 // Pay out wager to winner (or refund both on draw)
263 if pvpWagers.Has(lobbyId) {
264 wagerStr := pvpWagers.Get(lobbyId).(string)
265 parts := strings.SplitN(wagerStr, ":", 4)
266 if len(parts) == 4 {
267 coinId := parts[0]
268 wager, _ := strconv.ParseUint(parts[1], 10, 64)
269
270 if wager > 0 {
271 rake := wager * 2 / 100 // 2% rake to admin
272 payout := (wager * 2) - rake
273
274 if winner == "draw" {
275 // Refund both players their wager
276 for _, addr := range []string{creator, challenger} {
277 balKey := addr + ":" + coinId
278 bal := uint64(0)
279 if balances.Has(balKey) {
280 bal, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
281 }
282 balances.Set(balKey, strconv.FormatUint(bal+wager, 10))
283 }
284 } else {
285 // Pay winner 2× wager minus 2% rake
286 winKey := winner + ":" + coinId
287 winBal := uint64(0)
288 if balances.Has(winKey) {
289 winBal, _ = strconv.ParseUint(balances.Get(winKey).(string), 10, 64)
290 }
291 balances.Set(winKey, strconv.FormatUint(winBal+payout, 10))
292
293 // Rake goes to admin
294 adminKey := admin.String() + ":" + coinId
295 adminBal := uint64(0)
296 if balances.Has(adminKey) {
297 adminBal, _ = strconv.ParseUint(balances.Get(adminKey).(string), 10, 64)
298 }
299 balances.Set(adminKey, strconv.FormatUint(adminBal+rake, 10))
300 }
301
302 // Remove wager record after payout to prevent double-payouts
303 pvpWagers.Remove(lobbyId)
304 }
305 }
306 }
307 } else {
308 pvpLobbies.Set(lobbyId+":status", "revealed")
309 }
310}
311
312// ClaimPvpTimeout allows a lobby participant to claim a timeout win.
313// Only the creator or challenger may call this.
314// Phase 6: refunds the escrowed wager to all parties who contributed.
315func ClaimPvpTimeout(cur realm, lobbyId string) {
316 requireNotPaused()
317 caller := cur.Previous().Address().String()
318
319 if !pvpLobbies.Has(lobbyId) {
320 panic("lobby not found")
321 }
322 lobbyJson := pvpLobbies.Get(lobbyId).(string)
323
324 // Extract creator address
325 creatorStart := strings.Index(lobbyJson, `"creator":"`) + len(`"creator":"`)
326 creatorEnd := strings.Index(lobbyJson[creatorStart:], `"`) + creatorStart
327 creator := lobbyJson[creatorStart:creatorEnd]
328
329 // Get challenger (may not exist yet)
330 challenger := ""
331 if pvpLobbies.Has(lobbyId + ":challenger") {
332 challenger = pvpLobbies.Get(lobbyId + ":challenger").(string)
333 }
334
335 // Verify caller is a participant
336 if caller != creator && caller != challenger {
337 panic("not a lobby participant")
338 }
339
340 // Require minimum 10 minutes since lobby creation
341 createdAtStart := strings.Index(lobbyJson, `"createdAt":`) + len(`"createdAt":`)
342 createdAtEnd := strings.IndexAny(lobbyJson[createdAtStart:], `,}`) + createdAtStart
343 createdAt, _ := strconv.ParseInt(lobbyJson[createdAtStart:createdAtEnd], 10, 64)
344 if time.Now().Unix()-createdAt < 600 {
345 panic("timeout not yet claimable — minimum 10 minutes must elapse")
346 }
347
348 pvpLobbies.Set(lobbyId+":winner", caller)
349 pvpLobbies.Set(lobbyId+":status", "finished")
350
351 // Phase 6: Refund escrowed wagers.
352 // On timeout the creator always escrowed; the challenger may or may not have joined.
353 // Refund both if they both escrowed (challenger joined but neither revealed in time).
354 // If only the creator escrowed (challenger never joined), refund only the creator.
355 if pvpWagers.Has(lobbyId) {
356 wagerStr := pvpWagers.Get(lobbyId).(string)
357 parts := strings.SplitN(wagerStr, ":", 4)
358 if len(parts) == 4 {
359 coinId := parts[0]
360 wager, _ := strconv.ParseUint(parts[1], 10, 64)
361 creatorInWager := parts[2]
362 challengerInWager := parts[3]
363 // Refund whoever escrowed (creator always did; challenger may have joined)
364 if wager > 0 {
365 for _, addr := range []string{creatorInWager, challengerInWager} {
366 if addr == "" {
367 continue
368 }
369 balKey := addr + ":" + coinId
370 bal := uint64(0)
371 if balances.Has(balKey) {
372 bal, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
373 }
374 balances.Set(balKey, strconv.FormatUint(bal+wager, 10))
375 }
376 pvpWagers.Remove(lobbyId)
377 }
378 }
379 }
380}
381
382func ExpireLobbies(cur realm) {
383 if !isAdmin(cur) {
384 panic("unauthorized")
385 }
386}