Skip to content

Commit 0469e97

Browse files
authored
Merge pull request #2760 from NicolasKion/feature/damage-projection-graph
Add damage projection graph
2 parents 89ac4ee + f74861b commit 0469e97

4 files changed

Lines changed: 413 additions & 0 deletions

File tree

graphs/data/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020

2121
from . import fitDamageStats
22+
from . import fitDamageEnvelope
2223
from . import fitEwarStats
2324
from . import fitRemoteReps
2425
from . import fitShieldRegen
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# =============================================================================
2+
# Copyright (C) 2010 Diego Duclos
3+
#
4+
# This file is part of pyfa.
5+
#
6+
# pyfa is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation, either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# pyfa is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with pyfa. If not, see <http://www.gnu.org/licenses/>.
18+
# =============================================================================
19+
20+
21+
from .graph import FitDamageEnvelopeGraph
22+
23+
FitDamageEnvelopeGraph.register()
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
# =============================================================================
2+
# Copyright (C) 2010 Diego Duclos
3+
#
4+
# This file is part of pyfa.
5+
#
6+
# pyfa is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation, either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# pyfa is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with pyfa. If not, see <http://www.gnu.org/licenses/>.
18+
# =============================================================================
19+
20+
21+
import eos.config
22+
from eos.const import FittingHardpoint
23+
from eos.saveddata.targetProfile import TargetProfile
24+
from eos.utils.spoolSupport import SpoolOptions, SpoolType
25+
from graphs.calc import checkLockRange
26+
from graphs.data.base import SmoothPointGetter
27+
from graphs.data.fitDamageStats.calc.application import (_calcMissileFactor, _calcTurretChanceToHit, _calcTurretMult,
28+
getApplicationPerKey, )
29+
from service.settings import GraphSettings
30+
31+
32+
def _buildResistProfile(tgtResists, tgtFullHp):
33+
if not GraphSettings.getInstance().get('ignoreResists'):
34+
emRes, thermRes, kinRes, exploRes = tgtResists
35+
else:
36+
emRes = thermRes = kinRes = exploRes = 0
37+
return TargetProfile(emAmount=emRes, thermalAmount=thermRes, kineticAmount=kinRes, explosiveAmount=exploRes,
38+
hp=tgtFullHp)
39+
40+
41+
def _typedDmgScalar(dmgTyped, applicationMult, profile):
42+
"""Apply application multiplier and resist profile, return scalar EHP/s."""
43+
if applicationMult == 0:
44+
return 0
45+
scaled = dmgTyped * applicationMult
46+
scaled.profile = profile
47+
return scaled.total
48+
49+
50+
def _turretApplication(snapshot, src, tgt, atkSpeed, atkAngle, distance, tgtSpeed, tgtAngle, tgtSigRadius):
51+
cth = _calcTurretChanceToHit(atkSpeed=atkSpeed, atkAngle=atkAngle, atkRadius=src.getRadius(),
52+
atkOptimalRange=snapshot['maxRange'] or 0, atkFalloffRange=snapshot['falloff'] or 0,
53+
atkTracking=snapshot['tracking'], atkOptimalSigRadius=snapshot['optimalSigRadius'], distance=distance,
54+
tgtSpeed=tgtSpeed, tgtAngle=tgtAngle, tgtRadius=tgt.getRadius(), tgtSigRadius=tgtSigRadius)
55+
return _calcTurretMult(cth)
56+
57+
58+
def _missileApplication(snapshot, distance, tgtSpeed, tgtSigRadius):
59+
rangeData = snapshot['missileMaxRangeData']
60+
if rangeData is None:
61+
return 0
62+
lowerRange, higherRange, higherChance = rangeData
63+
if distance is None or distance <= lowerRange:
64+
distanceFactor = 1
65+
elif lowerRange < distance <= higherRange:
66+
distanceFactor = higherChance
67+
else:
68+
distanceFactor = 0
69+
if distanceFactor == 0:
70+
return 0
71+
applicationFactor = _calcMissileFactor(atkEr=snapshot['aoeCloudSize'], atkEv=snapshot['aoeVelocity'],
72+
atkDrf=snapshot['aoeDamageReductionFactor'], tgtSpeed=tgtSpeed, tgtSigRadius=tgtSigRadius)
73+
return distanceFactor * applicationFactor
74+
75+
76+
def _snapshotTurret(mod, dmgTyped, charge):
77+
return {'kind': 'turret', 'charge': charge, 'dmg': dmgTyped, 'maxRange': mod.maxRange, 'falloff': mod.falloff,
78+
'tracking': mod.getModifiedItemAttr('trackingSpeed'),
79+
'optimalSigRadius': mod.getModifiedItemAttr('optimalSigRadius')}
80+
81+
82+
def _snapshotMissile(mod, dmgTyped, charge):
83+
return {'kind': 'missile', 'charge': charge, 'dmg': dmgTyped, 'missileMaxRangeData': mod.missileMaxRangeData,
84+
'aoeCloudSize': mod.getModifiedChargeAttr('aoeCloudSize'),
85+
'aoeVelocity': mod.getModifiedChargeAttr('aoeVelocity'),
86+
'aoeDamageReductionFactor': mod.getModifiedChargeAttr('aoeDamageReductionFactor'),
87+
'isFoF': 'fofMissileLaunching' in (charge.effects if charge else {})}
88+
89+
90+
def _isAmmoEnvelopeWeapon(mod):
91+
"""Turret or standard missile launcher with valid charges."""
92+
if mod.hardpoint not in (FittingHardpoint.TURRET, FittingHardpoint.MISSILE):
93+
return False
94+
# Skip exotic weapon groups handled separately by stock app logic
95+
if mod.item.group.name in ('Missile Launcher Bomb', 'Structure Guided Bomb Launcher'):
96+
return False
97+
if 'ChainLightning' in mod.item.effects:
98+
return False
99+
if mod.isBreacher:
100+
return False
101+
return bool(mod.getValidCharges())
102+
103+
104+
def _snapshotForCurrentCharge(mod):
105+
"""Build a snapshot dict for whatever charge is currently loaded on mod."""
106+
spoolOptions = SpoolOptions(SpoolType.SPOOL_SCALE, eos.config.settings['globalDefaultSpoolupPercentage'], False)
107+
dmgTyped = mod.getDps(spoolOptions=spoolOptions)
108+
if mod.hardpoint == FittingHardpoint.TURRET:
109+
return _snapshotTurret(mod, dmgTyped, mod.charge)
110+
return _snapshotMissile(mod, dmgTyped, mod.charge)
111+
112+
113+
def _collectWeaponCandidates(src):
114+
"""For each ammo-bearing weapon, return list of per-charge snapshots.
115+
116+
Charge-dependent attributes (optimal/falloff/tracking/missile range/AoE) are
117+
only applied to the module's modified attributes by a full fit recalc.
118+
Since ammo effects are gun-local in EVE (a crystal in laser-1 does not
119+
affect laser-2's attributes), we load up to N different ammos onto N
120+
different weapons of the same group, recalc the fit once, and snapshot
121+
all N (weapon, ammo) pairs from that single recalc. For a group of size
122+
K weapons and M ammos this needs ceil(M / K) recalcs instead of M.
123+
Originals are always restored via try/finally even if a calc raises.
124+
"""
125+
fit = src.item
126+
weapon_mods = [mod for mod in fit.activeModulesIter() if _isAmmoEnvelopeWeapon(mod)]
127+
if not weapon_mods:
128+
return []
129+
130+
# Group by (item ID, state) — within such a group, snapshots can be shared
131+
# across mods, and DPS reads need consistent per-mod state.
132+
groups = {}
133+
for mod in weapon_mods:
134+
groups.setdefault((mod.item.ID, mod.state), []).append(mod)
135+
136+
originals = {id(mod): mod.charge for mod in weapon_mods}
137+
snapshots_by_mod = {id(mod): [] for mod in weapon_mods}
138+
spoolOptions = SpoolOptions(SpoolType.SPOOL_SCALE, eos.config.settings['globalDefaultSpoolupPercentage'], False)
139+
140+
try:
141+
for group_mods in groups.values():
142+
valid_charges = sorted(group_mods[0].getValidCharges(), key=lambda c: c.name)
143+
if not valid_charges:
144+
continue
145+
chunk_size = len(group_mods)
146+
for chunk_start in range(0, len(valid_charges), chunk_size):
147+
chunk = valid_charges[chunk_start:chunk_start + chunk_size]
148+
# Assign one chunk-ammo per group mod (extras stay on their previous charge)
149+
for i, charge in enumerate(chunk):
150+
group_mods[i].charge = charge
151+
fit.clear()
152+
fit.calculateModifiedAttributes()
153+
# Snapshot per (assignee mod, charge); copy to all group mods since
154+
# within an (item ID, state) group attributes for a given ammo match.
155+
for i, charge in enumerate(chunk):
156+
assignee = group_mods[i]
157+
dmgTyped = assignee.getDps(spoolOptions=spoolOptions)
158+
if dmgTyped.total <= 0:
159+
continue
160+
if assignee.hardpoint == FittingHardpoint.TURRET:
161+
snap = _snapshotTurret(assignee, dmgTyped, charge)
162+
else:
163+
snap = _snapshotMissile(assignee, dmgTyped, charge)
164+
for target_mod in group_mods:
165+
snapshots_by_mod[id(target_mod)].append(snap)
166+
finally:
167+
for mod in weapon_mods:
168+
mod.charge = originals[id(mod)]
169+
fit.clear()
170+
fit.calculateModifiedAttributes()
171+
172+
weapons = [{'mod': mod, 'candidates': snapshots_by_mod[id(mod)]} for mod in weapon_mods if
173+
snapshots_by_mod[id(mod)]]
174+
for weapon in weapons:
175+
weapon['candidates'] = _pruneDominated(weapon['candidates'], src)
176+
return weapons
177+
178+
179+
def _pruneDominated(candidates, src):
180+
"""Drop candidates whose effective-DPS curve is dominated everywhere.
181+
182+
Sample each candidate's application-only multiplier (ignoring resists,
183+
which are mod-independent and uniformly scale all candidates) over a
184+
coarse distance grid. A candidate X is dominated if there exists Y such
185+
that Y's raw_damage * multiplier(distance) >= X's at every sample.
186+
"""
187+
if len(candidates) <= 1:
188+
return candidates
189+
# Sample multipliers under a neutral mid-range scenario; this captures
190+
# the shape of each ammo's range envelope without depending on misc inputs.
191+
sampleDistances = [0, 1000, 5000, 10000, 20000, 40000, 80000, 160000, 320000]
192+
tgtSpeed = 0
193+
atkSpeed = 0
194+
tgtSigRadius = 125
195+
sigRefMod = src.getSigRadius() # not directly used, kept for clarity
196+
del sigRefMod
197+
# For each candidate, build a scalar score vector across samples.
198+
scores = []
199+
for snap in candidates:
200+
rawTotal = snap['dmg'].total
201+
vec = []
202+
for d in sampleDistances:
203+
if snap['kind'] == 'turret':
204+
# Use only the range factor (drop tracking — angular speed is 0 here)
205+
# by passing 0 atkSpeed/tgtSpeed/tgtAngle.
206+
mult = _turretApplication(snap, src, src, atkSpeed, 0, d, tgtSpeed, 0, tgtSigRadius)
207+
else:
208+
mult = _missileApplication(snap, d, tgtSpeed, tgtSigRadius)
209+
vec.append(rawTotal * mult)
210+
scores.append(vec)
211+
# Mark dominated
212+
n = len(candidates)
213+
eps = 1e-9
214+
keep = [True] * n
215+
for i in range(n):
216+
if not keep[i]:
217+
continue
218+
for j in range(n):
219+
if i == j or not keep[j]:
220+
continue
221+
# j dominates i if scores[j][k] >= scores[i][k] - eps for all k
222+
# and scores[j][k] > scores[i][k] + eps for at least one k
223+
dominates = True
224+
strict = False
225+
for k in range(len(sampleDistances)):
226+
if scores[j][k] + eps < scores[i][k]:
227+
dominates = False
228+
break
229+
if scores[j][k] > scores[i][k] + eps:
230+
strict = True
231+
if dominates and strict:
232+
keep[i] = False
233+
break
234+
return [c for c, k in zip(candidates, keep) if k]
235+
236+
237+
def _bestWeaponDpsAtDistance(weapon, src, tgt, atkSpeed, atkAngle, distance, tgtSpeed, tgtAngle, tgtSigRadius, profile,
238+
inLockRange):
239+
if not inLockRange:
240+
# Special case: FoF missiles ignore lock range
241+
candidates = [c for c in weapon['candidates'] if c.get('isFoF')]
242+
if not candidates:
243+
return 0
244+
else:
245+
candidates = weapon['candidates']
246+
best = 0
247+
for snap in candidates:
248+
if snap['kind'] == 'turret':
249+
mult = _turretApplication(snap, src, tgt, atkSpeed, atkAngle, distance, tgtSpeed, tgtAngle, tgtSigRadius)
250+
else:
251+
mult = _missileApplication(snap, distance, tgtSpeed, tgtSigRadius)
252+
scalar = _typedDmgScalar(snap['dmg'], mult, profile)
253+
if scalar > best:
254+
best = scalar
255+
return best
256+
257+
258+
class Distance2EnvelopeDpsGetter(SmoothPointGetter):
259+
_baseResolution = 50
260+
_extraDepth = 2
261+
262+
def _getCommonData(self, miscParams, src, tgt):
263+
# Snapshot per-weapon ammo candidates once. _calculatePoint reuses these
264+
# for every distance step so we avoid repeated charge swaps.
265+
weapons = _collectWeaponCandidates(src)
266+
# Track ammo-envelope weapon IDs so we can subtract their stock contribution
267+
# from the common application map below.
268+
envelopeMods = {id(w['mod']) for w in weapons}
269+
# Standard application path covers everything else (drones, fighters,
270+
# smartbombs, doomsdays, modules without valid charges, etc.).
271+
defaultSpool = eos.config.settings['globalDefaultSpoolupPercentage']
272+
spoolOptions = SpoolOptions(SpoolType.SPOOL_SCALE, defaultSpool, False)
273+
nonEnvelopeDmg = {}
274+
for mod in src.item.activeModulesIter():
275+
if id(mod) in envelopeMods:
276+
continue
277+
if not mod.isDealingDamage():
278+
continue
279+
nonEnvelopeDmg[mod] = mod.getDps(spoolOptions=spoolOptions)
280+
for drone in src.item.activeDronesIter():
281+
if not drone.isDealingDamage():
282+
continue
283+
nonEnvelopeDmg[drone] = drone.getDps()
284+
for fighter in src.item.activeFightersIter():
285+
if not fighter.isDealingDamage():
286+
continue
287+
for effectID, effectDps in fighter.getDpsPerEffect().items():
288+
nonEnvelopeDmg[(fighter, effectID)] = effectDps
289+
return {'weapons': weapons, 'nonEnvelopeDmg': nonEnvelopeDmg, 'tgtResists': tgt.getResists(),
290+
'tgtFullHp': tgt.getFullHp()}
291+
292+
def _calculatePoint(self, x, miscParams, src, tgt, commonData):
293+
distance = x
294+
tgtSpeed = miscParams['tgtSpeed']
295+
tgtSigRadius = miscParams.get('tgtSigRad', tgt.getSigRadius())
296+
atkSpeed = miscParams.get('atkSpeed', 0) or 0
297+
atkAngle = miscParams.get('atkAngle', 0) or 0
298+
tgtAngle = miscParams.get('tgtAngle', 0) or 0
299+
profile = _buildResistProfile(commonData['tgtResists'], commonData['tgtFullHp'])
300+
inLockRange = checkLockRange(src=src, distance=distance)
301+
302+
total = 0
303+
# Sum optimum-ammo contribution for each ammo-bearing weapon
304+
for weapon in commonData['weapons']:
305+
total += _bestWeaponDpsAtDistance(weapon=weapon, src=src, tgt=tgt, atkSpeed=atkSpeed, atkAngle=atkAngle,
306+
distance=distance, tgtSpeed=tgtSpeed, tgtAngle=tgtAngle, tgtSigRadius=tgtSigRadius, profile=profile,
307+
inLockRange=inLockRange)
308+
309+
# Add fixed-ammo contributors (drones, fighters, smartbombs, etc.) using
310+
# the standard application math from fitDamageStats.
311+
if commonData['nonEnvelopeDmg']:
312+
applicationMap = getApplicationPerKey(src=src, tgt=tgt, atkSpeed=atkSpeed, atkAngle=atkAngle,
313+
distance=distance, tgtSpeed=tgtSpeed, tgtAngle=tgtAngle, tgtSigRadius=tgtSigRadius)
314+
for key, dmgTyped in commonData['nonEnvelopeDmg'].items():
315+
mult = applicationMap.get(key, 0)
316+
total += _typedDmgScalar(dmgTyped, mult, profile)
317+
return total

0 commit comments

Comments
 (0)