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

million.gno

9.02 Kb · 275 lines
  1// Package million is a pay-per-pixel collaborative canvas: anyone can paint
  2// any pixel of a Width x Height grid in one of 15 colors by sending exactly
  3// Price() ugnot per pixel along with the call. Pixels can be repainted by
  4// anyone, for the same price. Payments are forwarded to the admin wallet
  5// as they arrive, and the admin paints for free.
  6//
  7// The grid logic lives in gno.land/p/g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr/canvas/v0; this realm adds the
  8// payment guard, the owner controls and the read API used by the web client.
  9package million
 10
 11import (
 12	"chain"
 13	"chain/banker"
 14	"chain/runtime/unsafe"
 15	"strconv"
 16	"strings"
 17
 18	"gno.land/p/g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr/canvas/v0"
 19	"gno.land/p/nt/ufmt/v0"
 20)
 21
 22const (
 23	// Width and Height are fixed for the life of this version: changing
 24	// them would change the storage layout, so it means a new /vN.
 25	// 1250 x 800 is one million pixels in a 16:10 laptop aspect ratio.
 26	Width  = 1250
 27	Height = 800
 28
 29	// MaxBatch bounds a single PaintBatch call so one tx cannot blow the
 30	// block gas limit or overflow the price arithmetic.
 31	MaxBatch = 500
 32
 33	// DefaultPrice is the initial price per pixel, in ugnot (0.05 GNOT).
 34	DefaultPrice int64 = 50_000
 35
 36	// DefaultAdmin receives every payment, paints for free and holds the
 37	// owner controls. Change it before deploying; TransferOwnership moves
 38	// it afterwards.
 39	DefaultAdmin = "g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr"
 40
 41	Denom = "ugnot"
 42)
 43
 44var (
 45	board = canvas.New(Width, Height)
 46	price = DefaultPrice
 47	owner = address(DefaultAdmin)
 48
 49	// Stats.
 50	revenue int64 // total ugnot ever paid for pixels
 51	paints  int64 // number of successful Paint/PaintBatch calls
 52
 53	// Change tracking, so clients fetch only the rows that moved.
 54	// version bumps once per successful paint call; rowVersion holds the
 55	// version of the last paint that touched each row (0 = never painted).
 56	version    int64
 57	rowVersion = make([]int64, Height)
 58)
 59
 60// Paint colors one pixel. Send exactly Price() ugnot (nothing for the admin).
 61func Paint(cur realm, x, y, color int) {
 62	if color <= 0 || color > int(canvas.MaxColor) {
 63		panic(canvas.ErrInvalidColor)
 64	}
 65	paint([]canvas.Pixel{{X: x, Y: y, Color: byte(color)}}, cur)
 66}
 67
 68// PaintBatch colors up to MaxBatch pixels in one call. The argument is
 69// "x,y,color;x,y,color;...". Send exactly Price() * count ugnot (nothing
 70// for the admin).
 71func PaintBatch(cur realm, pixels string) {
 72	px, err := canvas.ParsePixels(pixels)
 73	if err != nil {
 74		panic(err)
 75	}
 76	paint(px, cur)
 77}
 78
 79// paint takes the realm as its LAST parameter on purpose: a realm first
 80// parameter would make it a crossing function and Previous() would then
 81// be this realm instead of the user.
 82func paint(px []canvas.Pixel, rlm realm) {
 83	if !rlm.IsCurrent() {
 84		panic("spoofed realm")
 85	}
 86	// Payment guard: unsafe.OriginSend() describes the coins attached to
 87	// the transaction, which only provably landed here when the caller is
 88	// a plain user call (no intermediate or ephemeral realm). Keep the two
 89	// checks together. unsafe is imported for OriginSend only; caller
 90	// identity always comes from the realm handle.
 91	if !rlm.Previous().IsUserCall() {
 92		panic("must be called directly by a user (maketx call)")
 93	}
 94	painter := rlm.Previous().Address()
 95	n := len(px)
 96	if n == 0 {
 97		panic("nothing to paint")
 98	}
 99	if n > MaxBatch {
100		panic(ufmt.Sprintf("too many pixels: %d > %d", n, MaxBatch))
101	}
102	for _, p := range px {
103		if err := board.Check(p); err != nil {
104			panic(err)
105		}
106	}
107
108	cost := price * int64(n)
109	if painter == owner {
110		cost = 0 // the admin paints for free
111	}
112	if sent := sentUgnot(unsafe.OriginSend()); sent != cost {
113		panic(ufmt.Sprintf("must send exactly %d%s for %d pixel(s), got %d%s", cost, Denom, n, sent, Denom))
114	}
115	if cost > 0 {
116		// Forward the payment to the admin right away, so nothing
117		// accumulates in the realm. BankerTypeOriginSend can only move
118		// what this very message sent, which is exactly cost.
119		bnk := banker.NewBanker(banker.BankerTypeOriginSend, rlm)
120		bnk.SendCoins(rlm.Address(), owner, chain.NewCoins(chain.NewCoin(Denom, cost)))
121	}
122
123	version++
124	for _, p := range px {
125		board.Set(p)
126		rowVersion[p.Y] = version
127	}
128	revenue += cost
129	paints++
130
131	// The pixel list itself is not emitted: event attribute values are
132	// size-capped by the node and a full batch exceeds it. Clients re-read
133	// Rows() after a paint.
134	chain.Emit("Paint",
135		"painter", painter.String(),
136		"count", strconv.Itoa(n),
137		"paid", strconv.Itoa(int(cost))+Denom,
138	)
139}
140
141// sentUgnot returns the ugnot amount in coins and rejects any other denom,
142// so a caller cannot pay with something the realm does not price.
143func sentUgnot(coins chain.Coins) int64 {
144	var amount int64
145	for _, c := range coins {
146		if c.Denom != Denom {
147			panic("only " + Denom + " is accepted")
148		}
149		amount += c.Amount
150	}
151	return amount
152}
153
154// --- owner controls ---
155
156// assertOwner panics unless the verified caller behind rlm is the owner.
157// The realm is deliberately not the first parameter (see paint); the
158// leading blank int is the same idiom gno.land/p/nt/ownable uses.
159func assertOwner(_ int, rlm realm) {
160	if !rlm.IsCurrent() {
161		panic("spoofed realm")
162	}
163	if rlm.Previous().Address() != owner {
164		panic("owner only")
165	}
166}
167
168// SetPrice changes the price per pixel, in ugnot. Zero makes painting free
169// for everyone.
170func SetPrice(cur realm, ugnot int64) {
171	assertOwner(0, cur)
172	if ugnot < 0 {
173		panic("price must not be negative")
174	}
175	price = ugnot
176}
177
178// Withdraw sends amount ugnot held by the realm to the given address.
179// Payments are forwarded on arrival, so this only matters for coins sent
180// to the realm outside of a paint call.
181func Withdraw(cur realm, to address, amount int64) {
182	assertOwner(0, cur)
183	if !to.IsValid() {
184		panic("invalid address")
185	}
186	if amount <= 0 {
187		panic("amount must be positive")
188	}
189	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
190	bnk.SendCoins(cur.Address(), to, chain.NewCoins(chain.NewCoin(Denom, amount)))
191}
192
193// TransferOwnership hands the admin role (payments, free painting and the
194// owner controls) to newOwner.
195func TransferOwnership(cur realm, newOwner address) {
196	assertOwner(0, cur)
197	if !newOwner.IsValid() {
198		panic("invalid address")
199	}
200	owner = newOwner
201}
202
203// --- read API (used by the web client through vm/qeval) ---
204
205func GetWidth() int    { return Width }
206func GetHeight() int   { return Height }
207func Price() int64     { return price }
208func Owner() address   { return owner }
209func Painted() int     { return board.Painted() }
210func Revenue() int64   { return revenue }
211func Paints() int64    { return paints }
212func MaxBatchSize() int { return MaxBatch }
213
214// Pixel returns the color index at (x, y); 0 means never painted.
215func Pixel(x, y int) int { return int(board.Get(x, y)) }
216
217// Rows returns rows [from, to) as one hex digit per pixel, Width digits per
218// row, with no separators. Clients page through the grid with it.
219func Rows(from, to int) string { return board.Rows(from, to) }
220
221// Version is the number of successful paint calls so far. Clients remember
222// it and ask DirtyRows for what changed since.
223func Version() int64 { return version }
224
225// DirtyRows lists the rows painted after version `since`, as "y,y,y" in
226// ascending order. DirtyRows(0) is every row that was ever painted, which
227// is all a fresh client needs to load: the others are blank.
228func DirtyRows(since int64) string {
229	if since < 0 {
230		since = 0
231	}
232	var sb strings.Builder
233	for y, v := range rowVersion {
234		if v <= since {
235			continue
236		}
237		if sb.Len() > 0 {
238			sb.WriteByte(',')
239		}
240		sb.WriteString(strconv.Itoa(y))
241	}
242	return sb.String()
243}
244
245// Palette returns the 16 palette colors as comma-separated CSS hex values,
246// index 0 being the blank color.
247func Palette() string {
248	return strings.Join(canvas.Palette[:], ",")
249}
250
251// Render shows the game stats on gnoweb. The path is ignored.
252func Render(_ string) string {
253	var sb strings.Builder
254	sb.WriteString("# Million Gno\n\n")
255	sb.WriteString("A pay-per-pixel canvas. Pick a color, pay the price, own the pixel until someone repaints it. Payments go to the admin wallet, which paints for free.\n\n")
256	sb.WriteString("| | |\n|---|---|\n")
257	sb.WriteString(ufmt.Sprintf("| Size | %d x %d |\n", Width, Height))
258	sb.WriteString(ufmt.Sprintf("| Price per pixel | %d%s |\n", price, Denom))
259	sb.WriteString(ufmt.Sprintf("| Pixels painted | %d / %d |\n", board.Painted(), Width*Height))
260	sb.WriteString(ufmt.Sprintf("| Paint calls | %d |\n", paints))
261	sb.WriteString(ufmt.Sprintf("| Version | %d |\n", version))
262	sb.WriteString(ufmt.Sprintf("| Revenue | %d%s |\n", revenue, Denom))
263	sb.WriteString(ufmt.Sprintf("| Admin | %s |\n", owner.String()))
264	sb.WriteString("\n## Palette\n\n")
265	for i := 1; i <= int(canvas.MaxColor); i++ {
266		sb.WriteString(ufmt.Sprintf("- %d: `%s`\n", i, canvas.Palette[i]))
267	}
268	sb.WriteString("\n## Paint\n\n")
269	sb.WriteString("- [Paint one pixel](" + Realm + "$help&func=Paint): `Paint(x, y, color)` with `-send <price>ugnot`\n")
270	sb.WriteString("- [Paint a batch](" + Realm + "$help&func=PaintBatch): `PaintBatch(\"x,y,color;x,y,color\")` with `-send <price*count>ugnot`\n")
271	return sb.String()
272}
273
274// Realm is this realm's gnoweb path.
275const Realm = "/r/g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr/million/v0"