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