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

10.46 Kb · 343 lines
  1package game29
  2
  3import (
  4	"crypto/sha256"
  5	"encoding/hex"
  6	"strconv"
  7	"strings"
  8	"time"
  9
 10)
 11
 12// ── PvP lobbies ──────────────────────────────────────────────────────────────
 13//
 14// Storage (pvpLobbies, string values — PvpLobby(key) can read any of them):
 15//   <id>                → lobby JSON (re-rendered on every change)
 16//   <id>:creator / :challenger / :game / :coin / :bet / :status / :createdAt / :last
 17//   <id>:commit:<addr>  → sha256(choice+":"+salt) hex, written once
 18//   <id>:reveal:<addr>  → choice (after the hash matched)
 19//   <id>:salt:<addr>    → salt
 20//   <id>:winner         → addr | "draw"
 21// Lobby ids come from a counter, so two lobbies can never collide.
 22// Status: open → joined → finished | cancelled.
 23
 24func lget(id, f string) string {
 25	k := id + ":" + f
 26	if !pvpLobbies.Has(k) {
 27		return ""
 28	}
 29	return pvpLobbies.Get(k).(string)
 30}
 31
 32func lset(id, f, v string) { pvpLobbies.Set(id+":"+f, v) }
 33
 34func renderLobby(id string) {
 35	bet := lget(id, "bet")
 36	if bet == "" {
 37		bet = "0"
 38	}
 39	js := `{"lobbyId":` + jsonStr(id) + `,"creator":` + jsonStr(lget(id, "creator")) +
 40		`,"challenger":` + jsonStr(lget(id, "challenger")) + `,"game":` + jsonStr(lget(id, "game")) +
 41		`,"coinId":` + jsonStr(lget(id, "coin")) + `,"betAmount":` + bet +
 42		`,"status":` + jsonStr(lget(id, "status")) + `,"winner":` + jsonStr(lget(id, "winner")) +
 43		`,"round":1,"createdAt":` + lget(id, "createdAt") + `}`
 44	pvpLobbies.Set(id, js)
 45}
 46
 47func touch(id string) { lset(id, "last", strconv.FormatInt(time.Now().Unix(), 10)) }
 48
 49func lastAction(id string) int64 {
 50	v, _ := strconv.ParseInt(lget(id, "last"), 10, 64)
 51	return v
 52}
 53
 54// CreatePvpLobby escrows the creator's wager and opens a lobby.
 55func CreatePvpLobby(cur realm, game string, coinId string, betAmount string) string {
 56	caller := requirePlayer(cur)
 57	if _, ok := pvpGames[game]; !ok {
 58		panic("unknown PvP game: " + game)
 59	}
 60	if _, ok := coinRarity[coinId]; !ok {
 61		panic("unknown coin id: " + coinId)
 62	}
 63	wager, err := strconv.ParseUint(betAmount, 10, 64)
 64	if err != nil || wager > 1000000000 {
 65		panic("invalid bet amount")
 66	}
 67	if wager > 0 {
 68		panic("PvP wagers are off until they are rewritten; create a lobby with bet 0")
 69	}
 70	pvpCounter++
 71	id := "pvp-" + zeroPad(pvpCounter, 10)
 72	now := strconv.FormatInt(time.Now().Unix(), 10)
 73	lset(id, "creator", caller)
 74	lset(id, "game", game)
 75	lset(id, "coin", coinId)
 76	lset(id, "bet", strconv.FormatUint(wager, 10))
 77	lset(id, "status", "open")
 78	lset(id, "createdAt", now)
 79	lset(id, "last", now)
 80	if wager > 0 {
 81		pvpWagers.Set(id, coinId+":"+strconv.FormatUint(wager, 10)+":"+caller+":")
 82	}
 83	renderLobby(id)
 84	return id
 85}
 86
 87func JoinPvpLobby(cur realm, lobbyId string) {
 88	caller := requirePlayer(cur)
 89	if lget(lobbyId, "status") != "open" {
 90		panic("lobby not open")
 91	}
 92	creator := lget(lobbyId, "creator")
 93	if caller == creator {
 94		panic("cannot join your own lobby")
 95	}
 96	if pvpWagered(lobbyId) {
 97		panic("PvP wagers are off: this wagered lobby can only be refunded")
 98	}
 99	wager, _ := strconv.ParseUint(lget(lobbyId, "bet"), 10, 64)
100	coin := lget(lobbyId, "coin")
101	if wager > 0 {
102		debitBal(caller, coin, wager)
103		pvpWagers.Set(lobbyId, coin+":"+strconv.FormatUint(wager, 10)+":"+creator+":"+caller)
104	}
105	lset(lobbyId, "challenger", caller)
106	lset(lobbyId, "status", "joined")
107	touch(lobbyId)
108	renderLobby(lobbyId)
109}
110
111// pvpWagered: a lobby that escrowed coins before wagers were switched off.
112// It can only be cancelled or refunded after the timeout.
113func pvpWagered(id string) bool { b := lget(id, "bet"); return b != "" && b != "0" }
114
115// CancelPvpLobby lets the creator close an open lobby nobody joined (refund).
116// Works while paused: a refund of the player's own escrow is never blocked.
117func CancelPvpLobby(cur realm, lobbyId string) {
118	caller := cur.Previous().Address().String()
119	if lget(lobbyId, "status") != "open" || lget(lobbyId, "creator") != caller {
120		panic("only the creator can cancel an open lobby")
121	}
122	settlePvp(lobbyId, "refund")
123	lset(lobbyId, "status", "cancelled")
124	renderLobby(lobbyId)
125}
126
127func memberOf(lobbyId, addr string) bool {
128	return addr != "" && (addr == lget(lobbyId, "creator") || addr == lget(lobbyId, "challenger"))
129}
130
131// CommitPvpMove stores sha256(choice+":"+salt) for the caller. Members only,
132// once per player per lobby, only while the lobby is joined.
133func CommitPvpMove(cur realm, lobbyId string, moveHash string) {
134	caller := requirePlayer(cur)
135	if lget(lobbyId, "status") != "joined" {
136		panic("lobby is not in progress")
137	}
138	if !memberOf(lobbyId, caller) {
139		panic("not a lobby participant")
140	}
141	if lget(lobbyId, "commit:"+caller) != "" {
142		panic("move already committed")
143	}
144	moveHash = strings.ToLower(moveHash)
145	if !isHex64(moveHash) {
146		panic("moveHash must be sha256(choice:salt) as 64 hex chars")
147	}
148	lset(lobbyId, "commit:"+caller, moveHash)
149	touch(lobbyId)
150}
151
152func validChoice(game, choice string) bool {
153	for _, c := range pvpGames[game] {
154		if c == choice {
155			return true
156		}
157	}
158	return false
159}
160
161func moveHash(choice, salt string) string {
162	h := sha256.Sum256([]byte(choice + ":" + salt))
163	return hex.EncodeToString(h[:])
164}
165
166// RevealPvpMove reveals a committed move. The tx fails unless
167// sha256(choice+":"+salt) equals the stored commit. A matching reveal of an
168// invalid choice forfeits the match. When both have revealed, it settles.
169func RevealPvpMove(cur realm, lobbyId string, choice string, salt string) {
170	caller := requirePlayer(cur)
171	if lget(lobbyId, "status") != "joined" {
172		panic("lobby is not in progress")
173	}
174	if !memberOf(lobbyId, caller) {
175		panic("not a lobby participant")
176	}
177	if pvpWagered(lobbyId) {
178		panic("PvP wagers are off: this wagered lobby can only be refunded after the timeout")
179	}
180	creator, challenger := lget(lobbyId, "creator"), lget(lobbyId, "challenger")
181	if lget(lobbyId, "commit:"+creator) == "" || lget(lobbyId, "commit:"+challenger) == "" {
182		panic("both players must commit moves before revealing")
183	}
184	if lget(lobbyId, "reveal:"+caller) != "" {
185		panic("already revealed")
186	}
187	if moveHash(choice, salt) != lget(lobbyId, "commit:"+caller) {
188		panic("reveal does not match your commit")
189	}
190	lset(lobbyId, "reveal:"+caller, choice)
191	lset(lobbyId, "salt:"+caller, salt)
192	touch(lobbyId)
193
194	game := lget(lobbyId, "game")
195	if !validChoice(game, choice) {
196		// Committed garbage: forfeit to the opponent.
197		opp := creator
198		if caller == creator {
199			opp = challenger
200		}
201		settlePvp(lobbyId, opp)
202		return
203	}
204	cr, chr := lget(lobbyId, "reveal:"+creator), lget(lobbyId, "reveal:"+challenger)
205	if cr == "" || chr == "" {
206		renderLobby(lobbyId)
207		return
208	}
209	settlePvp(lobbyId, pvpWinner(game, creator, challenger, cr, chr, lget(lobbyId, "salt:"+creator), lget(lobbyId, "salt:"+challenger)))
210}
211
212// pvpWinner: rps uses the usual rules. coin_flip / higher_lower flip a coin
213// from sha256(creatorSalt:challengerSalt) — neither player controls it alone —
214// and whoever alone called it wins (both or neither right = draw).
215func pvpWinner(game, creator, challenger, cChoice, chChoice, cSalt, chSalt string) string {
216	if game == "rps" {
217		if cChoice == chChoice {
218			return "draw"
219		}
220		if (cChoice == "rock" && chChoice == "scissors") || (cChoice == "scissors" && chChoice == "paper") || (cChoice == "paper" && chChoice == "rock") {
221			return creator
222		}
223		return challenger
224	}
225	h := sha256.Sum256([]byte(cSalt + ":" + chSalt))
226	outcome := pvpGames[game][int(h[0]&1)]
227	cOk, chOk := cChoice == outcome, chChoice == outcome
228	if cOk && !chOk {
229		return creator
230	}
231	if chOk && !cOk {
232		return challenger
233	}
234	return "draw"
235}
236
237// settlePvp pays out the escrow. result = winner addr, "draw" or "refund".
238func settlePvp(lobbyId, result string) {
239	if pvpWagers.Has(lobbyId) {
240		parts := strings.SplitN(pvpWagers.Get(lobbyId).(string), ":", 4)
241		if len(parts) == 4 {
242			coin := parts[0]
243			wager, _ := strconv.ParseUint(parts[1], 10, 64)
244			payers := []string{}
245			for _, a := range parts[2:] {
246				if a != "" {
247					payers = append(payers, a)
248				}
249			}
250			if result == "draw" || result == "refund" || len(payers) < 2 {
251				for _, a := range payers {
252					creditBal(a, coin, wager)
253				}
254			} else {
255				pot := wager * uint64(len(payers))
256				rake := pot * pvpRakePct / 100
257				creditBal(result, coin, pot-rake)
258				creditBal(admin.String(), coin, rake)
259			}
260		}
261		pvpWagers.Remove(lobbyId)
262	}
263	if result != "refund" {
264		lset(lobbyId, "winner", result)
265	}
266	lset(lobbyId, "status", "finished")
267	renderLobby(lobbyId)
268}
269
270// ClaimPvpTimeout resolves a stuck match after pvpTimeoutSec without action:
271//   - only one player committed → that player wins;
272//   - both committed, only one revealed → the revealer wins;
273//   - otherwise → both refunded.
274// Any lobby member may call it. Escrow can never stay locked.
275func ClaimPvpTimeout(cur realm, lobbyId string) {
276	caller := cur.Previous().Address().String()
277	st := lget(lobbyId, "status")
278	if st != "joined" && st != "open" {
279		panic("lobby already settled")
280	}
281	if !memberOf(lobbyId, caller) {
282		panic("not a lobby participant")
283	}
284	if time.Now().Unix()-lastAction(lobbyId) < pvpTimeoutSec {
285		panic("timeout not yet claimable — 10 minutes must pass without a move")
286	}
287	if st == "open" || pvpWagered(lobbyId) {
288		settlePvp(lobbyId, "refund")
289		lset(lobbyId, "status", "cancelled")
290		renderLobby(lobbyId)
291		return
292	}
293	cr, ch := lget(lobbyId, "creator"), lget(lobbyId, "challenger")
294	cc, chc := lget(lobbyId, "commit:"+cr) != "", lget(lobbyId, "commit:"+ch) != ""
295	rv, chv := lget(lobbyId, "reveal:"+cr) != "", lget(lobbyId, "reveal:"+ch) != ""
296	switch {
297	case cc && !chc:
298		settlePvp(lobbyId, cr)
299	case chc && !cc:
300		settlePvp(lobbyId, ch)
301	case cc && chc && rv && !chv:
302		settlePvp(lobbyId, cr)
303	case cc && chc && chv && !rv:
304		settlePvp(lobbyId, ch)
305	default:
306		settlePvp(lobbyId, "refund")
307	}
308}
309
310// ExpireLobbies is a no-op kept for API compatibility (timeouts are per lobby).
311func ExpireLobbies(cur realm) {}
312
313func PvpLobby(lobbyId string) string {
314	if !pvpLobbies.Has(lobbyId) {
315		return "null"
316	}
317	return pvpLobbies.Get(lobbyId).(string)
318}
319
320func OpenLobbies() string {
321	var parts []string
322	pvpLobbies.Iterate("pvp-", "pvp.", func(key string, val any) bool {
323		if !strings.Contains(key, ":") && lget(key, "status") == "open" {
324			parts = append(parts, val.(string))
325		}
326		return len(parts) >= 100
327	})
328	return "[" + strings.Join(parts, ",") + "]"
329}
330
331func sha256Sum(s string) string {
332	x := sha256.Sum256([]byte(s))
333	return hex.EncodeToString(x[:])
334}
335
336// zeroPad formats n with leading zeros to width w (ufmt has no %0Nd).
337func zeroPad(n uint64, w int) string {
338	s := strconv.FormatUint(n, 10)
339	for len(s) < w {
340		s = "0" + s
341	}
342	return s
343}