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.

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}