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.

sea.gno

19.28 Kb · 959 lines
  1package fish
  2
  3import (
  4	"chain/runtime/unsafe"
  5	"math"
  6	"time"
  7)
  8
  9// One shared sea. Cells are 8 world units. A sector center is the chain position.
 10// Springs and shelter discs use the same layout as the practice sea. No RNG.
 11
 12const (
 13	seaRadius         = 360
 14	sector            = 8
 15	unitMilli         = 1000
 16	sectorMilli       = 8000
 17	mouthAlignNum     = 552264
 18	mouthContact      = 1000
 19	healsPerDay       = 2
 20	healPercent       = 40
 21	healCooldownMs    = 8 * 3600 * 1000
 22	ictOffsetMs       = 7 * 3600 * 1000
 23	dayMs             = 24 * 3600 * 1000
 24	springPeriodMs    = 20000
 25	springLifeMs      = 15000
 26	springRadiusMilli = 3000
 27	springHealPercent = 20
 28	shelterHPMax      = 64
 29	shelterRebuildMs  = 20000
 30	burstWindowMs     = 1000
 31	burstCoolMs       = 2000
 32	viewerShards      = 10
 33	viewerWatchMs     = 15000
 34	viewerWindowMs    = 30000
 35	minCheer          = ugnot
 36	growMediumAt      = 2
 37	growLargeAt       = 4
 38	growEvolveAt      = 6
 39)
 40
 41type cheerRow struct {
 42	fish   int
 43	who    address
 44	amount int64
 45	live   bool
 46}
 47
 48type claimRow struct {
 49	who     address
 50	amount  int64
 51	kind    string
 52	killAt  int64
 53	expires int64
 54	claimed bool
 55	killer  address
 56	victim  address
 57	killID  int
 58}
 59
 60type watchRow struct {
 61	who   address
 62	since int64
 63}
 64
 65type disc struct {
 66	x     int
 67	z     int
 68	r     int
 69	inner int
 70	kind  string
 71}
 72
 73var (
 74	cheers      []cheerRow
 75	claims      []claimRow
 76	watches     []watchRow
 77	killSeq     int
 78	shelterHP   []int
 79	shelterDown []int64
 80	shelterTable = []disc{
 81		{x: 96_000, z: 8_000, inner: 5_200, kind: "reef"},
 82		{x: -120_000, z: 72_000, inner: 5_600, kind: "reef"},
 83		{x: 28_000, z: -110_000, inner: 4_400, kind: "cave"},
 84		{x: -140_000, z: 150_000, inner: 4_400, kind: "cave"},
 85	}
 86	rockTable = []disc{
 87		{x: 18_000, z: -24_000, r: 3_600},
 88		{x: -8_000, z: 48_000, r: 3_600},
 89		{x: 120_000, z: -48_000, r: 4_200},
 90	}
 91)
 92
 93func Turn(cur realm, id, dx, dz int) {
 94	caller := requireUser(cur)
 95	takeExact(0)
 96	body := must(id)
 97	if body.owner != caller {
 98		panic("fish: owner")
 99	}
100	if !body.inMatch || body.pool <= 0 {
101		panic("fish: match")
102	}
103	if dx < -1 || dx > 1 || dz < -1 || dz > 1 || (dx == 0 && dz == 0) {
104		panic("fish: face")
105	}
106	body.faceDx = dx
107	body.faceDz = dz
108}
109
110func Move(cur realm, id int) {
111	caller := requireUser(cur)
112	takeExact(0)
113	body := swimming(id)
114	if body.owner != caller {
115		panic("fish: owner")
116	}
117	now := time.Now().UnixMilli()
118	if now < body.nextMoveMilli {
119		panic("fish: move")
120	}
121	stepFish(body, now)
122}
123
124func Burst(cur realm, id int) {
125	caller := requireUser(cur)
126	takeExact(0)
127	body := swimming(id)
128	if body.owner != caller {
129		panic("fish: owner")
130	}
131	now := time.Now().UnixMilli()
132	if now < body.nextBurstAt {
133		return
134	}
135	body.burstUntil = now + burstWindowMs
136	body.nextBurstAt = body.burstUntil + burstCoolMs
137}
138
139func Heal(cur realm, id int) {
140	caller := requireUser(cur)
141	takeExact(0)
142	body := must(id)
143	if body.owner != caller {
144		panic("fish: owner")
145	}
146	if body.inMatch {
147		panic("fish: match")
148	}
149	now := time.Now().UnixMilli()
150	day, used, ready := rolledHeal(body, now)
151	if body.hpNow >= body.hp && !body.mustHeal {
152		panic("fish: hp")
153	}
154	if used >= healsPerDay || now < ready {
155		panic("fish: heal")
156	}
157	used++
158	if used >= healsPerDay {
159		ready = now + healCooldownMs
160	}
161	writeHeal(body, day, used, ready)
162	gain := body.hp * healPercent / 100
163	body.hpNow += gain
164	if body.hpNow > body.hp {
165		body.hpNow = body.hp
166	}
167	if body.hpNow < 1 {
168		body.hpNow = 1
169	}
170	body.mustHeal = false
171}
172
173func RushHeal(cur realm, id int) {
174	caller := requireUser(cur)
175	body := must(id)
176	if body.owner != caller {
177		panic("fish: owner")
178	}
179	now := time.Now().UnixMilli()
180	day, used, ready := rolledHeal(body, now)
181	if used < healsPerDay || now >= ready {
182		panic("fish: heal")
183	}
184	remaining := ready - now
185	cost := int64(tierOf(body.rarity)) * remaining * ugnot / (16 * 3_600_000)
186	takeExact(cost)
187	writeHeal(body, day, healsPerDay-1, 0)
188	send(burnSink, cost, cur)
189}
190
191// Cheer locks ugnot on a fish already in the sea. The deposit comes back when
192// that fish leaves or is downed. It does not change bite.
193func Cheer(cur realm, id int) {
194	caller := requireUser(cur)
195	body := must(id)
196	sent := unsafe.OriginSend()
197	if len(sent) != 1 || sent[0].Denom != denom || sent[0].Amount < minCheer {
198		panic("fish: payment")
199	}
200	amount := sent[0].Amount
201	if caller == body.owner {
202		panic("fish: cheer")
203	}
204	if !liveFish(body, time.Now().Unix()) {
205		panic("fish: match")
206	}
207	if cheerLocked(id)+amount > body.pool/2 {
208		panic("fish: cap")
209	}
210	cheers = append(cheers, cheerRow{fish: id, who: caller, amount: amount, live: true})
211}
212
213func Watch(cur realm, on bool) {
214	caller := requireUser(cur)
215	takeExact(0)
216	if !on {
217		dropWatch(caller)
218		return
219	}
220	for i := range watches {
221		if watches[i].who == caller {
222			return
223		}
224	}
225	watches = append(watches, watchRow{who: caller, since: time.Now().UnixMilli()})
226}
227
228func Claim(cur realm, id int) {
229	caller := requireUser(cur)
230	takeExact(0)
231	if id < 1 || id > len(claims) {
232		panic("fish: claim")
233	}
234	row := &claims[id-1]
235	if row.claimed {
236		panic("fish: claim")
237	}
238	now := time.Now().UnixMilli()
239	if row.expires > 0 && now >= row.expires {
240		amt := row.amount
241		row.claimed = true
242		row.amount = 0
243		send(seasonSink, amt, cur)
244		return
245	}
246	if row.kind == "watch" {
247		if caller == row.killer || caller == row.victim {
248			panic("fish: claim")
249		}
250		if !watchEligible(caller, row.killAt) {
251			panic("fish: watch")
252		}
253		if watchClaimed(caller, row.killID) {
254			panic("fish: claim")
255		}
256		row.who = caller
257	} else if row.who != caller {
258		panic("fish: owner")
259	}
260	amt := row.amount
261	row.claimed = true
262	row.amount = 0
263	send(caller, amt, cur)
264}
265
266func swimming(id int) *fishBody {
267	body := must(id)
268	if !liveFish(body, time.Now().Unix()) {
269		panic("fish: match")
270	}
271	if body.mustHeal {
272		panic("fish: heal")
273	}
274	return body
275}
276
277func walletInSea(owner address) bool {
278	for _, body := range bodies {
279		if body == nil || body.burned || !body.inMatch {
280			continue
281		}
282		if body.owner == owner {
283			return true
284		}
285	}
286	return false
287}
288
289func tooHurt(body *fishBody) bool {
290	if body.hp <= 0 {
291		return true
292	}
293	return body.hpNow*100 < body.hp*30
294}
295
296func placeSwimmer(body *fishBody) {
297	ensureSea()
298	for sx := 0; sx < 90; sx++ {
299		if tryPlace(body, sx, 0) {
300			return
301		}
302	}
303	for sz := 1; sz < 40; sz++ {
304		for sx := -40; sx < 40; sx++ {
305			if tryPlace(body, sx, sz) {
306				return
307			}
308		}
309	}
310	panic("fish: sea")
311}
312
313func tryPlace(body *fishBody, sx, sz int) bool {
314	if spawnBlocked(sx, sz) || cellTaken(sx, sz, body.id) {
315		return false
316	}
317	body.sx = sx
318	body.sz = sz
319	body.faceDx = 0
320	body.faceDz = -1
321	return true
322}
323
324func stepFish(body *fishBody, now int64) {
325	if body.faceDx == 0 && body.faceDz == 0 {
326		panic("fish: face")
327	}
328	nx := body.sx + body.faceDx
329	nz := body.sz + body.faceDz
330	if !sectorInSea(nx, nz) {
331		panic("fish: sea")
332	}
333	if cellBlocked(nx, nz, body.size, now) {
334		panic("fish: cover")
335	}
336	body.sx = nx
337	body.sz = nz
338	body.nextMoveMilli = now + moveDelay(body, now)
339	touchSpring(body, now)
340}
341
342func sectorInSea(sx, sz int) bool {
343	x, z := cellMilli(sx, sz)
344	dx := int64(x)
345	dz := int64(z)
346	limit := int64(seaRadius * unitMilli)
347	return dx*dx+dz*dz <= limit*limit
348}
349
350func cellTaken(sx, sz, self int) bool {
351	for _, body := range bodies {
352		if body == nil || body.burned || body.id == self || !body.inMatch {
353			continue
354		}
355		if body.sx == sx && body.sz == sz {
356			return true
357		}
358	}
359	return false
360}
361
362func spawnBlocked(sx, sz int) bool {
363	if !sectorInSea(sx, sz) {
364		return true
365	}
366	ensureSea()
367	for i := range rockTable {
368		if discHolds(sx, sz, rockTable[i].x, rockTable[i].z, rockTable[i].r) {
369			return true
370		}
371	}
372	now := time.Now().UnixMilli()
373	for i := range shelterTable {
374		refreshShelter(i, now)
375		if shelterHP[i] <= 0 {
376			continue
377		}
378		if discHolds(sx, sz, shelterTable[i].x, shelterTable[i].z, shelterTable[i].inner) {
379			return true
380		}
381	}
382	return false
383}
384
385func cellBlocked(sx, sz int, size string, now int64) bool {
386	if !sectorInSea(sx, sz) {
387		return true
388	}
389	ensureSea()
390	for i := range rockTable {
391		if discHolds(sx, sz, rockTable[i].x, rockTable[i].z, rockTable[i].r) {
392			return true
393		}
394	}
395	for i := range shelterTable {
396		refreshShelter(i, now)
397		if shelterHP[i] <= 0 || shelterAdmits(shelterTable[i].kind, size) {
398			continue
399		}
400		if discHolds(sx, sz, shelterTable[i].x, shelterTable[i].z, shelterTable[i].inner) {
401			return true
402		}
403	}
404	return false
405}
406
407func cellMilli(sx, sz int) (int, int) {
408	return sx*sectorMilli + sectorMilli/2, sz*sectorMilli + sectorMilli/2
409}
410
411func insideDisc(x, z, cx, cz, r int) bool {
412	dx := int64(x - cx)
413	dz := int64(z - cz)
414	rr := int64(r)
415	return dx*dx+dz*dz <= rr*rr
416}
417
418func ensureSea() {
419	if len(shelterHP) == len(shelterTable) && len(shelterDown) == len(shelterTable) {
420		return
421	}
422	shelterHP = make([]int, len(shelterTable))
423	shelterDown = make([]int64, len(shelterTable))
424	for i := range shelterHP {
425		shelterHP[i] = shelterHPMax
426	}
427}
428
429func refreshShelter(i int, now int64) {
430	ensureSea()
431	if shelterHP[i] > 0 {
432		return
433	}
434	if shelterDown[i] > 0 && now >= shelterDown[i] {
435		shelterHP[i] = shelterHPMax
436		shelterDown[i] = 0
437	}
438}
439
440func shelterAdmits(kind, size string) bool {
441	if size == "large" {
442		return false
443	}
444	if kind == "cave" {
445		return size == "small"
446	}
447	return size == "small" || size == "medium"
448}
449
450func coverIndex(body *fishBody, now int64) int {
451	ensureSea()
452	for i := range shelterTable {
453		refreshShelter(i, now)
454		if shelterHP[i] <= 0 || !shelterAdmits(shelterTable[i].kind, body.size) {
455			continue
456		}
457		if discHolds(body.sx, body.sz, shelterTable[i].x, shelterTable[i].z, shelterTable[i].inner) {
458			return i
459		}
460	}
461	return -1
462}
463
464func redirectShelter(atk, vic *fishBody, now int64) bool {
465	idx := coverIndex(vic, now)
466	if idx < 0 || shelterAdmits(shelterTable[idx].kind, atk.size) {
467		return false
468	}
469	if discHolds(atk.sx, atk.sz, shelterTable[idx].x, shelterTable[idx].z, shelterTable[idx].inner) {
470		return false
471	}
472	dmg := strikeDamage(biteNow(atk), shelterHPMax, atk.size, "medium")
473	if dmg > shelterHP[idx] {
474		dmg = shelterHP[idx]
475	}
476	shelterHP[idx] -= dmg
477	if shelterHP[idx] <= 0 {
478		shelterHP[idx] = 0
479		shelterDown[idx] = now + shelterRebuildMs
480	}
481	return true
482}
483
484func mouthHit(atk, def *fishBody) bool {
485	return mouthAimed(atk, def, def.dodge)
486}
487
488// shelterMouth is a bite against the pocket, not against the fish's dodge.
489// The attacker must stand outside, must not fit inside, and must face the fish.
490func shelterMouth(atk, vic *fishBody, now int64) bool {
491	idx := coverIndex(vic, now)
492	if idx < 0 || shelterAdmits(shelterTable[idx].kind, atk.size) {
493		return false
494	}
495	if discHolds(atk.sx, atk.sz, shelterTable[idx].x, shelterTable[idx].z, shelterTable[idx].inner) {
496		return false
497	}
498	return mouthAimed(atk, vic, 0)
499}
500
501// Sector centers sit off the practice discs, same as a spring on z = 0.
502// The cell that holds the point counts as inside.
503func discHolds(sx, sz, cx, cz, r int) bool {
504	x, z := cellMilli(sx, sz)
505	if insideDisc(x, z, cx, cz, r) {
506		return true
507	}
508	px := int(math.Floor(float64(cx) / float64(sectorMilli)))
509	pz := int(math.Floor(float64(cz) / float64(sectorMilli)))
510	return sx == px && sz == pz
511}
512
513func mouthAimed(atk, def *fishBody, dodge int) bool {
514	if !inReachDodge(atk, def, dodge) {
515		return false
516	}
517	ax, az := cellMilli(atk.sx, atk.sz)
518	bx, bz := cellMilli(def.sx, def.sz)
519	dx := bx - ax
520	dz := bz - az
521	dist2 := int64(dx)*int64(dx) + int64(dz)*int64(dz)
522	if dist2 <= int64(mouthContact)*int64(mouthContact) {
523		return true
524	}
525	fx, fz := faceMilli(atk.faceDx, atk.faceDz)
526	lead := mouthLead(atk.size)
527	mx := dx - fx*lead/1000
528	mz := dz - fz*lead/1000
529	mouth2 := int64(mx)*int64(mx) + int64(mz)*int64(mz)
530	if mouth2 == 0 {
531		return true
532	}
533	dot := int64(mx)*int64(fx) + int64(mz)*int64(fz)
534	if dot <= 0 {
535		return false
536	}
537	return dot*dot >= int64(mouthAlignNum)*mouth2
538}
539
540func faceMilli(dx, dz int) (int, int) {
541	if dx == 0 && dz == 0 {
542		return 0, -1000
543	}
544	span := dx*dx + dz*dz
545	if span == 1 {
546		return dx * 1000, dz * 1000
547	}
548	if span == 2 {
549		sx := 707
550		sz := 707
551		if dx < 0 {
552			sx = -707
553		}
554		if dz < 0 {
555			sz = -707
556		}
557		return sx, sz
558	}
559	return 0, -1000
560}
561
562func mouthLead(size string) int {
563	switch size {
564	case "small":
565		return 700
566	case "large":
567		return 1450
568	default:
569		return 1000
570	}
571}
572
573func biteNow(body *fishBody) int {
574	b := biteOf(body.rarity, body.size, body.trait)
575	if body.evolved && body.aspectBps > 0 {
576		b = (b*body.aspectBps + 5000) / 10000
577	}
578	if b < 1 {
579		return 1
580	}
581	return b
582}
583
584func strikeFought(bite, hpMax int, atkSize, defSize, atkR, defR string) int {
585	dmg := bite * 100 / (hpMax + 100)
586	if dmg < 1 {
587		dmg = 1
588	}
589	if defR == preyOf(atkR) {
590		dmg = dmg * 3 / 2
591		if dmg < 1 {
592			dmg = 1
593		}
594		return dmg
595	}
596	if sizeIndex(atkSize)-sizeIndex(defSize) >= 2 {
597		dmg = dmg * 5000 / 10000
598		if dmg < 1 {
599			dmg = 1
600		}
601	}
602	if atkR == preyOf(defR) {
603		dmg = dmg * 2 / 3
604		if dmg < 1 {
605			dmg = 1
606		}
607	}
608	return dmg
609}
610
611func preyOf(rarity string) string {
612	switch rarity {
613	case "common":
614		return "mythic"
615	case "uncommon":
616		return "common"
617	case "rare":
618		return "uncommon"
619	case "epic":
620		return "rare"
621	case "legendary":
622		return "epic"
623	case "mythic":
624		return "legendary"
625	default:
626		panic("fish: rarity")
627	}
628}
629
630func nextRarity(rarity string) string {
631	switch rarity {
632	case "common":
633		return "uncommon"
634	case "uncommon":
635		return "rare"
636	case "rare":
637		return "epic"
638	case "epic":
639		return "legendary"
640	case "legendary":
641		return "mythic"
642	default:
643		return ""
644	}
645}
646
647func growFromKill(atk, vic *fishBody) {
648	if sizeIndex(vic.size) >= sizeIndex(atk.size)+1 {
649		atk.growth += 2
650	} else {
651		atk.growth++
652	}
653	next := atk.size
654	if next == "small" && atk.growth >= growMediumAt {
655		next = "medium"
656	}
657	if next == "medium" && atk.growth >= growLargeAt {
658		next = "large"
659	}
660	if next != atk.size {
661		old := atk.hp
662		if old < 1 {
663			old = 1
664		}
665		atk.size = next
666		atk.hp = hpOf(atk.rarity, next, atk.trait)
667		atk.dodge = dodgeOf(next)
668		atk.hpNow = atk.hpNow * atk.hp / old
669		if atk.hpNow < 1 {
670			atk.hpNow = 1
671		}
672		if atk.hpNow > atk.hp {
673			atk.hpNow = atk.hp
674		}
675	}
676	if atk.evolved || atk.size != "large" || atk.growth < growEvolveAt {
677		return
678	}
679	aspect := nextRarity(atk.rarity)
680	if aspect == "" {
681		return
682	}
683	atk.evolved = true
684	atk.aspect = aspect
685	atk.aspectBps = 10800
686	atk.hp = (atk.hp*108 + 50) / 100
687	if atk.hp < 1 {
688		atk.hp = 1
689	}
690	atk.hpNow = (atk.hpNow*108 + 50) / 100
691	if atk.hpNow < 1 {
692		atk.hpNow = 1
693	}
694	if atk.hpNow > atk.hp {
695		atk.hpNow = atk.hp
696	}
697}
698
699func worldSpeed(body *fishBody) int {
700	base := 100
701	switch body.rarity {
702	case "common":
703		base = 100
704	case "uncommon":
705		base = 97
706	case "rare":
707		base = 94
708	case "epic":
709		base = 91
710	case "legendary":
711		base = 88
712	case "mythic":
713		base = 85
714	default:
715		panic("fish: rarity")
716	}
717	units := base * 220 / 1000
718	if body.size == "small" {
719		units = units * 116 / 100
720	}
721	if body.size == "large" {
722		units = units * 88 / 100
723	}
724	if body.trait == "swift" {
725		units = units * 112 / 100
726	}
727	if units < 1 {
728		return 1
729	}
730	return units
731}
732
733func moveDelay(body *fishBody, now int64) int64 {
734	units := worldSpeed(body)
735	if now < body.burstUntil {
736		units = units * 7 / 4
737	}
738	if units < 1 {
739		units = 1
740	}
741	delay := int64(sector*1000) / int64(units)
742	if delay < 1 {
743		return 1
744	}
745	return delay
746}
747
748func tierOf(rarity string) int {
749	switch rarity {
750	case "common":
751		return 1
752	case "uncommon":
753		return 2
754	case "rare":
755		return 3
756	case "epic":
757		return 4
758	case "legendary":
759		return 5
760	case "mythic":
761		return 6
762	default:
763		panic("fish: rarity")
764	}
765}
766
767func rolledHeal(body *fishBody, now int64) (day int64, used int, ready int64) {
768	day = (now + ictOffsetMs) / dayMs
769	used = body.healsUsed
770	ready = body.healReadyAt
771	if body.healDay != day {
772		used = 0
773		ready = 0
774	}
775	if used >= healsPerDay && ready > 0 && now >= ready {
776		used = healsPerDay - 1
777		ready = 0
778	}
779	return
780}
781
782func writeHeal(body *fishBody, day int64, used int, ready int64) {
783	body.healDay = day
784	body.healsUsed = used
785	body.healReadyAt = ready
786}
787
788func cheerLocked(id int) int64 {
789	var n int64
790	for i := range cheers {
791		if cheers[i].live && cheers[i].fish == id {
792			n += cheers[i].amount
793		}
794	}
795	return n
796}
797
798func returnCheers(body *fishBody, rlm realm) {
799	for i := range cheers {
800		if !cheers[i].live || cheers[i].fish != body.id {
801			continue
802		}
803		amt := cheers[i].amount
804		who := cheers[i].who
805		cheers[i].live = false
806		cheers[i].amount = 0
807		send(who, amt, rlm)
808	}
809}
810
811func bookCheerClaims(atk, vic *fishBody, cheer, now int64) int64 {
812	if cheer <= 0 {
813		return 0
814	}
815	var weights []int
816	var owners []address
817	for i := range cheers {
818		if !cheers[i].live || cheers[i].fish != atk.id || cheers[i].amount <= 0 {
819			continue
820		}
821		weights = append(weights, int(cheers[i].amount))
822		owners = append(owners, cheers[i].who)
823	}
824	if len(weights) == 0 {
825		return 0
826	}
827	shares := splitWeights(cheer, weights, owners[0], owners)
828	killSeq++
829	var placed int64
830	for i, share := range shares {
831		if share <= 0 {
832			continue
833		}
834		claims = append(claims, claimRow{
835			who:     owners[i],
836			amount:  share,
837			kind:    "cheer",
838			killAt:  now,
839			killer:  atk.owner,
840			victim:  vic.owner,
841			killID:  killSeq,
842		})
843		placed += share
844	}
845	return placed
846}
847
848func bookWatchClaims(atk, vic *fishBody, viewer, now int64) int64 {
849	if viewer <= 0 || !anyEligible(atk.owner, vic.owner, now) {
850		return 0
851	}
852	shard := viewer / int64(viewerShards)
853	if shard <= 0 {
854		return 0
855	}
856	killSeq++
857	var placed int64
858	for i := 0; i < viewerShards; i++ {
859		claims = append(claims, claimRow{
860			amount:  shard,
861			kind:    "watch",
862			killAt:  now,
863			expires: now + viewerWindowMs,
864			killer:  atk.owner,
865			victim:  vic.owner,
866			killID:  killSeq,
867		})
868		placed += shard
869	}
870	return placed
871}
872
873func anyEligible(killer, victim address, killAt int64) bool {
874	for i := range watches {
875		if watchEligible(watches[i].who, killAt) && watches[i].who != killer && watches[i].who != victim {
876			return true
877		}
878	}
879	return false
880}
881
882func watchEligible(who address, killAt int64) bool {
883	for i := range watches {
884		if watches[i].who == who && watches[i].since <= killAt-viewerWatchMs {
885			return true
886		}
887	}
888	return false
889}
890
891func watchClaimed(who address, killID int) bool {
892	for i := range claims {
893		if claims[i].claimed && claims[i].kind == "watch" && claims[i].killID == killID && claims[i].who == who {
894			return true
895		}
896	}
897	return false
898}
899
900func dropWatch(who address) {
901	next := make([]watchRow, 0, len(watches))
902	for i := range watches {
903		if watches[i].who != who {
904			next = append(next, watches[i])
905		}
906	}
907	watches = next
908}
909
910func springMilli(now int64) (x, z int, born int64, ok bool) {
911	if now < 0 {
912		return 0, 0, 0, false
913	}
914	slot := now / springPeriodMs
915	born = slot * springPeriodMs
916	if now >= born+springLifeMs {
917		return 0, 0, born, false
918	}
919	angle := math.Mod(float64(slot)*2.399963, 2*math.Pi)
920	radius := 48 + (slot*17)%9*22
921	x = int(math.Floor(math.Cos(angle)*float64(radius)*1000 + 0.5))
922	z = int(math.Floor(math.Sin(angle)*float64(radius)*1000 + 0.5))
923	return x, z, born, true
924}
925
926func springTouches(sx, sz, springX, springZ int) bool {
927	fx, fz := cellMilli(sx, sz)
928	dx := int64(fx - springX)
929	dz := int64(fz - springZ)
930	limit := int64(springRadiusMilli)
931	if dx*dx+dz*dz <= limit*limit {
932		return true
933	}
934	ssx := int(math.Floor(float64(springX) / float64(sectorMilli)))
935	ssz := int(math.Floor(float64(springZ) / float64(sectorMilli)))
936	return sx == ssx && sz == ssz
937}
938
939func touchSpring(body *fishBody, now int64) {
940	if body == nil || body.burned || body.mustHeal || !body.inMatch || body.pool <= 0 {
941		return
942	}
943	x, z, born, ok := springMilli(now)
944	if !ok || body.springBorn == born {
945		return
946	}
947	if !springTouches(body.sx, body.sz, x, z) {
948		return
949	}
950	body.springBorn = born
951	gain := body.hp * springHealPercent / 100
952	if gain < 1 {
953		gain = 1
954	}
955	body.hpNow += gain
956	if body.hpNow > body.hp {
957		body.hpNow = body.hp
958	}
959}