Analyse+Planer: sourceID fuer Reprisal/Feint/Addle via Cast-Events

- Neuer Abschnitt 2c in analysis.php: Cast-Events fuer Reprisal/Feint/Addle
  per 1 GQL-Request (3 Aliase) abfragen, da dataType:Buffs nur Friendly-
  Targets liefert (Boss-Debuffs fehlen dort)
- buffSourceTimeline mit Cast-Timestamps + 10s Dauer befuellt
- findBuffSourcePlayer(): sucht aktiven Caster zum Schadens-Zeitpunkt
- resolveMitigations(): gibt sourcePlayerType aus buffSourceTimeline zurueck
- guessJob() in planner.js: sourcePlayerType als erste Prioritaet vor
  job-basiertem Fallback -> DRK Reprisal, MNK Feint etc. korrekt zugeordnet
- analysis.js: Debuff-Icons im Header zeigen Job im Tooltip (z.B. DRK - Reprisal)
- Cache v5 -> v6

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xziino 2026-05-24 15:22:55 +02:00
parent a9b3cc8666
commit 636a65965a
4 changed files with 94 additions and 13 deletions

View File

@ -172,7 +172,7 @@ const MITIGATION_ABILITIES = [
'Addle' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001203, 'extraAbilityGameID' => 7560], 'Addle' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001203, 'extraAbilityGameID' => 7560],
]; ];
function resolveMitigations(string $buffStr, array $mitigIdMap): array { function resolveMitigations(string $buffStr, array $mitigIdMap, array $buffSourceTimeline = [], array $players = [], float $ts = 0): array {
if ($buffStr === '') return []; if ($buffStr === '') return [];
$result = []; $result = [];
$seen = []; $seen = [];
@ -182,18 +182,33 @@ function resolveMitigations(string $buffStr, array $mitigIdMap): array {
$name = $mitigIdMap[$id]['name']; $name = $mitigIdMap[$id]['name'];
if (isset($seen[$name])) continue; if (isset($seen[$name])) continue;
$seen[$name] = true; $seen[$name] = true;
$result[] = [ $entry = [
'key' => $mitigIdMap[$id]['key'] ?? $name, 'key' => $mitigIdMap[$id]['key'] ?? $name,
'name' => $name, 'name' => $name,
'dr' => $mitigIdMap[$id]['dr'], 'dr' => $mitigIdMap[$id]['dr'],
'buffType' => $mitigIdMap[$id]['buffType'], 'buffType' => $mitigIdMap[$id]['buffType'],
'extraAbilityGameID' => $mitigIdMap[$id]['extraAbilityGameID'] ?? null, 'extraAbilityGameID' => $mitigIdMap[$id]['extraAbilityGameID'] ?? null,
]; ];
$source = findBuffSourcePlayer($buffSourceTimeline, $id, $ts, $players);
if ($source) $entry['sourcePlayerType'] = $source['type'];
$result[] = $entry;
} }
} }
return $result; return $result;
} }
// Findet den Spieler der einen Buff zum Zeitpunkt $ts gecastet hat (anhand der applybuff-Timeline).
function findBuffSourcePlayer(array $sourceTimeline, int $statusId, float $ts, array $players): ?array {
$best = null;
foreach ($sourceTimeline[$statusId] ?? [] as $entry) {
if ($entry['apply'] > $ts + 200) continue; // noch nicht aktiv
if ($entry['remove'] !== null && $entry['remove'] < $ts - 200) continue; // schon abgelaufen
if ($best === null || $entry['apply'] > $best['apply']) $best = $entry;
}
if ($best === null || empty($best['sourceId'])) return null;
return $players[$best['sourceId']] ?? null;
}
// Fallback for shields consumed by a hit: the damage event's buffs field no // Fallback for shields consumed by a hit: the damage event's buffs field no
// longer contains the shield ID (already removed), but the applybuff/removebuff // longer contains the shield ID (already removed), but the applybuff/removebuff
// timeline shows it was active just before the hit. // timeline shows it was active just before the hit.
@ -326,9 +341,10 @@ for ($page = 0; $page < 10; $page++) {
// ── 2b. Shield buff/debuff timeline ──────────────────────────────────────── // ── 2b. Shield buff/debuff timeline ────────────────────────────────────────
// Builds applybuff/removebuff intervals per target so we can detect shields // Builds applybuff/removebuff intervals per target so we can detect shields
// that were consumed by a hit (absent from the damage event's buffs snapshot). // that were consumed by a hit (absent from the damage event's buffs snapshot).
$shieldTimeline = []; // targetId → statusId → [[apply, remove|null], ...] $shieldTimeline = []; // targetId → statusId → [[apply, remove|null], ...]
$statusNames = []; // statusId → localized display name from Buffs events $buffSourceTimeline = []; // statusId → [[apply, remove|null, sourceId], ...] — wer hat den Buff gecastet?
$statusActionIds = []; // statusId → applybuff extraAbilityGameID from FFLogs $statusNames = []; // statusId → localized display name from Buffs events
$statusActionIds = []; // statusId → applybuff extraAbilityGameID from FFLogs
if (!empty($trackedStatusIds)) { if (!empty($trackedStatusIds)) {
$nextPage = $startTime; $nextPage = $startTime;
@ -373,6 +389,21 @@ if (!empty($trackedStatusIds)) {
$type = $ev['type'] ?? ''; $type = $ev['type'] ?? '';
$meta = $mitigIdMap[$abId] ?? null; $meta = $mitigIdMap[$abId] ?? null;
// Source-Tracking für alle getrackten Abilities (unabhängig von buffType)
$srcId = (int)($ev['sourceID'] ?? 0);
if ($srcId > 0 && isset($players[$srcId])) {
if ($type === 'applybuff') {
$buffSourceTimeline[$abId][] = ['apply' => $ts, 'remove' => null, 'sourceId' => $srcId];
} elseif ($type === 'removebuff') {
for ($i = count($buffSourceTimeline[$abId] ?? []) - 1; $i >= 0; $i--) {
if ($buffSourceTimeline[$abId][$i]['remove'] === null) {
$buffSourceTimeline[$abId][$i]['remove'] = $ts;
break;
}
}
}
}
if (($meta['buffType'] ?? null) !== 'shield') continue; if (($meta['buffType'] ?? null) !== 'shield') continue;
if ($type === 'applybuff') { if ($type === 'applybuff') {
@ -394,6 +425,50 @@ if (!empty($trackedStatusIds)) {
} }
} }
// ── 2c. Boss-Debuff-Source via Casts ───────────────────────────────────────
// dataType: Buffs liefert nur Events auf Spieler (Friendly). Reprisal/Feint/Addle
// werden auf den Boss (Hostile) angewendet und tauchen dort nicht auf.
// Lösung: Cast-Events der drei Abilities direkt abfragen — 1 GQL-Request, 3 Aliase.
$dbReprisalActionId = (int)(MITIGATION_ABILITIES['Reprisal']['extraAbilityGameID'] ?? 0);
$dbFeintActionId = (int)(MITIGATION_ABILITIES['Feint']['extraAbilityGameID'] ?? 0);
$dbAddleActionId = (int)(MITIGATION_ABILITIES['Addle']['extraAbilityGameID'] ?? 0);
$dbReprisalStatusId = (int)(MITIGATION_ABILITIES['Reprisal']['statusId'] ?? 0);
$dbFeintStatusId = (int)(MITIGATION_ABILITIES['Feint']['statusId'] ?? 0);
$dbAddleStatusId = (int)(MITIGATION_ABILITIES['Addle']['statusId'] ?? 0);
if ($dbReprisalActionId && $dbFeintActionId && $dbAddleActionId) {
$dbResult = fflogs_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
reprisal: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbReprisalActionId, startTime: $startTime, endTime: $endTime) { data }
feint: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbFeintActionId, startTime: $startTime, endTime: $endTime) { data }
addle: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbAddleActionId, startTime: $startTime, endTime: $endTime) { data }
}
}
}
GQL);
if (isset($dbResult['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
foreach ([
'reprisal' => ['statusId' => $dbReprisalStatusId, 'durationMs' => 10000],
'feint' => ['statusId' => $dbFeintStatusId, 'durationMs' => 10000],
'addle' => ['statusId' => $dbAddleStatusId, 'durationMs' => 10000],
] as $alias => $info) {
foreach ($dbResult['data']['reportData']['report'][$alias]['data'] ?? [] as $ev) {
if (($ev['type'] ?? '') !== 'cast') continue;
$srcId = (int)($ev['sourceID'] ?? 0);
if ($srcId <= 0 || !isset($players[$srcId]) || !$info['statusId']) continue;
$ts = (float)($ev['timestamp'] ?? 0);
$buffSourceTimeline[$info['statusId']][] = [
'apply' => $ts,
'remove' => $ts + $info['durationMs'],
'sourceId' => $srcId,
];
}
}
}
foreach ($statusNames as $statusId => $displayName) { foreach ($statusNames as $statusId => $displayName) {
if (isset($mitigIdMap[$statusId])) { if (isset($mitigIdMap[$statusId])) {
$mitigIdMap[$statusId]['name'] = $displayName; $mitigIdMap[$statusId]['name'] = $displayName;
@ -527,8 +602,8 @@ foreach ($clusters as $group) {
'overkill' => $tgt['overkill'], 'overkill' => $tgt['overkill'],
'hp' => $tgt['hp'], 'hp' => $tgt['hp'],
'maxHp' => $tgt['maxHp'], 'maxHp' => $tgt['maxHp'],
'mitigations' => (function() use ($tgt, $mitigIdMap, $shieldTimeline) { 'mitigations' => (function() use ($tgt, $mitigIdMap, $shieldTimeline, $buffSourceTimeline, $players) {
$mitigations = resolveMitigations($tgt['buffs'], $mitigIdMap); $mitigations = resolveMitigations($tgt['buffs'], $mitigIdMap, $buffSourceTimeline, $players, $tgt['ts']);
if ($tgt['absorbed'] > 0 && !empty($shieldTimeline)) { if ($tgt['absorbed'] > 0 && !empty($shieldTimeline)) {
$existing = []; $existing = [];
foreach ($mitigations as $m) { foreach ($mitigations as $m) {

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
const CACHED_LOG_DIR = __DIR__ . '/../cached_logs'; const CACHED_LOG_DIR = __DIR__ . '/../cached_logs';
const CACHED_LOG_VERSION = 'v4'; const CACHED_LOG_VERSION = 'v6';
function cache_language(string $language): string { function cache_language(string $language): string {
$language = strtolower(trim($language)); $language = strtolower(trim($language));

View File

@ -605,10 +605,12 @@
].map(m => { ].map(m => {
const iconSrc = mitigationIcon(m); const iconSrc = mitigationIcon(m);
if (!iconSrc) return ''; if (!iconSrc) return '';
const dr = m.dr > 0 ? ` ${m.dr}%` : ''; const dr = m.dr > 0 ? ` ${m.dr}%` : '';
const jobAbbr = m.sourcePlayerType ? (JOB_ABBR[m.sourcePlayerType] ?? '') : '';
const label = jobAbbr ? `${jobAbbr} · ${m.name}` : m.name;
return m.missing return m.missing
? `<img class="aoe-target-buff-icon aoe-buff-missing" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr} fehlt (war im Referenz-Pull aktiv)">` ? `<img class="aoe-target-buff-icon aoe-buff-missing" src="${iconSrc}" alt="${m.name}" title="${label}${dr} fehlt (war im Referenz-Pull aktiv)">`
: `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`; : `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${label}${dr}">`;
}).join(''); }).join('');
// Current targets // Current targets

View File

@ -2238,7 +2238,11 @@ function sortedAssignments(assignments) {
); );
} }
function guessJob(abilityName, players) { function guessJob(abilityName, players, mitigation = null) {
// Direkte Zuordnung über sourcePlayerType aus dem applybuff-Event (zuverlässigste Quelle)
if (mitigation?.sourcePlayerType) {
return JOB_FROM_TYPE[mitigation.sourcePlayerType] ?? '';
}
if (ABILITY_JOB_MAP[abilityName]) return ABILITY_JOB_MAP[abilityName]; if (ABILITY_JOB_MAP[abilityName]) return ABILITY_JOB_MAP[abilityName];
const jobs = (players ?? []).map(p => JOB_FROM_TYPE[p.type] ?? ''); const jobs = (players ?? []).map(p => JOB_FROM_TYPE[p.type] ?? '');
if (abilityName === 'Reprisal') { if (abilityName === 'Reprisal') {
@ -2293,7 +2297,7 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga
ability: key, ability: key,
abilityName: mitigationDisplayName(m) || mitigationNames[key], abilityName: mitigationDisplayName(m) || mitigationNames[key],
actionId: m.extraAbilityGameID ?? null, actionId: m.extraAbilityGameID ?? null,
job: guessJob(key, players), job: guessJob(key, players, m),
buffType: m.buffType ?? '', buffType: m.buffType ?? '',
timestamp: Math.max(0, relTs + IMPORT_OFFSET_MS), timestamp: Math.max(0, relTs + IMPORT_OFFSET_MS),
}); });