fish.gno
14.00 Kb · 675 lines
1// Package fish is the Shoal collection. Each fish is a GRC721 on this realm.
2// List, cancel, buy, and Bite live here too. Gno cannot pull a foreign token into
3// Bazaar, so the book is not a second package.
4//
5// Not deployed. Init does not call bazaarv2 Reserve or Register.
6package fish2
7
8import (
9 "chain"
10 "chain/banker"
11 "chain/runtime/unsafe"
12 "errors"
13 "strconv"
14 "strings"
15
16 "gno.land/p/nt/grc721/metadata/v0"
17 "gno.land/p/nt/grc721/v0"
18 "gno.land/p/nt/seqid/v0"
19)
20
21const (
22 denom = "ugnot"
23 ugnot = 1_000_000
24 mintFee = 5 * ugnot
25 layFee = 2 * ugnot
26 upgradeFee = 15 * ugnot
27 protocolBps = 200
28 maxRoyalty = 1000
29 maxPrice = 1_000_000 * ugnot
30 combatLockMs = 5000
31 layBlocks = 2
32 listCap = 50
33)
34
35type fishBody struct {
36 id int
37 name string
38 rarity string
39 species string
40 size string
41 trait string
42 hp int
43 hpNow int
44 dodge int
45 bouts int
46 layBoutsUsed int
47 layOpen bool
48 layHeight int64
49 pool int64
50 inMatch bool
51 boutPaid bool
52 mustHeal bool
53 startUnix int64
54 endUnix int64
55 sx int
56 sz int
57 faceDx int
58 faceDz int
59 growth int
60 evolved bool
61 aspect string
62 aspectBps int
63 healDay int64
64 healsUsed int
65 healReadyAt int64
66 springBorn int64
67 nextMoveMilli int64
68 burstUntil int64
69 nextBurstAt int64
70 nextBiteMilli int64
71 lastCombatMilli int64
72 bittenBy int
73 bittenUntil int64
74 owner address
75 seller address
76 price int64
77 listed bool
78 burned bool
79 image string
80}
81
82var (
83 bodies []*fishBody
84 nextID int
85 minted int
86 inited bool
87 token *grc721.Token
88 ledger *grc721.PrivateLedger
89 metaView *metadata.Metadata
90 metaLed *metadata.Ledger
91 tokenSeq seqid.ID
92 creator address
93 self address
94 upgradeSink address
95 lpSink address
96 burnSink address
97 protocolSink address
98 seasonSink address
99 royaltyBps int64
100 imageBase string
101 portrait string
102 colName string
103 colSymbol string
104)
105
106func Init(cur realm, name, symbol, images string, royalty int64, upgrade, lp, burn, protocol, season address) {
107 if !cur.IsCurrent() {
108 panic("fish: realm")
109 }
110 if !cur.Previous().IsUserCall() {
111 panic("fish: eoa")
112 }
113 if inited {
114 panic("fish: init")
115 }
116 takeExact(0)
117 if !validCollectionName(name) || !validSymbol(symbol) {
118 panic("fish: name")
119 }
120 if !strings.HasPrefix(images, "https://") || strings.Contains(images, "|") || len(images) > 160 {
121 panic("fish: image")
122 }
123 if royalty < 0 || royalty > maxRoyalty {
124 panic("fish: royalty")
125 }
126 if !upgrade.IsValid() || !lp.IsValid() || !burn.IsValid() || !protocol.IsValid() || !season.IsValid() {
127 panic("fish: sink")
128 }
129 tok, led := grc721.NewToken(name, symbol, tokenSeq.Next(), cur)
130 token = tok
131 ledger = led
132 metaView, metaLed = metadata.NewMetadata(led)
133 creator = cur.Previous().Address()
134 self = cur.Address()
135 upgradeSink = upgrade
136 lpSink = lp
137 burnSink = burn
138 protocolSink = protocol
139 seasonSink = season
140 royaltyBps = royalty
141 imageBase = images
142 colName = name
143 colSymbol = symbol
144 bodies = []*fishBody{nil}
145 nextID = 0
146 minted = 0
147 inited = true
148 chain.Emit("Init", "creator", creator.String(), "symbol", symbol)
149}
150
151func Mint(cur realm, name, size, trait string) int {
152 caller := requireUser(cur)
153 if unplayed(caller) {
154 panic("fish: unplayed")
155 }
156 if !validFishName(name) {
157 panic("fish: name")
158 }
159 if !validSize(size) {
160 panic("fish: size")
161 }
162 if !validTrait(trait) {
163 panic("fish: trait")
164 }
165 takeExact(mintFee)
166 payMint(mintFee, cur)
167 return issue(caller, name, "common", size, trait)
168}
169
170// SetArt sets one https image on every unburned fish. The creator may call it again.
171func SetArt(cur realm, image string) {
172 caller := requireUser(cur)
173 requireInit()
174 if caller != creator {
175 panic("fish: owner")
176 }
177 takeExact(0)
178 if !validArt(image) {
179 panic("fish: image")
180 }
181 portrait = image
182 for _, body := range bodies {
183 if body == nil || body.burned {
184 continue
185 }
186 body.image = image
187 writeURI(body)
188 }
189 chain.Emit("SetArt", "image", image)
190}
191
192func Portrait() string {
193 requireInit()
194 return portrait
195}
196
197func Transfer(cur realm, to address, id int) {
198 caller := requireUser(cur)
199 takeExact(0)
200 if !to.IsValid() || to == caller || to == self {
201 panic("fish: addr")
202 }
203 body := must(id)
204 if body.owner != caller {
205 panic("fish: owner")
206 }
207 guardMovable(body)
208 moveToken(caller, caller, to, id)
209 body.owner = to
210}
211
212func TransferFrom(cur realm, from, to, tid string) {
213 caller := requireUser(cur)
214 takeExact(0)
215 id := parseID(tid)
216 src := address(from)
217 dst := address(to)
218 if !src.IsValid() || !dst.IsValid() || dst == self {
219 panic("fish: addr")
220 }
221 body := must(id)
222 if body.owner != src {
223 panic("fish: owner")
224 }
225 guardMovable(body)
226 moveToken(caller, src, dst, id)
227 body.owner = dst
228}
229
230func Approve(cur realm, to, tid string) {
231 caller := requireUser(cur)
232 takeExact(0)
233 id := parseID(tid)
234 body := must(id)
235 guardMovable(body)
236 who := address(to)
237 if to != "" && !who.IsValid() {
238 panic("fish: addr")
239 }
240 if err := ledger.Approve(caller, who, tokenID(id)); err != nil {
241 panic("fish: approve")
242 }
243}
244
245func SetApprovalForAll(cur realm, operator string, approved bool) {
246 caller := requireUser(cur)
247 takeExact(0)
248 op := address(operator)
249 if !op.IsValid() {
250 panic("fish: addr")
251 }
252 if err := ledger.SetApprovalForAll(caller, op, approved); err != nil {
253 panic("fish: approve")
254 }
255}
256
257func OwnerOf(tid string) (address, error) {
258 requireInit()
259 id, err := strconv.Atoi(tid)
260 if err != nil || id < 1 || id >= len(bodies) || bodies[id] == nil || bodies[id].burned {
261 return address(""), errors.New("fish: unknown")
262 }
263 return bodies[id].owner, nil
264}
265
266func Name() string {
267 requireInit()
268 return token.GetName()
269}
270
271func Symbol() string {
272 requireInit()
273 return token.GetSymbol()
274}
275
276func GrcName() string { return Name() }
277func GrcSymbol() string { return Symbol() }
278
279func BalanceOf(addr string) int64 {
280 requireInit()
281 who := address(addr)
282 if !who.IsValid() {
283 return 0
284 }
285 n, err := token.BalanceOf(who)
286 if err != nil {
287 return 0
288 }
289 return n
290}
291
292func GrcBalanceOf(addr string) int64 { return BalanceOf(addr) }
293
294func TokenURI(tid string) string {
295 id := parseID(tid)
296 must(id)
297 uri, err := metaView.TokenURI(tokenID(id))
298 if err != nil || uri == "" {
299 panic("fish: uri")
300 }
301 return uri
302}
303
304func TokensOf(owner string) string {
305 requireInit()
306 who := address(owner)
307 if !who.IsValid() {
308 return ""
309 }
310 out := ""
311 for _, body := range bodies {
312 if body == nil || body.burned || body.owner != who {
313 continue
314 }
315 if out != "" {
316 out += ","
317 }
318 out += strconv.Itoa(body.id)
319 }
320 return out
321}
322
323func TokenOfOwnerByIndex(owner string, index int64) string {
324 requireInit()
325 who := address(owner)
326 if !who.IsValid() {
327 panic("fish: addr")
328 }
329 if index < 0 {
330 panic("fish: index")
331 }
332 var seen int64
333 for _, body := range bodies {
334 if body == nil || body.burned || body.owner != who {
335 continue
336 }
337 if seen == index {
338 return strconv.Itoa(body.id)
339 }
340 seen++
341 }
342 panic("fish: index")
343}
344
345func TotalSupply() int64 {
346 requireInit()
347 return token.TotalSupply()
348}
349
350func GetApproved(id int) string {
351 requireInit()
352 who, err := token.GetApproved(tokenID(id))
353 if err != nil || !who.IsValid() {
354 return ""
355 }
356 return who.String()
357}
358
359func IsApprovedForAll(owner, operator string) bool {
360 requireInit()
361 a := address(owner)
362 b := address(operator)
363 if !a.IsValid() || !b.IsValid() {
364 return false
365 }
366 return token.IsApprovedForAll(a, b)
367}
368
369func MintPrice() int64 { return mintFee }
370func MaxSupply() int { return 0 }
371func Minted() int { return minted }
372func RoyaltyBps() int64 { return royaltyBps }
373func Creator() address { return creator }
374func ProtocolBps() int64 { return protocolBps }
375
376func BoutsOf(id int) int { return must(id).bouts }
377func PoolOf(id int) int64 { return must(id).pool }
378func Listed(id int) bool { return must(id).listed }
379func InMatch(id int) bool { return must(id).inMatch }
380func LayOpen(id int) bool { return must(id).layOpen }
381func RarityOf(id int) string { return must(id).rarity }
382func SizeOf(id int) string { return must(id).size }
383func TraitOf(id int) string { return must(id).trait }
384func SpeciesOf(id int) string { return must(id).species }
385
386func issue(to address, name, rarity, size, trait string) int {
387 nextID++
388 id := nextID
389 maxHP := hpOf(rarity, size, trait)
390 body := &fishBody{
391 id: id,
392 name: name,
393 rarity: rarity,
394 species: speciesOf(rarity),
395 size: size,
396 trait: trait,
397 hp: maxHP,
398 hpNow: maxHP,
399 dodge: dodgeOf(size),
400 faceDx: 0,
401 faceDz: -1,
402 aspectBps: 10000,
403 springBorn: -1,
404 lastCombatMilli: -1,
405 owner: to,
406 image: artOf(rarity),
407 }
408 for len(bodies) <= id {
409 bodies = append(bodies, nil)
410 }
411 bodies[id] = body
412 minted++
413 mintGRC(to, body)
414 return id
415}
416
417func mintGRC(to address, body *fishBody) {
418 tid := tokenID(body.id)
419 if err := ledger.Mint(to, tid); err != nil {
420 panic("fish: mint")
421 }
422 writeURI(body)
423}
424
425func writeURI(body *fishBody) {
426 if err := metaLed.SetTokenURI(tokenID(body.id), tokenJSON(body)); err != nil {
427 panic("fish: uri")
428 }
429}
430
431func artOf(rarity string) string {
432 if portrait != "" {
433 return portrait
434 }
435 return imageBase + "/" + rarity + ".png"
436}
437
438func burnGRC(id int) {
439 if err := ledger.Burn(tokenID(id)); err != nil {
440 panic("fish: burn")
441 }
442}
443
444func moveToken(spender, from, to address, id int) {
445 if err := ledger.TransferFrom(spender, from, to, tokenID(id)); err != nil {
446 panic("fish: transfer")
447 }
448}
449
450func tokenJSON(body *fishBody) string {
451 return "data:application/json;charset=utf-8," +
452 `{"name":"` + body.name + `","description":"Shoal fish","image":"` + body.image +
453 `","attributes":[{"trait_type":"Species","value":"` + body.species +
454 `"},{"trait_type":"Size","value":"` + body.size +
455 `"},{"trait_type":"Trait","value":"` + body.trait +
456 `"},{"trait_type":"Rarity","value":"` + body.rarity +
457 `"},{"trait_type":"HP","value":"` + strconv.Itoa(body.hp) + `"}]}`
458}
459
460func requireUser(cur realm) address {
461 if !cur.IsCurrent() {
462 panic("fish: realm")
463 }
464 if !cur.Previous().IsUserCall() {
465 panic("fish: eoa")
466 }
467 return cur.Previous().Address()
468}
469
470func requireInit() {
471 if !inited || token == nil {
472 panic("fish: init")
473 }
474}
475
476func takeExact(n int64) {
477 sent := unsafe.OriginSend()
478 if n == 0 {
479 if len(sent) != 0 {
480 panic("fish: payment")
481 }
482 return
483 }
484 if len(sent) != 1 || sent[0].Denom != denom || sent[0].Amount != n {
485 panic("fish: payment")
486 }
487}
488
489func payMint(fee int64, rlm realm) {
490 up := fee * 7000 / 10000
491 lp := fee * 2000 / 10000
492 burn := fee - up - lp
493 send(upgradeSink, up, rlm)
494 send(lpSink, lp, rlm)
495 send(burnSink, burn, rlm)
496}
497
498func send(to address, amt int64, rlm realm) {
499 if amt == 0 {
500 return
501 }
502 if !to.IsValid() {
503 panic("fish: sink")
504 }
505 bk := banker.NewBanker(banker.BankerTypeRealmSend, rlm)
506 bk.SendCoins(rlm.Address(), to, chain.Coins{{Denom: denom, Amount: amt}})
507}
508
509func must(id int) *fishBody {
510 requireInit()
511 if id < 1 || id >= len(bodies) || bodies[id] == nil || bodies[id].burned {
512 panic("fish: unknown")
513 }
514 return bodies[id]
515}
516
517func guardMovable(body *fishBody) {
518 if body.listed {
519 panic("fish: listed")
520 }
521 if body.inMatch {
522 panic("fish: match")
523 }
524 if body.pool != 0 {
525 panic("fish: pool")
526 }
527 if body.layOpen {
528 panic("fish: lay")
529 }
530}
531
532func unplayed(owner address) bool {
533 for _, body := range bodies {
534 if body == nil || body.burned || body.bouts >= 1 {
535 continue
536 }
537 if body.owner == owner || (body.listed && body.seller == owner) {
538 return true
539 }
540 }
541 return false
542}
543
544func tokenID(id int) grc721.TokenID {
545 return grc721.TokenID(strconv.Itoa(id))
546}
547
548func parseID(tid string) int {
549 id, err := strconv.Atoi(tid)
550 if err != nil || id < 1 {
551 panic("fish: unknown")
552 }
553 return id
554}
555
556func validFishName(name string) bool {
557 if len(name) < 1 || len(name) > 20 {
558 return false
559 }
560 for _, c := range name {
561 if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != ' ' {
562 return false
563 }
564 }
565 return true
566}
567
568func validCollectionName(name string) bool {
569 return name != "" && len(name) <= 64 && !strings.Contains(name, "|")
570}
571
572func validArt(image string) bool {
573 if !strings.HasPrefix(image, "https://") || len(image) > 160 || strings.Contains(image, "|") {
574 return false
575 }
576 if strings.Contains(image, `"`) || strings.Contains(image, "\n") || strings.Contains(image, "\\") {
577 return false
578 }
579 return true
580}
581
582func validSymbol(symbol string) bool {
583 if symbol == "" || len(symbol) > grc721.MaxSymbolLen {
584 return false
585 }
586 for _, c := range symbol {
587 if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') {
588 return false
589 }
590 }
591 return true
592}
593
594func validSize(size string) bool {
595 return size == "small" || size == "medium" || size == "large"
596}
597
598func validTrait(trait string) bool {
599 return trait == "swift" || trait == "fang" || trait == "hide"
600}
601
602func speciesOf(rarity string) string {
603 switch rarity {
604 case "common":
605 return "Minnow"
606 case "uncommon":
607 return "Mackerel"
608 case "rare":
609 return "Snapper"
610 case "epic":
611 return "Angler"
612 case "legendary":
613 return "Koi Dragon"
614 case "mythic":
615 return "Abyss Leviathan"
616 default:
617 panic("fish: rarity")
618 }
619}
620
621func hpBase(rarity string) int {
622 switch rarity {
623 case "common":
624 return 100
625 case "uncommon":
626 return 112
627 case "rare":
628 return 126
629 case "epic":
630 return 142
631 case "legendary":
632 return 160
633 case "mythic":
634 return 180
635 default:
636 panic("fish: rarity")
637 }
638}
639
640func hpOf(rarity, size, trait string) int {
641 hp := hpBase(rarity)
642 if size == "small" {
643 hp = hp * 88 / 100
644 }
645 if size == "large" {
646 hp = hp * 120 / 100
647 }
648 if trait == "hide" {
649 hp = hp * 112 / 100
650 }
651 if hp < 1 {
652 hp = 1
653 }
654 return hp
655}
656
657func dodgeOf(size string) int {
658 if size == "small" {
659 return 24
660 }
661 return 0
662}
663
664func arenaStake(stake int64) bool {
665 if stake >= 5*ugnot && stake < 20*ugnot {
666 return true
667 }
668 if stake >= 20*ugnot && stake < 80*ugnot {
669 return true
670 }
671 if stake >= 80*ugnot && stake < 300*ugnot {
672 return true
673 }
674 return stake >= 300*ugnot && stake <= 1000*ugnot
675}