Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

pixels.gno

23.74 Kb · 707 lines
  1// Package pixels is the real, permanent GNO Pixels realm -- gno.land/r/pixelsandbox
  2// (this project's earlier scratch deployment, never redeployable in
  3// place once live) was always the deliberately generic placeholder
  4// name described in this project's own README, kept around exactly so
  5// the design could keep changing without colliding with whatever this
  6// real deployment ended up being called.
  7//
  8// Expandable bounds instead of a fixed size, per-pixel provenance (who
  9// placed it, at what block), and an owner-gated import hook for
 10// migrating the scratch realm's already-placed pixels into this one.
 11package pixels
 12
 13import (
 14	"encoding/base64"
 15	"strconv"
 16	"strings"
 17
 18	"chain"
 19	"chain/banker"
 20	runtime "chain/runtime"
 21	unsaferealm "chain/runtime/unsafe"
 22
 23	"gno.land/p/nt/avl/v0"
 24	"gno.land/p/nt/ufmt/v0"
 25)
 26
 27const (
 28	// Starts at the full MaxBoardDim from genesis rather than the
 29	// original 64x64-then-auto-expand design -- with real community
 30	// placements landing right at the logo's edges as it neared
 31	// completion, waiting for the 80%-occupancy trigger would mean
 32	// people cramming free-form art tight against it first. Centered on
 33	// (31.5, 31.5), the original 64-wide board's own center, so every
 34	// existing target_pattern.js / migrated-pixel coordinate keeps
 35	// exactly the same (x, y) and lands in the same place relative to
 36	// the logo, just with far more breathing room around it.
 37	InitialMinX, InitialMaxX = int64(-32), int64(95)
 38	InitialMinY, InitialMaxY = int64(-32), int64(95)
 39
 40	MaxBoardDim = 128
 41	ExpandStep  = 8
 42
 43	ExpandThresholdPercent = 80
 44
 45	CooldownBlocks = 1
 46	cellPx         = 8
 47
 48	// FeePerPixelUgnot is charged for any placement that doesn't match
 49	// the current owner-curated official target (see officialTarget) --
 50	// placing toward the official design stays free, everything else
 51	// (free-form painting, or bulk-helping finish someone else's custom
 52	// canvas) costs this per cell, paid to collectionOwner.
 53	FeePerPixelUgnot = 100_000 // 0.1 GNOT
 54
 55	// VandalismFeeUgnot applies instead of FeePerPixelUgnot when a cell
 56	// IS part of the official target area but the caller paints it a
 57	// different color than the design calls for -- overwriting/defacing
 58	// an in-progress logo cell costs more than painting blank free-form
 59	// space, so griefing the logo is strictly more expensive than
 60	// helping (free) or painting elsewhere (the normal rate).
 61	VandalismFeeUgnot = 1_000_000 // 1 GNOT
 62
 63	// MaxBulkPixels bounds SetPixels' batch size -- keeps a single
 64	// transaction's gas/arg-size reasonable and bounds the worst-case
 65	// payment amount a caller has to attach.
 66	MaxBulkPixels = 10
 67
 68	// MaxCommunityDesignPixels bounds a single submitted design -- the
 69	// per-byte storage deposit already prices submission naturally, this
 70	// is just a hard ceiling against one submission dominating the
 71	// realm's storage.
 72	MaxCommunityDesignPixels = 4000
 73	MaxCommunityDesignNameLen = 60
 74)
 75
 76var palette = []string{
 77	"#000000", "#ffffff", "#808080", "#ef4444",
 78	"#fb923c", "#facc15", "#4ade80", "#3b82f6", "#a855f7",
 79}
 80
 81// collectionOwner is the address given for this project -- deploying
 82// account (whoever signs the addpkg transaction) and owner don't have
 83// to be the same address; this constant is what actually gates
 84// ForceExpand/ImportHistoricalPixel/CloseMigrationWindow regardless of
 85// who pays for the deploy itself.
 86const collectionOwner address = "g188mapat33awn7r9uk08l0jc9my0n07fpmspxel"
 87
 88var (
 89	minX, maxX = InitialMinX, InitialMaxX
 90	minY, maxY = InitialMinY, InitialMaxY
 91
 92	grid            avl.Tree
 93	placedBy        avl.Tree
 94	placedAtHeight  avl.Tree
 95	lastPlacedBlock avl.Tree
 96	placementCounts avl.Tree // address.String() -> int64, total successful placements (leaderboard)
 97	officialTarget  avl.Tree // key(x,y) -> int64 colorIndex, owner-curated free-to-place design
 98
 99	// communityDesigns holds every community-submitted design (image trace
100	// or typed text, already positioned in absolute board coordinates by
101	// whoever submitted it) -- unlike officialTarget these are NOT free;
102	// placing into one costs the normal FeePerPixelUgnot like anywhere
103	// else, submission just makes the design visible to everyone as a
104	// "help fill this in" target instead of only the submitter's own
105	// browser knowing its shape.
106	communityDesigns     avl.Tree // designID -> encoded "x,y,c;x,y,c;..." string
107	communityDesignNames avl.Tree // designID -> display name
108	communityDesignCount int64
109
110	// firstPlacement (see the commented-out block below recordPlacement)
111	// would have snapshotted each address's first placement on-chain for
112	// a future airdrop -- deliberately left disabled: it's an extra write
113	// bundled into a placer's own transaction, so it's an extra cost to
114	// them for a snapshot that only benefits a future project they don't
115	// get anything from today. Decided to build that snapshot by scanning
116	// transaction history off-chain instead, whenever the airdrop
117	// actually happens -- zero cost to placers now, at the price of that
118	// scan needing to be built later (and needing sapphire-1 indexer
119	// support confirmed at that point, which hasn't been checked yet).
120
121	totalPlacements int64
122	occupiedCells   int64
123	expansionsCount int64
124
125	migrationOpen = true
126)
127
128func key(x, y int64) string {
129	return strconv.FormatInt(x, 10) + "," + strconv.FormatInt(y, 10)
130}
131
132func callerAddress() address {
133	return unsaferealm.PreviousRealm().Address()
134}
135
136func assertOwner() {
137	if callerAddress() != collectionOwner {
138		panic("owner-only")
139	}
140}
141
142func getPixel(x, y int64) int64 {
143	if c, ok := grid.Get(key(x, y)).(int64); ok {
144		return c
145	}
146	return 0
147}
148
149func boardArea() int64  { return (maxX - minX + 1) * (maxY - minY + 1) }
150func boardWidth() int64 { return maxX - minX + 1 }
151
152func maybeExpand() {
153	if boardWidth() >= MaxBoardDim {
154		return
155	}
156	if occupiedCells*100 < boardArea()*ExpandThresholdPercent {
157		return
158	}
159	expand()
160}
161
162func expand() {
163	step := int64(ExpandStep)
164	if boardWidth()+2*step > MaxBoardDim {
165		step = (MaxBoardDim - boardWidth()) / 2
166	}
167	if step <= 0 {
168		return
169	}
170	minX -= step
171	maxX += step
172	minY -= step
173	maxY += step
174	expansionsCount++
175}
176
177func ForceExpand(cur realm) {
178	assertOwner()
179	if boardWidth() >= MaxBoardDim {
180		panic("already at max board size")
181	}
182	expand()
183}
184
185func inBounds(x, y int64) bool {
186	return x >= minX && x <= maxX && y >= minY && y <= maxY
187}
188
189func recordPlacement(x, y, colorIndex int64, placer address, height int64) {
190	k := key(x, y)
191	wasDefault := grid.Get(k) == nil
192	if colorIndex == 0 {
193		grid.Remove(k)
194		if !wasDefault {
195			occupiedCells--
196		}
197	} else {
198		grid.Set(k, colorIndex)
199		if wasDefault {
200			occupiedCells++
201		}
202	}
203	placedBy.Set(k, placer.String())
204	placedAtHeight.Set(k, height)
205	bumpPlacementCount(placer)
206	// recordFirstPlacementIfNeeded(placer, x, y, colorIndex, height) -- see below
207}
208
209// --- disabled: on-chain first-placement snapshot for a future airdrop ---
210//
211// Built and verified live on topaz-1 (pixelparttest1): correctly captured
212// each address's first (x, y, colorIndex, blockHeight) exactly once,
213// including through ImportHistoricalPixel's migration-replay path, and
214// never overwritten by that address's later placements. Left here,
215// commented out, in case the off-chain-scan approach turns out not to
216// work for sapphire-1 and this needs to come back.
217//
218// type firstPlacementEntry struct {
219// 	X, Y, ColorIndex, BlockHeight int64
220// }
221//
222// func recordFirstPlacementIfNeeded(placer address, x, y, colorIndex, height int64) {
223// 	key := placer.String()
224// 	if _, ok := firstPlacement.Get(key).(firstPlacementEntry); ok {
225// 		return
226// 	}
227// 	firstPlacement.Set(key, firstPlacementEntry{X: x, Y: y, ColorIndex: colorIndex, BlockHeight: height})
228// }
229
230// bumpPlacementCount counts every successful placement action (including
231// recolors and erasures of an existing cell) toward the leaderboard --
232// simpler and more transparent than trying to track "cells currently
233// attributed to this address", which is a fuzzy concept on a canvas
234// anyone can recolor.
235func bumpPlacementCount(placer address) {
236	k := placer.String()
237	count, _ := placementCounts.Get(k).(int64)
238	placementCounts.Set(k, count+1)
239}
240
241func isOfficialTarget(x, y, colorIndex int64) bool {
242	c, ok := officialTarget.Get(key(x, y)).(int64)
243	return ok && c == colorIndex
244}
245
246// inOfficialTargetArea reports whether (x, y) has ANY owner-curated
247// target color registered, regardless of what colorIndex is being
248// proposed -- used to tell "wrong color on a logo cell" (vandalism,
249// charged VandalismFeeUgnot) apart from "cell outside the design
250// entirely" (charged the normal FeePerPixelUgnot).
251func inOfficialTargetArea(x, y int64) bool {
252	_, ok := officialTarget.Get(key(x, y)).(int64)
253	return ok
254}
255
256func IsOfficialTarget(x, y, colorIndex int64) bool { return isOfficialTarget(x, y, colorIndex) }
257func InOfficialTargetArea(x, y int64) bool         { return inOfficialTargetArea(x, y) }
258
259// pixelFee returns what a single (x, y, colorIndex) placement costs:
260// free if it matches the official target exactly, VandalismFeeUgnot if
261// the cell is part of the target area but the color is wrong, or the
262// normal FeePerPixelUgnot for anywhere else on the board.
263func pixelFee(x, y, colorIndex int64) int64 {
264	if isOfficialTarget(x, y, colorIndex) {
265		return 0
266	}
267	if inOfficialTargetArea(x, y) {
268		return VandalismFeeUgnot
269	}
270	return FeePerPixelUgnot
271}
272
273func FeePerPixel() int64          { return FeePerPixelUgnot }
274func VandalismFeePerPixel() int64 { return VandalismFeeUgnot }
275func MaxBulkPixelsPerTx() int64   { return MaxBulkPixels }
276
277// SetOfficialTarget merges entries into the free-to-place design --
278// owner-only, additive rather than replacing. Registering the full
279// ~800-cell logo costs real storage deposit (measured ~0.19 GNOT/cell
280// on testnet), enough that doing it in one transaction risks needing
281// the whole amount upfront in a single call; additive means it can be
282// split across as many affordable calls as needed, called repeatedly
283// over time. encoded is "x,y,c;x,y,c;...", matching the CSV-ish
284// convention already used by TopPlacers. Calling this with a
285// coordinate already in the target just overwrites that one entry's
286// color, same as you'd expect.
287func SetOfficialTarget(cur realm, encoded string) {
288	assertOwner()
289	if encoded == "" {
290		return
291	}
292	for _, entry := range strings.Split(encoded, ";") {
293		parts := strings.SplitN(entry, ",", 3)
294		if len(parts) != 3 {
295			panic("malformed entry: " + entry)
296		}
297		x, err1 := strconv.ParseInt(parts[0], 10, 64)
298		y, err2 := strconv.ParseInt(parts[1], 10, 64)
299		c, err3 := strconv.ParseInt(parts[2], 10, 64)
300		if err1 != nil || err2 != nil || err3 != nil {
301			panic("malformed entry: " + entry)
302		}
303		officialTarget.Set(key(x, y), c)
304	}
305}
306
307// ClearOfficialTarget wipes the entire free-to-place design -- owner-
308// only. Split out from SetOfficialTarget now that registration is
309// additive, so "start over from nothing" is still possible but isn't
310// the default behavior of every registration call.
311func ClearOfficialTarget(cur realm) {
312	assertOwner()
313	officialTarget = avl.Tree{}
314}
315
316// SubmitCommunityDesign registers a new community-submitted design --
317// anyone can call this, not just the owner. encoded is
318// "x,y,c;x,y,c;...", already in absolute board coordinates (the
319// submitter positions it client-side before submitting). Placing any
320// of these cells still costs the normal FeePerPixelUgnot, same as any
321// other free-form cell -- this just makes the design's shape and
322// position visible on-chain so other visitors' browsers can render a
323// progress bar for it and help fill it in, the same way everyone
324// already helps finish the owner-curated logo.
325func SubmitCommunityDesign(cur realm, name string, encoded string) string {
326	runtime.AssertOriginCall()
327	if len(name) > MaxCommunityDesignNameLen {
328		panic(ufmt.Sprintf("name too long: max %d characters", MaxCommunityDesignNameLen))
329	}
330	if encoded == "" {
331		panic("empty design")
332	}
333	entries := strings.Split(encoded, ";")
334	if len(entries) > MaxCommunityDesignPixels {
335		panic(ufmt.Sprintf("design too large: %d pixels, max %d", len(entries), MaxCommunityDesignPixels))
336	}
337	for _, entry := range entries {
338		parts := strings.SplitN(entry, ",", 3)
339		if len(parts) != 3 {
340			panic("malformed entry: " + entry)
341		}
342		_, err1 := strconv.ParseInt(parts[0], 10, 64)
343		_, err2 := strconv.ParseInt(parts[1], 10, 64)
344		c, err3 := strconv.ParseInt(parts[2], 10, 64)
345		if err1 != nil || err2 != nil || err3 != nil {
346			panic("malformed entry: " + entry)
347		}
348		if c < 0 || c >= int64(len(palette)) {
349			panic("invalid color index in design")
350		}
351	}
352	id := "d" + strconv.FormatInt(communityDesignCount, 10)
353	communityDesignCount++
354	communityDesigns.Set(id, encoded)
355	communityDesignNames.Set(id, name)
356	return id
357}
358
359// ListCommunityDesigns returns "id,name,pixelCount;id,name,pixelCount;..."
360// for every submitted design, in submission order.
361func ListCommunityDesigns() string {
362	var b strings.Builder
363	for i := int64(0); i < communityDesignCount; i++ {
364		id := "d" + strconv.FormatInt(i, 10)
365		encoded, ok := communityDesigns.Get(id).(string)
366		if !ok {
367			continue
368		}
369		name, _ := communityDesignNames.Get(id).(string)
370		count := len(strings.Split(encoded, ";"))
371		if i > 0 {
372			b.WriteString(";")
373		}
374		b.WriteString(id + "," + name + "," + strconv.FormatInt(int64(count), 10))
375	}
376	return b.String()
377}
378
379// GetCommunityDesign returns the raw "x,y,c;x,y,c;..." for one design,
380// or "" if id doesn't exist.
381func GetCommunityDesign(id string) string {
382	encoded, _ := communityDesigns.Get(id).(string)
383	return encoded
384}
385
386// RemoveCommunityDesign lets the owner delist a design (offensive name,
387// spam, abuse of the free-submission path) -- owner-only moderation
388// hook. Pixels already placed toward it stay exactly as painted (this
389// only removes it from ListCommunityDesigns/GetCommunityDesign, it
390// doesn't touch the grid), and the id is never reused.
391func RemoveCommunityDesign(cur realm, id string) {
392	assertOwner()
393	communityDesigns.Remove(id)
394	communityDesignNames.Remove(id)
395}
396
397// collectPayment verifies at least feeUgnot of ugnot was attached to
398// this call (via the standard "send" field on /vm.m_call) and forwards
399// exactly that much from the realm's own balance to collectionOwner,
400// leaving any overpayment sitting in the realm's balance rather than
401// dealing with refund complexity -- callers control how much they
402// attach, so overpaying is their own choice, not something to protect
403// against.
404//
405// chain/runtime/unsafe's own doc on OriginSend warns the envelope is
406// shared across the whole call chain, so a malicious intermediate
407// realm could consume it after a naive check passes (TOCTOU) -- the
408// doc recommends pairing OriginSend with AssertOriginCall AND
409// IsUserCall. AssertOriginCall specifically panics if invoked from
410// anywhere but a top-level entry point (its own doc: "panic... when
411// invoked by another method, even from the same realm or package"),
412// so callers (SetPixel, SetPixels) call it themselves before reaching
413// here rather than this helper calling it on their behalf.
414func collectPayment(cur realm, feeUgnot int64) {
415	if !unsaferealm.PreviousRealm().IsUserCall() {
416		panic("payment must come from a direct user call")
417	}
418	sent := unsaferealm.OriginSend()
419	var total int64
420	for _, c := range sent {
421		if c.Denom == "ugnot" {
422			total += c.Amount
423		}
424	}
425	if total < feeUgnot {
426		panic(ufmt.Sprintf("insufficient payment: this placement costs %d ugnot, got %d", feeUgnot, total))
427	}
428	banker_ := banker.NewBanker(banker.BankerTypeRealmSend, cur)
429	banker_.SendCoins(cur.Address(), collectionOwner, chain.Coins{chain.NewCoin("ugnot", feeUgnot)})
430}
431
432func SetPixel(cur realm, x, y, colorIndex int64) {
433	runtime.AssertOriginCall()
434	maybeExpand()
435	if !inBounds(x, y) {
436		panic("out of bounds")
437	}
438	if colorIndex < 0 || colorIndex >= int64(len(palette)) {
439		panic("invalid color index")
440	}
441	caller := callerAddress()
442	callerKey := caller.String()
443	if last, ok := lastPlacedBlock.Get(callerKey).(int64); ok {
444		elapsed := runtime.ChainHeight() - last
445		if elapsed < CooldownBlocks {
446			panic(ufmt.Sprintf("cooldown active: %d more block(s) to wait", CooldownBlocks-elapsed))
447		}
448	}
449	if fee := pixelFee(x, y, colorIndex); fee > 0 {
450		collectPayment(cur, fee)
451	}
452	recordPlacement(x, y, colorIndex, caller, runtime.ChainHeight())
453	lastPlacedBlock.Set(callerKey, runtime.ChainHeight())
454	totalPlacements++
455}
456
457// SetPixels places up to MaxBulkPixels pixels in one transaction.
458// encoded is "x,y,c;x,y,c;...". Cooldown is checked and updated once
459// for the whole batch rather than per pixel -- SetPixel's own per-call
460// cooldown would otherwise reject every entry after the first within
461// the same block, since they all execute in the same transaction/
462// height. Fee is the sum of pixelFee(x, y, c) across every entry,
463// collected as one payment.
464func SetPixels(cur realm, encoded string) {
465	runtime.AssertOriginCall()
466	entries := strings.Split(encoded, ";")
467	if len(entries) == 0 || len(entries) > MaxBulkPixels {
468		panic(ufmt.Sprintf("SetPixels accepts 1 to %d pixels per call", MaxBulkPixels))
469	}
470	maybeExpand()
471	caller := callerAddress()
472	callerKey := caller.String()
473	if last, ok := lastPlacedBlock.Get(callerKey).(int64); ok {
474		elapsed := runtime.ChainHeight() - last
475		if elapsed < CooldownBlocks {
476			panic(ufmt.Sprintf("cooldown active: %d more block(s) to wait", CooldownBlocks-elapsed))
477		}
478	}
479
480	xs := make([]int64, len(entries))
481	ys := make([]int64, len(entries))
482	cs := make([]int64, len(entries))
483	var feeTotal int64
484	for i, entry := range entries {
485		parts := strings.SplitN(entry, ",", 3)
486		if len(parts) != 3 {
487			panic("malformed entry: " + entry)
488		}
489		x, err1 := strconv.ParseInt(parts[0], 10, 64)
490		y, err2 := strconv.ParseInt(parts[1], 10, 64)
491		c, err3 := strconv.ParseInt(parts[2], 10, 64)
492		if err1 != nil || err2 != nil || err3 != nil {
493			panic("malformed entry: " + entry)
494		}
495		if !inBounds(x, y) {
496			panic("out of bounds")
497		}
498		if c < 0 || c >= int64(len(palette)) {
499			panic("invalid color index")
500		}
501		feeTotal += pixelFee(x, y, c)
502		xs[i], ys[i], cs[i] = x, y, c
503	}
504
505	if feeTotal > 0 {
506		collectPayment(cur, feeTotal)
507	}
508
509	height := runtime.ChainHeight()
510	for i := range xs {
511		recordPlacement(xs[i], ys[i], cs[i], caller, height)
512		totalPlacements++
513	}
514	lastPlacedBlock.Set(callerKey, height)
515}
516
517func ImportHistoricalPixel(cur realm, x, y, colorIndex int64, originalPlacer address, originalHeight int64) {
518	assertOwner()
519	if !migrationOpen {
520		panic("migration window is closed")
521	}
522	if colorIndex < 0 || colorIndex >= int64(len(palette)) {
523		panic("invalid color index")
524	}
525	for x < minX || x > maxX || y < minY || y > maxY {
526		if boardWidth() >= MaxBoardDim {
527			panic("historical coordinate exceeds MaxBoardDim -- cannot import")
528		}
529		expand()
530	}
531	recordPlacement(x, y, colorIndex, originalPlacer, originalHeight)
532	totalPlacements++
533}
534
535func CloseMigrationWindow(cur realm) {
536	assertOwner()
537	migrationOpen = false
538}
539
540func MigrationOpen() bool { return migrationOpen }
541
542func GetPixel(x, y int64) int64 { return getPixel(x, y) }
543
544func PlacedBy(x, y int64) (address, int64) {
545	k := key(x, y)
546	addr, _ := placedBy.Get(k).(string)
547	height, _ := placedAtHeight.Get(k).(int64)
548	return address(addr), height
549}
550
551func Bounds() (int64, int64, int64, int64) { return minX, maxX, minY, maxY }
552
553func BoardWidth() int64  { return boardWidth() }
554func BoardHeight() int64 { return maxY - minY + 1 }
555
556func TotalPlacements() int64 { return totalPlacements }
557func OccupiedCells() int64   { return occupiedCells }
558func ExpansionsCount() int64 { return expansionsCount }
559
560func PaletteCSV() string { return strings.Join(palette, ",") }
561
562type placerCount struct {
563	addr  string
564	count int64
565}
566
567// TopPlacers returns up to n "address,count" pairs, semicolon-separated,
568// sorted by count descending -- the leaderboard. Iterated fresh on every
569// call rather than kept pre-sorted, since contributor counts are small
570// enough (real participants, not per-pixel) for a plain insertion sort
571// to be cheap regardless of how large the canvas itself grows. Gno's
572// "sort" package predates Go's generics-based sort.Slice, so this is
573// hand-rolled rather than using sort.Interface for a one-off local type.
574func TopPlacers(n int64) string {
575	var entries []placerCount
576	placementCounts.Iterate("", "", func(k string, v any) bool {
577		entries = append(entries, placerCount{k, v.(int64)})
578		return false
579	})
580	for i := 1; i < len(entries); i++ {
581		cur := entries[i]
582		j := i - 1
583		for j >= 0 && entries[j].count < cur.count {
584			entries[j+1] = entries[j]
585			j--
586		}
587		entries[j+1] = cur
588	}
589	if n >= 0 && int64(len(entries)) > n {
590		entries = entries[:n]
591	}
592	var b strings.Builder
593	for i, e := range entries {
594		if i > 0 {
595			b.WriteString(";")
596		}
597		b.WriteString(e.addr + "," + strconv.FormatInt(e.count, 10))
598	}
599	return b.String()
600}
601
602// FirstPlacementOf/ListParticipants/ParticipantCount -- disabled along
603// with firstPlacement/recordFirstPlacementIfNeeded above. See that
604// comment for why. Query shapes kept here for reference in case the
605// off-chain scan doesn't pan out for sapphire-1 and this needs reviving.
606//
607// func FirstPlacementOf(addr address) (x, y, colorIndex, blockHeight int64, found bool) {
608// 	entry, ok := firstPlacement.Get(addr.String()).(firstPlacementEntry)
609// 	if !ok {
610// 		return 0, 0, 0, 0, false
611// 	}
612// 	return entry.X, entry.Y, entry.ColorIndex, entry.BlockHeight, true
613// }
614//
615// func ListParticipants() string {
616// 	var b strings.Builder
617// 	first := true
618// 	firstPlacement.Iterate("", "", func(k string, v any) bool {
619// 		entry := v.(firstPlacementEntry)
620// 		if !first {
621// 			b.WriteString(";")
622// 		}
623// 		first = false
624// 		b.WriteString(k + "," +
625// 			strconv.FormatInt(entry.X, 10) + "," +
626// 			strconv.FormatInt(entry.Y, 10) + "," +
627// 			strconv.FormatInt(entry.ColorIndex, 10) + "," +
628// 			strconv.FormatInt(entry.BlockHeight, 10))
629// 		return false
630// 	})
631// 	return b.String()
632// }
633//
634// func ParticipantCount() int64 {
635// 	n := int64(0)
636// 	firstPlacement.Iterate("", "", func(k string, v any) bool {
637// 		n++
638// 		return false
639// 	})
640// 	return n
641// }
642
643func CooldownRemaining(addr address) int64 {
644	last, ok := lastPlacedBlock.Get(addr.String()).(int64)
645	if !ok {
646		return 0
647	}
648	elapsed := runtime.ChainHeight() - last
649	if elapsed >= CooldownBlocks {
650		return 0
651	}
652	return CooldownBlocks - elapsed
653}
654
655func Snapshot() string {
656	var b strings.Builder
657	for y := minY; y <= maxY; y++ {
658		for x := minX; x <= maxX; x++ {
659			b.WriteString(strconv.FormatInt(getPixel(x, y), 10))
660		}
661	}
662	return b.String()
663}
664
665func canvasDataURI() string {
666	w := (maxX - minX + 1) * cellPx
667	h := (maxY - minY + 1) * cellPx
668	var svg strings.Builder
669	svg.WriteString(ufmt.Sprintf(
670		`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`,
671		w, h,
672	))
673	svg.WriteString(ufmt.Sprintf(`<rect width="100%%" height="100%%" fill="%s"/>`, palette[0]))
674	grid.Iterate("", "", func(k string, v any) bool {
675		parts := strings.SplitN(k, ",", 2)
676		x, _ := strconv.ParseInt(parts[0], 10, 64)
677		y, _ := strconv.ParseInt(parts[1], 10, 64)
678		c := v.(int64)
679		svg.WriteString(ufmt.Sprintf(
680			`<rect x="%d" y="%d" width="%d" height="%d" fill="%s"/>`,
681			(x-minX)*cellPx, (y-minY)*cellPx, cellPx, cellPx, palette[c],
682		))
683		return false
684	})
685	svg.WriteString("</svg>")
686	return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg.String()))
687}
688
689func Render(path string) string {
690	var b strings.Builder
691	b.WriteString("# GNO Pixels\n\n")
692	b.WriteString(ufmt.Sprintf(
693		"A %dx%d collaborative on-chain canvas (grows toward a %dx%d max as it fills). `SetPixel(x, y, colorIndex)` takes no address -- the pixel always belongs to whoever signs, and there's a %d-block cooldown per address between placements.\n\n",
694		boardWidth(), maxY-minY+1, MaxBoardDim, MaxBoardDim, CooldownBlocks,
695	))
696	b.WriteString(ufmt.Sprintf(
697		"**Total placements:** %d | **Cells painted:** %d / %d | **Expansions so far:** %d\n\n",
698		totalPlacements, occupiedCells, boardArea(), expansionsCount,
699	))
700	b.WriteString(ufmt.Sprintf("![canvas](%s)\n\n", canvasDataURI()))
701	b.WriteString("## Palette\n\n")
702	b.WriteString("| Index | Color |\n|---|---|\n")
703	for i, hex := range palette {
704		b.WriteString(ufmt.Sprintf("| %d | %s |\n", i, hex))
705	}
706	return b.String()
707}