@@ -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+
578686def 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