arena.gno
36.72 Kb · 871 lines
1// Package arena is Gnotchi's sealed-plan battle realm: commit-reveal challenges between
2// two gnomes, resolved deterministically on chain against app/js/rules.js's own battle
3// math. This is the realm the project's strongest on-chain claim rests on. Everywhere else
4// in this project, "it's on chain" buys permanence and ownership. Here it buys something
5// harder: neither player has to trust the other, or Gnotchi's own server, about the
6// result of a battle that gets resolved -- because the challenger's plan is
7// cryptographically sealed before the opponent plays, and the chain, not either player's
8// client, is the one that checks the seal and resolves the fight.
9//
10// THE PRECISE LIMIT OF THAT CLAIM, stated here rather than left for someone to discover,
11// because this file cannot be edited once deployed. The guarantee is about TAMPERING, not
12// about COMPLETION. The opponent's plan is posted in the clear at Accept (see below for
13// why that asymmetry is correct), and resolveBattle is fully deterministic, so from the
14// moment of Accept the challenger can compute the exact outcome and only then decide
15// whether to call Reveal at all. A challenger who would lose is best off going quiet. The
16// cost of going quiet is a forfeit -- but ClaimForfeit is the OPPONENT's call and costs
17// them a transaction, so a challenge nobody claims simply sits accepted forever with no
18// result recorded. Nothing here can be made to lie about a battle; a battle can be made
19// not to finish. The recorded history is therefore selection-biased toward results
20// challengers were happy to publish, which matters to anything that later reads `ranked`
21// or `winner` in aggregate -- see NOTES.md, and see there too for why the obvious fix
22// (making ClaimForfeit permissionless) was rejected rather than overlooked.
23//
24// WHETHER THIS IS DEPLOYED IS DELIBERATELY NOT RECORDED HERE. This file ships on chain
25// exactly as written and cannot be edited afterwards, so any deploy status stated in it
26// is frozen at authoring time and becomes a lie the moment it changes.
27// garden/v2/gnomod.toml said "NOT deployed" in just such a line, was then deployed, and
28// now carries that false claim on chain permanently. Live status lives in the repo root's
29// STATUS.md and DECISIONS.md, which can still be corrected; the authority above both is
30// the chain itself (`vm/qfile` on this path).
31//
32// ============================================================================
33// WHY THIS NEVER STORES A GNOME, A TYPE OR A LEVEL
34// ============================================================================
35//
36// A gnome's type (Gardener/Explorer/Tinkerer, the input the battle's type-edge bonus
37// needs) and level (the input ranked eligibility needs) already live in garden/v2,
38// already deployed, already the single source of truth for "whose gnome is this and what
39// is it". Duplicating either field into this package's own storage would create two
40// places that could disagree, and only one of them -- garden/v2 -- is the one players
41// actually see on their gnome's own page. So every battle resolution here reads both
42// participants' current type and level directly from garden/v2's own exported
43// GnomeByAddress, by importing it like any other package, at the moment the battle is
44// actually resolved (Reveal or ClaimForfeit) -- never cached, never copied, never taken as
45// a caller-supplied argument. That import is what makes "any realm can read a gnome
46// without a deal" (see repo root DECISIONS.md, 2026-10-09, "Other realms can read a gnome
47// without permission") a property rather than a sentence in a document: no API key, no
48// permission, no agreement with garden/v2, and no way for garden/v2 to withdraw the
49// access. This file is the other realm.
50//
51// The import below is written against garden/v2's exact exported GnomeByAddress
52// signature, and this realm cannot resolve a single battle unless garden/v2 is live at
53// precisely the path imported below. So whoever deploys this must confirm that path on
54// chain first -- not from this file, and not from a document, but with a `vm/qfile` read.
55// See this package's own gnomod.toml and NOTES.md.
56//
57// ============================================================================
58// THE HASHING, AND WHY IT MUST MATCH app/js/rules.js EXACTLY
59// ============================================================================
60//
61// app/js/rules.js's own commitHash is `sha256(planString + ':' + saltHex)`, hex-encoded,
62// using Node's crypto.createHash('sha256'). A plan sealed in the browser with that
63// function must verify here with the IDENTICAL bytes hashed the IDENTICAL way, or every
64// sealed challenge ever made in the app would fail to reveal on chain. Gno's own
65// crypto/sha256 (gnovm/stdlibs/crypto/sha256, wrapping Go's stdlib sha256.Sum256) plus
66// encoding/hex give exactly that: verified directly, not assumed -- a scratch package
67// computing CommitHash("S,G,Q", "deadbeef") in Gno via sha256.Sum256(...)+hex.EncodeToString
68// and the equivalent crypto.createHash('sha256').update(...).digest('hex') in Node produce
69// the byte-for-byte identical 64-character hex string
70// (dbce29c54438799e885fd3712ade046d71cd387649c1f14e837ab5f351a38c86). So the brief's own
71// worry -- "if gno's crypto makes an exact match impossible, say so loudly" -- does not
72// apply: an exact match is possible, and commitDigest below is it. See NOTES.md for the
73// verification command.
74//
75// ============================================================================
76// THE COMMIT-REVEAL STATE MACHINE
77// ============================================================================
78//
79// Every challenge is exactly one of four states, and every exported call here is exactly
80// one state transition, enforced by checking the CURRENT status before doing anything
81// else (validate-then-commit, same discipline as garden/v2's SaveDay):
82//
83// pending -> accepted (Accept, by the opponent, within challengeExpiresHours)
84// accepted -> resolved (Reveal, by the challenger, within revealWindowHours of accept)
85// accepted -> forfeited (ClaimForfeit, by the opponent, once revealWindowHours has passed)
86//
87// There is no path back from resolved or forfeited, and no path from pending straight to
88// resolved or forfeited -- a plan can only ever be revealed against a challenge the named
89// opponent has actually accepted. Whichever of Reveal/ClaimForfeit executes first, while
90// the challenge is still "accepted", locks the result; the other call then fails against
91// the now-changed status (a late Reveal fails its own deadline check regardless, and a
92// too-early ClaimForfeit fails its own deadline check regardless -- see each function's own
93// comment). This makes the state machine race-free without needing a lock beyond the
94// single mutable record already in the avl.Tree.
95//
96// ============================================================================
97// SECURITY: NO recover(), SAME RULE AS garden/v2 AND hollowmere/v1
98// ============================================================================
99//
100// Nothing in this file calls recover(). Every mutating function here validates completely
101// against a value copy before ever touching the stored *arenaChallenge, so an uncaught
102// panic at any point before the final commit leaves the persisted record exactly as it was,
103// with no partial effect to undo -- the same validate-then-commit discipline garden/v2's
104// SaveDay uses, for the same reason: a recover() anywhere in this realm, even one added
105// later for an unrelated reason, could convert a rejected transition into a silently
106// successful one.
107//
108// ============================================================================
109// SCOPE
110// ============================================================================
111//
112// Built: Challenge, Accept, Reveal, ClaimForfeit, the ranked-eligibility fairness rules
113// (level 3 to rank, 3 ranked battles a gnome a day, 2 ranked results a pair a week -- all
114// enforced structurally, never bought, because this realm accepts no coins at all), and a
115// public Render/ChallengeStatus read surface.
116//
117// NOT built, on purpose: Elo ratings and season points. Brief 5.2 puts both in `season/v1`,
118// a realm that reads arena's own results -- it does not exist yet and this assignment did
119// not ask for it. Nothing here computes a rating or a season point, and there is no
120// admin, no owner and no upgrade hatch, matching every other deployed realm in this
121// project. See NOTES.md for what a future season realm would need from here, and for the
122// cases where app/js/rules.js itself leaves the fairness rules' exact mechanics
123// unspecified.
124package arena
125
126import (
127 "crypto/sha256"
128 "encoding/hex"
129 "strconv"
130 "strings"
131 "time"
132
133 "gno.land/p/nt/avl/v0"
134 "gno.land/p/nt/seqid/v0"
135 "gno.land/p/nt/ufmt/v0"
136
137 "chain"
138 "chain/runtime/unsafe"
139
140 "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/garden/v2"
141)
142
143const (
144 // ---- battle constants, ported from app/js/rules.config.json's "battle" block by hand,
145 // the same way garden/v2's constants cite their rules.config.json keys. Gno has no way
146 // to read a JSON file at compile time, so these must be updated by hand if
147 // rules.config.json ever changes.
148 battlePuff = int64(10) // battle.puff
149 battleRounds = 3 // battle.rounds
150 battleWinHit = int64(3) // battle.winHit
151 battleEdge = int64(1) // battle.typeEdge
152 battleTieLoss = int64(1) // battle.tieLoss
153
154 moveQuick = "Q"
155 moveStrong = "S"
156 moveGuard = "G"
157
158 typeGardener = "gardener"
159 typeExplorer = "explorer"
160 typeTinkerer = "tinkerer"
161
162 // ---- commit-reveal timing, from rules.config.json's "time" block.
163 challengeExpiresHours = int64(24) // time.challengeExpiresHours
164 revealWindowHours = int64(24) // time.revealWindowHours
165
166 // ---- fairness, from rules.config.json's "ranked"/"xp" blocks. See NOTES.md: rules.js
167 // itself has no function that actually enforces these two -- its own eloUpdate only
168 // models the per-pair-per-week downgrade, and nothing in rules.js touches the
169 // per-gnome-per-day cap or the level-3 floor at all. This realm enforces both the same
170 // way it enforces the one rules.js does model: a battle that fails a fairness check
171 // still happens and still produces a real, final result -- it is simply not eligible to
172 // be counted "ranked" (ranked = false on the record), exactly mirroring rules.js's own
173 // eloUpdate(...) returning {ranked: false} rather than refusing to play the game. A
174 // fairness check in this realm NEVER blocks Challenge/Accept/Reveal/ClaimForfeit from
175 // succeeding.
176 rankedFromLevel = int64(3) // xp.rankedFromLevel
177 rankedPerGnomePerDay = int64(3) // ranked.perGnomePerDay
178 rankedGamesPerPairPerWeek = int64(2) // ranked.rankedGamesPerPairPerWeek
179
180 hourSeconds = int64(3600)
181 daySeconds = int64(86400)
182 weekSeconds = int64(7 * 86400)
183
184 stPending = "pending"
185 stAccepted = "accepted"
186 stResolved = "resolved"
187 stForfeited = "forfeited"
188)
189
190// styleBeatsMap: Quick beats Strong, Strong beats Guard, Guard beats Quick (brief 3.6).
191var styleBeatsMap = map[string]string{
192 moveQuick: moveStrong,
193 moveStrong: moveGuard,
194 moveGuard: moveQuick,
195}
196
197// typeBeatsMap: Gardener beats Explorer, Explorer beats Tinkerer, Tinkerer beats Gardener.
198var typeBeatsMap = map[string]string{
199 typeGardener: typeExplorer,
200 typeExplorer: typeTinkerer,
201 typeTinkerer: typeGardener,
202}
203
204// arenaChallenge is one sealed-plan battle, pending through to resolved/forfeited.
205type arenaChallenge struct {
206 challenger address
207 opponent address
208 commit string // sha256(planString + ":" + saltHex) hex, posted at Challenge time
209
210 status string
211
212 createdAt int64 // Challenge time
213 acceptedAt int64 // Accept time; 0 until accepted
214 resolvedAt int64 // Reveal/ClaimForfeit time; 0 until resolved or forfeited
215
216 opponentPlan string // set at Accept, in the clear
217 challengerPlan string // set at Reveal, after the commit check passes
218 salt string // set at Reveal; see ChallengeProof for why it is kept
219
220 winner string // "challenger" | "opponent" | "draw"; set once resolved/forfeited
221 puffChallenger int64
222 puffOpponent int64
223 ranked bool
224}
225
226var (
227 challenges = avl.NewTree() // challengeID string -> *arenaChallenge
228 nextID seqid.ID
229
230 dailyRanked = avl.NewTree() // "<addr>:<dayNumber>" -> int64 ranked-battle count
231 pairWeekly = avl.NewTree() // "<addrLo>|<addrHi>:<weekNumber>" -> int64 ranked-result count
232
233 totalChallenges int64
234 totalResolved int64
235)
236
237// Challenge posts only commitHash -- the sealed hash of the challenger's plan and salt.
238// This realm never sees, and never could see, the plan itself at this stage: there is no
239// plan parameter on this function at all, so a plan cannot arrive here even by mistake.
240// See the package doc for why that asymmetry (challenger sealed, opponent's Accept plan in
241// the clear) is deliberate and correct, not a gap.
242// Challenge returns the new challenge's ID. This is an addition beyond the brief's own
243// parameter-only signature notation, not a deviation from it: nothing in the brief
244// forbids a return value, the caller genuinely needs the ID back to tell the opponent
245// which challenge to Accept, and the alternative -- forcing every caller to parse the
246// emitted "Challenge" event for the same value this function already has in hand -- is
247// strictly worse for no safety benefit. See NOTES.md.
248//
249// THE unsafe.OriginSend() GUARD BELOW, AND ITS PRICE, recorded once here for all four
250// mutating functions that use it. OriginSend reports the coins attached to the ORIGIN
251// TRANSACTION, not the coins this call received -- which is exactly why the stdlib
252// quarantines it under `unsafe`. Its purpose here is narrow and worth keeping: this realm
253// has no banker, so any coin that reaches its address is stuck forever, and refusing the
254// call is kinder than silently swallowing a user's GNOT.
255//
256// The price is permanent and goes in the other direction: because the check is
257// transaction-scoped, any future transaction that attaches coins to some OTHER realm in
258// the same call chain makes all four of these functions panic even though arena received
259// nothing. A paid-entry tournament realm wrapping Challenge/Accept/Reveal is therefore
260// impossible against this version. The read surface is unaffected -- ChallengeStatus,
261// ChallengeProof and Render are non-crossing and carry no such check -- so a read-only
262// season/v1 is fine; it is write-path composition specifically that is foreclosed.
263//
264// This is a forced trade, not an oversight: gno 0.9 exposes no call-scoped "coins sent to
265// me" primitive at all. `gno doc chain/runtime/unsafe` lists OriginSend as the only coin
266// receipt reader, and `gno doc chain` has no equivalent -- checked, not assumed. The
267// stdlib's own advice is to pair it with runtime.AssertOriginCall(), which would forbid
268// realm callers outright and so is strictly worse for composability than what is here.
269func Challenge(cur realm, opponent address, commitHash string) string {
270 if !unsafe.OriginSend().IsZero() {
271 panic("arena: challenge accepts no coins; this realm has no banker and cannot return them")
272 }
273
274 caller := cur.Previous().Address()
275 if !caller.IsValid() {
276 panic("arena: challenge requires a valid caller address")
277 }
278
279 if !opponent.IsValid() {
280 panic("arena: invalid opponent address")
281 }
282
283 if opponent == caller {
284 panic("arena: cannot challenge your own gnome")
285 }
286
287 if !validCommitHex(commitHash) {
288 panic("arena: commitHash must be a 64-character lowercase hex sha256 digest")
289 }
290
291 // The challenger must have a gnome; the opponent is deliberately NOT checked here -- see
292 // NOTES.md for why a challenge can target an address with no gnome yet (it simply sits
293 // un-acceptable until, if ever, that address adopts one).
294 if !hasGnome(caller) {
295 panic("arena: no gnome adopted for this address")
296 }
297
298 now := time.Now().Unix()
299 id := nextID.Next().String()
300
301 challenges.Set(id, &arenaChallenge{
302 challenger: caller,
303 opponent: opponent,
304 commit: commitHash,
305 status: stPending,
306 createdAt: now,
307 })
308 totalChallenges++
309
310 chain.Emit(
311 "Challenge",
312 "challengeId", id,
313 "challenger", caller.String(),
314 "opponent", opponent.String(),
315 )
316
317 return id
318}
319
320// Accept posts the opponent's own plan in the clear -- they learn nothing from the sealed
321// commit, so there is nothing to protect by hiding their own choice. See the package doc.
322func Accept(cur realm, challengeID, plan string) {
323 if !unsafe.OriginSend().IsZero() {
324 panic("arena: accept accepts no coins; this realm has no banker and cannot return them")
325 }
326
327 caller := cur.Previous().Address()
328 if !caller.IsValid() {
329 panic("arena: accept requires a valid caller address")
330 }
331
332 c := mustChallenge(challengeID)
333
334 if c.status != stPending {
335 panic("arena: this challenge cannot be accepted (status: " + c.status + ")")
336 }
337
338 now := time.Now().Unix()
339 if now > c.createdAt+challengeExpiresHours*hourSeconds {
340 panic("arena: this challenge expired; it was not accepted within 24 hours")
341 }
342
343 if caller != c.opponent {
344 panic("arena: only the challenged opponent can accept this challenge")
345 }
346
347 if !hasGnome(caller) {
348 panic("arena: no gnome adopted for this address")
349 }
350
351 // Store the NORMALISED plan, not the caller's bytes. parsePlan accepts surrounding
352 // whitespace and either case on each move (strings.TrimSpace strips the whole Unicode
353 // whitespace class, so "\n\nQ , s ,G" parses), and the raw string would then be echoed
354 // straight into Render's markdown and returned by ChallengeProof. That is the same
355 // reflected-string class as Render's own path guard, one function away; the payload
356 // alphabet here is only whitespace and Q/S/G, so the worst reachable outcome is a
357 // mangled list or a stray indented code block on one challenge's page rather than a
358 // link or an image -- but the pattern has no business being here at all.
359 //
360 // Normalising loses nothing: unlike the challenger's plan, the opponent's is not
361 // covered by any commitment, so no byte has to survive verbatim for Reveal's hash
362 // check. parsePlan is idempotent over its own output, so a third party re-deriving the
363 // battle from ChallengeProof gets the same moves either way.
364 normalisedPlan := strings.Join(parsePlan(plan), ",")
365
366 work := *c
367 work.opponentPlan = normalisedPlan
368 work.acceptedAt = now
369 work.status = stAccepted
370 *c = work
371 challenges.Set(challengeID, c)
372
373 chain.Emit(
374 "Accept",
375 "challengeId", challengeID,
376 "opponent", caller.String(),
377 "plan", normalisedPlan,
378 )
379}
380
381// Reveal posts the challenger's plan and salt. The realm recomputes commitDigest and
382// rejects any plan/salt pair that does not reproduce the posted commitHash, then resolves
383// the battle itself -- against both gnomes' CURRENT type and level, read live from
384// garden/v2, never from anything cached here.
385func Reveal(cur realm, challengeID, plan, salt string) {
386 if !unsafe.OriginSend().IsZero() {
387 panic("arena: reveal accepts no coins; this realm has no banker and cannot return them")
388 }
389
390 caller := cur.Previous().Address()
391 if !caller.IsValid() {
392 panic("arena: reveal requires a valid caller address")
393 }
394
395 c := mustChallenge(challengeID)
396
397 if c.status != stAccepted {
398 panic("arena: this challenge is not awaiting a reveal (status: " + c.status + ")")
399 }
400
401 if caller != c.challenger {
402 panic("arena: only the challenger can reveal")
403 }
404
405 now := time.Now().Unix()
406 if now > c.acceptedAt+revealWindowHours*hourSeconds {
407 panic("arena: the 24-hour reveal window has passed; the opponent may now claim forfeit")
408 }
409
410 // Format check BEFORE the hash check, mirroring app/js/rules.js's own control flow:
411 // its commitHash(planString, saltHex) calls parsePlan(planString) first and lets that
412 // throw propagate, rather than hashing (or verifyReveal returning false for) a
413 // malformed plan. A caller here who never went through rules.js at all could otherwise
414 // hand-craft a hash over a malformed string; parsePlan below closes that regardless of
415 // whether the hash would have matched.
416 challengerMoves := parsePlan(plan)
417
418 computed := commitDigest(plan, salt)
419 if computed != c.commit {
420 panic("arena: revealed plan does not match the sealed commitment")
421 }
422
423 // c.opponentPlan was already validated by parsePlan inside Accept; re-parsing here is a
424 // cheap, defensive re-check, not a trust assumption.
425 opponentMoves := parsePlan(c.opponentPlan)
426
427 _, _, _, _, challengerLevel, challengerType, _, foundChallenger := garden.GnomeByAddress(c.challenger)
428 _, _, _, _, opponentLevel, opponentType, _, foundOpponent := garden.GnomeByAddress(c.opponent)
429 if !foundChallenger || !foundOpponent {
430 // Unreachable in practice -- gnomes are soulbound and never removed once adopted,
431 // and both addresses were already required to have one (Challenge, Accept). Kept as
432 // a defensive panic rather than a silent zero-value type/level, consistent with this
433 // project's "never invent a fact" rule.
434 panic("arena: a gnome in this challenge no longer exists")
435 }
436
437 result := resolveBattle(challengerMoves, challengerType, opponentMoves, opponentType, battlePuff)
438 ranked := rankedEligible(c.challenger, c.opponent, challengerLevel, opponentLevel, now)
439 if ranked {
440 recordRanked(c.challenger, c.opponent, now)
441 }
442
443 work := *c
444 work.challengerPlan = plan
445 work.salt = salt
446 work.winner = result.winner
447 work.puffChallenger = result.puffChallenger
448 work.puffOpponent = result.puffOpponent
449 work.ranked = ranked
450 work.resolvedAt = now
451 work.status = stResolved
452 *c = work
453 challenges.Set(challengeID, c)
454 totalResolved++
455
456 chain.Emit(
457 "Reveal",
458 "challengeId", challengeID,
459 "winner", work.winner,
460 "puffChallenger", strconv.FormatInt(work.puffChallenger, 10),
461 "puffOpponent", strconv.FormatInt(work.puffOpponent, 10),
462 "ranked", strconv.FormatBool(ranked),
463 )
464}
465
466// ClaimForfeit lets the opponent claim the win once revealWindowHours has passed with no
467// Reveal. Per the brief: "the result counts as a normal ranked loss" for the challenger --
468// this realm applies the SAME ranked-eligibility rule to a forfeit as to a genuine reveal
469// (see NOTES.md), rather than treating a forfeit as automatically ranked regardless of the
470// fairness caps.
471func ClaimForfeit(cur realm, challengeID string) {
472 if !unsafe.OriginSend().IsZero() {
473 panic("arena: claim forfeit accepts no coins; this realm has no banker and cannot return them")
474 }
475
476 caller := cur.Previous().Address()
477 if !caller.IsValid() {
478 panic("arena: claim forfeit requires a valid caller address")
479 }
480
481 c := mustChallenge(challengeID)
482
483 if c.status != stAccepted {
484 panic("arena: this challenge is not awaiting a reveal (status: " + c.status + ")")
485 }
486
487 if caller != c.opponent {
488 panic("arena: only the accepting opponent can claim forfeit")
489 }
490
491 now := time.Now().Unix()
492 if now <= c.acceptedAt+revealWindowHours*hourSeconds {
493 panic("arena: the 24-hour reveal window has not passed yet; the challenger can still reveal")
494 }
495
496 _, _, _, _, challengerLevel, _, _, foundChallenger := garden.GnomeByAddress(c.challenger)
497 _, _, _, _, opponentLevel, _, _, foundOpponent := garden.GnomeByAddress(c.opponent)
498 if !foundChallenger || !foundOpponent {
499 panic("arena: a gnome in this challenge no longer exists")
500 }
501
502 ranked := rankedEligible(c.challenger, c.opponent, challengerLevel, opponentLevel, now)
503 if ranked {
504 recordRanked(c.challenger, c.opponent, now)
505 }
506
507 work := *c
508 work.winner = "opponent"
509 work.puffChallenger = 0
510 work.puffOpponent = battlePuff
511 work.ranked = ranked
512 work.resolvedAt = now
513 work.status = stForfeited
514 *c = work
515 challenges.Set(challengeID, c)
516 totalResolved++
517
518 chain.Emit(
519 "ClaimForfeit",
520 "challengeId", challengeID,
521 "opponent", caller.String(),
522 "ranked", strconv.FormatBool(ranked),
523 )
524}
525
526// ChallengeStatus is the plain read surface for one challenge's full state -- everything a
527// future season realm, or the web app, needs to know about a single resolved or
528// in-progress battle, without parsing Render's markdown.
529func ChallengeStatus(challengeID string) (
530 challenger, opponent address,
531 status string,
532 createdAt, acceptedAt, resolvedAt int64,
533 winner string,
534 puffChallenger, puffOpponent int64,
535 ranked, found bool,
536) {
537 v := challenges.Get(challengeID)
538 c, ok := v.(*arenaChallenge)
539 if !ok {
540 return address(""), address(""), "", 0, 0, 0, "", 0, 0, false, false
541 }
542 return c.challenger, c.opponent, c.status, c.createdAt, c.acceptedAt, c.resolvedAt,
543 c.winner, c.puffChallenger, c.puffOpponent, c.ranked, true
544}
545
546// ChallengeProof returns everything a third party needs to re-derive a finished battle
547// from scratch, without trusting this realm, either player, or Gnotchi's server.
548//
549// This realm's entire reason to exist is that nobody has to be trusted about the result.
550// ChallengeStatus returns the result -- which is this realm ASSERTING a winner. That is
551// exactly the kind of claim the realm was built to avoid having to make. Given commit,
552// challengerPlan, salt and opponentPlan, anyone can check sha256(challengerPlan + ":" +
553// salt) == commit, feed both plans through app/js/rules.js's resolveBattle, and get the
554// same winner and the same puff -- or catch this realm being wrong. Without these four
555// fields the "you don't have to trust us" claim is itself something you'd have to take on
556// trust, and since a realm cannot be edited, a getter missing at deploy time stays
557// missing until a v2 and a migration.
558//
559// Before a Reveal, challengerPlan and salt are empty strings, so this getter cannot leak
560// a plan early: there is no plan here to leak until the challenger has published it
561// themselves. commit is readable from the moment of Challenge, but it always was -- it is
562// a public argument of that transaction -- so exposing it here reveals nothing new.
563//
564// THE SEAL'S STRENGTH IS THE SALT'S, AND THIS REALM CANNOT ENFORCE THAT. Stated plainly
565// because the honest version is narrower than it first looks. There are only 27 possible
566// plans: battleRounds is 3 and there are 3 legal moves, so 3^3. commitDigest is a single
567// bare sha256 with no KDF and no iteration. So a commit whose salt is known, reused, or
568// derived from anything predictable falls to 27 hashes -- a complete break of the only
569// thing this realm exists to guarantee. Reveal deliberately imposes no minimum salt
570// length, because a length check added here would permanently reject any challenge
571// already sealed with a shorter salt and force its challenger to forfeit. The entropy
572// requirement therefore lives entirely in the client: app/js/battle.js seals with 32
573// fresh crypto.getRandomValues bytes per plan, never reused. Any other client MUST do the
574// same; 27 candidates is not a margin to be casual with.
575//
576// The preimage also binds neither the challenge ID nor the challenger's address, so the
577// same commit can be posted in several challenges. No advantage was found in doing so --
578// a replayer commits to a plan they will not learn until the original reveals, which is
579// strictly worse than choosing one -- but it is recorded here because the preimage has to
580// match app/js/rules.js byte for byte and so can never be changed.
581//
582// A forfeited challenge has no challengerPlan by definition -- the challenger never
583// revealed one, which is why it was forfeited. `found` is false for an unknown ID.
584func ChallengeProof(challengeID string) (
585 commit string,
586 challengerPlan string,
587 salt string,
588 opponentPlan string,
589 found bool,
590) {
591 v := challenges.Get(challengeID)
592 c, ok := v.(*arenaChallenge)
593 if !ok {
594 return "", "", "", "", false
595 }
596 return c.commit, c.challengerPlan, c.salt, c.opponentPlan, true
597}
598
599// Render: "" shows the realm's totals; any other path is treated as a challengeID.
600func Render(path string) string {
601 if path == "" {
602 return ufmt.Sprintf(
603 "# Arena\n\nSealed-plan battles, resolved on chain. No dice, and neither player "+
604 "nor Gnotchi has to be trusted about the result.\n\n"+
605 "* **Total challenges**: %d\n* **Total resolved**: %d\n",
606 totalChallenges, totalResolved,
607 )
608 }
609
610 // Validate before echoing. `path` is whatever a URL put after the realm path, so it is
611 // attacker-controlled, and gnoweb renders this return value as markdown: an unchecked
612 // echo lets anyone hand out a gnotchi.com-adjacent link whose page contains their own
613 // headings, links or images, apparently published by this realm. garden/v2 guards its
614 // own Render the same way (its paths are addresses, so it uses IsValid there). A
615 // rejected path is never reflected at all, which is strictly safer than escaping it.
616 if !validChallengeID(path) {
617 return "# Arena\n\nThat is not a challenge ID.\n"
618 }
619
620 v := challenges.Get(path)
621 c, ok := v.(*arenaChallenge)
622 if !ok {
623 return ufmt.Sprintf("# Arena\n\nNo such challenge `%s`.\n", path)
624 }
625
626 str := ufmt.Sprintf("# Challenge %s\n\n", path)
627 str += ufmt.Sprintf("* **Challenger**: %s\n", c.challenger.String())
628 str += ufmt.Sprintf("* **Opponent**: %s\n", c.opponent.String())
629 str += ufmt.Sprintf("* **Status**: %s\n", c.status)
630 if c.status == stAccepted || c.status == stResolved {
631 str += ufmt.Sprintf("* **Opponent's plan**: %s\n", c.opponentPlan)
632 }
633 if c.status == stResolved || c.status == stForfeited {
634 str += ufmt.Sprintf("* **Winner**: %s\n", c.winner)
635 str += ufmt.Sprintf("* **Puff**: challenger %d, opponent %d\n", c.puffChallenger, c.puffOpponent)
636 str += ufmt.Sprintf("* **Ranked**: %s\n", strconv.FormatBool(c.ranked))
637 }
638 return str
639}
640
641// ============================================================================
642// Helpers
643// ============================================================================
644
645func mustChallenge(id string) *arenaChallenge {
646 v := challenges.Get(id)
647 c, ok := v.(*arenaChallenge)
648 if !ok {
649 panic("arena: no such challenge")
650 }
651 return c
652}
653
654func hasGnome(addr address) bool {
655 _, _, _, _, _, _, _, found := garden.GnomeByAddress(addr)
656 return found
657}
658
659// validCommitHex checks only the SHAPE of a posted commitHash (64 lowercase hex chars,
660// matching the exact output of both Node's crypto.createHash('sha256').digest('hex') and
661// this file's own commitDigest) -- an early, player-facing failure, not a correctness
662// requirement: Reveal's own byte-for-byte comparison is what actually enforces the seal.
663func validCommitHex(s string) bool {
664 if len(s) != 64 {
665 return false
666 }
667 for _, r := range s {
668 if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
669 return false
670 }
671 }
672 return true
673}
674
675// validChallengeID accepts only what Challenge itself can mint: a seqid.ID rendered by
676// cford32, which is Crockford base32 and therefore a strict subset of lowercase
677// alphanumerics. 16 is a deliberate ceiling rather than an exact width -- seqid's encoded
678// width grows with the counter, so pinning one number here would start rejecting real IDs
679// later, and this realm cannot be edited once deployed. Nothing outside this set can ever
680// be a key in `challenges`, so narrowing to it loses no lookup.
681func validChallengeID(s string) bool {
682 if len(s) == 0 || len(s) > 16 {
683 return false
684 }
685 for _, r := range s {
686 if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'z')) {
687 return false
688 }
689 }
690 return true
691}
692
693// commitDigest ports app/js/rules.js's commitHash exactly: sha256(planString + ":" +
694// saltHex), hex-encoded. See the package doc for the cross-checked proof this matches
695// Node's crypto output byte for byte.
696func commitDigest(planString, saltHex string) string {
697 sum := sha256.Sum256([]byte(planString + ":" + saltHex))
698 return hex.EncodeToString(sum[:])
699}
700
701// parsePlan ports app/js/rules.js's parsePlan: exactly battleRounds comma-separated moves,
702// each trimmed and uppercased to one of Q, S or G.
703func parsePlan(s string) []string {
704 parts := strings.Split(s, ",")
705 if len(parts) != battleRounds {
706 panic(planFormatError())
707 }
708 moves := make([]string, battleRounds)
709 for i, p := range parts {
710 m := strings.ToUpper(strings.TrimSpace(p))
711 if m != moveQuick && m != moveStrong && m != moveGuard {
712 panic(planFormatError())
713 }
714 moves[i] = m
715 }
716 return moves
717}
718
719func planFormatError() string {
720 return "arena: a plan is exactly " + strconv.Itoa(battleRounds) + " moves, each Q, S or G, e.g. \"S,G,Q\""
721}
722
723func styleBeats(a, b string) bool { return styleBeatsMap[a] == b }
724
725// typeBeats: empty string (no type assigned yet) never beats, and is never beaten by,
726// anything -- ports app/js/rules.js's typeBeats: `!!a && !!b && TYPE_BEATS[a] === b`.
727func typeBeats(a, b string) bool { return a != "" && b != "" && typeBeatsMap[a] == b }
728
729type battleResult struct {
730 winner string // "challenger" | "opponent" | "draw"
731 puffChallenger int64
732 puffOpponent int64
733}
734
735// resolveBattle ports app/js/rules.js's resolveBattle exactly: no randomness, a knock-out
736// ends the fight early, the round's winner hits for winHit plus typeEdge with the type
737// edge, a tie costs both battleTieLoss. puff is an explicit parameter (not just the
738// package's own battlePuff constant) purely so tests can mirror rules.test.js's own
739// trick of lowering it to force an early knock-out within three rounds -- every real call
740// from Reveal always passes battlePuff.
741func resolveBattle(challengerMoves []string, challengerType string, opponentMoves []string, opponentType string, puff int64) battleResult {
742 puffC, puffO := puff, puff
743
744 for i := 0; i < battleRounds; i++ {
745 mc, mo := challengerMoves[i], opponentMoves[i]
746 var dmgC, dmgO int64
747
748 switch {
749 case mc == mo:
750 dmgC, dmgO = battleTieLoss, battleTieLoss
751 case styleBeats(mc, mo):
752 dmgO = battleWinHit
753 if typeBeats(challengerType, opponentType) {
754 dmgO += battleEdge
755 }
756 default:
757 dmgC = battleWinHit
758 if typeBeats(opponentType, challengerType) {
759 dmgC += battleEdge
760 }
761 }
762
763 puffC = clampPuff(puffC - dmgC)
764 puffO = clampPuff(puffO - dmgO)
765 if puffC == 0 || puffO == 0 {
766 break
767 }
768 }
769
770 winner := "draw"
771 switch {
772 case puffC == 0 && puffO == 0:
773 winner = "draw"
774 case puffC == 0:
775 winner = "opponent"
776 case puffO == 0:
777 winner = "challenger"
778 case puffC > puffO:
779 winner = "challenger"
780 case puffO > puffC:
781 winner = "opponent"
782 }
783
784 return battleResult{winner: winner, puffChallenger: puffC, puffOpponent: puffO}
785}
786
787func clampPuff(v int64) int64 {
788 if v < 0 {
789 return 0
790 }
791 return v
792}
793
794func dayNumber(unixSeconds int64) int64 { return unixSeconds / daySeconds }
795func weekNumber(unixSeconds int64) int64 { return unixSeconds / weekSeconds }
796
797func dayKey(addr address, now int64) string {
798 return addr.String() + ":" + strconv.FormatInt(dayNumber(now), 10)
799}
800
801// pairKey is symmetric: challenger-vs-opponent and opponent-vs-challenger must land on the
802// same weekly counter.
803func pairKey(a, b address) string {
804 as, bs := a.String(), b.String()
805 if as < bs {
806 return as + "|" + bs
807 }
808 return bs + "|" + as
809}
810
811func weekKey(pk string, now int64) string {
812 return pk + ":" + strconv.FormatInt(weekNumber(now), 10)
813}
814
815func getCounter(tree *avl.Tree, key string) int64 {
816 v := tree.Get(key)
817 n, ok := v.(int64)
818 if !ok {
819 return 0
820 }
821 return n
822}
823
824func incCounter(tree *avl.Tree, key string) {
825 tree.Set(key, getCounter(tree, key)+1)
826}
827
828// rankedEligible: level 3 to rank (xp.rankedFromLevel), at most rankedPerGnomePerDay ranked
829// results a gnome a day, at most rankedGamesPerPairPerWeek a pair a week. Never blocks a
830// battle -- see the const block's own comment for why this is a downgrade, not a refusal.
831//
832// KNOWN AND PERMANENT: THE PLAYER CHOOSES WHEN THIS RUNS, SO THEY CHOOSE WHAT IT SEES.
833// Every input below -- both daily counters, both pair counters, both levels -- is read
834// live at Reveal (or ClaimForfeit) time, and that moment is picked by the revealer, who by
835// then already knows the outcome from the opponent's cleartext plan. A challenger holding
836// several accepted challenges can reveal their three wins first, exhausting their own
837// rankedPerGnomePerDay allowance, after which every loss revealed that day is recorded
838// ranked = false. Free, repeatable daily, and invisible in the stored record.
839//
840// There is no fix available inside this function. Deciding eligibility at Challenge time
841// instead would just move the choice to a moment the challenger controls even more
842// completely, and the counters cannot be consumed before the battle exists. A real fix
843// needs the plan asymmetry to go (both sides sealed) or an ordering the players do not
844// pick -- either of which is a different realm, not a change to this line.
845//
846// Nothing reads `ranked` today: season/v1 does not exist (see the SCOPE note in the
847// package doc). That bounds the harm to zero right now and is precisely why it is written
848// down here instead of deferred -- whoever builds season/v1 will be reading an immutable
849// field whose distribution is skewed in the ranked player's favour, and they should learn
850// that from this comment rather than from the leaderboard.
851func rankedEligible(challenger, opponent address, challengerLevel, opponentLevel, now int64) bool {
852 if challengerLevel < rankedFromLevel || opponentLevel < rankedFromLevel {
853 return false
854 }
855 if getCounter(dailyRanked, dayKey(challenger, now)) >= rankedPerGnomePerDay {
856 return false
857 }
858 if getCounter(dailyRanked, dayKey(opponent, now)) >= rankedPerGnomePerDay {
859 return false
860 }
861 if getCounter(pairWeekly, weekKey(pairKey(challenger, opponent), now)) >= rankedGamesPerPairPerWeek {
862 return false
863 }
864 return true
865}
866
867func recordRanked(challenger, opponent address, now int64) {
868 incCounter(dailyRanked, dayKey(challenger, now))
869 incCounter(dailyRanked, dayKey(opponent, now))
870 incCounter(pairWeekly, weekKey(pairKey(challenger, opponent), now))
871}