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}