fishduel.gno
11.12 Kb · 477 lines
1// Package fishduel is a two-person trial.
2// Swim stays off chain. Settle pays one covered stake from three sealed samples.
3// Each player binds an ed25519 mark key at Register. The realm cannot check a loose
4// secp256k1 wallet signature. There is no machine fish and no FISH draw.
5// shoal/fish stays the live collection. Mint is 1 GNOT and the stake floor is 1 GNOT.
6// A sample note is bout:seq:height:id:x:z:time:secret:sig
7// The mark signs the sha256 of bout,seq,height,id,x,z,time,secret joined by commas.
8// secret is 32 lowercase hex chars. sig is 128 lowercase hex chars. The public key is 64.
9package fishduel
10
11import (
12 "chain"
13 "chain/banker"
14 "chain/runtime"
15 "chain/runtime/unsafe"
16 "crypto/ed25519"
17 "crypto/sha256"
18 "encoding/hex"
19 "strconv"
20 "strings"
21 "time"
22)
23
24const (
25 denom = "ugnot"
26 unit int64 = 1_000_000
27 mintFee int64 = 1_000_000
28 stakeMin int64 = 1_000_000
29 biteStat = 69
30 hpStat = 88
31 reach = 2
32 hitGap int64 = 2_400
33 cellMs int64 = 320
34 quietMs int64 = 60_000
35 seatMax = 8
36 box = 32
37 stepCap = 16
38 packMax = 2_400
39)
40
41type swimmer struct {
42 owner address
43 name string
44 pool int64
45 hp int
46 inSea bool
47 ended bool
48 markSet bool
49 mark [32]byte
50}
51
52type note struct {
53 bout int
54 seq int
55 height int64
56 id int
57 x int
58 z int
59 at int64
60 secret string
61 sig string
62}
63
64var (
65 inited bool
66 self address
67 lpSink address
68 team address
69 burn address
70 minted int
71 openBout int
72 boutOpen bool
73 seatMilli int64
74 seatHeight int64
75 roster [seatMax + 1]swimmer
76)
77
78// Init records three GNOT sinks. They must be distinct. Call once, from an EOA, with no coins.
79// The mint fee is 60/20/10 and the last tenth joins team. Kill cheer also joins team.
80func Init(cur realm, lp, crew, ash address) {
81 if !cur.Previous().IsUserCall() {
82 panic("fishduel: eoa")
83 }
84 takeExact(0)
85 if inited {
86 panic("fishduel: init")
87 }
88 if !lp.IsValid() || !crew.IsValid() || !ash.IsValid() || lp == crew || lp == ash || crew == ash {
89 panic("fishduel: addr")
90 }
91 inited = true
92 self = cur.Address()
93 lpSink = lp
94 team = crew
95 burn = ash
96 chain.Emit("Init", "self", self.String())
97}
98
99// Mint sells one trial fish for 1 GNOT. The fee leaves this realm in the same call.
100func Mint(cur realm, name string) int {
101 caller := gate(cur, mintFee)
102 if minted >= seatMax || !validName(name) {
103 panic("fishduel: mint")
104 }
105 minted++
106 roster[minted] = swimmer{owner: caller, name: name, hp: hpStat}
107 payMint(mintFee, cur)
108 chain.Emit("Mint", "id", strconv.Itoa(minted), "owner", caller.String())
109 return minted
110}
111
112// Register seats a fish and binds its mark key. The attached coins are the stake.
113func Register(cur realm, id int, stake int64, pub string) {
114 caller := gate(cur, stake)
115 if stake < stakeMin {
116 panic("fishduel: stake")
117 }
118 fish := mustFish(id)
119 if fish.owner != caller || fish.inSea || fish.ended || fish.markSet || seated(caller) || seatedCount() >= 2 {
120 panic("fishduel: register")
121 }
122 raw := mustHex(pub, 32)
123 for i := 0; i < 32; i++ {
124 fish.mark[i] = raw[i]
125 }
126 fish.markSet = true
127 fish.pool = stake
128 fish.inSea = true
129 if seatedCount() == 2 {
130 openBout++
131 boutOpen = true
132 seatMilli = time.Now().UnixMilli()
133 seatHeight = runtime.ChainHeight()
134 }
135 chain.Emit("Register", "id", strconv.Itoa(id), "bout", strconv.Itoa(openBout))
136}
137
138// Leave returns the seated stake. While the other fish is seated, Leave waits 60 seconds.
139func Leave(cur realm, id int) {
140 caller := gate(cur, 0)
141 fish := mustFish(id)
142 if fish.owner != caller || !fish.inSea {
143 panic("fishduel: leave")
144 }
145 if otherSeated(id) && time.Now().UnixMilli() < seatMilli+quietMs {
146 panic("fishduel: leave")
147 }
148 if otherSeated(id) {
149 boutOpen = false
150 }
151 stake := fish.pool
152 fish.pool = 0
153 fish.inSea = false
154 send(fish.owner, stake, cur)
155 chain.Emit("Leave", "id", strconv.Itoa(id), "stake", strconv.FormatInt(stake, 10))
156}
157
158// Settle pays a kill from six notes, killer then victim, three samples, separated by slashes.
159func Settle(cur realm, id, target int, pack string) {
160 caller := gate(cur, 0)
161 if !boutOpen || id == target || len(pack) == 0 || len(pack) > packMax {
162 panic("fishduel: settle")
163 }
164 atk := mustFish(id)
165 vic := mustFish(target)
166 if atk.owner != caller || !atk.inSea || !vic.inSea || atk.owner == vic.owner || atk.pool < stakeMin || vic.pool < stakeMin {
167 panic("fishduel: settle")
168 }
169 notes := strings.Split(pack, "/")
170 if len(notes) != 6 {
171 panic("fishduel: note")
172 }
173 var got [3][2]note
174 for i := 0; i < 6; i++ {
175 got[i/2][i%2] = parseNote(notes[i])
176 }
177 checkNotes(id, target, got)
178 covered := atk.pool
179 if vic.pool < covered {
180 covered = vic.pool
181 }
182 excess := vic.pool - covered
183 owner := vic.owner
184 vic.hp = 0
185 vic.pool = 0
186 vic.inSea = false
187 vic.ended = true
188 boutOpen = false
189 killer, lp, crew, buyback := splitKill(covered)
190 send(atk.owner, killer, cur)
191 send(lpSink, lp, cur)
192 send(team, crew, cur)
193 send(burn, buyback, cur)
194 send(owner, excess, cur)
195 chain.Emit("Kill", "id", strconv.Itoa(id), "target", strconv.Itoa(target), "covered", strconv.FormatInt(covered, 10))
196}
197
198// Strike is floor(bite * 100 / (hpMax + 100)), at least 1.
199func Strike(bite, hpMax int) int {
200 if bite < 1 {
201 bite = 1
202 }
203 if hpMax < 0 {
204 hpMax = 0
205 }
206 dmg := bite * 100 / (hpMax + 100)
207 if dmg < 1 {
208 return 1
209 }
210 return dmg
211}
212
213func Realm() address { return self }
214func Version() string { return "fishduel-1" }
215func MintPrice() int64 { return mintFee }
216func StakeMin() int64 { return stakeMin }
217func Minted() int { return minted }
218func Bout() int { return openBout }
219func BoutOpen() bool { return boutOpen }
220func SeatHeight() int64 { return seatHeight }
221func Height() int64 { return runtime.ChainHeight() }
222func Now() int64 { return time.Now().UnixMilli() }
223func OwnerOf(id int) address { return mustFish(id).owner }
224func PoolOf(id int) int64 { return mustFish(id).pool }
225func HpOf(id int) int { return mustFish(id).hp }
226func InSea(id int) bool { return mustFish(id).inSea }
227func Ended(id int) bool { return mustFish(id).ended }
228
229func MarkOf(id int) string {
230 fish := mustFish(id)
231 if !fish.markSet {
232 return ""
233 }
234 return hex.EncodeToString(fish.mark[:])
235}
236
237func Render(path string) string {
238 if path != "" {
239 return "# Fishduel\n\nUnknown path.\n"
240 }
241 return "# Fishduel\n\nTwo-person trial. Mint, Register, Settle, and Leave. This is not the shoal/fish collection.\n"
242}
243
244func gate(cur realm, pay int64) address {
245 if !cur.Previous().IsUserCall() {
246 panic("fishduel: eoa")
247 }
248 requireInit()
249 takeExact(pay)
250 return cur.Previous().Address()
251}
252
253func checkNotes(id, target int, got [3][2]note) {
254 hp := hpStat
255 var prevH int64
256 var prevT int64
257 var ax, az, bx, bz int
258 for i := 0; i < 3; i++ {
259 a := got[i][0]
260 b := got[i][1]
261 if a.bout != openBout || b.bout != openBout || a.seq != i+1 || b.seq != i+1 {
262 panic("fishduel: sample")
263 }
264 if a.height != b.height || a.height < seatHeight || a.height > runtime.ChainHeight() {
265 panic("fishduel: sample")
266 }
267 if i > 0 && (a.height <= prevH || a.at-prevT < hitGap) {
268 panic("fishduel: sample")
269 }
270 if a.id != id || b.id != target || !inBox(a.x, a.z) || !inBox(b.x, b.z) {
271 panic("fishduel: sample")
272 }
273 if gap(a.x, a.z, b.x, b.z) > reach {
274 panic("fishduel: range")
275 }
276 if i > 0 && (gap(ax, az, a.x, a.z) > stepMax(a.at-prevT) || gap(bx, bz, b.x, b.z) > stepMax(a.at-prevT)) {
277 panic("fishduel: step")
278 }
279 if !signed(id, a) || !signed(target, b) {
280 panic("fishduel: sig")
281 }
282 dmg := Strike(biteStat, hpStat)
283 if i < 2 {
284 if hp <= dmg {
285 panic("fishduel: hp")
286 }
287 hp -= dmg
288 } else if hp > dmg || a.at > time.Now().UnixMilli() {
289 panic("fishduel: hp")
290 }
291 prevH = a.height
292 prevT = a.at
293 ax, az, bx, bz = a.x, a.z, b.x, b.z
294 }
295}
296
297func signed(id int, n note) bool {
298 fish := mustFish(id)
299 if !fish.markSet || n.id != id {
300 return false
301 }
302 sum := sha256.Sum256([]byte(canon(n)))
303 sig := mustHex(n.sig, 64)
304 return ed25519.Verify(fish.mark[:], sum[:], sig)
305}
306
307func canon(n note) string {
308 return strconv.Itoa(n.bout) + "," + strconv.Itoa(n.seq) + "," + strconv.FormatInt(n.height, 10) + "," + strconv.Itoa(n.id) + "," + strconv.Itoa(n.x) + "," + strconv.Itoa(n.z) + "," + strconv.FormatInt(n.at, 10) + "," + n.secret
309}
310
311func parseNote(raw string) note {
312 p := strings.Split(raw, ":")
313 if len(p) != 9 || len(p[7]) != 32 || len(p[8]) != 128 {
314 panic("fishduel: note")
315 }
316 return note{atoi(p[0]), atoi(p[1]), atoi64(p[2]), atoi(p[3]), atoi(p[4]), atoi(p[5]), atoi64(p[6]), p[7], p[8]}
317}
318
319func stepMax(dt int64) int {
320 n := int(dt / cellMs)
321 if n < 1 {
322 return 1
323 }
324 if n > stepCap {
325 return stepCap
326 }
327 return n
328}
329
330func gap(ax, az, bx, bz int) int {
331 dx := ax - bx
332 if dx < 0 {
333 dx = -dx
334 }
335 dz := az - bz
336 if dz < 0 {
337 dz = -dz
338 }
339 if dx > dz {
340 return dx
341 }
342 return dz
343}
344
345func inBox(x, z int) bool {
346 return x >= -box && x <= box && z >= -box && z <= box
347}
348
349func splitKill(covered int64) (killer, lp, crew, buyback int64) {
350 killer = covered * 80 / 100
351 lp = covered * 10 / 100
352 teamPart := covered * 3 / 100
353 buyback = covered * 3 / 100
354 cheer := covered * 4 / 100
355 buyback += covered - killer - lp - teamPart - buyback - cheer
356 crew = teamPart + cheer
357 return
358}
359
360func payMint(fee int64, rlm realm) {
361 lp := fee * 6000 / 10000
362 crew := fee * 2000 / 10000
363 ash := fee * 1000 / 10000
364 send(lpSink, lp, rlm)
365 send(team, crew+fee-lp-crew-ash, rlm)
366 send(burn, ash, rlm)
367}
368
369func send(to address, amt int64, rlm realm) {
370 if !rlm.IsCurrent() {
371 panic("fishduel: realm")
372 }
373 if amt == 0 {
374 return
375 }
376 bk := banker.NewBanker(banker.BankerTypeRealmSend, rlm)
377 bk.SendCoins(rlm.Address(), to, chain.Coins{{Denom: denom, Amount: amt}})
378}
379
380func mustFish(id int) *swimmer {
381 requireInit()
382 if id < 1 || id > minted {
383 panic("fishduel: fish")
384 }
385 return &roster[id]
386}
387
388func seated(who address) bool {
389 for i := 1; i <= minted; i++ {
390 if roster[i].owner == who && roster[i].inSea {
391 return true
392 }
393 }
394 return false
395}
396
397func seatedCount() int {
398 n := 0
399 for i := 1; i <= minted; i++ {
400 if roster[i].inSea {
401 n++
402 }
403 }
404 return n
405}
406
407func otherSeated(id int) bool {
408 for i := 1; i <= minted; i++ {
409 if i != id && roster[i].inSea {
410 return true
411 }
412 }
413 return false
414}
415
416func validName(name string) bool {
417 if len(name) < 1 || len(name) > 16 {
418 return false
419 }
420 for _, c := range name {
421 if (c < 'a' || c > 'z') && (c < '0' || c > '9') {
422 return false
423 }
424 }
425 return true
426}
427
428func requireInit() {
429 if !inited || self == address("") {
430 panic("fishduel: init")
431 }
432}
433
434func takeExact(n int64) {
435 sent := unsafe.OriginSend()
436 if n == 0 {
437 if len(sent) != 0 {
438 panic("fishduel: payment")
439 }
440 return
441 }
442 if len(sent) != 1 || sent[0].Denom != denom || sent[0].Amount != n {
443 panic("fishduel: payment")
444 }
445}
446
447func mustHex(s string, n int) []byte {
448 if len(s) != n*2 {
449 panic("fishduel: hex")
450 }
451 for _, c := range s {
452 if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
453 panic("fishduel: hex")
454 }
455 }
456 raw, err := hex.DecodeString(s)
457 if err != nil || len(raw) != n {
458 panic("fishduel: hex")
459 }
460 return raw
461}
462
463func atoi(s string) int {
464 n, err := strconv.Atoi(s)
465 if err != nil {
466 panic("fishduel: note")
467 }
468 return n
469}
470
471func atoi64(s string) int64 {
472 n, err := strconv.ParseInt(s, 10, 64)
473 if err != nil {
474 panic("fishduel: note")
475 }
476 return n
477}