combat.gno
11.55 Kb · 530 lines
1package fish
2
3import (
4 "chain"
5 "strconv"
6 "time"
7
8 "gno.land/r/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/shoal/party"
9 "gno.land/r/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/shoal/shoaltoken"
10)
11
12type damageRow struct {
13 owner address
14 dmg int
15}
16
17// blows[victimID] sums HP actually removed, keyed by the attacker's owner.
18var blows [][]damageRow
19
20// Bite is one user call and takes no payment. The hit test is sector-center
21// reach plus a 42 degree mouth cone. A miss does not start cooldown. Members
22// of the same party cannot bite each other. A killing blow pays the smaller
23// Pool X and leaves the NFT at 30% HP. The killer share is split by HP removed.
24// Empty cheer pays the team sink. A new kill does not drop watch shards.
25// Buyback GNOT that cannot buy one FISH from the reserve burns. Spent GNOT joins liquidity.
26func Bite(cur realm, id, target int) {
27 caller := requireUser(cur)
28 takeExact(0)
29 if id == target {
30 panic("fish: bite")
31 }
32 atk := must(id)
33 vic := must(target)
34 if atk.owner != caller {
35 panic("fish: owner")
36 }
37 if atk.mustHeal || vic.mustHeal {
38 panic("fish: heal")
39 }
40 now := time.Now().Unix()
41 if !liveFish(atk, now) || !liveFish(vic, now) {
42 panic("fish: match")
43 }
44 if atk.owner == vic.owner {
45 panic("fish: bite")
46 }
47 if party.SameParty(cross(cur), atk.owner, vic.owner) {
48 panic("fish: party")
49 }
50 nowMs := time.Now().UnixMilli()
51 // A cave or reef does not dodge. The cone still has to face the fish inside.
52 if !mouthHit(atk, vic) && !shelterMouth(atk, vic, nowMs) {
53 panic("fish: range")
54 }
55 if nowMs < atk.nextBiteMilli {
56 panic("fish: cool")
57 }
58 if atk.bittenBy == vic.id && nowMs < atk.bittenUntil {
59 panic("fish: cool")
60 }
61 cool := nowMs + cooldownMs(atk.rarity)
62 atk.nextBiteMilli = cool
63 atk.lastCombatMilli = nowMs
64 if redirectShelter(atk, vic, nowMs) {
65 chain.Emit("Bite", "id", strconv.Itoa(id), "target", strconv.Itoa(target), "damage", "shelter")
66 return
67 }
68 vic.lastCombatMilli = nowMs
69 vic.bittenBy = atk.id
70 vic.bittenUntil = cool
71 dmg := strikeFought(biteNow(atk), vic.hp, atk.size, vic.size, atk.rarity, vic.rarity)
72 landed := dmg
73 if landed > vic.hpNow {
74 landed = vic.hpNow
75 }
76 addDamage(vic.id, atk.owner, landed)
77 if vic.hpNow > dmg {
78 vic.hpNow -= dmg
79 chain.Emit("Bite", "id", strconv.Itoa(id), "target", strconv.Itoa(target), "damage", strconv.Itoa(dmg))
80 return
81 }
82 payKill(atk, vic, cur)
83}
84
85func payKill(atk, vic *fishBody, rlm realm) {
86 if !rlm.IsCurrent() {
87 panic("fish: realm")
88 }
89 covered := coveredStake(atk.pool, vic.pool)
90 excess := vic.pool - covered
91 killer, lp, team, buyback, cheer := splitParts(covered)
92 stake := atk.pool
93 nowMs := time.Now().UnixMilli()
94 creditBout(atk)
95 creditBout(vic)
96 growFromKill(atk, vic)
97 returnCheers(vic, rlm)
98 vic.pool = 0
99 vic.inMatch = false
100 vic.boutPaid = false
101 vic.mustHeal = true
102 vic.hpNow = downHP(vic.hp)
103 cheerHeld := bookCheerClaims(atk, vic, cheer, nowMs)
104 team += cheer - cheerHeld
105 send(vic.owner, excess, rlm)
106 payKiller(atk.owner, vic.id, killer, rlm)
107 send(lpSink, lp, rlm)
108 send(teamSink, team, rlm)
109 spent := settleBuyback(buyback, rlm)
110 send(lpSink, spent, rlm)
111 send(burnSink, buyback-spent, rlm)
112 payWinFish(atk.owner, stake, rlm)
113 clearDamage(vic.id)
114 chain.Emit(
115 "Kill",
116 "id", strconv.Itoa(atk.id),
117 "target", strconv.Itoa(vic.id),
118 "covered", strconv.FormatInt(covered, 10),
119 "killer", strconv.FormatInt(killer, 10),
120 )
121}
122
123// payKiller splits the killer share by HP removed among current members.
124// The party realm holds no coins. A solo owner, or a party with no recorded
125// damage, receives the whole share. Dust goes to the highest damage, and a
126// tie prefers the owner who landed the blow.
127func payKiller(owner address, victim int, amount int64, rlm realm) {
128 if amount <= 0 {
129 return
130 }
131 pid := party.PartyOf(cross(rlm), owner)
132 if pid == 0 {
133 send(owner, amount, rlm)
134 return
135 }
136 n := party.MemberCount(cross(rlm), pid)
137 if n <= 0 {
138 send(owner, amount, rlm)
139 return
140 }
141 owners := make([]address, n)
142 weights := make([]int, n)
143 total := 0
144 for i := 0; i < n; i++ {
145 who := party.MemberAt(cross(rlm), pid, i)
146 owners[i] = who
147 w := damageOf(victim, who)
148 if w < 0 {
149 w = 0
150 }
151 weights[i] = w
152 total += w
153 }
154 if total <= 0 {
155 send(owner, amount, rlm)
156 return
157 }
158 shares := splitWeights(amount, weights, owner, owners)
159 for i := range owners {
160 send(owners[i], shares[i], rlm)
161 }
162}
163
164func splitWeights(amount int64, weights []int, prefer address, owners []address) []int64 {
165 total := 0
166 for _, w := range weights {
167 if w > 0 {
168 total += w
169 }
170 }
171 out := make([]int64, len(weights))
172 if amount <= 0 || total <= 0 {
173 return out
174 }
175 var used int64
176 best := -1
177 bestW := 0
178 for i, w := range weights {
179 if w <= 0 {
180 continue
181 }
182 share := amount * int64(w) / int64(total)
183 out[i] = share
184 used += share
185 if best < 0 || w > bestW || (w == bestW && i < len(owners) && owners[i] == prefer) {
186 best = i
187 bestW = w
188 }
189 }
190 if used < amount && best >= 0 {
191 out[best] += amount - used
192 }
193 return out
194}
195
196func addDamage(id int, owner address, n int) {
197 if n <= 0 || id < 1 || !owner.IsValid() {
198 return
199 }
200 for len(blows) <= id {
201 blows = append(blows, nil)
202 }
203 rows := blows[id]
204 for i := 0; i < len(rows); i++ {
205 if rows[i].owner == owner {
206 rows[i].dmg += n
207 blows[id] = rows
208 return
209 }
210 }
211 blows[id] = append(rows, damageRow{owner: owner, dmg: n})
212}
213
214func damageOf(id int, owner address) int {
215 if id < 0 || id >= len(blows) {
216 return 0
217 }
218 for i := 0; i < len(blows[id]); i++ {
219 if blows[id][i].owner == owner && blows[id][i].dmg > 0 {
220 return blows[id][i].dmg
221 }
222 }
223 return 0
224}
225
226// clearDamage drops incoming blows so the next entry starts clean.
227func clearDamage(id int) {
228 if id < 0 || id >= len(blows) {
229 return
230 }
231 blows[id] = nil
232}
233
234// creditBout counts one finished entry. Leave does not call it.
235// SettleMatch does not count the entry again.
236func creditBout(body *fishBody) {
237 if body.boutPaid {
238 return
239 }
240 body.boutPaid = true
241 body.bouts++
242}
243
244func liveFish(body *fishBody, now int64) bool {
245 if now < 0 {
246 return false
247 }
248 return body.inMatch && body.pool > 0
249}
250
251func downHP(max int) int {
252 hp := max * 30 / 100
253 if hp < 1 {
254 return 1
255 }
256 return hp
257}
258
259func coveredStake(killerPool, victimPool int64) int64 {
260 if killerPool < 0 {
261 killerPool = 0
262 }
263 if victimPool < 0 {
264 victimPool = 0
265 }
266 if killerPool < victimPool {
267 return killerPool
268 }
269 return victimPool
270}
271
272// splitParts is 80/10/3/3/4. Killer, liquidity, team, FISH buyback, cheer.
273// Dust joins the buyback.
274func splitParts(amount int64) (killer, lp, team, buyback, cheer int64) {
275 if amount < 0 {
276 amount = 0
277 }
278 killer = amount * 8000 / 10000
279 lp = amount * 1000 / 10000
280 team = amount * 300 / 10000
281 buyback = amount * 300 / 10000
282 cheer = amount * 400 / 10000
283 dust := amount - killer - lp - team - buyback - cheer
284 buyback += dust
285 return
286}
287
288// settleBuyback spends whole FISH from the reserve at 0.02 GNOT each.
289// GNOT that cannot buy one FISH stays with the caller to burn. This does not mint.
290func settleBuyback(budget int64, rlm realm) int64 {
291 if !rlm.IsCurrent() {
292 panic("fish: realm")
293 }
294 units := reserveUnits(budget / twapUgnot)
295 if units <= 0 {
296 return 0
297 }
298 got := shoaltoken.Draw(cross(rlm), fishReserve, burnSink, units)
299 if got != units {
300 panic("fish: fish")
301 }
302 return (units / fishUnit) * twapUgnot
303}
304
305// payWinFish pays 1 whole FISH per 1 whole GNOT of the winner stake, then the party bonus.
306// Both come from the reserve after buyback. A short reserve pays less. This does not mint.
307func payWinFish(owner address, stake int64, rlm realm) {
308 if !rlm.IsCurrent() {
309 panic("fish: realm")
310 }
311 whole := stake / ugnot
312 if whole < 0 {
313 whole = 0
314 }
315 baseUnits := reserveUnits(whole)
316 if baseUnits > 0 {
317 got := shoaltoken.Draw(cross(rlm), fishReserve, owner, baseUnits)
318 if got != baseUnits {
319 panic("fish: fish")
320 }
321 }
322 baseFish := baseUnits / fishUnit
323 owners := partyMembers(owner, rlm)
324 members := len(owners)
325 if members == 0 {
326 members = 1
327 }
328 bonusFish := baseFish * int64(partyFishBonusBps(members)) / 10000
329 bonusUnits := reserveUnits(bonusFish)
330 if bonusUnits <= 0 {
331 return
332 }
333 shares := splitPartyFish(bonusUnits/fishUnit, owners, owner)
334 for i := range owners {
335 part := shares[i] * fishUnit
336 if part <= 0 {
337 continue
338 }
339 got := shoaltoken.Draw(cross(rlm), fishReserve, owners[i], part)
340 if got != part {
341 panic("fish: fish")
342 }
343 }
344}
345
346func reserveUnits(whole int64) int64 {
347 if whole <= 0 || !fishTokenReady() || !fishReserve.IsValid() {
348 return 0
349 }
350 units := whole * fishUnit
351 room := shoaltoken.BalanceOf(fishReserve)
352 allow := shoaltoken.Allowance(fishReserve, self)
353 if allow < room {
354 room = allow
355 }
356 room = room / fishUnit * fishUnit
357 if units > room {
358 return room
359 }
360 return units
361}
362
363func partyMembers(owner address, rlm realm) []address {
364 pid := party.PartyOf(cross(rlm), owner)
365 if pid == 0 {
366 return nil
367 }
368 n := party.MemberCount(cross(rlm), pid)
369 out := make([]address, n)
370 for i := 0; i < n; i++ {
371 out[i] = party.MemberAt(cross(rlm), pid, i)
372 }
373 return out
374}
375
376func partyFishBonusBps(members int) int {
377 if members <= 1 {
378 return 0
379 }
380 n := members
381 if n > 8 {
382 n = 8
383 }
384 return (n - 1) * 1000 / 7
385}
386
387func splitPartyFish(bonus int64, owners []address, killer address) []int64 {
388 out := make([]int64, len(owners))
389 if bonus <= 0 || len(owners) == 0 {
390 return out
391 }
392 each := bonus / int64(len(owners))
393 dustAt := 0
394 for i, who := range owners {
395 out[i] = each
396 if who == killer {
397 dustAt = i
398 }
399 }
400 out[dustAt] += bonus - each*int64(len(owners))
401 return out
402}
403
404func strikeDamage(bite, hpMax int, atkSize, defSize string) int {
405 dmg := bite * 100 / (hpMax + 100)
406 if dmg < 1 {
407 dmg = 1
408 }
409 if sizeIndex(atkSize)-sizeIndex(defSize) >= 2 {
410 dmg = dmg * 5000 / 10000
411 if dmg < 1 {
412 dmg = 1
413 }
414 }
415 return dmg
416}
417
418func sizeIndex(size string) int {
419 switch size {
420 case "small":
421 return 0
422 case "medium":
423 return 1
424 case "large":
425 return 2
426 default:
427 panic("fish: size")
428 }
429}
430
431func biteOf(rarity, size, trait string) int {
432 var base int64
433 switch rarity {
434 case "common":
435 base = 22
436 case "uncommon":
437 base = 26
438 case "rare":
439 base = 31
440 case "epic":
441 base = 37
442 case "legendary":
443 base = 44
444 case "mythic":
445 base = 52
446 default:
447 panic("fish: rarity")
448 }
449 traitBps := int64(10000)
450 if trait == "fang" {
451 traitBps = 11200
452 }
453 sizeBps := int64(10000)
454 if size == "small" {
455 sizeBps = 8600
456 } else if size == "large" {
457 sizeBps = 11800
458 }
459 v := (base*traitBps*sizeBps + 50_000_000) / 100_000_000
460 if v < 1 {
461 v = 1
462 }
463 return int(v)
464}
465
466func cooldownMs(rarity string) int64 {
467 switch rarity {
468 case "common":
469 return 2400
470 case "uncommon":
471 return 2550
472 case "rare":
473 return 2700
474 case "epic":
475 return 2850
476 case "legendary":
477 return 3000
478 case "mythic":
479 return 3200
480 default:
481 panic("fish: rarity")
482 }
483}
484
485// reachUnits is the dodged bite reach in 1/10000 of a world unit.
486// A sector is 8 world units, so the next center is 80000 units away.
487func reachUnits(rarity, size string, dodgeTenths int) int64 {
488 var spec int64
489 switch rarity {
490 case "common":
491 spec = 10000
492 case "uncommon":
493 spec = 10600
494 case "rare":
495 spec = 11200
496 case "epic":
497 spec = 11800
498 case "legendary":
499 spec = 12400
500 case "mythic":
501 spec = 13200
502 default:
503 panic("fish: rarity")
504 }
505 sizeMod := int64(10000)
506 if size == "small" {
507 sizeMod = 8600
508 } else if size == "large" {
509 sizeMod = 12200
510 }
511 reach := spec * sizeMod * 8 / 10000
512 reach -= int64(dodgeTenths) * 1000
513 if reach < 10_000 {
514 return 10_000
515 }
516 return reach
517}
518
519func inReach(atk, def *fishBody) bool {
520 return inReachDodge(atk, def, def.dodge)
521}
522
523func inReachDodge(atk, def *fishBody, dodge int) bool {
524 dx := int64(atk.sx - def.sx)
525 dz := int64(atk.sz - def.sz)
526 span := dx*dx + dz*dz
527 reach := reachUnits(atk.rarity, atk.size, dodge)
528 dist2 := span * 80_000 * 80_000
529 return dist2 <= reach*reach
530}