fishsea.gno
10.81 Kb · 427 lines
1// Package fishsea is the person-versus-person sea.
2// CommitMint pays 150 GNOT and the whole fee goes to the wallet that called Init.
3// RevealMint, once the chain is at least two blocks later, rolls rarity and trait
4// from sha256 of the caller, the commit height, that fixed height plus two, and the id.
5// Waiting longer does not change the roll. The player does not pass trait, size, or rarity.
6// Swim stays off chain. Register locks at least 10 GNOT. Leave returns it after 5 seconds.
7// Settle pays one covered stake: 80% winner, 10% liquidity, 10% team.
8// There is no machine fish and no FISH draw.
9// A seated wallet can settle any other seated fish. The realm does not replay the bite.
10// fishlive stays as deployed. This package does not move it.
11// fishsea-1.
12package fishsea
13
14import (
15 "chain"
16 "chain/banker"
17 "chain/runtime"
18 "chain/runtime/unsafe"
19 "crypto/sha256"
20 "strconv"
21 "time"
22)
23
24const (
25 denom = "ugnot"
26 mintFee int64 = 150_000_000
27 stakeMin int64 = 10_000_000
28 quietMs int64 = 5_000
29 capFish = 128
30)
31
32var (
33 hpBase = [6]int{100, 122, 148, 178, 214, 256}
34 biteBase = [6]int{24, 30, 37, 45, 54, 66}
35 spdBase = [6]int{108, 104, 100, 96, 92, 88}
36 species = [6]string{"Bubble", "Tideblade", "Abysswing", "Embermaw", "Aurelion", "Astravex"}
37 rarities = [6]string{"common", "uncommon", "rare", "epic", "legendary", "mythic"}
38 traits = [3]string{"swift", "fang", "hide"}
39)
40
41type fish struct {
42 owner address
43 name string
44 rarity string
45 trait string
46 bite int
47 hpMax int
48 hp int
49 speed int
50 pool int64
51 bouts int
52 commitH int64
53 touched int64
54 inSea bool
55 revealed bool
56 mustHeal bool
57}
58
59var (
60 inited bool
61 self address
62 mintTo address
63 lpSink address
64 team address
65 minted int
66 roster []fish
67)
68
69// Init records the caller as the mint wallet, plus liquidity and team.
70// Those three must be distinct. Call once, from an EOA, with no coins.
71func Init(cur realm, lp, crew address) {
72 if !cur.Previous().IsUserCall() {
73 panic("fishsea: eoa")
74 }
75 takeExact(0)
76 caller := cur.Previous().Address()
77 if inited || !caller.IsValid() || !lp.IsValid() || !crew.IsValid() || caller == lp || caller == crew || lp == crew {
78 panic("fishsea: init")
79 }
80 inited = true
81 self = cur.Address()
82 mintTo = caller
83 lpSink = lp
84 team = crew
85 chain.Emit("Init", "mint", caller.String(), "lp", lp.String(), "team", crew.String())
86}
87
88// CommitMint sells one unrevealed fish for 150 GNOT. The fee leaves this realm in the same call.
89func CommitMint(cur realm) int {
90 caller := gate(cur, mintFee)
91 if minted >= capFish || pending(caller) != 0 {
92 panic("fishsea: mint")
93 }
94 height := runtime.ChainHeight()
95 if height < 1 {
96 panic("fishsea: mint")
97 }
98 id := grow()
99 roster[id].owner = caller
100 roster[id].commitH = height
101 payMint(mintFee, cur)
102 chain.Emit("Commit", "id", strconv.Itoa(id), "owner", caller.String())
103 return id
104}
105
106// RevealMint assigns rarity, trait, and the species name. Entropy is the commit height plus two.
107func RevealMint(cur realm, id int) {
108 caller := gate(cur, 0)
109 row := mustFish(id)
110 entropyH := row.commitH + 2
111 if row.owner != caller || row.revealed || runtime.ChainHeight() < entropyH {
112 panic("fishsea: reveal")
113 }
114 rarity, trait, bite, hp, speed := Roll(caller, row.commitH, entropyH, id)
115 row.rarity = rarity
116 row.trait = trait
117 row.bite = bite
118 row.hpMax = hp
119 row.hp = hp
120 row.speed = speed
121 row.name = pickName(tierOf(rarity))
122 row.revealed = true
123 chain.Emit("Reveal", "id", strconv.Itoa(id), "rarity", rarity, "trait", trait, "name", row.name)
124}
125
126// Register seats one revealed fish. The attached coins are the stake.
127func Register(cur realm, id int, stake int64) {
128 caller := gate(cur, stake)
129 row := mustFish(id)
130 if stake < stakeMin || row.owner != caller || !row.revealed || row.inSea || row.mustHeal || seated(caller) {
131 panic("fishsea: register")
132 }
133 row.pool = stake
134 row.inSea = true
135 row.touched = time.Now().UnixMilli()
136 chain.Emit("Register", "id", strconv.Itoa(id), "stake", strconv.FormatInt(stake, 10))
137}
138
139// Leave returns the seated stake once 5 seconds have passed since Register.
140func Leave(cur realm, id int) {
141 caller := gate(cur, 0)
142 row := mustFish(id)
143 if row.owner != caller || !row.inSea || time.Now().UnixMilli() < row.touched+quietMs {
144 panic("fishsea: leave")
145 }
146 stake := row.pool
147 row.pool = 0
148 row.inSea = false
149 send(caller, stake, cur)
150 chain.Emit("Leave", "id", strconv.Itoa(id), "stake", strconv.FormatInt(stake, 10))
151}
152
153// Settle pays the smaller stake. The NFT stays. HP falls to 30% and must heal.
154// The caller has to own the killer. This realm does not check who bit whom.
155func Settle(cur realm, id int, target int) {
156 caller := gate(cur, 0)
157 if id == target {
158 panic("fishsea: settle")
159 }
160 atk := mustFish(id)
161 vic := mustFish(target)
162 if atk.owner != caller || !atk.inSea || !vic.inSea || atk.owner == vic.owner || atk.pool < stakeMin || vic.pool < stakeMin {
163 panic("fishsea: settle")
164 }
165 covered := atk.pool
166 if vic.pool < covered {
167 covered = vic.pool
168 }
169 excess := vic.pool - covered
170 owner := vic.owner
171 vic.pool = 0
172 vic.inSea = false
173 vic.mustHeal = true
174 vic.hp = vic.hpMax * 30 / 100
175 if vic.hp < 1 {
176 vic.hp = 1
177 }
178 atk.bouts++
179 vic.bouts++
180 killer, lp, crew := splitKill(covered)
181 send(atk.owner, killer, cur)
182 send(lpSink, lp, cur)
183 send(team, crew, cur)
184 send(owner, excess, cur)
185 chain.Emit("Kill", "id", strconv.Itoa(id), "target", strconv.Itoa(target), "covered", strconv.FormatInt(covered, 10))
186}
187
188// Heal clears mustHeal outside the sea so Register can run again.
189func Heal(cur realm, id int) {
190 caller := gate(cur, 0)
191 row := mustFish(id)
192 if row.owner != caller || row.inSea || !row.mustHeal || !row.revealed {
193 panic("fishsea: heal")
194 }
195 row.mustHeal = false
196 row.hp = row.hpMax
197 chain.Emit("Heal", "id", strconv.Itoa(id))
198}
199
200// Roll is the reveal hash. entropyH must be commitH+2. Trait adds 12%, half up.
201func Roll(caller address, commitH int64, entropyH int64, id int) (rarity string, trait string, bite int, hp int, speed int) {
202 if id < 1 || commitH < 1 || entropyH != commitH+2 {
203 panic("fishsea: roll")
204 }
205 raw := caller.String() + "|" + strconv.FormatInt(commitH, 10) + "|" + strconv.FormatInt(entropyH, 10) + "|" + strconv.Itoa(id)
206 sum := sha256.Sum256([]byte(raw))
207 n := (int(sum[0])<<8 | int(sum[1])) % 10000
208 tier := 0
209 if n == 0 {
210 tier = 5
211 } else if n < 4 {
212 tier = 4
213 } else if n < 14 {
214 tier = 3
215 } else if n < 54 {
216 tier = 2
217 } else if n < 254 {
218 tier = 1
219 }
220 kind := int(sum[2]) % 3
221 bite = biteBase[tier]
222 hp = hpBase[tier]
223 speed = spdBase[tier]
224 if kind == 0 {
225 speed = (speed*112 + 50) / 100
226 } else if kind == 1 {
227 bite = (bite*112 + 50) / 100
228 } else {
229 hp = (hp*112 + 50) / 100
230 }
231 return rarities[tier], traits[kind], bite, hp, speed
232}
233
234func Realm() address { return self }
235func MintTo() address { return mintTo }
236func Version() string { return "fishsea-1" }
237func Name() string { return "GnoFish Sea" }
238func Symbol() string { return "SEA" }
239func MintPrice() int64 { return mintFee }
240func StakeMin() int64 { return stakeMin }
241func Minted() int { return minted }
242func Height() int64 { return runtime.ChainHeight() }
243
244func OwnerOf(id string) string {
245 return mustFish(atoi(id)).owner.String()
246}
247
248func TokenURI(id string) string {
249 row := mustFish(atoi(id))
250 if !row.revealed {
251 panic("fishsea: uri")
252 }
253 return `{"name":"` + row.name + `","image":""}`
254}
255
256func TokensOf(owner string) string {
257 who := address(owner)
258 if !who.IsValid() {
259 return ""
260 }
261 out := ""
262 for i := 1; i <= minted; i++ {
263 if roster[i].owner != who {
264 continue
265 }
266 if out != "" {
267 out += ","
268 }
269 out += strconv.Itoa(i)
270 }
271 return out
272}
273
274func TraitOf(id int) string { return mustFish(id).trait }
275func RarityOf(id int) string { return mustFish(id).rarity }
276func SizeOf(id int) string {
277 mustFish(id)
278 return "medium"
279}
280func BoutsOf(id int) int { return mustFish(id).bouts }
281func InMatch(id int) bool { return mustFish(id).inSea }
282func PoolOf(id int) int64 { return mustFish(id).pool }
283func Revealed(id int) bool { return mustFish(id).revealed }
284func MustHeal(id int) bool { return mustFish(id).mustHeal }
285func CommitOf(id int) int64 { return mustFish(id).commitH }
286func HpOf(id int) int { return mustFish(id).hp }
287func BiteOf(id int) int { return mustFish(id).bite }
288func SpeedOf(id int) int { return mustFish(id).speed }
289func NameOf(id int) string { return mustFish(id).name }
290
291func Pending(owner string) int {
292 who := address(owner)
293 if !who.IsValid() {
294 return 0
295 }
296 return pending(who)
297}
298
299func Render(path string) string {
300 if path != "" {
301 return "# GnoFish Sea\n\nUnknown path.\n"
302 }
303 return "# GnoFish Sea\n\nPerson versus person. The mint fee goes to the deploy wallet. A win pays 80% to the winner, 10% to liquidity, and 10% to the team.\n"
304}
305
306func gate(cur realm, pay int64) address {
307 if !cur.Previous().IsUserCall() {
308 panic("fishsea: eoa")
309 }
310 if !inited || self == address("") {
311 panic("fishsea: init")
312 }
313 takeExact(pay)
314 return cur.Previous().Address()
315}
316
317func payMint(fee int64, rlm realm) {
318 send(mintTo, fee, rlm)
319}
320
321func splitKill(covered int64) (killer int64, lp int64, crew int64) {
322 killer = covered * 80 / 100
323 lp = covered * 10 / 100
324 crew = covered - killer - lp
325 return
326}
327
328func send(to address, amt int64, rlm realm) {
329 if !rlm.IsCurrent() {
330 panic("fishsea: realm")
331 }
332 if amt == 0 {
333 return
334 }
335 bk := banker.NewBanker(banker.BankerTypeRealmSend, rlm)
336 bk.SendCoins(rlm.Address(), to, chain.Coins{{Denom: denom, Amount: amt}})
337}
338
339func grow() int {
340 if len(roster) == 0 {
341 roster = append(roster, fish{})
342 }
343 if minted >= capFish || len(roster) != minted+1 {
344 panic("fishsea: mint")
345 }
346 roster = append(roster, fish{})
347 minted++
348 return minted
349}
350
351func mustFish(id int) *fish {
352 if !inited || id < 1 || id > minted || id >= len(roster) {
353 panic("fishsea: fish")
354 }
355 return &roster[id]
356}
357
358func pending(who address) int {
359 for i := 1; i <= minted; i++ {
360 if roster[i].owner == who && !roster[i].revealed {
361 return i
362 }
363 }
364 return 0
365}
366
367func seated(who address) bool {
368 for i := 1; i <= minted; i++ {
369 if roster[i].owner == who && roster[i].inSea {
370 return true
371 }
372 }
373 return false
374}
375
376func pickName(tier int) string {
377 base := species[tier]
378 if !nameTaken(base) {
379 return base
380 }
381 for n := 2; n <= capFish; n++ {
382 candidate := base + strconv.Itoa(n)
383 if !nameTaken(candidate) {
384 return candidate
385 }
386 }
387 panic("fishsea: name")
388}
389
390func nameTaken(name string) bool {
391 for i := 1; i <= minted; i++ {
392 if roster[i].revealed && roster[i].name == name {
393 return true
394 }
395 }
396 return false
397}
398
399func tierOf(rarity string) int {
400 for i := 0; i < len(rarities); i++ {
401 if rarities[i] == rarity {
402 return i
403 }
404 }
405 panic("fishsea: roll")
406}
407
408func takeExact(n int64) {
409 sent := unsafe.OriginSend()
410 if n == 0 {
411 if len(sent) != 0 {
412 panic("fishsea: payment")
413 }
414 return
415 }
416 if len(sent) != 1 || sent[0].Denom != denom || sent[0].Amount != n {
417 panic("fishsea: payment")
418 }
419}
420
421func atoi(s string) int {
422 n, err := strconv.Atoi(s)
423 if err != nil {
424 panic("fishsea: fish")
425 }
426 return n
427}