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