Skip to content

Commit fcf4a1b

Browse files
committed
Merge feat/boss-cast-timeline into release/v0.3.0
Boss-side cast events get walked at import time (and on every top-log ref detail fetch). Per-ability aggregates (count + cast_seconds sample anchored on first+last cast) go into fight.extras['boss_casts'] and each detail_payload['boss_casts']. Prompts (EN+DE) teach the model to derive cycle period and align defensive cooldowns against boss-pressure cycles. Conflicts resolved manually: - prompts.py: kept the enhanced phase_transitions block from phase-transitions-fight-relative AND added the new boss_casts block from this branch. - parser.py: kept the phase-normalisation loop body (HEAD) and reused its fight_start_offset_ms variable as the source for wcl_fight_start_ms in extras.
2 parents eb277e2 + 7f6f674 commit fcf4a1b

6 files changed

Lines changed: 290 additions & 1 deletion

File tree

backend/app/services/ai/analyzer.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,10 @@ async def _fetch_top_log_references(
246246
"buffs": detail.get("buffs") or [],
247247
"debuffs": detail.get("debuffs") or [],
248248
"damage_taken": detail.get("damage_taken") or [],
249+
# Boss-cast cycle timeline of the reference kill, same
250+
# shape as the user fight's ``fight.boss_casts`` so the
251+
# AI can compare cycle alignment directly.
252+
"boss_casts": detail.get("boss_casts") or [],
249253
},
250254
}
251255
)
@@ -446,6 +450,12 @@ async def _collect_localized_names(
446450
for g in gear:
447451
if g.get("item_id"):
448452
item_ids.add(int(g["item_id"]))
453+
# Boss-cast timeline IDs are spell IDs (the boss is casting actual
454+
# spells), so they belong under kind=spell. Same goes for the
455+
# reference-detail boss_casts further down.
456+
for entry in fight_summary.get("boss_casts") or []:
457+
if entry.get("ability_id"):
458+
spell_ids.add(int(entry["ability_id"]))
449459

450460
for ref in references:
451461
detail = ref.get("detail") or {}
@@ -465,6 +475,9 @@ async def _collect_localized_names(
465475
for tid in detail.get("talent_ids") or []:
466476
if tid:
467477
talent_ids.add(int(tid))
478+
for entry in detail.get("boss_casts") or []:
479+
if entry.get("ability_id"):
480+
spell_ids.add(int(entry["ability_id"]))
468481

469482
names = await lookup_names(
470483
session,
@@ -579,6 +592,12 @@ async def request_analysis(
579592
"duration_ms": fight.duration_ms,
580593
"boss_percentage": fight.boss_percentage,
581594
"phase_transitions": fight_extras.get("phase_transitions") or [],
595+
# Per-ability boss-cast timeline (fight-relative seconds since
596+
# fight start). One entry per top-N boss ability with the cast
597+
# count and a sample of timestamps. Lets the AI infer the cycle
598+
# period of each ability and judge whether the player's defensive
599+
# cooldown frequency matched the boss-pressure cadence.
600+
"boss_casts": fight_extras.get("boss_casts") or [],
582601
}
583602
player_extras = player.extras or {}
584603
parse_metrics = player_extras.get("parse_metrics") or {}

backend/app/services/ai/prompts.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,25 @@ def stat_glossary_for(locale: Locale) -> dict[str, str]:
133133
``is_intermission`` flag. Quote the phase by *name* when available
134134
("missed a CD usage at the Sanguine Intermission transition at 1:42"),
135135
not by raw id. Do not waste cooldowns on intermission phases.
136+
- **Use ``fight.boss_casts`` (and the matching ``detail.boss_casts`` on
137+
each top-log reference) to align the player's defensive cooldowns with
138+
actual boss-pressure cycles.** Each entry is one boss ability with
139+
``ability_id``, ``count`` (total casts in the fight) and
140+
``cast_seconds`` (a sample of cast timestamps in fight-relative
141+
seconds). To estimate the cycle period: ``round((max-min)/(count-1))``
142+
on ``cast_seconds`` ≈ seconds between hits. Then compare to the
143+
player's defensive cast count (in ``top_casts``):
144+
- Boss ability with cycle ≈ 25 s on a 6:00 fight = ~14 expected hits.
145+
- Player's 60-s-CD defensive used 3× over the same window = covers
146+
~5 of those 14. That's a concrete shortfall you can quote.
147+
- If the top-log reference's ``boss_casts`` for the same ability
148+
shows the same cycle but their defensive cast count matches every
149+
*other* hit (≈ 7 uses), call out the gap explicitly: "boss did X
150+
~14×, top log mitigated every other one (7 defensives), you used
151+
it 3× → 4 missed mitigation windows".
152+
Quote the boss ability by its localised name from ``localized_names``
153+
(under the ``spell:<id>`` key — boss casts are real spells), never by
154+
raw id.
136155
- Examine ``damage_taken`` to flag mechanics the player is eating that top
137156
performers avoid. Do not invent boss mechanics — only reference abilities
138157
that are actually in the data.
@@ -265,6 +284,24 @@ def stat_glossary_for(locale: Locale) -> dict[str, str]:
265284
vorhanden („CD-Nutzung am Übergang zur Blutigen Intermission bei 1:42
266285
verpasst"), nicht über die rohe ID. Verschwende keine Cooldowns in
267286
Intermission-Phasen.
287+
- **Nutze ``fight.boss_casts`` (und das passende ``detail.boss_casts``
288+
jeder Top-Log-Referenz), um defensive Cooldowns des Spielers gegen die
289+
tatsächlichen Boss-Druck-Zyklen abzugleichen.** Jeder Eintrag ist eine
290+
Boss-Fähigkeit mit ``ability_id``, ``count`` (Gesamt-Casts im Fight)
291+
und ``cast_seconds`` (Stichprobe der Cast-Zeitpunkte in Fight-Sekunden).
292+
Zyklus-Periode schätzen via ``round((max-min)/(count-1))`` auf
293+
``cast_seconds`` ≈ Sekunden zwischen Treffern. Dann mit der Defensiv-
294+
Cast-Zahl aus ``top_casts`` vergleichen:
295+
- Boss-Ability mit ~25 s Zyklus auf 6:00 Fight = ~14 erwartete Hits.
296+
- 60-s-CD Defensive 3× genutzt → deckt ~5 von 14 ab. Konkrete Lücke.
297+
- Wenn die Top-Log-Referenz für dieselbe Ability den gleichen Zyklus
298+
zeigt, aber jeden *zweiten* Hit mitigiert (≈ 7 Defensives), das
299+
Gap explizit benennen: „Boss castet X ~14×, Top-Log mitigiert
300+
jeden zweiten Hit (7 Defensives), du 3× → 4 verpasste
301+
Mitigations-Fenster".
302+
Zitiere die Boss-Ability über ihren lokalisierten Namen aus
303+
``localized_names`` (unter dem ``spell:<id>``-Key — Boss-Casts sind
304+
echte Spells), nie über die rohe ID.
268305
- Werte ``damage_taken`` aus, um Mechaniken zu flaggen, die der Spieler
269306
frisst, die Top-Performer aber dodgen. Erfinde keine Boss-Mechaniken —
270307
beziehe dich nur auf Fähigkeiten, die tatsächlich in den Daten stehen.

backend/app/services/report_service.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
)
1818
from app.services.wcl.client import WclClient
1919
from app.services.wcl.parser import (
20+
aggregate_boss_casts,
2021
parse_damage_done_table,
2122
parse_deaths_table,
23+
parse_enemy_cast_events_page,
2224
parse_gear_from_player_details,
2325
parse_healing_done_table,
2426
parse_player_details,
@@ -27,6 +29,7 @@
2729
parse_report_rankings_for_fight,
2830
)
2931
from app.services.wcl.queries import (
32+
REPORT_ENEMY_CAST_EVENTS,
3033
REPORT_OVERVIEW,
3134
REPORT_PLAYER_DETAILS,
3235
REPORT_RANKINGS,
@@ -36,6 +39,49 @@
3639
logger = logging.getLogger(__name__)
3740

3841

42+
async def fetch_boss_cast_timeline(
43+
client: WclClient,
44+
*,
45+
code: str,
46+
fight_id: int,
47+
fight_start_ms: int,
48+
max_pages: int = 10,
49+
) -> list[dict[str, Any]]:
50+
"""Walk ``report.events(dataType: Casts, hostilityType: Enemies)`` for one
51+
fight and return the compact per-ability cast timeline the AI prompt wants.
52+
53+
Pagination: WCL returns up to ``limit`` events per page (we ask for 1000)
54+
and includes ``nextPageTimestamp`` whenever there's more data. We follow
55+
it but cap at ``max_pages`` (= 10k events worst case) so one runaway log
56+
can't stall the import — long fights with 4-6 boss abilities cycling
57+
every 25-45s typically fit in 1-2 pages.
58+
59+
Returns the same shape as ``aggregate_boss_casts``: a list of
60+
``{ability_id, count, cast_seconds: [...]}`` entries. Empty list on
61+
upstream failure (logged) so the import can still complete; missing
62+
boss-cast data just means the AI doesn't get the timeline for this
63+
fight, not that the import fails.
64+
"""
65+
collected: list[dict[str, Any]] = []
66+
next_ts: float | None = None
67+
for _ in range(max_pages):
68+
variables: dict[str, Any] = {"code": code, "fightIDs": [fight_id]}
69+
if next_ts is not None:
70+
variables["startTime"] = next_ts
71+
try:
72+
payload = await client.query(REPORT_ENEMY_CAST_EVENTS, variables)
73+
except Exception: # noqa: BLE001
74+
logger.exception(
75+
"boss cast events fetch failed for code=%s fight=%s", code, fight_id
76+
)
77+
return []
78+
page, next_ts = parse_enemy_cast_events_page(payload)
79+
collected.extend(page)
80+
if next_ts is None:
81+
break
82+
return aggregate_boss_casts(collected, fight_start_ms=fight_start_ms)
83+
84+
3985
async def create_import_skeleton(
4086
session: AsyncSession, *, raw_input: str, owner_user_id: Any | None
4187
) -> Report:
@@ -117,6 +163,26 @@ async def run_report_import(
117163

118164
all_fight_ids = list(fights_by_id.keys())
119165
if all_fight_ids:
166+
# Boss-cast timeline per fight — one ``report.events`` walk each.
167+
# Done as its own pass (not inside _populate_players) because this
168+
# data is fight-scoped, not player-scoped: every player on the
169+
# fight sees the same boss-cast pattern.
170+
for fight_wcl_id, fight_row in fights_by_id.items():
171+
fight_start_ms = int(
172+
(fight_row.extras or {}).get("wcl_fight_start_ms") or 0
173+
)
174+
boss_casts = await fetch_boss_cast_timeline(
175+
client,
176+
code=code,
177+
fight_id=fight_wcl_id,
178+
fight_start_ms=fight_start_ms,
179+
)
180+
# Mutate in place — JSONB will pick this up on the next flush.
181+
merged_extras = dict(fight_row.extras or {})
182+
merged_extras["boss_casts"] = boss_casts
183+
fight_row.extras = merged_extras
184+
await session.flush()
185+
120186
await _populate_players(session, client, code, all_fight_ids, fights_by_id)
121187

122188
report.import_status = "ready"

backend/app/services/top_logs_service.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from app.config import settings
2626
from app.models import GameSpec, Role, TopLog
2727
from app.services.wcl.client import WclClient
28+
from app.services.report_service import fetch_boss_cast_timeline
2829
from app.services.wcl.parser import (
2930
composition_from_player_details,
3031
parse_aura_table,
@@ -282,6 +283,24 @@ async def _fetch_detail_for_top_log(
282283
return None
283284
actor_id = target["actor_id"]
284285

286+
# Pull the fight's report-relative start offset from the same payload —
287+
# we extended REPORT_PLAYER_DETAILS to include ``fights{startTime}`` so
288+
# the boss-cast timeline below can convert event timestamps into
289+
# fight-relative seconds without a separate WCL roundtrip.
290+
fight_start_ms = 0
291+
pd_report = (pd_payload.get("reportData") or {}).get("report") or {}
292+
for fmeta in pd_report.get("fights") or []:
293+
try:
294+
if int(fmeta.get("id", -1)) == int(fight_id):
295+
fight_start_ms = int(fmeta.get("startTime") or 0)
296+
break
297+
except (TypeError, ValueError):
298+
continue
299+
300+
boss_casts = await fetch_boss_cast_timeline(
301+
client, code=code, fight_id=int(fight_id), fight_start_ms=fight_start_ms
302+
)
303+
285304
casts_payload = await client.query(
286305
REPORT_CASTS, {"code": code, "fightIDs": [fight_id], "sourceID": actor_id}
287306
)
@@ -329,6 +348,11 @@ async def _fetch_detail_for_top_log(
329348
"buffs": buffs,
330349
"debuffs": debuffs,
331350
"damage_taken": damage_taken,
351+
# Per-ability boss-cast timeline for THIS fight (fight-relative
352+
# seconds since fight start). Same shape as the user-fight's
353+
# ``fight_summary.boss_casts`` so the AI can compare cycle periods
354+
# between the user's pull and the reference kill directly.
355+
"boss_casts": boss_casts,
332356
"metric": metric,
333357
}
334358

backend/app/services/wcl/parser.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,14 @@ def parse_report_overview(payload: dict[str, Any]) -> dict[str, Any]:
116116
"boss_percentage": f.get("bossPercentage"),
117117
"duration_ms": max(0, int(end_ms - start_ms)),
118118
"start_time": _ms_to_dt(start_ms),
119-
"extras": {"phase_transitions": phase_transitions},
119+
"extras": {
120+
"phase_transitions": phase_transitions,
121+
# WCL's report.events ships timestamps in this same
122+
# report-relative offset space, so the import flow uses
123+
# this value to convert boss-cast timestamps into
124+
# fight-relative seconds. Keep it cheap to fetch later.
125+
"wcl_fight_start_ms": fight_start_offset_ms,
126+
},
120127
}
121128
)
122129
return {
@@ -575,6 +582,107 @@ def parse_aura_table(payload: dict[str, Any]) -> list[dict[str, Any]]:
575582
return out[:25]
576583

577584

585+
def parse_enemy_cast_events_page(payload: dict[str, Any]) -> tuple[list[dict[str, Any]], float | None]:
586+
"""Pull one page of enemy ``Casts`` events out of a ``report.events`` response.
587+
588+
Returns ``(events, next_page_timestamp)``. ``data`` ships as either a
589+
JSON string or an already-decoded list depending on WCL's mood; we
590+
handle both. Only ``type='cast'`` events are kept (``begincast`` is
591+
the start-of-cast signal — for boss-pressure analysis we want the
592+
actual hit moment, not the wind-up).
593+
"""
594+
import json as _json
595+
596+
rd = (payload or {}).get("reportData", {})
597+
report = rd.get("report") if rd else None
598+
events_blob = (report or {}).get("events") or {}
599+
next_ts = events_blob.get("nextPageTimestamp")
600+
raw = events_blob.get("data") or []
601+
if isinstance(raw, str):
602+
try:
603+
raw = _json.loads(raw)
604+
except (ValueError, TypeError):
605+
raw = []
606+
if not isinstance(raw, list):
607+
raw = []
608+
out: list[dict[str, Any]] = []
609+
for ev in raw:
610+
if not isinstance(ev, dict):
611+
continue
612+
if ev.get("type") != "cast":
613+
continue
614+
try:
615+
ability_id = int(ev.get("abilityGameID") or 0)
616+
ts = int(ev.get("timestamp") or 0)
617+
except (TypeError, ValueError):
618+
continue
619+
if not ability_id:
620+
continue
621+
out.append({"ability_id": ability_id, "timestamp_ms": ts})
622+
return out, (float(next_ts) if next_ts is not None else None)
623+
624+
625+
def aggregate_boss_casts(
626+
events: list[dict[str, Any]],
627+
*,
628+
fight_start_ms: int,
629+
top_n_abilities: int = 6,
630+
max_samples_per_ability: int = 10,
631+
) -> list[dict[str, Any]]:
632+
"""Collapse a flat list of cast events into a compact per-ability summary.
633+
634+
Output shape (one entry per ability, sorted by cast count descending):
635+
636+
``{ability_id, count, cast_seconds: [t0, t1, ...]}``
637+
638+
``cast_seconds`` lists *fight-relative* seconds since fight start
639+
(negative values from pre-pull buffs are clamped to 0) so the AI can
640+
eyeball the cycle period without doing the conversion itself.
641+
642+
We cap the list to ``max_samples_per_ability`` per ability and
643+
``top_n_abilities`` total — that's enough to see the pattern (4-5
644+
samples already reveal the cycle) without exploding the prompt for
645+
long pull-heavy fights.
646+
"""
647+
if not events:
648+
return []
649+
by_id: dict[int, list[int]] = {}
650+
for ev in events:
651+
aid = int(ev.get("ability_id") or 0)
652+
ts = int(ev.get("timestamp_ms") or 0)
653+
if not aid:
654+
continue
655+
by_id.setdefault(aid, []).append(ts)
656+
ranked = sorted(
657+
by_id.items(), key=lambda kv: (-len(kv[1]), kv[0])
658+
)[:top_n_abilities]
659+
out: list[dict[str, Any]] = []
660+
for aid, ts_list in ranked:
661+
ts_list.sort()
662+
# Sample evenly across the timeline. Anchor on the first AND last
663+
# cast so the AI can derive the true cycle period via
664+
# ``(max-min)/(count-1)`` on the sample — if we used a plain
665+
# ``int(i*step)`` scheme the tail would be cut off (e.g. for 92
666+
# entries we'd top out at index 81, throwing off any cycle math
667+
# using ``count``).
668+
if len(ts_list) > max_samples_per_ability:
669+
n = max_samples_per_ability
670+
ts_list = [
671+
ts_list[round(i * (len(ts_list) - 1) / (n - 1))] for i in range(n)
672+
]
673+
cast_seconds = [
674+
round(max(0, t - fight_start_ms) / 1000, 1) for t in ts_list
675+
]
676+
out.append(
677+
{
678+
"ability_id": aid,
679+
"count": len(by_id[aid]),
680+
"cast_seconds": cast_seconds,
681+
}
682+
)
683+
return out
684+
685+
578686
def parse_damage_taken_table(payload: dict[str, Any]) -> list[dict[str, Any]]:
579687
"""Parse a DamageTaken table (filtered to enemy sources) into per-ability totals."""
580688
rd = (payload or {}).get("reportData", {})

0 commit comments

Comments
 (0)