Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}