combat.gno
9.02 Kb · 419 lines
1package fish
2
3import (
4 "chain"
5 "strconv"
6 "time"
7
8 "gno.land/r/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/party"
9)
10
11type damageRow struct {
12 owner address
13 dmg int
14}
15
16// blows[victimID] sums HP actually removed, keyed by the attacker's owner.
17var blows [][]damageRow
18
19// Bite is one user call and takes no payment. The hit test is sector-center
20// reach plus a 42 degree mouth cone. A miss does not start cooldown. Members
21// of the same party cannot bite each other. A killing blow pays the smaller
22// Pool X and leaves the NFT at 30% HP. The killer share is split by HP removed.
23// Empty cheer and viewer shares pay the season sink.
24func Bite(cur realm, id, target int) {
25 caller := requireUser(cur)
26 takeExact(0)
27 if id == target {
28 panic("fish: bite")
29 }
30 atk := must(id)
31 vic := must(target)
32 if atk.owner != caller {
33 panic("fish: owner")
34 }
35 if atk.mustHeal || vic.mustHeal {
36 panic("fish: heal")
37 }
38 now := time.Now().Unix()
39 if !liveFish(atk, now) || !liveFish(vic, now) {
40 panic("fish: match")
41 }
42 if atk.owner == vic.owner {
43 panic("fish: bite")
44 }
45 if party.SameParty(cross(cur), atk.owner, vic.owner) {
46 panic("fish: party")
47 }
48 nowMs := time.Now().UnixMilli()
49 // A cave or reef does not dodge. The cone still has to face the fish inside.
50 if !mouthHit(atk, vic) && !shelterMouth(atk, vic, nowMs) {
51 panic("fish: range")
52 }
53 if nowMs < atk.nextBiteMilli {
54 panic("fish: cool")
55 }
56 if atk.bittenBy == vic.id && nowMs < atk.bittenUntil {
57 panic("fish: cool")
58 }
59 cool := nowMs + cooldownMs(atk.rarity)
60 atk.nextBiteMilli = cool
61 atk.lastCombatMilli = nowMs
62 if redirectShelter(atk, vic, nowMs) {
63 chain.Emit("Bite", "id", strconv.Itoa(id), "target", strconv.Itoa(target), "damage", "shelter")
64 return
65 }
66 vic.lastCombatMilli = nowMs
67 vic.bittenBy = atk.id
68 vic.bittenUntil = cool
69 dmg := strikeFought(biteNow(atk), vic.hp, atk.size, vic.size, atk.rarity, vic.rarity)
70 landed := dmg
71 if landed > vic.hpNow {
72 landed = vic.hpNow
73 }
74 addDamage(vic.id, atk.owner, landed)
75 if vic.hpNow > dmg {
76 vic.hpNow -= dmg
77 chain.Emit("Bite", "id", strconv.Itoa(id), "target", strconv.Itoa(target), "damage", strconv.Itoa(dmg))
78 return
79 }
80 payKill(atk, vic, cur)
81}
82
83func payKill(atk, vic *fishBody, rlm realm) {
84 if !rlm.IsCurrent() {
85 panic("fish: realm")
86 }
87 covered := coveredStake(atk.pool, vic.pool)
88 excess := vic.pool - covered
89 killer, lp, season, burn, cheer, viewer := splitParts(covered)
90 nowMs := time.Now().UnixMilli()
91 creditBout(atk)
92 creditBout(vic)
93 growFromKill(atk, vic)
94 returnCheers(vic, rlm)
95 vic.pool = 0
96 vic.inMatch = false
97 vic.boutPaid = false
98 vic.mustHeal = true
99 vic.hpNow = downHP(vic.hp)
100 cheerHeld := bookCheerClaims(atk, vic, cheer, nowMs)
101 season += cheer - cheerHeld
102 viewHeld := bookWatchClaims(atk, vic, viewer, nowMs)
103 season += viewer - viewHeld
104 send(vic.owner, excess, rlm)
105 payKiller(atk.owner, vic.id, killer, rlm)
106 send(lpSink, lp, rlm)
107 send(seasonSink, season, rlm)
108 send(burnSink, burn, rlm)
109 clearDamage(vic.id)
110 chain.Emit(
111 "Kill",
112 "id", strconv.Itoa(atk.id),
113 "target", strconv.Itoa(vic.id),
114 "covered", strconv.FormatInt(covered, 10),
115 "killer", strconv.FormatInt(killer, 10),
116 )
117}
118
119// payKiller splits the killer share by HP removed among current members.
120// The party realm holds no coins. A solo owner, or a party with no recorded
121// damage, receives the whole share. Dust goes to the highest damage, and a
122// tie prefers the owner who landed the blow.
123func payKiller(owner address, victim int, amount int64, rlm realm) {
124 if amount <= 0 {
125 return
126 }
127 pid := party.PartyOf(cross(rlm), owner)
128 if pid == 0 {
129 send(owner, amount, rlm)
130 return
131 }
132 n := party.MemberCount(cross(rlm), pid)
133 if n <= 0 {
134 send(owner, amount, rlm)
135 return
136 }
137 owners := make([]address, n)
138 weights := make([]int, n)
139 total := 0
140 for i := 0; i < n; i++ {
141 who := party.MemberAt(cross(rlm), pid, i)
142 owners[i] = who
143 w := damageOf(victim, who)
144 if w < 0 {
145 w = 0
146 }
147 weights[i] = w
148 total += w
149 }
150 if total <= 0 {
151 send(owner, amount, rlm)
152 return
153 }
154 shares := splitWeights(amount, weights, owner, owners)
155 for i := range owners {
156 send(owners[i], shares[i], rlm)
157 }
158}
159
160func splitWeights(amount int64, weights []int, prefer address, owners []address) []int64 {
161 total := 0
162 for _, w := range weights {
163 if w > 0 {
164 total += w
165 }
166 }
167 out := make([]int64, len(weights))
168 if amount <= 0 || total <= 0 {
169 return out
170 }
171 var used int64
172 best := -1
173 bestW := 0
174 for i, w := range weights {
175 if w <= 0 {
176 continue
177 }
178 share := amount * int64(w) / int64(total)
179 out[i] = share
180 used += share
181 if best < 0 || w > bestW || (w == bestW && i < len(owners) && owners[i] == prefer) {
182 best = i
183 bestW = w
184 }
185 }
186 if used < amount && best >= 0 {
187 out[best] += amount - used
188 }
189 return out
190}
191
192func addDamage(id int, owner address, n int) {
193 if n <= 0 || id < 1 || !owner.IsValid() {
194 return
195 }
196 for len(blows) <= id {
197 blows = append(blows, nil)
198 }
199 rows := blows[id]
200 for i := 0; i < len(rows); i++ {
201 if rows[i].owner == owner {
202 rows[i].dmg += n
203 blows[id] = rows
204 return
205 }
206 }
207 blows[id] = append(rows, damageRow{owner: owner, dmg: n})
208}
209
210func damageOf(id int, owner address) int {
211 if id < 0 || id >= len(blows) {
212 return 0
213 }
214 for i := 0; i < len(blows[id]); i++ {
215 if blows[id][i].owner == owner && blows[id][i].dmg > 0 {
216 return blows[id][i].dmg
217 }
218 }
219 return 0
220}
221
222// clearDamage drops incoming blows so the next entry starts clean.
223func clearDamage(id int) {
224 if id < 0 || id >= len(blows) {
225 return
226 }
227 blows[id] = nil
228}
229
230// creditBout counts one finished entry. Leave does not call it.
231// SettleMatch does not count the entry again.
232func creditBout(body *fishBody) {
233 if body.boutPaid {
234 return
235 }
236 body.boutPaid = true
237 body.bouts++
238}
239
240func liveFish(body *fishBody, now int64) bool {
241 if now < 0 {
242 return false
243 }
244 return body.inMatch && body.pool > 0
245}
246
247func downHP(max int) int {
248 hp := max * 30 / 100
249 if hp < 1 {
250 return 1
251 }
252 return hp
253}
254
255func coveredStake(killerPool, victimPool int64) int64 {
256 if killerPool < 0 {
257 killerPool = 0
258 }
259 if victimPool < 0 {
260 victimPool = 0
261 }
262 if killerPool < victimPool {
263 return killerPool
264 }
265 return victimPool
266}
267
268// splitParts is 80/10/3/2/4/1. Dust joins burn. Season here is only the 3%.
269func splitParts(amount int64) (killer, lp, season, burn, cheer, viewer int64) {
270 if amount < 0 {
271 amount = 0
272 }
273 killer = amount * 8000 / 10000
274 lp = amount * 1000 / 10000
275 season = amount * 300 / 10000
276 burnPart := amount * 200 / 10000
277 cheer = amount * 400 / 10000
278 viewer = amount * 100 / 10000
279 dust := amount - killer - lp - season - burnPart - cheer - viewer
280 burn = burnPart + dust
281 return
282}
283
284// splitKill folds empty cheer and viewer into season. Callers that book those
285// shares use splitParts instead.
286func splitKill(amount int64) (killer, lp, season, burn int64) {
287 var cheer, viewer int64
288 killer, lp, season, burn, cheer, viewer = splitParts(amount)
289 season += cheer + viewer
290 return
291}
292
293func strikeDamage(bite, hpMax int, atkSize, defSize string) int {
294 dmg := bite * 100 / (hpMax + 100)
295 if dmg < 1 {
296 dmg = 1
297 }
298 if sizeIndex(atkSize)-sizeIndex(defSize) >= 2 {
299 dmg = dmg * 5000 / 10000
300 if dmg < 1 {
301 dmg = 1
302 }
303 }
304 return dmg
305}
306
307func sizeIndex(size string) int {
308 switch size {
309 case "small":
310 return 0
311 case "medium":
312 return 1
313 case "large":
314 return 2
315 default:
316 panic("fish: size")
317 }
318}
319
320func biteOf(rarity, size, trait string) int {
321 var base int64
322 switch rarity {
323 case "common":
324 base = 22
325 case "uncommon":
326 base = 26
327 case "rare":
328 base = 31
329 case "epic":
330 base = 37
331 case "legendary":
332 base = 44
333 case "mythic":
334 base = 52
335 default:
336 panic("fish: rarity")
337 }
338 traitBps := int64(10000)
339 if trait == "fang" {
340 traitBps = 11200
341 }
342 sizeBps := int64(10000)
343 if size == "small" {
344 sizeBps = 8600
345 } else if size == "large" {
346 sizeBps = 11800
347 }
348 v := (base*traitBps*sizeBps + 50_000_000) / 100_000_000
349 if v < 1 {
350 v = 1
351 }
352 return int(v)
353}
354
355func cooldownMs(rarity string) int64 {
356 switch rarity {
357 case "common":
358 return 2400
359 case "uncommon":
360 return 2550
361 case "rare":
362 return 2700
363 case "epic":
364 return 2850
365 case "legendary":
366 return 3000
367 case "mythic":
368 return 3200
369 default:
370 panic("fish: rarity")
371 }
372}
373
374// reachUnits is the dodged bite reach in 1/10000 of a world unit.
375// A sector is 8 world units, so the next center is 80000 units away.
376func reachUnits(rarity, size string, dodgeTenths int) int64 {
377 var spec int64
378 switch rarity {
379 case "common":
380 spec = 10000
381 case "uncommon":
382 spec = 10600
383 case "rare":
384 spec = 11200
385 case "epic":
386 spec = 11800
387 case "legendary":
388 spec = 12400
389 case "mythic":
390 spec = 13200
391 default:
392 panic("fish: rarity")
393 }
394 sizeMod := int64(10000)
395 if size == "small" {
396 sizeMod = 8600
397 } else if size == "large" {
398 sizeMod = 12200
399 }
400 reach := spec * sizeMod * 8 / 10000
401 reach -= int64(dodgeTenths) * 1000
402 if reach < 10_000 {
403 return 10_000
404 }
405 return reach
406}
407
408func inReach(atk, def *fishBody) bool {
409 return inReachDodge(atk, def, def.dodge)
410}
411
412func inReachDodge(atk, def *fishBody, dodge int) bool {
413 dx := int64(atk.sx - def.sx)
414 dz := int64(atk.sz - def.sz)
415 span := dx*dx + dz*dz
416 reach := reachUnits(atk.rarity, atk.size, dodge)
417 dist2 := span * 80_000 * 80_000
418 return dist2 <= reach*reach
419}