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}