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.

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}