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("\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}