Spaces:
Running
Running
| # -*- coding: utf-8 -*- | |
| import xmltodict | |
| import base64 | |
| import binascii | |
| from collections import defaultdict | |
| from dataclasses import dataclass | |
| from xml.parsers.expat import ExpatError | |
| from core.game_state import GameState | |
| from core.kick_off_event import KickOffEvent | |
| from core.team_state import TeamState | |
| from core.calculator import Calculator | |
| from modelling.action_probability import ActionProbabilityEnricher | |
| from modelling.skill_transition import SkillTransitionBackend | |
| from modelling.row_schema import build_roll_event_row | |
| from modelling.synthetic_roll_sources import ( | |
| build_ejection_roll_events, | |
| build_ko_recovery_roll_events, | |
| build_officious_ref_roll_events, | |
| build_touchdown_roll_events, | |
| extract_step_player_situations, | |
| is_ko_situation, | |
| ) | |
| from core.roll_category_constants import ( | |
| CONSEQUENCE_ROLL_CATEGORIES_LOWER, | |
| INITIATIVE_ROLL_CATEGORIES_LOWER, | |
| ) | |
| from core.lookup_registry import ( | |
| get_normalized_block_face, | |
| get_skill_rule, | |
| get_skill_lookup, | |
| get_special_action_rule, | |
| get_special_skill_usage_aliases, | |
| ) | |
| from core.processor_parse_stage import ( | |
| run_parse_stage, | |
| run_post_parse_synthetic_stage, | |
| ) | |
| from core.processor_stages import ( | |
| run_annotation_stage, | |
| run_probability_enrichment_stage, | |
| run_step_turn_stage, | |
| ) | |
| from core.ruleset import RuleSet | |
| SKILL_ID_TO_NAME = get_skill_lookup() | |
| PRO_RULE = get_skill_rule("pro") | |
| BRAWLER_RULE = get_skill_rule("brawler") | |
| SPECIAL_SKILL_USAGE_ALIASES = get_special_skill_usage_aliases() | |
| BREATHE_FIRE_RULE = get_special_action_rule("Breathe Fire") | |
| BOMB_HIT_RULE = get_special_action_rule("Bomb Hit") | |
| THROW_BOMB_RULE = get_special_action_rule("Throw Bomb") | |
| INTERCEPTION_RULE = get_special_action_rule("Interception") | |
| FIREBALL_RULE = get_special_action_rule("Fireball") | |
| TTM_RULE = get_special_action_rule("Throw Team Mate") | |
| ALWAYS_HUNGRY_RULE = get_special_action_rule("Always Hungry") | |
| TTM_SCATTER_RULE = get_special_action_rule("TTM Scatter") | |
| TTM_LANDING_RULE = get_special_action_rule("TTM Landing") | |
| LONER_RULE = get_special_action_rule("Loner") | |
| PICK_ME_UP_RULE = get_special_action_rule("Pick Me Up") | |
| ARGUE_THE_CALL_RULE = get_special_action_rule("Argue the Call") | |
| class ReplayStepRollExtractor: | |
| """Dedicated extractor object for per-step roll event extraction.""" | |
| processor: "GameStateProcessor" | |
| def extract_roll_events_from_step(self, step_data, game_turn, forced_blitz_player_id=None, step_number=None): | |
| return self.processor._extract_roll_events_from_step_impl( | |
| step_data, | |
| game_turn, | |
| forced_blitz_player_id=forced_blitz_player_id, | |
| step_number=step_number, | |
| ) | |
| class GameStateProcessor: | |
| """Processes replay data to extract game state, steps, and events.""" | |
| def __init__(self, game_state=None, ruleset=None): | |
| """Initialize replay processor. | |
| Processes replay data to extract game state, steps, and events. | |
| """ | |
| self.game_state = game_state or GameState() | |
| self.step_counter = 0 | |
| self.calculator = Calculator() | |
| self._warning_keys = set() | |
| # Create team state trackers | |
| self.team_states = { | |
| 0: TeamState(team_id=0), # Home team | |
| 1: TeamState(team_id=1) # Away team | |
| } | |
| self.ruleset = ruleset if ruleset is not None else RuleSet() | |
| self.step_roll_extractor = ReplayStepRollExtractor(processor=self) | |
| self.action_probability_enricher = ActionProbabilityEnricher(self, ruleset=self.ruleset) | |
| self.skill_transition_backend = SkillTransitionBackend(self) | |
| def _event_player_has_skill_id(self, event, skill_id): | |
| """Return True when the acting player's skill_ids set contains skill_id. | |
| Uses the central player_lookup (populated from the roster at parse time), | |
| which stores raw ID strings as-is from the replay data. This is the | |
| canonical ID-based skill check; prefer it over name-based comparisons. | |
| """ | |
| if not skill_id or not isinstance(event, dict): | |
| return False | |
| player_lookup = self._safe_player_lookup(event.get("player_id")) | |
| skill_ids = player_lookup.get("skill_ids") | |
| if not isinstance(skill_ids, list): | |
| return False | |
| return str(skill_id) in skill_ids | |
| def _event_player_has_skill(self, event, skill_name): | |
| """Return True when the acting player's skill list contains skill_name (name-based). | |
| Checks event-level player_skills snapshot first; falls back to player_lookup. | |
| Prefer _event_player_has_skill_id for skills that have a known ID. | |
| """ | |
| if not skill_name or not isinstance(event, dict): | |
| return False | |
| raw_skills = event.get("player_skills") | |
| if not isinstance(raw_skills, list): | |
| player_lookup = self._safe_player_lookup(event.get("player_id")) | |
| raw_skills = player_lookup.get("skills") | |
| if not isinstance(raw_skills, list): | |
| return False | |
| normalized_target = str(skill_name).strip().lower() | |
| for skill in raw_skills: | |
| if str(skill).strip().lower() == normalized_target: | |
| return True | |
| return False | |
| def _enrich_action_probabilities(self): | |
| """Apply canonical action reroll assumptions to processed roll events. | |
| This is run in processor-space so reports remain read-only presentation. | |
| """ | |
| self.action_probability_enricher.enrich_action_probabilities() | |
| def _maybe_decode_base64_string(self, value): | |
| """Decode base64 string content when applicable; otherwise return as-is.""" | |
| if not isinstance(value, str): | |
| return value | |
| try: | |
| decoded = base64.b64decode(value, validate=True).decode("utf-8") | |
| # Avoid converting ordinary short numeric values. | |
| if decoded and any(ch.isalpha() for ch in decoded): | |
| return decoded | |
| except (binascii.Error, UnicodeDecodeError, ValueError): | |
| pass | |
| return value | |
| def _skill_id_to_name(self, skill_id): | |
| """Map BB skill ids to display names used across reports and modelling.""" | |
| return SKILL_ID_TO_NAME.get(str(skill_id)) | |
| def _classify_special_skill_usage(self, skill_value): | |
| """Return normalized special weapon skill label for ResultSkillUsage payloads.""" | |
| normalized = str(skill_value or "").strip().lower() | |
| mapped = SPECIAL_SKILL_USAGE_ALIASES.get(normalized) | |
| if mapped: | |
| return mapped | |
| if "chain" in normalized and "saw" in normalized: | |
| return SPECIAL_SKILL_USAGE_ALIASES.get("chainsaw", "Chainsaw") | |
| return None | |
| def _collect_breathe_fire_usage_hints(self, replay_steps): | |
| """Collect step-local Breathe Fire usage anchors from ResultSkillUsage payloads.""" | |
| hints = [] | |
| if not isinstance(replay_steps, list): | |
| return hints | |
| for step_index, step_data in enumerate(replay_steps, start=1): | |
| if not isinstance(step_data, dict): | |
| continue | |
| exec_sequences = self._ensure_list(step_data.get("EventExecuteSequence")) | |
| for seq_item in exec_sequences: | |
| if not isinstance(seq_item, dict): | |
| continue | |
| sequence = seq_item.get("Sequence") | |
| if not isinstance(sequence, dict): | |
| continue | |
| step_results = self._ensure_list(sequence.get("StepResult")) | |
| for step_result in step_results: | |
| if not isinstance(step_result, dict): | |
| continue | |
| step_node = step_result.get("Step") | |
| step_player_id = None | |
| step_target_id = None | |
| if isinstance(step_node, dict): | |
| step_message = self._parse_message_data_xml(step_node.get("MessageData")) | |
| if isinstance(step_message, dict) and len(step_message) > 0: | |
| root_key = next(iter(step_message.keys())) | |
| root = step_message.get(root_key) | |
| if isinstance(root, dict): | |
| step_player_id = root.get("PlayerId") | |
| step_target_id = root.get("TargetId") | |
| string_messages = step_result.get("Results", {}).get("StringMessage") | |
| parsed_messages = self._parse_string_messages(self._ensure_list(string_messages)) | |
| for parsed in parsed_messages: | |
| if not isinstance(parsed, dict): | |
| continue | |
| if str(parsed.get("payload_type") or "") != "ResultSkillUsage": | |
| continue | |
| root = parsed.get("payload_root") | |
| if not isinstance(root, dict): | |
| continue | |
| skill_label = self._classify_special_skill_usage(root.get("Skill")) | |
| if skill_label != "Breathe Fire": | |
| continue | |
| used_value = str(root.get("Used") or "").strip().lower() | |
| is_used = used_value in ("", "1", "true", "yes") | |
| if not is_used: | |
| continue | |
| hints.append( | |
| { | |
| "step_number": step_index, | |
| "player_id": ( | |
| root.get("PlayerId") | |
| or root.get("Id") | |
| or root.get("SourceId") | |
| or root.get("AttackerId") | |
| or step_player_id | |
| ), | |
| "target_id": ( | |
| root.get("TargetId") | |
| or root.get("Target") | |
| or root.get("VictimId") | |
| or step_target_id | |
| ), | |
| } | |
| ) | |
| return hints | |
| def _annotate_breathe_fire_actions(self, replay_steps): | |
| """Label action rows that correspond to anchored Breathe Fire usages. | |
| Strong matching strategy: | |
| 1) Anchor from replay-level ResultSkillUsage where Skill=105 and Used=1. | |
| 2) Map to nearby action rows with the known Breathe Fire roll signature. | |
| """ | |
| usage_hints = self._collect_breathe_fire_usage_hints(replay_steps) | |
| if len(usage_hints) == 0: | |
| return | |
| # Strong row signature observed in replay payloads for Breathe Fire resolution. | |
| breathe_fire_signature = BREATHE_FIRE_RULE.get("signature", {}) | |
| breathe_fire_skill_id = BREATHE_FIRE_RULE.get("skill_id") | |
| breathe_fire_label = BREATHE_FIRE_RULE.get("synthetic_action_label", "Breathe Fire") | |
| action_events = getattr(self, "_events_by_cat", {}).get( | |
| breathe_fire_signature.get("roll_category", "action"), () | |
| ) | |
| signature_rows = [] | |
| for idx, event in enumerate(action_events): | |
| if str(event.get("step_name") or "") != breathe_fire_signature.get("step_name", ""): | |
| continue | |
| if str(event.get("step_type") or "") != breathe_fire_signature.get("step_type", ""): | |
| continue | |
| if str(event.get("roll_type") or "") != breathe_fire_signature.get("roll_type", ""): | |
| continue | |
| player_id = event.get("player_id") | |
| if player_id is None: | |
| continue | |
| skill_ids = self._safe_player_lookup(player_id).get("skill_ids", []) | |
| if not isinstance(skill_ids, list) or breathe_fire_skill_id not in skill_ids: | |
| continue | |
| signature_rows.append(idx) | |
| if len(signature_rows) == 0: | |
| return | |
| claimed_rows = set() | |
| sorted_hints = sorted(usage_hints, key=lambda h: self._to_int(h.get("step_number"), default=0) or 0) | |
| for hint_pos, hint in enumerate(sorted_hints): | |
| hint_step = self._to_int(hint.get("step_number"), default=None) | |
| if hint_step is None: | |
| continue | |
| hint_player = hint.get("player_id") | |
| hint_target = hint.get("target_id") | |
| next_hint_step = None | |
| if hint_pos + 1 < len(sorted_hints): | |
| next_hint_step = self._to_int(sorted_hints[hint_pos + 1].get("step_number"), default=None) | |
| max_step = hint_step + 3 | |
| if next_hint_step is not None: | |
| max_step = min(max_step, next_hint_step - 1) | |
| matched_any = False | |
| for row_idx in signature_rows: | |
| if row_idx in claimed_rows: | |
| continue | |
| event = action_events[row_idx] | |
| event_step = self._to_int(event.get("step_number"), default=None) | |
| if event_step is None or event_step < hint_step or event_step > max_step: | |
| continue | |
| if hint_player is not None and str(event.get("player_id")) != str(hint_player): | |
| continue | |
| if hint_target is not None and event.get("target_player_id") is not None: | |
| if str(event.get("target_player_id")) != str(hint_target): | |
| continue | |
| event["is_breathe_fire_action"] = True | |
| event["is_synthetic_skill_action"] = True | |
| event["synthetic_action_label"] = breathe_fire_label | |
| claimed_rows.add(row_idx) | |
| matched_any = True | |
| if not matched_any: | |
| self._emit_processing_warning( | |
| ( | |
| f"Breathe Fire usage on step {hint_step} was detected but no matching " | |
| "action row was found nearby." | |
| ), | |
| key=("breathe-fire-unmatched", hint_step, str(hint_player), str(hint_target)), | |
| ) | |
| def _annotate_bomb_hit_actions(self): | |
| """Label bomb hit-resolution action rows using the observed roll type only. | |
| Current detection rule: | |
| - action rows with roll_type=88 | |
| """ | |
| bomb_hit_signature = BOMB_HIT_RULE.get("signature", {}) | |
| events = getattr(self, "_events_by_cat", {}).get( | |
| bomb_hit_signature.get("roll_category", "action"), () | |
| ) | |
| for event in events: | |
| if str(event.get("roll_type") or "") != bomb_hit_signature.get("roll_type", ""): | |
| continue | |
| dice_roller_raw = event.get("dice_roller") | |
| if dice_roller_raw is None: | |
| dice_roller_raw = event.get("team_id") | |
| flipped_dice_roller = self._to_int(dice_roller_raw, default=None) | |
| if flipped_dice_roller in (0, 1): | |
| event["dice_roller"] = 1 - flipped_dice_roller | |
| event["is_bomb_hit_action"] = True | |
| event["is_synthetic_skill_action"] = True | |
| event["synthetic_action_label"] = BOMB_HIT_RULE.get("synthetic_action_label", "Bomb Hit") | |
| def _annotate_throw_bomb_actions(self): | |
| """Label throw bomb action rows for Bombardier players. | |
| Detection rule: | |
| - action rows with step_type=32 performed by a player with Bombardier skill (skill_id=80) | |
| """ | |
| throw_bomb_signature = THROW_BOMB_RULE.get("signature", {}) | |
| skill_id = THROW_BOMB_RULE.get("skill_id") | |
| label = THROW_BOMB_RULE.get("synthetic_action_label", "Throw Bomb") | |
| events = getattr(self, "_events_by_cat", {}).get( | |
| throw_bomb_signature.get("roll_category", "action"), () | |
| ) | |
| for event in events: | |
| if str(event.get("step_type") or "") != throw_bomb_signature.get("step_type", ""): | |
| continue | |
| player_id = event.get("player_id") | |
| if player_id is None: | |
| continue | |
| skill_ids = self._safe_player_lookup(player_id).get("skill_ids", []) | |
| if not isinstance(skill_ids, list) or skill_id not in skill_ids: | |
| continue | |
| event["is_throw_bomb_action"] = True | |
| event["is_synthetic_skill_action"] = True | |
| event["synthetic_action_label"] = label | |
| def _annotate_interception_actions(self): | |
| """Label interception attempt action rows. | |
| Detection rule: | |
| - action rows with step_type=29 and roll_type=6 | |
| """ | |
| interception_signature = INTERCEPTION_RULE.get("signature", {}) | |
| label = INTERCEPTION_RULE.get("synthetic_action_label", "Interception") | |
| events = getattr(self, "_events_by_cat", {}).get( | |
| interception_signature.get("roll_category", "action"), () | |
| ) | |
| for event in events: | |
| if str(event.get("step_type") or "") != interception_signature.get("step_type", ""): | |
| continue | |
| if str(event.get("roll_type") or "") != interception_signature.get("roll_type", ""): | |
| continue | |
| event["is_interception_action"] = True | |
| event["is_synthetic_skill_action"] = True | |
| event["synthetic_action_label"] = label | |
| def _annotate_pass_initiation_actions(self): | |
| """Label the actual throw test row as Pass for report display. | |
| Replay payloads can tag the follow-up scatter row (`roll_type=8`) as Pass | |
| while leaving the initiating throw test (`roll_type=5`, `step_type=11`) | |
| as a generic Action row. Fill only the display label on non-synthetic | |
| rows so Throw Bomb / Throw Team Mate sequences keep their existing | |
| synthetic labels. | |
| """ | |
| events = getattr(self, "_events_by_cat", {}).get("action", ()) | |
| for event in events: | |
| if str(event.get("roll_type") or "") != "5": | |
| continue | |
| if str(event.get("step_type") or "") != "11": | |
| continue | |
| if bool(event.get("is_synthetic_skill_action")): | |
| continue | |
| if str(event.get("synthetic_action_label") or "").strip(): | |
| continue | |
| if str(event.get("display_action_type") or "").strip(): | |
| continue | |
| event["display_action_type"] = "Pass" | |
| def _annotate_pass_scatter_actions(self): | |
| """Label follow-up pass direction rows as Scatter and ignore scoring math. | |
| For ordinary passes, replay payloads emit the actual throw test as | |
| `roll_type=5` and then a follow-up direction row as `roll_type=8` with | |
| three directional dice. That second row is not a success/fail action | |
| attempt and should not contribute to probability summaries. | |
| Bomb and Throw Team Mate scatters already carry synthetic labels, so this | |
| method only targets non-synthetic rows whose same-step earlier row was | |
| identified as a Pass initiation. | |
| """ | |
| events = getattr(self, "_events_by_cat", {}).get("action", ()) | |
| events_by_step = {} | |
| for event in events: | |
| step_number = event.get("step_number") | |
| if isinstance(step_number, int): | |
| events_by_step.setdefault(step_number, []).append(event) | |
| for step_events in events_by_step.values(): | |
| ordered = sorted( | |
| step_events, | |
| key=lambda event: int(event.get("roll_ordinal_in_step") or 0), | |
| ) | |
| seen_pass_initiation = False | |
| for event in ordered: | |
| if str(event.get("display_action_type") or "").strip() == "Pass": | |
| seen_pass_initiation = True | |
| continue | |
| if not seen_pass_initiation: | |
| continue | |
| if str(event.get("roll_type") or "") != "8": | |
| continue | |
| if bool(event.get("is_synthetic_skill_action")): | |
| continue | |
| if str(event.get("synthetic_action_label") or "").strip(): | |
| continue | |
| event["display_action_type"] = "Scatter" | |
| event["is_synthetic_skill_action"] = True | |
| event["synthetic_action_label"] = "Scatter" | |
| event["is_marker_only_action"] = True | |
| event["exclude_from_surprise"] = True | |
| event["result_classification"] = "unknown" | |
| event["report_result_classification"] = "unknown" | |
| event["probability_success"] = None | |
| event["probability_neutral"] = None | |
| event["probability_fail"] = None | |
| event.pop("action_attempt_label", None) | |
| event.pop("action_attempt_ordinal_in_activation", None) | |
| def _enrich_pick_me_up_probabilities(self): | |
| """Override probabilities for Pick Me Up! rolls. | |
| Pick Me Up! succeeds on 5+ (p_success=1/3, p_neutral=2/3, p_fail=0). | |
| A missed roll is neutral (boar stays prone but no harm done), not a failure. | |
| """ | |
| pmu_probs = PICK_ME_UP_RULE.get("probabilities", {}) | |
| p_success = float(pmu_probs.get("success", 1.0 / 3.0)) | |
| p_neutral = float(pmu_probs.get("neutral", 2.0 / 3.0)) | |
| p_fail = float(pmu_probs.get("fail", 0.0)) | |
| for event in getattr(self, "_events_by_cat", {}).get("action", ()): | |
| if not event.get("is_pick_me_up_action"): | |
| continue | |
| event["probability_success"] = p_success | |
| event["probability_neutral"] = p_neutral | |
| event["probability_fail"] = p_fail | |
| # A missed roll is neutral (p_fail=0); reclassify game-reported 'fail' to 'neutral'. | |
| current = str(event.get("result_classification") or "") | |
| if current == "fail": | |
| event["result_classification"] = "neutral" | |
| event["report_result_classification"] = "neutral" | |
| event["result_value"] = 0 | |
| def _enrich_argue_the_call_probabilities(self): | |
| """Override probabilities for Argue the Call rolls. | |
| Argue the Call succeeds on 6+ (p_success=1/6, p_fail=5/6, no neutral). | |
| """ | |
| argue_probs = ARGUE_THE_CALL_RULE.get("probabilities", {}) | |
| p_success = float(argue_probs.get("success", 1.0 / 6.0)) | |
| p_neutral = float(argue_probs.get("neutral", 0.0)) | |
| p_fail = float(argue_probs.get("fail", 5.0 / 6.0)) | |
| for event in getattr(self, "_events_by_cat", {}).get("action", ()): | |
| if not event.get("is_argue_the_call_action"): | |
| continue | |
| event["probability_success"] = p_success | |
| event["probability_neutral"] = p_neutral | |
| event["probability_fail"] = p_fail | |
| # Keep Argue-the-Call binary for reporting and surprise stats. | |
| current = str(event.get("result_classification") or "") | |
| if current == "neutral": | |
| difficulty = event.get("difficulty") | |
| dice_values = event.get("dice_values") or [] | |
| try: | |
| target = int(difficulty) | |
| best_die = max(int(v) for v in dice_values) if dice_values else None | |
| except (TypeError, ValueError): | |
| target = None | |
| best_die = None | |
| if best_die is not None and target is not None: | |
| if best_die >= target: | |
| event["result_classification"] = "success" | |
| event["report_result_classification"] = "success" | |
| event["result_value"] = 1 | |
| else: | |
| event["result_classification"] = "fail" | |
| event["report_result_classification"] = "fail" | |
| event["result_value"] = -1 | |
| def _enrich_loner_probabilities(self): | |
| """Override probabilities for Loner reroll-check rolls. | |
| Loner is binary (success/fail, no neutral outcome). The success probability | |
| varies by skill variant: | |
| - Loner (4+) id=44: 3/6 = 0.5 | |
| - Loner (3+) id=1008: 4/6 = 0.6667 | |
| - Loner (5+) id=1022: 2/6 = 0.3333 | |
| When the player's skill_ids are unavailable, defaults to Loner (4+) as the | |
| most common variant. Probabilities are defined in action_probability.yaml | |
| under special_action_rules.Loner.skill_probabilities. | |
| """ | |
| skill_probs = LONER_RULE.get("skill_probabilities") or {} | |
| # Ordered fallback chain: try Loner (4+) first as default. | |
| default_probs = ( | |
| skill_probs.get("44") | |
| or next(iter(skill_probs.values()), None) | |
| or {"success": 0.5, "fail": 0.5} | |
| ) | |
| loner_skill_ids = set(LONER_RULE.get("skill_ids") or skill_probs.keys()) | |
| for event in getattr(self, "_events_by_cat", {}).get("action", ()): | |
| if not event.get("is_loner_action"): | |
| continue | |
| # Determine the Loner variant from the player's skill_ids. | |
| player_id = event.get("player_id") | |
| probs = default_probs | |
| if player_id is not None: | |
| player_skill_ids = self._safe_player_lookup(player_id).get("skill_ids") or [] | |
| for sid in player_skill_ids: | |
| if str(sid) in loner_skill_ids: | |
| probs = skill_probs[str(sid)] | |
| break | |
| event["probability_success"] = float(probs["success"]) | |
| event["probability_fail"] = float(probs["fail"]) | |
| event["probability_neutral"] = 0.0 | |
| def _collect_foul_hints_from_board_state(self, replay_steps): | |
| """Return dict mapping step_number -> team_id_str for steps where a team's | |
| FoulAvailable decreased in the board state (board-state cross-check signal). | |
| TeamState is ordered [team-0, team-1] with no explicit Id field; array index | |
| is used directly as team_id. | |
| """ | |
| result = {} | |
| prev_fa = {} # team_idx -> was_available (bool) | |
| for raw_idx, step in enumerate(replay_steps): | |
| step_number = raw_idx + 1 | |
| if not isinstance(step, dict): | |
| prev_fa = {} | |
| continue | |
| board_state = step.get("BoardState") | |
| if not isinstance(board_state, dict): | |
| prev_fa = {} | |
| continue | |
| list_teams = board_state.get("ListTeams") or {} | |
| team_states = list_teams.get("TeamState") or [] | |
| if isinstance(team_states, dict): | |
| team_states = [team_states] | |
| # A Blitz kickoff step (EventBlitz present) clears FoulAvailable as a | |
| # phase-transition artifact — fouling is not permitted on the Blitz | |
| # extra turn. Do not record a foul hint here; update prev_fa so | |
| # subsequent steps can still detect real fouls normally. | |
| is_blitz_kickoff_step = "EventBlitz" in step | |
| for team_idx, team_state in enumerate(team_states): | |
| if not isinstance(team_state, dict): | |
| continue | |
| fa_raw = team_state.get("FoulAvailable") | |
| is_available = fa_raw is not None and str(fa_raw).strip() not in ("", "0") | |
| was_available = prev_fa.get(team_idx) | |
| if ( | |
| was_available is not None | |
| and was_available | |
| and not is_available | |
| and not is_blitz_kickoff_step | |
| ): | |
| result[step_number] = str(team_idx) | |
| prev_fa[team_idx] = is_available | |
| return result | |
| def _annotate_foul_armour_injury_rolls(self, replay_steps): | |
| """Re-attribute armour and injury rolls that follow a foul to the fouling player. | |
| During parse, these rolls pick up the step actor (the fouled player). This | |
| method corrects player_id, player_name, team_id, and dice_roller on those | |
| rolls to reflect the fouler, and cross-checks detection against the board-state | |
| FoulAvailable counter. | |
| """ | |
| ev_by_cat = getattr(self, "_events_by_cat", {}) | |
| board_state_fouls = self._collect_foul_hints_from_board_state(replay_steps) | |
| foul_events = [e for e in ev_by_cat.get("action", ()) if e.get("is_foul_action")] | |
| detected_step_numbers = {e.get("step_number") for e in foul_events} | |
| for bs_step, bs_team in board_state_fouls.items(): | |
| if bs_step not in detected_step_numbers: | |
| self._emit_processing_warning( | |
| f"Board state FoulAvailable decrease for team {bs_team} at step {bs_step} " | |
| "but no synthetic foul event was emitted for this step.", | |
| key=("foul-bs-unmatched", bs_step), | |
| ) | |
| if not foul_events: | |
| return | |
| by_step = {} | |
| for event in (*ev_by_cat.get("armour", ()), *ev_by_cat.get("injury", ())): | |
| sn = event.get("step_number") | |
| by_step.setdefault(sn, []).append(event) | |
| def _has_double(ev): | |
| vals = ev.get("dice_values") or [] if ev else [] | |
| return len(vals) >= 2 and len(set(vals)) < len(vals) | |
| for foul_event in foul_events: | |
| step_number = foul_event.get("step_number") | |
| fouler_player_id = foul_event.get("player_id") | |
| fouler_player_name = foul_event.get("player_name") | |
| fouler_team_id = foul_event.get("team_id") | |
| foul_ordinal = foul_event.get("roll_ordinal_in_step") or 0 | |
| fouler_skills = foul_event.get("player_skills") or [] | |
| has_sneaky_git = any( | |
| str(s).lower().startswith("sneaky git") for s in fouler_skills | |
| ) | |
| dirty_player_skills = [] | |
| for raw_skill in fouler_skills: | |
| skill_text = str(raw_skill).strip() | |
| normalized = skill_text.lower().replace(" ", "") | |
| if normalized in ("dirtyplayer(+1)", "dirtyplayer(+2)"): | |
| dirty_player_skills.append(skill_text) | |
| bs_team = board_state_fouls.get(step_number) | |
| if bs_team is not None and str(bs_team) != str(fouler_team_id): | |
| self._emit_processing_warning( | |
| f"Foul at step {step_number}: board state indicates team {bs_team} " | |
| f"but foul event identifies team {fouler_team_id}.", | |
| key=("foul-bs-mismatch", step_number), | |
| ) | |
| foul_armour_event = None | |
| foul_injury_event = None | |
| for event in by_step.get(step_number, []): | |
| if event is foul_event: | |
| continue | |
| roll_cat = str(event.get("roll_category") or "") | |
| if roll_cat not in ("armour", "injury"): | |
| continue | |
| ordinal = event.get("roll_ordinal_in_step") or 0 | |
| if ordinal <= foul_ordinal: | |
| continue | |
| event["player_id"] = fouler_player_id | |
| event["player_name"] = fouler_player_name | |
| event["team_id"] = fouler_team_id | |
| event["dice_roller"] = str(fouler_team_id) if fouler_team_id is not None else event.get("dice_roller") | |
| if roll_cat == "armour": | |
| event["is_foul_armour_roll"] = True | |
| event["action_type"] = "foul" | |
| event["propagated_foul_relevant_skills"] = list(dirty_player_skills) | |
| foul_armour_event = event | |
| else: | |
| event["is_foul_injury_roll"] = True | |
| event["action_type"] = "foul" | |
| event["propagated_foul_relevant_skills"] = list(dirty_player_skills) | |
| foul_injury_event = event | |
| # Sent-off check: a double on armour (unless Sneaky Git) or injury triggers ejection. | |
| sent_off = _has_double(foul_injury_event) or ( | |
| not has_sneaky_git and _has_double(foul_armour_event) | |
| ) | |
| if sent_off: | |
| foul_event["result_value"] = -1 | |
| foul_event["result_classification"] = "fail" | |
| foul_event["report_result_classification"] = "fail" | |
| foul_event["notes"] = "sent off" | |
| # Set foul action probabilities based on injury-roll presence and Sneaky Git. | |
| # For synthetic foul actions, any non-fail outcome is treated as success. | |
| # p_success = 1 - p_fail, p_neutral = 0, p_fail = P(sent off). | |
| if foul_injury_event is not None: | |
| p_fail = 1.0 / 6.0 if has_sneaky_git else 1.0 / 3.0 | |
| else: | |
| p_fail = 0.0 if has_sneaky_git else 1.0 / 6.0 | |
| foul_event["probability_success"] = round(1.0 - p_fail, 10) | |
| foul_event["probability_fail"] = p_fail | |
| foul_event["probability_neutral"] = 0.0 | |
| foul_event.pop("exclude_from_surprise", None) | |
| # Default result is success (not sent off); overridden to fail above if sent off. | |
| if not sent_off: | |
| foul_event["result_value"] = 1 | |
| foul_event["result_classification"] = "success" | |
| foul_event["report_result_classification"] = "success" | |
| def _annotate_block_armour_rolls(self): | |
| """Re-attribute attack-caused armour rows to the causal attacker/target. | |
| Some armour rows are parsed with the damaged player as both actor and target. | |
| This pass rewrites those rows when the nearest preceding non-armour event is | |
| an offensive attack action (block, stab, chainsaw, breathe fire, vomit). | |
| """ | |
| events = getattr(self.game_state, "roll_events", []) or [] | |
| if not events: | |
| return | |
| index_by_id = {id(event): idx for idx, event in enumerate(events) if isinstance(event, dict)} | |
| for event in getattr(self, "_events_by_cat", {}).get("armour", ()): | |
| if not isinstance(event, dict): | |
| continue | |
| if event.get("is_foul_armour_roll"): | |
| continue | |
| event_idx = index_by_id.get(id(event)) | |
| if event_idx is None: | |
| continue | |
| current_victim_id = event.get("player_id") | |
| source_attack_event = self._find_nearest_armour_source_event( | |
| events, | |
| event_idx, | |
| current_victim_id, | |
| ) | |
| if source_attack_event is None: | |
| continue | |
| event["player_id"] = source_attack_event.get("player_id") | |
| event["player_name"] = source_attack_event.get("player_name") | |
| event["team_id"] = source_attack_event.get("team_id") | |
| event["dice_roller"] = source_attack_event.get("dice_roller") | |
| event["player_skills"] = source_attack_event.get("player_skills") or [] | |
| event["target_player_id"] = source_attack_event.get("target_player_id") | |
| event["target_player_name"] = source_attack_event.get("target_player_name") | |
| event["target_player_skills"] = source_attack_event.get("target_player_skills") or [] | |
| event["target_has_ball"] = bool(source_attack_event.get("target_has_ball")) | |
| event["armour_source_category"] = str(source_attack_event.get("roll_category") or "").lower() | |
| event["armour_source_action_label"] = str( | |
| source_attack_event.get("synthetic_action_label") | |
| or source_attack_event.get("display_action_type") | |
| or source_attack_event.get("action_type") | |
| or "" | |
| ).strip() | |
| def _collect_fireball_usage_hints(self, replay_steps): | |
| """Collect step numbers where EventUseSpecialCard matches the Fireball card_id.""" | |
| hints = [] | |
| if not isinstance(replay_steps, list): | |
| return hints | |
| card_id = FIREBALL_RULE.get("card_id") | |
| if card_id is None: | |
| return hints | |
| for step_index, step_data in enumerate(replay_steps, start=1): | |
| if not isinstance(step_data, dict): | |
| continue | |
| ev = step_data.get("EventUseSpecialCard") | |
| if not isinstance(ev, dict): | |
| continue | |
| if str(ev.get("CardId") or "") != str(card_id): | |
| continue | |
| hints.append({"step_number": step_index, "gamer_id": ev.get("GamerId")}) | |
| return hints | |
| def _annotate_fireball_actions(self, replay_steps): | |
| """Label fireball hit-check action rows anchored to EventUseSpecialCard.""" | |
| usage_hints = self._collect_fireball_usage_hints(replay_steps) | |
| if len(usage_hints) == 0: | |
| return | |
| hint_steps = {hint["step_number"] for hint in usage_hints} | |
| fireball_signature = FIREBALL_RULE.get("signature", {}) | |
| label = FIREBALL_RULE.get("synthetic_action_label", "Fireball") | |
| events = getattr(self, "_events_by_cat", {}).get( | |
| fireball_signature.get("roll_category", "action"), () | |
| ) | |
| for event in events: | |
| if str(event.get("step_type") or "") != fireball_signature.get("step_type", ""): | |
| continue | |
| if str(event.get("roll_type") or "") != fireball_signature.get("roll_type", ""): | |
| continue | |
| event_step = self._to_int(event.get("step_number"), default=None) | |
| if event_step not in hint_steps: | |
| continue | |
| event["is_fireball_action"] = True | |
| event["is_synthetic_skill_action"] = True | |
| event["synthetic_action_label"] = label | |
| event["dice_roller"] = 1 | |
| def _collect_ttm_hints(self, replay_steps): | |
| """Collect ThrowerId/ThrownMateId from ThrowTeamMateStep XML, keyed by step number.""" | |
| hints = {} | |
| if not isinstance(replay_steps, list): | |
| return hints | |
| for step_index, step_data in enumerate(replay_steps, start=1): | |
| if not isinstance(step_data, dict): | |
| continue | |
| exec_sequences = self._ensure_list(step_data.get("EventExecuteSequence")) | |
| for seq_item in exec_sequences: | |
| if not isinstance(seq_item, dict): | |
| continue | |
| sequence = seq_item.get("Sequence") | |
| if not isinstance(sequence, dict): | |
| continue | |
| step_results = self._ensure_list(sequence.get("StepResult")) | |
| for sr in step_results: | |
| if not isinstance(sr, dict): | |
| continue | |
| step_node = sr.get("Step", {}) | |
| if not isinstance(step_node, dict): | |
| continue | |
| if step_node.get("Name") != "ThrowTeamMateStep": | |
| continue | |
| msg_data = step_node.get("MessageData", "") | |
| if not msg_data: | |
| continue | |
| parsed = self._parse_message_data_xml(msg_data) | |
| if not isinstance(parsed, dict): | |
| continue | |
| root = parsed.get("ThrowTeamMateStep", {}) | |
| if not isinstance(root, dict): | |
| continue | |
| thrower_id = root.get("ThrowerId") | |
| thrown_id = root.get("ThrownMateId") | |
| if thrower_id is not None: | |
| hints[step_index] = { | |
| "thrower_id": str(thrower_id), | |
| "thrown_id": str(thrown_id) if thrown_id is not None else None, | |
| } | |
| return hints | |
| def _annotate_ttm_actions(self, replay_steps): | |
| """Annotate all rolls within Throw Team Mate action sequences.""" | |
| events = getattr(self.game_state, "roll_events", None) | |
| if not isinstance(events, list): | |
| return | |
| hints = self._collect_ttm_hints(replay_steps) | |
| if not hints: | |
| return | |
| hint_step_numbers = set(hints.keys()) | |
| ttm_roll_type = TTM_RULE.get("signature", {}).get("roll_type", "41") | |
| ttm_label = TTM_RULE.get("synthetic_action_label", "Throw Team Mate") | |
| ah_roll_type = ALWAYS_HUNGRY_RULE.get("signature", {}).get("roll_type", "43") | |
| ah_label = ALWAYS_HUNGRY_RULE.get("synthetic_action_label", "Always Hungry") | |
| scatter_label = TTM_SCATTER_RULE.get("synthetic_action_label", "Scatter") | |
| landing_step_type = TTM_LANDING_RULE.get("signature", {}).get("step_type", "14") | |
| landing_roll_type = TTM_LANDING_RULE.get("signature", {}).get("roll_type", "42") | |
| landing_label = TTM_LANDING_RULE.get("synthetic_action_label", "Landing") | |
| loner_roll_type = LONER_RULE.get("signature", {}).get("roll_type", "71") | |
| for event in events: | |
| if not isinstance(event, dict): | |
| continue | |
| step_num = self._to_int(event.get("step_number"), default=None) | |
| if step_num not in hint_step_numbers: | |
| continue | |
| step_name = str(event.get("step_name") or "") | |
| roll_type = str(event.get("roll_type") or "") | |
| roll_cat = str(event.get("roll_category") or "") | |
| hint = hints[step_num] | |
| if step_name == "ThrowTeamMateStep": | |
| thrower_id = hint.get("thrower_id") | |
| if thrower_id and event.get("player_id") is None: | |
| thrower_lookup = self._safe_player_lookup(thrower_id) | |
| event["player_id"] = thrower_id | |
| event["player_name"] = thrower_lookup.get("name", f"Player {thrower_id}") | |
| event["team_id"] = thrower_lookup.get("team_id", event.get("team_id")) | |
| event["dice_roller"] = thrower_lookup.get("team_id", event.get("dice_roller")) | |
| event["player_skills"] = thrower_lookup.get("skills", []) | |
| if roll_cat != "action": | |
| continue | |
| if roll_type == ah_roll_type: | |
| event["is_always_hungry_action"] = True | |
| event["is_synthetic_skill_action"] = True | |
| event["synthetic_action_label"] = ah_label | |
| elif roll_type == ttm_roll_type: | |
| event["is_ttm_action"] = True | |
| event["is_synthetic_skill_action"] = True | |
| event["synthetic_action_label"] = ttm_label | |
| elif roll_type == loner_roll_type: | |
| pass | |
| else: | |
| event["is_ttm_scatter_action"] = True | |
| event["is_synthetic_skill_action"] = True | |
| event["synthetic_action_label"] = scatter_label | |
| event["player_name"] = "-" | |
| elif step_name == "PlayerStep": | |
| if str(event.get("step_type") or "") != landing_step_type: | |
| continue | |
| if roll_type != landing_roll_type: | |
| continue | |
| if roll_cat != "action": | |
| continue | |
| if event.get("player_id") is None: | |
| thrown_id = hint.get("thrown_id") | |
| if thrown_id: | |
| thrown_lookup = self._safe_player_lookup(thrown_id) | |
| event["player_id"] = thrown_id | |
| event["player_name"] = thrown_lookup.get("name", f"Player {thrown_id}") | |
| event["team_id"] = thrown_lookup.get("team_id", event.get("team_id")) | |
| event["player_skills"] = thrown_lookup.get("skills", []) | |
| event["is_ttm_landing_action"] = True | |
| event["is_synthetic_skill_action"] = True | |
| event["synthetic_action_label"] = landing_label | |
| def _annotate_loner_actions(self): | |
| """Annotate Loner reroll-check rows anywhere in the replay.""" | |
| loner_signature = LONER_RULE.get("signature", {}) | |
| label = LONER_RULE.get("synthetic_action_label", "Loner") | |
| events = getattr(self, "_events_by_cat", {}).get(loner_signature.get("roll_category", "action"), ()) | |
| for event in events: | |
| if str(event.get("roll_type") or "") != loner_signature.get("roll_type", "71"): | |
| continue | |
| event["is_loner_action"] = True | |
| event["is_synthetic_skill_action"] = True | |
| event["synthetic_action_label"] = label | |
| def _annotate_pick_me_up_actions(self): | |
| """Annotate Pick Me Up! rolls using the dedicated roll signature.""" | |
| pick_me_up_signature = PICK_ME_UP_RULE.get("signature", {}) | |
| label = PICK_ME_UP_RULE.get("synthetic_action_label", "Pick Me Up!") | |
| events = getattr(self, "_events_by_cat", {}).get(pick_me_up_signature.get("roll_category", "action"), ()) | |
| expected_step_type = pick_me_up_signature.get("step_type") | |
| expected_roll_type = str(pick_me_up_signature.get("roll_type", "92")) | |
| for event in events: | |
| if expected_step_type is not None and str(event.get("step_type") or "") != str(expected_step_type): | |
| continue | |
| if str(event.get("roll_type") or "") != expected_roll_type: | |
| continue | |
| # Diagnostics only: PMU should generally appear as 5+ in replay payload. | |
| raw_payload = event.get("raw") if isinstance(event.get("raw"), dict) else {} | |
| requirement = str(raw_payload.get("Requirement") or "").strip() | |
| difficulty = str(event.get("difficulty") or "").strip() | |
| if requirement not in ("", "5") or difficulty not in ("", "5"): | |
| self._emit_processing_warning( | |
| ( | |
| f"Pick Me Up candidate at step {event.get('step_number')} has " | |
| f"unexpected requirement/difficulty ({requirement}/{difficulty}); " | |
| "classifying as PMU by roll_type=92." | |
| ), | |
| key=("pmu-unexpected-target", event.get("step_number"), event.get("roll_ordinal_in_step")), | |
| ) | |
| event["is_pick_me_up_action"] = True | |
| event["is_synthetic_skill_action"] = True | |
| event["synthetic_action_label"] = label | |
| def _annotate_argue_the_call_actions(self): | |
| """Annotate Argue the Call rolls using its dedicated roll signature.""" | |
| argue_signature = ARGUE_THE_CALL_RULE.get("signature", {}) | |
| argue_label = ARGUE_THE_CALL_RULE.get("synthetic_action_label", "Argue the Call") | |
| events = getattr(self, "_events_by_cat", {}).get(argue_signature.get("roll_category", "action"), ()) | |
| expected_step_type = argue_signature.get("step_type") | |
| expected_roll_type = str(argue_signature.get("roll_type", "30")) | |
| for event in events: | |
| if expected_step_type is not None and str(event.get("step_type") or "") != str(expected_step_type): | |
| continue | |
| if str(event.get("roll_type") or "") != expected_roll_type: | |
| continue | |
| if event.get("is_pick_me_up_action"): | |
| continue | |
| event["is_argue_the_call_action"] = True | |
| event["is_synthetic_skill_action"] = True | |
| event["synthetic_action_label"] = argue_label | |
| def _is_offensive_armour_source_action(self, event): | |
| """Return True for action rows that can directly cause an armour roll.""" | |
| if not isinstance(event, dict): | |
| return False | |
| if bool(event.get("is_foul_action")): | |
| return False | |
| if bool(event.get("is_stab_action")): | |
| return True | |
| if bool(event.get("is_chainsaw_action")): | |
| return True | |
| if bool(event.get("is_breathe_fire_action")): | |
| return True | |
| labels = ( | |
| event.get("synthetic_action_label"), | |
| event.get("display_action_type"), | |
| event.get("action_type"), | |
| ) | |
| for label in labels: | |
| normalized = str(label or "").strip().lower() | |
| if normalized in ("stab", "chainsaw", "breathe fire"): | |
| return True | |
| if "vomit" in normalized: | |
| return True | |
| return False | |
| def _find_nearest_armour_source_event(self, events, event_idx, victim_player_id): | |
| """Return nearest preceding causal attack event for an armour row. | |
| The nearest preceding non-armour event must be a supported attack action, | |
| and must target the armour victim when both ids are known. | |
| """ | |
| for prior_idx in range(event_idx - 1, -1, -1): | |
| prior_event = events[prior_idx] | |
| if not isinstance(prior_event, dict): | |
| continue | |
| prior_category = str(prior_event.get("roll_category") or "").lower() | |
| if prior_category == "armour": | |
| continue | |
| is_supported_attack = ( | |
| prior_category == "block" | |
| or ( | |
| prior_category == "action" | |
| and self._is_offensive_armour_source_action(prior_event) | |
| ) | |
| ) | |
| if not is_supported_attack: | |
| break | |
| attack_target_id = prior_event.get("target_player_id") | |
| if ( | |
| victim_player_id is not None | |
| and attack_target_id is not None | |
| and str(attack_target_id) != str(victim_player_id) | |
| ): | |
| break | |
| return prior_event | |
| return None | |
| def _annotate_injury_mighty_blow_from_armour(self): | |
| """Propagate Mighty Blow from block-sourced armour rows to following injury rows.""" | |
| events = getattr(self.game_state, "roll_events", []) or [] | |
| if not events: | |
| return | |
| index_by_id = {id(event): idx for idx, event in enumerate(events) if isinstance(event, dict)} | |
| mighty_blow_allow = {"mighty blow (+1)", "mighty blow (+2)"} | |
| for injury_event in getattr(self, "_events_by_cat", {}).get("injury", ()): | |
| if not isinstance(injury_event, dict): | |
| continue | |
| if injury_event.get("is_foul_injury_roll"): | |
| continue | |
| injury_idx = index_by_id.get(id(injury_event)) | |
| if injury_idx is None: | |
| continue | |
| source_armour_event = None | |
| for prior_idx in range(injury_idx - 1, -1, -1): | |
| prior_event = events[prior_idx] | |
| if not isinstance(prior_event, dict): | |
| continue | |
| prior_category = str(prior_event.get("roll_category") or "").lower() | |
| if prior_category == "injury": | |
| continue | |
| if prior_category != "armour": | |
| break | |
| source_armour_event = prior_event | |
| break | |
| if source_armour_event is None: | |
| continue | |
| if str(source_armour_event.get("armour_source_category") or "").lower() != "block": | |
| continue | |
| mb_skills = [] | |
| for skill in source_armour_event.get("player_skills") or []: | |
| skill_text = str(skill).strip() | |
| if skill_text.lower() in mighty_blow_allow: | |
| mb_skills.append(skill_text) | |
| if not mb_skills: | |
| continue | |
| injury_event["propagated_armour_relevant_skills"] = mb_skills | |
| def _annotate_crowd_surf_injuries(self): | |
| """Detect crowd-surf injury rows and attribute them to the causal attacker. | |
| Crowd-surf injuries skip armour entirely. In the raw payload they carry | |
| ``Source='2'`` on the injury roll, so we use that marker to identify them. | |
| """ | |
| events = getattr(self.game_state, "roll_events", []) or [] | |
| if not events: | |
| return | |
| index_by_id = {id(event): idx for idx, event in enumerate(events) if isinstance(event, dict)} | |
| for injury_event in getattr(self, "_events_by_cat", {}).get("injury", ()): | |
| if not isinstance(injury_event, dict): | |
| continue | |
| if injury_event.get("is_foul_injury_roll"): | |
| continue | |
| raw = injury_event.get("raw") | |
| if not isinstance(raw, dict) or str(raw.get("Source") or "") != "2": | |
| continue | |
| injury_event["is_crowd_surf_injury"] = True | |
| injury_idx = index_by_id.get(id(injury_event)) | |
| if injury_idx is None: | |
| continue | |
| source_event = None | |
| for prior_idx in range(injury_idx - 1, -1, -1): | |
| prior_event = events[prior_idx] | |
| if not isinstance(prior_event, dict): | |
| continue | |
| prior_category = str(prior_event.get("roll_category") or "").lower() | |
| if prior_category in ("injury", "armour"): | |
| continue | |
| if prior_category in ("block", "action"): | |
| source_event = prior_event | |
| break | |
| if source_event is not None: | |
| injury_event["player_id"] = source_event.get("player_id") | |
| injury_event["player_name"] = source_event.get("player_name") | |
| injury_event["team_id"] = source_event.get("team_id") | |
| injury_event["dice_roller"] = source_event.get("dice_roller") | |
| injury_event["player_skills"] = list(source_event.get("player_skills") or []) | |
| injury_event["target_player_id"] = source_event.get("target_player_id") | |
| injury_event["target_player_name"] = source_event.get("target_player_name") | |
| injury_event["target_player_skills"] = list(source_event.get("target_player_skills") or []) | |
| injury_event["target_has_ball"] = bool(source_event.get("target_has_ball")) | |
| injury_event["injury_source_category"] = str(source_event.get("roll_category") or "").lower() | |
| notes = str(injury_event.get("notes") or "-").strip() | |
| if notes in ("", "-"): | |
| injury_event["notes"] = "crowd surf" | |
| elif "crowd surf" not in notes: | |
| injury_event["notes"] = notes + "; crowd surf" | |
| def _annotate_injury_attacker_from_armour(self): | |
| """Re-attribute injury rows to the causal attacker via the preceding armour row. | |
| For each non-foul injury row, find the nearest preceding armour row and copy | |
| the attacker identity (player_id, player_name, team_id, dice_roller, player_skills) | |
| and target identity from it. Also sets injury_source_category to mirror the | |
| armour row's armour_source_category so the renderer can apply the right skill | |
| policy. | |
| """ | |
| events = getattr(self.game_state, "roll_events", []) or [] | |
| if not events: | |
| return | |
| index_by_id = {id(event): idx for idx, event in enumerate(events) if isinstance(event, dict)} | |
| for injury_event in getattr(self, "_events_by_cat", {}).get("injury", ()): | |
| if not isinstance(injury_event, dict): | |
| continue | |
| if injury_event.get("is_foul_injury_roll"): | |
| continue | |
| if injury_event.get("is_crowd_surf_injury"): | |
| continue | |
| injury_idx = index_by_id.get(id(injury_event)) | |
| if injury_idx is None: | |
| continue | |
| source_armour_event = None | |
| for prior_idx in range(injury_idx - 1, -1, -1): | |
| prior_event = events[prior_idx] | |
| if not isinstance(prior_event, dict): | |
| continue | |
| prior_category = str(prior_event.get("roll_category") or "").lower() | |
| if prior_category == "injury": | |
| continue | |
| if prior_category != "armour": | |
| break | |
| source_armour_event = prior_event | |
| break | |
| if source_armour_event is None: | |
| continue | |
| injury_event["player_id"] = source_armour_event.get("player_id") | |
| injury_event["player_name"] = source_armour_event.get("player_name") | |
| injury_event["team_id"] = source_armour_event.get("team_id") | |
| injury_event["dice_roller"] = source_armour_event.get("dice_roller") | |
| injury_event["player_skills"] = list(source_armour_event.get("player_skills") or []) | |
| injury_event["target_player_id"] = source_armour_event.get("target_player_id") | |
| injury_event["target_player_name"] = source_armour_event.get("target_player_name") | |
| injury_event["target_player_skills"] = list(source_armour_event.get("target_player_skills") or []) | |
| injury_event["target_has_ball"] = bool(source_armour_event.get("target_has_ball")) | |
| injury_event["injury_source_category"] = str(source_armour_event.get("armour_source_category") or "").lower() | |
| def _enrich_breathe_fire_probabilities(self): | |
| """Override probabilities and result classification for Breathe Fire action rows. | |
| Fixed outcome model: | |
| die == 1 → fail (always possible with probability 1/6) | |
| die in (2, 3) → neutral (probability 2/6) | |
| die in (4..6) → success (probability 3/6) | |
| The prior probability distribution is fixed regardless of outcome: | |
| P(S) = 3/6 = 0.5 | |
| P(N) = 2/6 ≈ 0.333… | |
| P(F) = 1/6 ≈ 0.167… | |
| """ | |
| for event in getattr(self, "_events_by_cat", {}).get("action", ()): | |
| if not event.get("is_breathe_fire_action"): | |
| continue | |
| breathe_fire_results = BREATHE_FIRE_RULE.get("result_by_first_die", {}) | |
| breathe_fire_probabilities = BREATHE_FIRE_RULE.get("probabilities", {}) | |
| dice_values = event.get("dice_values") or [] | |
| if not isinstance(dice_values, list) or len(dice_values) == 0: | |
| self._emit_processing_warning( | |
| "Breathe Fire action row has no dice_values; skipping probability override.", | |
| key=("breathe-fire-no-dice", event.get("step_number"), event.get("player_id")), | |
| ) | |
| continue | |
| result_classification, probability_success, probability_neutral, probability_fail = ( | |
| self.calculator.calc_fixed_outcome_action( | |
| dice_values[0], | |
| breathe_fire_results, | |
| breathe_fire_probabilities, | |
| default_result="success", | |
| ) | |
| ) | |
| event["result_classification"] = result_classification | |
| event["report_result_classification"] = result_classification | |
| event["probability_success"] = probability_success | |
| event["probability_neutral"] = probability_neutral | |
| event["probability_fail"] = probability_fail | |
| def _emit_processing_warning(self, message, key=None): | |
| """Record and print a deduplicated processing warning.""" | |
| dedupe_key = key if key is not None else message | |
| if dedupe_key in self._warning_keys: | |
| return | |
| self._warning_keys.add(dedupe_key) | |
| self.game_state.processing_warnings.append(message) | |
| print(f"[GameStateProcessor][warning] {message}") | |
| def _normalize_player_name(self, player_name): | |
| """Normalize known replay name corruption artifacts to readable display names.""" | |
| if not isinstance(player_name, str): | |
| return player_name | |
| normalized = player_name.strip() | |
| known_name_fixes = { | |
| "#<�": "izzy", | |
| } | |
| return known_name_fixes.get(normalized, normalized) | |
| def _extract_player_skill_ids(self, player_data): | |
| """Extract and normalize raw player skill IDs from roster/BoardState payload.""" | |
| skill_ids = [] | |
| if not isinstance(player_data, dict): | |
| return skill_ids | |
| acquired_skills = player_data.get("AcquiredSkills") or {} | |
| innate_skills = player_data.get("InnateSkills") or {} | |
| skill_containers = [ | |
| acquired_skills.get("AcquiredSkillsItem"), | |
| innate_skills.get("InnateSkillsItem"), | |
| ] | |
| for skill_items in skill_containers: | |
| if skill_items is None: | |
| continue | |
| if not isinstance(skill_items, list): | |
| skill_items = [skill_items] | |
| for skill_id in skill_items: | |
| normalized = str(skill_id).strip() | |
| if normalized and normalized not in skill_ids: | |
| skill_ids.append(normalized) | |
| return skill_ids | |
| def _extract_player_skills(self, player_data): | |
| """Extract and normalize player skills from roster payload.""" | |
| skills = [] | |
| skill_ids = self._extract_player_skill_ids(player_data) | |
| for skill_id in skill_ids: | |
| skill_name = self._skill_id_to_name(skill_id) | |
| skills.append(skill_name if skill_name else "<unmapped>") | |
| return skills | |
| def _to_int(self, value, default=None): | |
| """Convert common numeric payload values to int.""" | |
| try: | |
| return int(value) | |
| except (TypeError, ValueError): | |
| return default | |
| def _normalize_team_id(self, value): | |
| if value is None: | |
| return None | |
| try: | |
| return str(int(value)) | |
| except (TypeError, ValueError): | |
| return str(value) | |
| def _opponent_team_id(self, team_id): | |
| normalized = self._normalize_team_id(team_id) | |
| if normalized == "0": | |
| return "1" | |
| if normalized == "1": | |
| return "0" | |
| return normalized | |
| def _resolve_dice_roller(self, team_id, roll_category): | |
| """Return the team that rolled the die for this event. | |
| For armour/injury, dice roller is the opponent of the event team. | |
| For action/block/kickoff/casualty and all other categories, dice roller equals team_id. | |
| """ | |
| normalized_team = self._normalize_team_id(team_id) | |
| category = str(roll_category or "").lower() | |
| if category in ("armour", "injury"): | |
| return self._opponent_team_id(normalized_team) | |
| return normalized_team | |
| def _normalize_regulation_game_turn(self, value): | |
| """Cap non-overtime game turns to the BB2020 regulation max of 16.""" | |
| try: | |
| normalized = int(value) | |
| except (TypeError, ValueError): | |
| return value | |
| if normalized < 0: | |
| return "0" | |
| if normalized > 16: | |
| return "16" | |
| return str(normalized) | |
| def _weather_name_from_roll_total(self, roll_total): | |
| """Map 2d6 weather roll totals to BB2020 weather names.""" | |
| try: | |
| value = int(roll_total) | |
| except (TypeError, ValueError): | |
| return "Unknown" | |
| if value == 2: | |
| return "Sweltering Heat" | |
| if value == 3: | |
| return "Very Sunny" | |
| if 4 <= value <= 10: | |
| return "Nice" | |
| if value == 11: | |
| return "Pouring Rain" | |
| if value == 12: | |
| return "Blizzard" | |
| return "Unknown" | |
| def extract_weather_event(self, step_data, step_number, game_turn): | |
| """Extract weather roll from a replay step when available. | |
| Primary source is EventWeatherRoll. We also allow a one-time bootstrap from | |
| BoardState.LastWeatherRoll if no weather has been seen yet. | |
| """ | |
| if not isinstance(step_data, dict): | |
| return None | |
| weather_payload = step_data.get("EventWeatherRoll") | |
| source = "EventWeatherRoll" | |
| if not isinstance(weather_payload, dict) and self.game_state.current_weather_roll_total is None: | |
| board_state = step_data.get("BoardState", {}) | |
| if isinstance(board_state, dict): | |
| weather_payload = board_state.get("LastWeatherRoll") | |
| source = "BoardState.LastWeatherRoll" | |
| if not isinstance(weather_payload, dict): | |
| return None | |
| dice_obj = weather_payload.get("Dice", {}) | |
| dice_entries = dice_obj.get("Die") if isinstance(dice_obj, dict) else None | |
| if dice_entries is None: | |
| dice_entries = weather_payload.get("Die") | |
| if dice_entries is None: | |
| return None | |
| if not isinstance(dice_entries, list): | |
| dice_entries = [dice_entries] | |
| dice_values = [] | |
| for die in dice_entries: | |
| if isinstance(die, dict): | |
| raw = die.get("Value") | |
| else: | |
| raw = die | |
| try: | |
| dice_values.append(int(raw)) | |
| except (TypeError, ValueError): | |
| continue | |
| if not dice_values: | |
| return None | |
| roll_total = sum(dice_values) | |
| return { | |
| "step_number": step_number, | |
| "game_turn": game_turn, | |
| "source": source, | |
| "dice_values": dice_values, | |
| "roll_total": roll_total, | |
| "weather_name": self._weather_name_from_roll_total(roll_total), | |
| } | |
| def _is_ko_situation(self, value): | |
| """Thin delegate — logic lives in synthetic_roll_sources.""" | |
| return is_ko_situation(value) | |
| def _extract_step_player_situations(self, step_data): | |
| """Thin delegate — logic lives in synthetic_roll_sources.""" | |
| return extract_step_player_situations(step_data) | |
| def _safe_player_lookup(self, player_id): | |
| """Return player lookup entry when present, else an empty dict.""" | |
| if player_id is None: | |
| return {} | |
| return self.game_state.player_lookup.get(str(player_id), {}) | |
| def _build_synthetic_roll_event( | |
| self, | |
| *, | |
| game_turn, | |
| step_number, | |
| player_id, | |
| team_id, | |
| roll_category, | |
| result_name, | |
| payload_type, | |
| result_value, | |
| probability_success, | |
| probability_neutral=0.0, | |
| probability_fail=None, | |
| dice_values=None, | |
| dice_total="-", | |
| difficulty="-", | |
| roll_type="-", | |
| outcome="-", | |
| modifier_values=None, | |
| target_player_id=None, | |
| target_player_name=None, | |
| target_player_skills=None, | |
| step_name="-", | |
| step_type="-", | |
| armour_holds="-", | |
| raw=None, | |
| player_name=None, | |
| player_skills=None, | |
| dice_roller_label=None, | |
| exclude_from_surprise=False, | |
| ): | |
| """Build a normalized synthetic roll event row.""" | |
| lookup = self._safe_player_lookup(player_id) | |
| team_id_str = str(team_id) if team_id is not None else "Unknown" | |
| if probability_fail is None: | |
| probability_fail = round(1.0 - float(probability_success) - float(probability_neutral), 10) | |
| if dice_values is None: | |
| dice_values = [] | |
| if modifier_values is None: | |
| modifier_values = [] | |
| if target_player_skills is None: | |
| target_player_skills = [] | |
| resolved_player_name = player_name if player_name is not None else lookup.get("name", "Unknown") | |
| if isinstance(player_skills, list): | |
| resolved_player_skills = player_skills | |
| else: | |
| lookup_skills = lookup.get("skills", []) | |
| resolved_player_skills = lookup_skills if isinstance(lookup_skills, list) else [] | |
| result_classification = "success" if result_value > 0 else "fail" if result_value < 0 else "neutral" | |
| event = { | |
| "game_turn": game_turn, | |
| "step_number": step_number, | |
| "player_id": player_id, | |
| "player_name": resolved_player_name, | |
| "team_id": team_id_str, | |
| "dice_roller": self._resolve_dice_roller(team_id_str, dice_roller_label or roll_category), | |
| "player_skills": resolved_player_skills, | |
| "target_player_id": target_player_id, | |
| "target_player_name": target_player_name, | |
| "target_player_skills": target_player_skills, | |
| "step_name": step_name, | |
| "step_type": step_type, | |
| "result_name": result_name, | |
| "payload_type": payload_type, | |
| "roll_category": roll_category, | |
| "dice_values": dice_values, | |
| "dice_total": dice_total, | |
| "difficulty": difficulty, | |
| "roll_type": roll_type, | |
| "outcome": outcome, | |
| "modifier_values": modifier_values, | |
| "result_value": result_value, | |
| "result_classification": result_classification, | |
| "report_result_classification": result_classification, | |
| "armour_holds": armour_holds, | |
| "probability_success": probability_success, | |
| "probability_neutral": probability_neutral, | |
| "probability_fail": probability_fail, | |
| "raw": raw if isinstance(raw, dict) else {}, | |
| } | |
| if exclude_from_surprise: | |
| event["exclude_from_surprise"] = True | |
| return build_roll_event_row(event) | |
| def _build_ko_recovery_roll_events(self, replay_steps): | |
| """Infer KO recovery outcomes from Situation transitions at touchdown kickoffs and halftime.""" | |
| return build_ko_recovery_roll_events(self, replay_steps) | |
| def _parse_player_characteristics(self, player_data): | |
| """Extract roster characteristic values keyed by replay characteristic id.""" | |
| if not isinstance(player_data, dict): | |
| return {} | |
| characteristics_node = player_data.get("Characteristics") | |
| if not isinstance(characteristics_node, dict): | |
| return {} | |
| raw_items = characteristics_node.get("PlayerCharacteristic", []) | |
| if isinstance(raw_items, dict): | |
| raw_items = [raw_items] | |
| parsed = {} | |
| for raw_item in raw_items: | |
| if not isinstance(raw_item, dict): | |
| continue | |
| characteristic_id = raw_item.get("Characteristic") | |
| if characteristic_id in (None, ""): | |
| continue | |
| key = str(characteristic_id) | |
| parsed[key] = { | |
| "value": str(raw_item.get("Value")) if raw_item.get("Value") is not None else None, | |
| "bonuses": str(raw_item.get("NbBonuses")) if raw_item.get("NbBonuses") is not None else None, | |
| "maluses": str(raw_item.get("NbMaluses")) if raw_item.get("NbMaluses") is not None else None, | |
| } | |
| return parsed | |
| def _build_player_lookup_los_index(self, replay_steps): | |
| """Return kickoff LOS participation keyed by player id from kickoff board snapshots.""" | |
| if not isinstance(replay_steps, list): | |
| return {} | |
| pitch_length = 26 | |
| pitch_width = 15 | |
| centre_zone_width = 7 | |
| los_left_x = (pitch_length // 2) - 1 | |
| los_right_x = pitch_length // 2 | |
| los_y_min = (pitch_width - centre_zone_width) // 2 | |
| los_y_max = los_y_min + centre_zone_width - 1 | |
| side_to_los_x = { | |
| "32": los_left_x, | |
| "64": los_right_x, | |
| } | |
| los_index = {} | |
| for step_number, step_data in enumerate(replay_steps, 1): | |
| if not isinstance(step_data, dict) or "EventKickOffTable" not in step_data: | |
| continue | |
| board_state = step_data.get("BoardState") | |
| if not isinstance(board_state, dict): | |
| continue | |
| team_states = board_state.get("ListTeams", {}).get("TeamState", []) | |
| if not isinstance(team_states, list): | |
| team_states = [team_states] | |
| for team_idx, team_state in enumerate(team_states[:2]): | |
| if not isinstance(team_state, dict): | |
| continue | |
| team_id = str(team_idx) | |
| side = str(team_state.get("Side")) if team_state.get("Side") is not None else None | |
| los_x = side_to_los_x.get(side) | |
| if los_x is None: | |
| los_x = los_left_x if team_id == "0" else los_right_x | |
| players = team_state.get("ListPitchPlayers", {}).get("PlayerState", []) | |
| if not isinstance(players, list): | |
| players = [players] | |
| for player in players: | |
| if not isinstance(player, dict): | |
| continue | |
| player_id = player.get("Id") | |
| if player_id in (None, ""): | |
| continue | |
| cell = player.get("Cell") | |
| if not isinstance(cell, dict): | |
| continue | |
| try: | |
| cell_x = int(cell.get("X")) | |
| cell_y = int(cell.get("Y")) | |
| except (TypeError, ValueError): | |
| continue | |
| if cell_x == -1 and cell_y == -1: | |
| continue | |
| if cell_x != los_x: | |
| continue | |
| if cell_y < los_y_min or cell_y > los_y_max: | |
| continue | |
| player_id_str = str(player_id) | |
| entries = los_index.setdefault(player_id_str, []) | |
| entries.append( | |
| { | |
| "step_number": step_number, | |
| "team_id": team_id, | |
| "x": cell_x, | |
| "y": cell_y, | |
| } | |
| ) | |
| return los_index | |
| def _iter_kickoff_snapshot_team_players(self, kickoff_step_number, team_id): | |
| """Yield on-pitch player snapshots for one team from a kickoff BoardState.""" | |
| raw_steps = getattr(self.game_state, "raw_replay_steps", None) | |
| if not isinstance(raw_steps, list): | |
| return [] | |
| step_index = self._to_int(kickoff_step_number, default=None) | |
| if step_index is None: | |
| return [] | |
| step_index -= 1 | |
| if step_index < 0 or step_index >= len(raw_steps): | |
| return [] | |
| step_data = raw_steps[step_index] | |
| if not isinstance(step_data, dict): | |
| return [] | |
| board_state = step_data.get("BoardState") | |
| if not isinstance(board_state, dict): | |
| return [] | |
| team_states = board_state.get("ListTeams", {}).get("TeamState", []) | |
| if not isinstance(team_states, list): | |
| team_states = [team_states] | |
| normalized_team_id = self._normalize_team_id(team_id) | |
| team_index = self._to_int(normalized_team_id, default=None) | |
| if team_index is None or team_index < 0 or team_index >= len(team_states): | |
| return [] | |
| team_state = team_states[team_index] | |
| if not isinstance(team_state, dict): | |
| return [] | |
| side = str(team_state.get("Side")) if team_state.get("Side") is not None else None | |
| los_x = 12 if side == "32" else 13 if side == "64" else 12 if normalized_team_id == "0" else 13 | |
| back_x = los_x - 1 if los_x == 12 else los_x + 1 | |
| players = team_state.get("ListPitchPlayers", {}).get("PlayerState", []) | |
| if not isinstance(players, list): | |
| players = [players] | |
| snapshots = [] | |
| for player in players: | |
| if not isinstance(player, dict): | |
| continue | |
| player_id = player.get("Id") | |
| if player_id in (None, ""): | |
| continue | |
| cell = player.get("Cell") | |
| if not isinstance(cell, dict): | |
| continue | |
| cell_x = self._to_int(cell.get("X"), default=None) | |
| cell_y = self._to_int(cell.get("Y"), default=None) | |
| if cell_x is None or cell_y is None: | |
| continue | |
| if cell_x < 0 or cell_y < 0: | |
| continue | |
| player_id_str = str(player_id) | |
| lookup = self._safe_player_lookup(player_id_str) | |
| skill_names = lookup.get("skills") if isinstance(lookup.get("skills"), list) else [] | |
| skill_names_lower = {str(skill).strip().lower() for skill in skill_names if skill not in (None, "")} | |
| ma_value = lookup.get("ma") | |
| effective_ma = self._to_int(ma_value, default=None) | |
| has_sprint = "sprint" in skill_names_lower | |
| if effective_ma is not None and has_sprint: | |
| effective_ma += 1 | |
| snapshots.append( | |
| { | |
| "player_id": player_id_str, | |
| "player_name": lookup.get("name", f"Player {player_id_str}"), | |
| "team_id": normalized_team_id, | |
| "x": cell_x, | |
| "y": cell_y, | |
| "side": side, | |
| "los_x": los_x, | |
| "back_x": back_x, | |
| "ma": self._to_int(ma_value, default=None), | |
| "effective_ma": effective_ma, | |
| "has_sprint": has_sprint, | |
| "has_stand_firm": "stand firm" in skill_names_lower, | |
| "has_sidestep": "sidestep" in skill_names_lower, | |
| "has_grab": "grab" in skill_names_lower, | |
| "has_juggernaut": "juggernaut" in skill_names_lower, | |
| "has_frenzy": "frenzy" in skill_names_lower, | |
| "is_fast": effective_ma is not None and effective_ma >= 7, | |
| "on_halfway_line": cell_x == los_x, | |
| "on_halfway_or_one_back": cell_x in (los_x, back_x), | |
| "skills": skill_names, | |
| } | |
| ) | |
| return snapshots | |
| def _build_ottd_turn_kickoff_index(self): | |
| """Return turn -> earliest kickoff context for OTTD candidate evaluation.""" | |
| index = {} | |
| for step in getattr(self.game_state, "steps", []) or []: | |
| if not getattr(step, "kick_off", None): | |
| continue | |
| turn_num = self._to_int(getattr(step, "game_turn", None), default=None) | |
| if turn_num not in (8, 16): | |
| continue | |
| existing = index.get(turn_num) | |
| if existing is not None and existing.get("kickoff_step_number") <= step.step_number: | |
| continue | |
| kicker_team_id = self._normalize_team_id(getattr(step.kick_off, "team_id", None)) | |
| index[turn_num] = { | |
| "kickoff_step_number": step.step_number, | |
| "kicker_team_id": kicker_team_id, | |
| "receiving_team_id": self._opponent_team_id(kicker_team_id) if kicker_team_id in ("0", "1") else None, | |
| } | |
| return index | |
| def _collect_turn_touchdowns_after_step(self, turn_num, step_number, team_id=None): | |
| """Return touchdown markers that happen after a kickoff within the same game turn.""" | |
| matches = [] | |
| for step in getattr(self.game_state, "steps", []) or []: | |
| if getattr(step, "touchdown", None) is None: | |
| continue | |
| if self._to_int(getattr(step, "game_turn", None), default=None) != turn_num: | |
| continue | |
| if self._to_int(getattr(step, "step_number", None), default=None) is None: | |
| continue | |
| if step.step_number <= step_number: | |
| continue | |
| touchdown = step.touchdown if isinstance(step.touchdown, dict) else {} | |
| touchdown_team_id = self._normalize_team_id(touchdown.get("team_id")) | |
| if team_id in ("0", "1") and touchdown_team_id != team_id: | |
| continue | |
| matches.append( | |
| { | |
| "step_number": step.step_number, | |
| "team_id": touchdown_team_id, | |
| "player_id": touchdown.get("player_id"), | |
| "player_name": touchdown.get("player_name", "Unknown"), | |
| "source": touchdown.get("source", "unknown"), | |
| } | |
| ) | |
| return sorted(matches, key=lambda row: row.get("step_number") or 0) | |
| def _collect_turn_ttm_actions_after_step(self, turn_num, step_number, team_id=None): | |
| """Return Throw Team Mate throw actions after a kickoff within the same game turn.""" | |
| matches = [] | |
| for event in getattr(self.game_state, "roll_events", []) or []: | |
| if not isinstance(event, dict) or not event.get("is_ttm_action"): | |
| continue | |
| if self._to_int(event.get("game_turn"), default=None) != turn_num: | |
| continue | |
| event_step = self._to_int(event.get("step_number"), default=None) | |
| if event_step is None or event_step <= step_number: | |
| continue | |
| event_team_id = self._normalize_team_id(event.get("team_id")) | |
| if team_id in ("0", "1") and event_team_id != team_id: | |
| continue | |
| matches.append( | |
| { | |
| "step_number": event_step, | |
| "team_id": event_team_id, | |
| "player_id": event.get("player_id"), | |
| "player_name": event.get("player_name", "Unknown"), | |
| "roll_type": event.get("roll_type"), | |
| } | |
| ) | |
| return sorted(matches, key=lambda row: row.get("step_number") or 0) | |
| def _minimum_ottd_players_required(self, pushes_needed, has_sidestep): | |
| """Return required players on pitch for a push OTTD setup.""" | |
| if pushes_needed == 1: | |
| return 4 if has_sidestep else 6 | |
| if pushes_needed == 2: | |
| return 6 if has_sidestep else 7 | |
| if pushes_needed == 3: | |
| return 6 if has_sidestep else 8 | |
| if pushes_needed == 4: | |
| return 8 if has_sidestep else 9 | |
| return None | |
| def _evaluate_ottd_defender_skill_blocker(self, receiving_players, kicking_players, pushes_needed): | |
| """Return whether LOS defenders fully block push OTTD by anti-push skills. | |
| Sidestep protection is ignored when the attacking team has Grab. | |
| Stand Firm protection is ignored when the attacking team has: | |
| - Juggernaut and exactly 1 push is needed, or | |
| - Juggernaut plus Frenzy and exactly 2 pushes are needed. | |
| """ | |
| los_defenders = [player for player in kicking_players if player.get("on_halfway_line")] | |
| attacking_has_grab = any(player.get("has_grab") for player in receiving_players) | |
| attacking_has_juggernaut = any(player.get("has_juggernaut") for player in receiving_players) | |
| attacking_has_juggernaut_frenzy = any( | |
| player.get("has_juggernaut") and player.get("has_frenzy") | |
| for player in receiving_players | |
| ) | |
| ignore_sidestep = attacking_has_grab | |
| ignore_stand_firm = ( | |
| pushes_needed == 1 and attacking_has_juggernaut | |
| ) or ( | |
| pushes_needed == 2 and attacking_has_juggernaut_frenzy | |
| ) | |
| evaluated_defenders = [] | |
| fully_blocked = bool(los_defenders) | |
| for defender in los_defenders: | |
| active_protections = [] | |
| ignored_protections = [] | |
| if defender.get("has_sidestep"): | |
| if ignore_sidestep: | |
| ignored_protections.append("Sidestep") | |
| else: | |
| active_protections.append("Sidestep") | |
| if defender.get("has_stand_firm"): | |
| if ignore_stand_firm: | |
| ignored_protections.append("Stand Firm") | |
| else: | |
| active_protections.append("Stand Firm") | |
| if not active_protections: | |
| fully_blocked = False | |
| evaluated_defenders.append( | |
| { | |
| "player_id": defender.get("player_id"), | |
| "player_name": defender.get("player_name"), | |
| "x": defender.get("x"), | |
| "y": defender.get("y"), | |
| "active_protections": active_protections, | |
| "ignored_protections": ignored_protections, | |
| "has_active_protection": bool(active_protections), | |
| } | |
| ) | |
| return { | |
| "los_defenders": evaluated_defenders, | |
| "los_defender_count": len(evaluated_defenders), | |
| "attacking_has_grab": attacking_has_grab, | |
| "attacking_has_juggernaut": attacking_has_juggernaut, | |
| "attacking_has_juggernaut_frenzy": attacking_has_juggernaut_frenzy, | |
| "ignore_sidestep": ignore_sidestep, | |
| "ignore_stand_firm": ignore_stand_firm, | |
| "fully_blocked": fully_blocked, | |
| } | |
| def _evaluate_ottd_candidate_turn(self, turn_num, kickoff_context): | |
| """Build a diagnostic OTTD decision for one structurally valid candidate turn.""" | |
| kickoff_step_number = kickoff_context.get("kickoff_step_number") | |
| receiving_team_id = kickoff_context.get("receiving_team_id") | |
| kicker_team_id = kickoff_context.get("kicker_team_id") | |
| receiving_players = self._iter_kickoff_snapshot_team_players(kickoff_step_number, receiving_team_id) | |
| receiving_players = sorted(receiving_players, key=lambda row: (row.get("x", 999), row.get("y", 999), row.get("player_name", ""))) | |
| kicking_players = self._iter_kickoff_snapshot_team_players(kickoff_step_number, kicker_team_id) | |
| kicking_players = sorted(kicking_players, key=lambda row: (row.get("x", 999), row.get("y", 999), row.get("player_name", ""))) | |
| touchdowns_after_kickoff = self._collect_turn_touchdowns_after_step( | |
| turn_num, | |
| kickoff_step_number, | |
| team_id=receiving_team_id, | |
| ) | |
| ttm_actions_after_kickoff = self._collect_turn_ttm_actions_after_step( | |
| turn_num, | |
| kickoff_step_number, | |
| team_id=receiving_team_id, | |
| ) | |
| touchdown_override = len(touchdowns_after_kickoff) > 0 and len(ttm_actions_after_kickoff) == 0 | |
| fastest_halfway_players = [ | |
| player for player in receiving_players | |
| if player.get("is_fast") and player.get("on_halfway_line") | |
| ] | |
| fastest_halfway_players.sort( | |
| key=lambda row: ( | |
| -(row.get("effective_ma") or -999), | |
| -1 if row.get("has_sidestep") else 0, | |
| row.get("y") or 999, | |
| row.get("player_name", ""), | |
| ) | |
| ) | |
| fastest_player = fastest_halfway_players[0] if fastest_halfway_players else None | |
| pushes_needed = None | |
| minimum_players_required = None | |
| minimum_players_met = False | |
| if fastest_player is not None and fastest_player.get("effective_ma") is not None: | |
| pushes_needed = 11 - fastest_player["effective_ma"] | |
| minimum_players_required = self._minimum_ottd_players_required( | |
| pushes_needed, | |
| fastest_player.get("has_sidestep", False), | |
| ) | |
| if minimum_players_required is not None: | |
| minimum_players_met = len(receiving_players) >= minimum_players_required | |
| defender_skill_block = self._evaluate_ottd_defender_skill_blocker( | |
| receiving_players, | |
| kicking_players, | |
| pushes_needed, | |
| ) | |
| all_players_shallow = bool(receiving_players) and all( | |
| player.get("on_halfway_or_one_back") for player in receiving_players | |
| ) | |
| has_fast_player_on_halfway_line = fastest_player is not None | |
| setup_based_decision = ( | |
| has_fast_player_on_halfway_line | |
| and minimum_players_required is not None | |
| and minimum_players_met | |
| and not all_players_shallow | |
| and not defender_skill_block.get("fully_blocked") | |
| ) | |
| final_decision = touchdown_override or setup_based_decision | |
| reason_lines = [ | |
| f"Structural candidate: yes (turn {turn_num} contains a kickoff at step {kickoff_step_number}).", | |
| f"Receiving team: {receiving_team_id if receiving_team_id is not None else '?'}; kicking team: {kicker_team_id if kicker_team_id is not None else '?' }.", | |
| ( | |
| "Touchdown override: yes - a touchdown was scored after the kickoff and no Throw Team Mate action " | |
| "was detected in the receiving team's turn." | |
| if touchdown_override else | |
| "Touchdown override: no." | |
| ), | |
| ( | |
| f"Receiving players on pitch at kickoff: {len(receiving_players)}." | |
| if receiving_players else | |
| "Receiving players on pitch at kickoff: 0." | |
| ), | |
| ] | |
| if fastest_player is None: | |
| reason_lines.append("Fast player on halfway line: no qualifying player found (effective MA >= 7 required).") | |
| else: | |
| sidestep_text = "yes" if fastest_player.get("has_sidestep") else "no" | |
| sprint_text = "yes" if fastest_player.get("has_sprint") else "no" | |
| reason_lines.append( | |
| "Fastest qualifying player on halfway line: " | |
| f"{fastest_player.get('player_name')} [id={fastest_player.get('player_id')}] " | |
| f"at ({fastest_player.get('x')},{fastest_player.get('y')}), " | |
| f"MA={fastest_player.get('ma')}, effective_MA={fastest_player.get('effective_ma')}, " | |
| f"Sprint={sprint_text}, Sidestep={sidestep_text}." | |
| ) | |
| reason_lines.append( | |
| f"Pushes needed: {pushes_needed}; minimum players required: " | |
| f"{minimum_players_required if minimum_players_required is not None else 'unavailable'}; " | |
| f"criterion met: {'yes' if minimum_players_met else 'no'}." | |
| ) | |
| reason_lines.append( | |
| "All receiving players are on the halfway line or one square back: " | |
| f"{'yes' if all_players_shallow else 'no'}." | |
| ) | |
| if defender_skill_block.get("los_defender_count"): | |
| blocker_parts = [] | |
| if defender_skill_block.get("ignore_sidestep"): | |
| blocker_parts.append("Sidestep ignored because attacking team has Grab") | |
| if defender_skill_block.get("ignore_stand_firm"): | |
| blocker_parts.append("Stand Firm ignored because attacking team has qualifying Juggernaut support") | |
| blocker_context = "; ".join(blocker_parts) if blocker_parts else "no anti-push skill overrides applied" | |
| reason_lines.append( | |
| f"Kicking LOS defenders with anti-push skills: {defender_skill_block.get('los_defender_count')} checked; " | |
| f"all protected after overrides: {'yes' if defender_skill_block.get('fully_blocked') else 'no'}; " | |
| f"{blocker_context}." | |
| ) | |
| else: | |
| reason_lines.append("Kicking LOS defenders with anti-push skills: none on the halfway line.") | |
| if ttm_actions_after_kickoff: | |
| ttm_summary = ", ".join( | |
| f"step {row.get('step_number')}:{row.get('player_name')}" | |
| for row in ttm_actions_after_kickoff | |
| ) | |
| reason_lines.append(f"Throw Team Mate actions after kickoff: {ttm_summary}.") | |
| else: | |
| reason_lines.append("Throw Team Mate actions after kickoff: none.") | |
| if touchdowns_after_kickoff: | |
| touchdown_summary = ", ".join( | |
| f"step {row.get('step_number')}:{row.get('player_name')}" | |
| for row in touchdowns_after_kickoff | |
| ) | |
| reason_lines.append(f"Touchdowns after kickoff: {touchdown_summary}.") | |
| else: | |
| reason_lines.append("Touchdowns after kickoff: none.") | |
| reason_lines.append( | |
| f"Final OTTD decision: {'yes' if final_decision else 'no'}." | |
| ) | |
| fast_halfway_players = [ | |
| { | |
| "player_id": player.get("player_id"), | |
| "player_name": player.get("player_name"), | |
| "effective_ma": player.get("effective_ma"), | |
| "x": player.get("x"), | |
| "y": player.get("y"), | |
| "los_x": player.get("los_x"), | |
| "side": player.get("side"), | |
| } | |
| for player in fastest_halfway_players | |
| ] | |
| return { | |
| "turn": turn_num, | |
| "is_candidate": True, | |
| "kickoff_step_number": kickoff_step_number, | |
| "kicker_team_id": kicker_team_id, | |
| "receiving_team_id": receiving_team_id, | |
| "touchdowns_after_kickoff": touchdowns_after_kickoff, | |
| "ttm_actions_after_kickoff": ttm_actions_after_kickoff, | |
| "touchdown_override": touchdown_override, | |
| "receiving_players": receiving_players, | |
| "kicking_players": kicking_players, | |
| "receiving_player_count": len(receiving_players), | |
| "fast_halfway_players": fast_halfway_players, | |
| "fastest_halfway_player": fastest_player, | |
| "pushes_needed": pushes_needed, | |
| "minimum_players_required": minimum_players_required, | |
| "minimum_players_met": minimum_players_met, | |
| "all_players_shallow": all_players_shallow, | |
| "defender_skill_block": defender_skill_block, | |
| "has_fast_player_on_halfway_line": has_fast_player_on_halfway_line, | |
| "setup_based_decision": setup_based_decision, | |
| "final_decision": final_decision, | |
| "criterion_1_pending": False, | |
| "reason_lines": reason_lines, | |
| } | |
| def _extract_step_team_positions(self, step_number, team_id): | |
| """Return on-pitch player positions for one team from a raw replay step.""" | |
| raw_steps = getattr(self.game_state, "raw_replay_steps", None) | |
| if not isinstance(raw_steps, list): | |
| return [] | |
| step_index = self._to_int(step_number, default=None) | |
| team_index = self._to_int(team_id, default=None) | |
| if step_index is None or team_index is None: | |
| return [] | |
| step_index -= 1 | |
| if step_index < 0 or step_index >= len(raw_steps): | |
| return [] | |
| step_data = raw_steps[step_index] | |
| if not isinstance(step_data, dict): | |
| return [] | |
| board_state = step_data.get("BoardState") | |
| if not isinstance(board_state, dict): | |
| return [] | |
| team_states = board_state.get("ListTeams", {}).get("TeamState", []) | |
| if not isinstance(team_states, list): | |
| team_states = [team_states] | |
| if team_index < 0 or team_index >= len(team_states): | |
| return [] | |
| team_state = team_states[team_index] | |
| if not isinstance(team_state, dict): | |
| return [] | |
| players = team_state.get("ListPitchPlayers", {}).get("PlayerState", []) | |
| if not isinstance(players, list): | |
| players = [players] | |
| positions = [] | |
| for player in players: | |
| if not isinstance(player, dict): | |
| continue | |
| player_id = player.get("Id") | |
| if player_id in (None, ""): | |
| continue | |
| cell = player.get("Cell") if isinstance(player.get("Cell"), dict) else None | |
| if not isinstance(cell, dict): | |
| continue | |
| x = self._to_int(cell.get("X"), default=None) | |
| y = self._to_int(cell.get("Y"), default=None) | |
| if x is None or y is None or x < 0 or y < 0: | |
| continue | |
| positions.append({"player_id": str(player_id), "x": x, "y": y}) | |
| return positions | |
| def _squares_into_opponent_half(self, player_position, los_x): | |
| """Return how many squares a player is into the opponent half from the halfway line.""" | |
| if not isinstance(player_position, dict): | |
| return None | |
| x = self._to_int(player_position.get("x"), default=None) | |
| los_x_int = self._to_int(los_x, default=None) | |
| if x is None or los_x_int is None: | |
| return None | |
| if los_x_int <= 12: | |
| return max(0, x - los_x_int) | |
| return max(0, los_x_int - x) | |
| def _evaluate_ottd_mode_lifecycle_for_turn(self, evaluation): | |
| """Evaluate OTTD mode on/off lifecycle for one accepted OTTD attempt turn. | |
| Implemented mode-off criteria: | |
| - C1: selected block die is non-push | |
| - C2: a fast, not-yet-activated starter reaches pushes_needed squares into opponent half | |
| - C3: all fast starters from the halfway line have activated | |
| """ | |
| if not isinstance(evaluation, dict) or not evaluation.get("final_decision"): | |
| return { | |
| "enabled": False, | |
| "reason": "turn is not classified as a final OTTD attempt", | |
| } | |
| turn_num = self._to_int(evaluation.get("turn"), default=None) | |
| kickoff_step = self._to_int(evaluation.get("kickoff_step_number"), default=None) | |
| receiving_team_id = self._normalize_team_id(evaluation.get("receiving_team_id")) | |
| pushes_needed = self._to_int(evaluation.get("pushes_needed"), default=None) | |
| fast_starters = evaluation.get("fast_halfway_players") or [] | |
| monitored = { | |
| str(player.get("player_id")): dict(player) | |
| for player in fast_starters | |
| if isinstance(player, dict) and player.get("player_id") is not None | |
| } | |
| monitored_ids = set(monitored.keys()) | |
| candidate_steps = [] | |
| for step in getattr(self.game_state, "steps", []) or []: | |
| if self._to_int(getattr(step, "game_turn", None), default=None) != turn_num: | |
| continue | |
| if kickoff_step is not None and step.step_number <= kickoff_step: | |
| continue | |
| if self._normalize_team_id(getattr(step, "turn_owner_team_id", None)) != receiving_team_id: | |
| continue | |
| candidate_steps.append(step) | |
| candidate_steps = sorted(candidate_steps, key=lambda s: s.step_number) | |
| mode_on_step = candidate_steps[0].step_number if candidate_steps else None | |
| touchdown_steps_after_kickoff = [ | |
| self._to_int(row.get("step_number"), default=None) | |
| for row in evaluation.get("touchdowns_after_kickoff") or [] | |
| if isinstance(row, dict) | |
| ] | |
| touchdown_steps_after_kickoff = [step for step in touchdown_steps_after_kickoff if step is not None] | |
| first_touchdown_step = min(touchdown_steps_after_kickoff) if touchdown_steps_after_kickoff else None | |
| activated = set() | |
| activation_step_by_player = {} | |
| turned_off = False | |
| turned_off_step = None | |
| turned_off_timing = None | |
| turned_off_criterion = None | |
| turned_off_reason = None | |
| # Replay block-face encoding: 2 is Push (0=ATT down, 1=Both down, 2=Push, 3=Stumbles, 4=Pow). | |
| push_face_value = 2 | |
| for step in candidate_steps: | |
| # C1: any selected non-push block face by the receiving team turns mode off. | |
| if not turned_off: | |
| step_number_int = self._to_int(step.step_number, default=None) | |
| for event in getattr(self.game_state, "roll_events", []) or []: | |
| if not isinstance(event, dict): | |
| continue | |
| if self._to_int(event.get("step_number"), default=None) != step_number_int: | |
| continue | |
| if str(event.get("roll_category") or "").lower() != "block": | |
| continue | |
| if self._normalize_team_id(event.get("team_id")) != receiving_team_id: | |
| continue | |
| selected_value = self._to_int(event.get("selected_block_die_value"), default=None) | |
| if selected_value is None: | |
| continue | |
| attacker_is_blitzing = bool(event.get("attacker_is_blitzing")) | |
| has_juggernaut = self._event_player_has_skill(event, "Juggernaut") | |
| # C1 exception: Juggernaut on a blitzing block with selected Both Down does not disable OTTD mode. | |
| both_down_face_value = 1 | |
| if attacker_is_blitzing and has_juggernaut and selected_value == both_down_face_value: | |
| continue | |
| if selected_value != push_face_value: | |
| rolled_values_raw = event.get("dice_values") if isinstance(event.get("dice_values"), list) else [] | |
| rolled_values = [ | |
| self._to_int(value, default=None) | |
| for value in rolled_values_raw | |
| ] | |
| rolled_values = [value for value in rolled_values if value is not None] | |
| push_was_available = push_face_value in rolled_values | |
| turned_off = True | |
| turned_off_step = step.step_number | |
| turned_off_timing = "before_step" if push_was_available else "after_step" | |
| turned_off_criterion = "C1" | |
| if push_was_available: | |
| turned_off_reason = ( | |
| f"step {step.step_number}: non-push selected block face ({selected_value}) by " | |
| f"{event.get('player_name', 'Unknown')} despite push being available in dice {rolled_values_raw}" | |
| ) | |
| else: | |
| turned_off_reason = ( | |
| f"step {step.step_number}: non-push selected block face ({selected_value}) by " | |
| f"{event.get('player_name', 'Unknown')} with no push available in dice {rolled_values_raw}" | |
| ) | |
| break | |
| if turned_off: | |
| break | |
| step_positions = self._extract_step_team_positions(step.step_number, receiving_team_id) | |
| positions_by_player_id = { | |
| str(pos.get("player_id")): pos | |
| for pos in step_positions | |
| if isinstance(pos, dict) and pos.get("player_id") is not None | |
| } | |
| # C2: player reaches required depth before they have activated. | |
| if not turned_off and pushes_needed is not None and pushes_needed >= 0: | |
| for player_id in sorted(monitored_ids - activated): | |
| pos = positions_by_player_id.get(player_id) | |
| if not isinstance(pos, dict): | |
| continue | |
| player_los_x = monitored.get(player_id, {}).get("los_x") | |
| into_half = self._squares_into_opponent_half(pos, player_los_x) | |
| if into_half is None: | |
| continue | |
| if into_half >= pushes_needed: | |
| turned_off = True | |
| turned_off_step = step.step_number | |
| turned_off_timing = "after_step" | |
| turned_off_criterion = "C2" | |
| turned_off_reason = ( | |
| f"step {step.step_number}: {monitored.get(player_id, {}).get('player_name', player_id)} " | |
| f"reached {into_half} squares into opponent half before activating " | |
| f"(pushes_needed={pushes_needed})" | |
| ) | |
| break | |
| if turned_off: | |
| break | |
| acting_player_id = getattr(step, "acting_player_id", None) | |
| acting_team_id = self._normalize_team_id(getattr(step, "acting_player_team_id", None)) | |
| if acting_player_id is not None and acting_team_id == receiving_team_id: | |
| acting_player_id_str = str(acting_player_id) | |
| if acting_player_id_str in monitored_ids and acting_player_id_str not in activated: | |
| activated.add(acting_player_id_str) | |
| activation_step_by_player[acting_player_id_str] = step.step_number | |
| # C3: all monitored fast starters have activated. | |
| if monitored_ids and activated == monitored_ids: | |
| turned_off = True | |
| turned_off_step = step.step_number | |
| turned_off_timing = "after_step" | |
| turned_off_criterion = "C3" | |
| turned_off_reason = ( | |
| f"step {step.step_number}: all fast starters from the halfway line have activated" | |
| ) | |
| break | |
| if turned_off and first_touchdown_step is not None and turned_off_step is not None and turned_off_step <= first_touchdown_step: | |
| turned_off_step = first_touchdown_step + 1 | |
| turned_off_timing = "after_step" | |
| touchdown_reason = f"touchdown scored at step {first_touchdown_step}" | |
| if turned_off_reason: | |
| turned_off_reason = f"{turned_off_reason}; delayed until after {touchdown_reason}" | |
| else: | |
| turned_off_reason = f"delayed until after {touchdown_reason}" | |
| return { | |
| "enabled": True, | |
| "mode_on_step": mode_on_step, | |
| "criterion_1_pending": False, | |
| "monitored_player_ids": sorted(monitored_ids), | |
| "monitored_players": [monitored[player_id] for player_id in sorted(monitored_ids)], | |
| "activated_player_ids": sorted(activated), | |
| "activation_step_by_player": activation_step_by_player, | |
| "turned_off": turned_off, | |
| "turned_off_step": turned_off_step, | |
| "turned_off_timing": turned_off_timing, | |
| "turned_off_criterion": turned_off_criterion, | |
| "turned_off_reason": turned_off_reason, | |
| } | |
| def _detect_ottd_attempt_turns(self): | |
| """Detect and classify OTTD candidate turns with diagnostic reasoning.""" | |
| kickoff_index = self._build_ottd_turn_kickoff_index() | |
| evaluations = {} | |
| final_attempt_turns = set() | |
| for turn_num in sorted(kickoff_index.keys()): | |
| evaluation = self._evaluate_ottd_candidate_turn(turn_num, kickoff_index[turn_num]) | |
| evaluation["ottd_mode"] = self._evaluate_ottd_mode_lifecycle_for_turn(evaluation) | |
| evaluations[turn_num] = evaluation | |
| if evaluation.get("final_decision"): | |
| final_attempt_turns.add(turn_num) | |
| self.game_state.ottd_candidate_turns = set(kickoff_index.keys()) | |
| self.game_state.ottd_attempt_turns = final_attempt_turns | |
| self.game_state.ottd_evaluations = evaluations | |
| def _annotate_player_lookup_kickoff_los(self, replay_steps): | |
| """Attach kickoff LOS participation summaries to player lookup entries.""" | |
| los_index = self._build_player_lookup_los_index(replay_steps) | |
| if not los_index: | |
| return | |
| for player_id, entries in los_index.items(): | |
| existing = self.game_state.player_lookup.get(player_id, {}) | |
| if not isinstance(existing, dict): | |
| existing = {} | |
| merged = dict(existing) | |
| merged["kickoff_los_entries"] = entries | |
| merged["kickoff_los_count"] = len(entries) | |
| merged["kickoff_los_step_numbers"] = [entry["step_number"] for entry in entries] | |
| self.game_state.player_lookup[player_id] = merged | |
| def _upsert_player_lookup( | |
| self, | |
| player_id, | |
| team_id=None, | |
| player_name=None, | |
| skills=None, | |
| skill_ids=None, | |
| number=None, | |
| characteristics=None, | |
| ma=None, | |
| ): | |
| """Create or update a player lookup entry with best-known data.""" | |
| if player_id is None: | |
| return | |
| player_id_str = str(player_id) | |
| existing = self.game_state.player_lookup.get(player_id_str, {}) | |
| merged = { | |
| 'name': existing.get('name', 'Unknown'), | |
| 'team_id': existing.get('team_id', 'Unknown'), | |
| 'skills': existing.get('skills', []), | |
| 'skill_ids': existing.get('skill_ids', []), | |
| 'number': existing.get('number'), | |
| 'characteristics': existing.get('characteristics', {}), | |
| 'ma': existing.get('ma'), | |
| 'kickoff_los_entries': existing.get('kickoff_los_entries', []), | |
| 'kickoff_los_count': existing.get('kickoff_los_count', 0), | |
| 'kickoff_los_step_numbers': existing.get('kickoff_los_step_numbers', []), | |
| } | |
| if team_id is not None: | |
| merged['team_id'] = str(team_id) | |
| if player_name not in (None, '', 'Unknown'): | |
| decoded_name = self._maybe_decode_base64_string(player_name) | |
| merged['name'] = self._normalize_player_name(decoded_name) | |
| if isinstance(skills, list) and len(skills) > 0: | |
| merged['skills'] = skills | |
| if isinstance(skill_ids, list) and len(skill_ids) > 0: | |
| merged['skill_ids'] = skill_ids | |
| if number not in (None, ''): | |
| merged['number'] = str(number) | |
| if isinstance(characteristics, dict) and len(characteristics) > 0: | |
| merged['characteristics'] = characteristics | |
| if ma not in (None, ''): | |
| try: | |
| merged['ma'] = int(str(ma)) | |
| except (TypeError, ValueError): | |
| merged['ma'] = str(ma) | |
| self.game_state.player_lookup[player_id_str] = merged | |
| def _extract_team_inducements(self, replay_steps): | |
| """Scan the pre-match inducement phase and populate team_states[i].inducements_purchased. | |
| Each purchase is appended as an InducementIdTypes string (e.g. "1"=keg, "7"=star player). | |
| Multiple purchases of the same type each add a separate entry so the list length | |
| directly counts how many of that type were bought. | |
| Known purchase events | |
| -------------------- | |
| EventBuyMercenary – player-type inducements (journeyman=6, star player=7). | |
| The bought category is identified by matching MercenaryType | |
| (an encoded player template ID) against the | |
| EventInducementsData category entries. | |
| Any other EventBuy* – forward-looking hook for non-player inducements (e.g. kegs). | |
| We expect a GamerId and Type/InducementType field. | |
| EventInducementsData – authoritative fallback for purchased counts. In this | |
| corpus, purchases are often reflected via category | |
| InPossession values rather than explicit EventBuy* | |
| payloads. We top up per-type counts from the | |
| maximum observed InPossession in the inducement | |
| phase, while preserving explicit EventBuy captures. | |
| """ | |
| # Cache the latest EventInducementsData; it is re-emitted each inducement turn. | |
| event_inducements_data = None | |
| current_gamer_id = "0" | |
| in_possession_by_team_and_type = { | |
| "0": defaultdict(int), | |
| "1": defaultdict(int), | |
| } | |
| def _record_in_possession_from_categories(team_key, categories): | |
| if team_key not in ("0", "1"): | |
| return | |
| if not isinstance(categories, list): | |
| categories = [categories] | |
| for cat in categories: | |
| if not isinstance(cat, dict): | |
| continue | |
| ind_type = str(cat.get("Type") or "").strip() | |
| if ind_type == "": | |
| continue | |
| in_possession = self._to_int(cat.get("InPossession"), default=0) or 0 | |
| if in_possession > in_possession_by_team_and_type[team_key][ind_type]: | |
| in_possession_by_team_and_type[team_key][ind_type] = in_possession | |
| def _signature_from_team_inducements_data(team_entry): | |
| if not isinstance(team_entry, dict): | |
| return set() | |
| cats = team_entry.get("InducementsCategories", {}).get("InducementsCategory", []) | |
| if not isinstance(cats, list): | |
| cats = [cats] | |
| signature = set() | |
| for cat in cats: | |
| if not isinstance(cat, dict): | |
| continue | |
| ind_type = str(cat.get("Type") or "").strip() | |
| cost = str(cat.get("Cost") or "").strip() | |
| max_count = str(cat.get("Max") or "").strip() | |
| if ind_type: | |
| signature.add((ind_type, cost, max_count)) | |
| return signature | |
| def _resolve_gamer_id(event_data, fallback_gamer_id): | |
| if not isinstance(event_data, dict): | |
| return fallback_gamer_id | |
| explicit = str(event_data.get("GamerId", "")).strip() | |
| if explicit in ("0", "1"): | |
| return explicit | |
| team_data = event_data.get("TeamInducementsData") | |
| if not isinstance(team_data, dict) or not isinstance(event_inducements_data, dict): | |
| return fallback_gamer_id | |
| event_signature = _signature_from_team_inducements_data(team_data) | |
| if not event_signature: | |
| return fallback_gamer_id | |
| team_list = ( | |
| event_inducements_data | |
| .get("TeamInducements", {}) | |
| .get("TeamInducements", []) | |
| ) | |
| if not isinstance(team_list, list): | |
| team_list = [team_list] | |
| best_team = None | |
| best_score = -1 | |
| for team_idx, team_entry in enumerate(team_list): | |
| if team_idx not in (0, 1): | |
| continue | |
| team_signature = _signature_from_team_inducements_data(team_entry) | |
| if not team_signature: | |
| continue | |
| score = len(event_signature & team_signature) | |
| if score > best_score: | |
| best_score = score | |
| best_team = str(team_idx) | |
| return best_team if best_team in ("0", "1") else fallback_gamer_id | |
| for step in replay_steps: | |
| if not isinstance(step, dict): | |
| continue | |
| # Track which team is buying this turn. | |
| new_ind_turn = step.get("EventNewInducementsTurn") | |
| if isinstance(new_ind_turn, dict): | |
| current_gamer_id = str(new_ind_turn.get("GamerId", current_gamer_id)) | |
| if step.get("EventInducementsData"): | |
| event_inducements_data = step["EventInducementsData"] | |
| team_list = ( | |
| event_inducements_data | |
| .get("TeamInducements", {}) | |
| .get("TeamInducements", []) | |
| if isinstance(event_inducements_data, dict) | |
| else [] | |
| ) | |
| if not isinstance(team_list, list): | |
| team_list = [team_list] | |
| for team_idx, team_entry in enumerate(team_list): | |
| if team_idx not in (0, 1) or not isinstance(team_entry, dict): | |
| continue | |
| cats = ( | |
| team_entry | |
| .get("InducementsCategories", {}) | |
| .get("InducementsCategory", []) | |
| ) | |
| _record_in_possession_from_categories(str(team_idx), cats) | |
| add_inducement = step.get("EventAddInducement") | |
| if isinstance(add_inducement, dict): | |
| inferred_gamer_id = _resolve_gamer_id(add_inducement, current_gamer_id) | |
| team_data = add_inducement.get("TeamInducementsData") | |
| if isinstance(team_data, dict): | |
| cats = ( | |
| team_data | |
| .get("InducementsCategories", {}) | |
| .get("InducementsCategory", []) | |
| ) | |
| _record_in_possession_from_categories(inferred_gamer_id, cats) | |
| # ── EventBuyMercenary (journeymen & star players) ────────────────── | |
| buy_merc = step.get("EventBuyMercenary") | |
| if isinstance(buy_merc, dict): | |
| gamer_id = str(buy_merc.get("GamerId", current_gamer_id)) | |
| mercenary_type = buy_merc.get("MercenaryType") | |
| inducement_type = None | |
| if event_inducements_data and mercenary_type is not None: | |
| team_list = ( | |
| event_inducements_data | |
| .get("TeamInducements", {}) | |
| .get("TeamInducements", []) | |
| ) | |
| if not isinstance(team_list, list): | |
| team_list = [team_list] | |
| team_idx = int(gamer_id) if gamer_id in ("0", "1") else 0 | |
| if team_idx < len(team_list): | |
| cats = ( | |
| team_list[team_idx] | |
| .get("InducementsCategories", {}) | |
| .get("InducementsCategory", []) | |
| ) | |
| if not isinstance(cats, list): | |
| cats = [cats] | |
| for cat in cats: | |
| if not isinstance(cat, dict): | |
| continue | |
| players = cat.get("Players") or {} | |
| if not isinstance(players, dict): | |
| continue | |
| pd = players.get("PlayerData") or {} | |
| if isinstance(pd, dict) and pd.get("IdPlayerTypes") == mercenary_type: | |
| inducement_type = cat.get("Type") | |
| break | |
| if inducement_type and gamer_id in ("0", "1"): | |
| self.team_states[int(gamer_id)].inducements_purchased.append(str(inducement_type)) | |
| # ── Any other EventBuy* (non-player inducements e.g. kegs) ───────── | |
| for key in step: | |
| if key.startswith("EventBuy") and key != "EventBuyMercenary": | |
| event_data = step[key] | |
| if isinstance(event_data, dict): | |
| gamer_id = _resolve_gamer_id(event_data, current_gamer_id) | |
| ind_type = ( | |
| event_data.get("Type") | |
| or event_data.get("InducementType") | |
| or event_data.get("Inducement") | |
| ) | |
| if ind_type and gamer_id in ("0", "1"): | |
| self.team_states[int(gamer_id)].inducements_purchased.append(str(ind_type)) | |
| # Stop once the inducement phase is over. | |
| if step.get("EventEndInducements") is not None: | |
| break | |
| for team_key, type_counts in in_possession_by_team_and_type.items(): | |
| if team_key not in ("0", "1"): | |
| continue | |
| state = self.team_states[int(team_key)] | |
| for ind_type, possessed_count in type_counts.items(): | |
| if possessed_count <= 0: | |
| continue | |
| already_recorded = state.inducements_purchased.count(str(ind_type)) | |
| missing = possessed_count - already_recorded | |
| if missing > 0: | |
| state.inducements_purchased.extend([str(ind_type)] * missing) | |
| def _extract_step_turn_boundary(self, step_data): | |
| """Extract explicit turn-end metadata from a replay step.""" | |
| boundary = { | |
| "explicit_turn_end": False, | |
| "end_turn_reason": None, | |
| "end_turn_type": None, | |
| "next_active_team_id": None, | |
| } | |
| if not isinstance(step_data, dict): | |
| return boundary | |
| end_turn = step_data.get("EventEndTurn") | |
| if isinstance(end_turn, dict): | |
| boundary["explicit_turn_end"] = True | |
| boundary["end_turn_reason"] = end_turn.get("Reason") | |
| boundary["end_turn_type"] = end_turn.get("FinishingTurnType") | |
| next_playing_gamer = self._normalize_team_id(end_turn.get("NextPlayingGamer")) | |
| if next_playing_gamer in ("0", "1"): | |
| boundary["next_active_team_id"] = next_playing_gamer | |
| active_gamer_changed = step_data.get("EventActiveGamerChanged") | |
| if isinstance(active_gamer_changed, dict): | |
| new_active_gamer = self._normalize_team_id(active_gamer_changed.get("NewActiveGamer")) | |
| if new_active_gamer in ("0", "1"): | |
| boundary["next_active_team_id"] = new_active_gamer | |
| return boundary | |
| def _infer_game_turn_owner(self): | |
| try: | |
| t0_gt = int(self.team_states[0].game_turn) | |
| t1_gt = int(self.team_states[1].game_turn) | |
| if t0_gt > t1_gt: | |
| return "0" | |
| if t1_gt > t0_gt: | |
| return "1" | |
| return None | |
| except (TypeError, ValueError): | |
| return None | |
| def _extract_observed_step_roll_context(self, roll_events): | |
| observed_categories = { | |
| str(event.get("roll_category") or "").strip().lower() | |
| for event in (roll_events or []) | |
| if isinstance(event, dict) | |
| } | |
| is_consequence_only_step = bool(observed_categories) and observed_categories.issubset(CONSEQUENCE_ROLL_CATEGORIES_LOWER) | |
| observed_step_names = { | |
| str(event.get("step_name") or "").strip() | |
| for event in (roll_events or []) | |
| if isinstance(event, dict) | |
| } | |
| is_damage_step_only = bool(observed_step_names) and observed_step_names.issubset({"DamageStep"}) | |
| is_consequence_like_step = is_consequence_only_step or is_damage_step_only | |
| return observed_categories, is_consequence_like_step | |
| def _infer_initiative_owner_from_roll_events(self, roll_events): | |
| initiative_teams = { | |
| self._normalize_team_id(event.get("team_id")) | |
| for event in (roll_events or []) | |
| if isinstance(event, dict) | |
| and str(event.get("roll_category") or "").strip().lower() in INITIATIVE_ROLL_CATEGORIES_LOWER | |
| and self._normalize_team_id(event.get("team_id")) in ("0", "1") | |
| } | |
| return next(iter(initiative_teams)) if len(initiative_teams) == 1 else None | |
| def _classify_step_turn_owner(self, step, step_data, kick_off, current_turn_owner, roll_events=None): | |
| """Return canonical turn ownership for a step and the next carried owner.""" | |
| kickoff_team_id = self._normalize_team_id(kick_off.team_id) if kick_off else None | |
| step_eag = step_data.get("EventActiveGamerChanged") | |
| eag_new = ( | |
| self._normalize_team_id(step_eag.get("NewActiveGamer")) | |
| if isinstance(step_eag, dict) else None | |
| ) | |
| step_et = step_data.get("EventEndTurn") | |
| et_next = ( | |
| self._normalize_team_id(step_et.get("NextPlayingGamer")) | |
| if isinstance(step_et, dict) else None | |
| ) | |
| has_end_turn = step.explicit_turn_end | |
| gt_owner = self._infer_game_turn_owner() | |
| step_owner = None | |
| next_owner_state = current_turn_owner | |
| active_fallback = self._normalize_team_id(step.active_team_id) | |
| # Consequence-only fallout rows (armour/injury/casualty) should stay on the | |
| # current activation even if EventActiveGamerChanged appears in this step. | |
| observed_categories, is_consequence_like_step = self._extract_observed_step_roll_context(roll_events) | |
| # Generic ball rolls are not reliable initiative evidence because bounce / | |
| # scatter fallout can be attributed to the non-active team mid-activation. | |
| initiative_owner = self._infer_initiative_owner_from_roll_events(roll_events) | |
| if kickoff_team_id in ("0", "1"): | |
| # Kickoff step: the kicker owns this step; set receiver as next owner. | |
| step_owner = kickoff_team_id | |
| receiver = eag_new or et_next | |
| if receiver not in ("0", "1"): | |
| receiver = self._opponent_team_id(kickoff_team_id) | |
| next_owner_state = receiver | |
| elif eag_new in ("0", "1") and not has_end_turn: | |
| # Mid-turn gamer switch: transfer ownership from the next step. | |
| # Keep the current step on the prior owner when it is consequence-only. | |
| if ( | |
| current_turn_owner in ("0", "1") | |
| and active_fallback == current_turn_owner | |
| and eag_new != current_turn_owner | |
| and ( | |
| not observed_categories | |
| or initiative_owner not in ("0", "1") | |
| or initiative_owner == current_turn_owner | |
| ) | |
| ): | |
| # Timer/UI-only or reaction-window active-gamer changes: the | |
| # opponent briefly takes UI control (e.g. to confirm a bomb | |
| # reaction or an opponent-scoped interaction) but the turn still | |
| # belongs to the current owner. Guard fires when there are no | |
| # rolls, or when roll evidence supports the current owner rather | |
| # than the incoming gamer. | |
| step_owner = current_turn_owner | |
| next_owner_state = current_turn_owner | |
| else: | |
| next_owner_state = eag_new | |
| if is_consequence_like_step and current_turn_owner in ("0", "1"): | |
| step_owner = current_turn_owner | |
| else: | |
| step_owner = eag_new | |
| elif has_end_turn: | |
| # End of someone's turn: this step belongs to whoever was active. | |
| step_owner = next_owner_state if next_owner_state in ("0", "1") else gt_owner | |
| # Determine next active team from explicit signals or GameTurn delta. | |
| next_owner = eag_new or et_next or gt_owner | |
| if next_owner in ("0", "1"): | |
| next_owner_state = next_owner | |
| if next_owner not in ("0", "1"): | |
| # When GT delta is ambiguous (equal counters), fall back to the | |
| # active_team_id, which tracks which team's counter changed. | |
| if active_fallback in ("0", "1"): | |
| next_owner = active_fallback | |
| if next_owner in ("0", "1"): | |
| next_owner_state = next_owner | |
| elif initiative_owner in ("0", "1") and ( | |
| active_fallback not in ("0", "1") or active_fallback == initiative_owner | |
| ): | |
| # No explicit handoff signal in this step; trust clear initiative rolls | |
| # only when they align with the active-team tracker. | |
| step_owner = initiative_owner | |
| next_owner_state = initiative_owner | |
| elif next_owner_state in ("0", "1"): | |
| # No change: carry forward. | |
| step_owner = next_owner_state | |
| elif gt_owner in ("0", "1"): | |
| # Bootstrap from GameTurn when no explicit signal has fired yet. | |
| step_owner = gt_owner | |
| next_owner_state = gt_owner | |
| return step_owner, next_owner_state | |
| def build_player_lookup(self, jsonData): | |
| """Build a lookup dictionary for quick player ID -> (name, team_id) access.""" | |
| rosters = jsonData["Replay"]["Rosters"] | |
| team_rosters = rosters.get("TeamRoster", []) | |
| if not isinstance(team_rosters, list): | |
| team_rosters = [team_rosters] | |
| for team_idx, team_roster in enumerate(team_rosters): | |
| team_id = team_roster.get("Team", {}).get("TeamId", team_idx) | |
| player_data_list = team_roster.get("Players", {}).get("PlayerData", []) | |
| if not isinstance(player_data_list, list): | |
| player_data_list = [player_data_list] | |
| for player_data in player_data_list: | |
| if isinstance(player_data, dict): | |
| player_id = player_data.get("Id", "Unknown") | |
| player_name = self._maybe_decode_base64_string(player_data.get("Name", "Unknown")) | |
| player_skill_ids = self._extract_player_skill_ids(player_data) | |
| player_skills = self._extract_player_skills(player_data) | |
| player_number = player_data.get("Number") | |
| player_characteristics = self._parse_player_characteristics(player_data) | |
| player_ma = None | |
| if isinstance(player_characteristics, dict): | |
| ma_entry = player_characteristics.get("0") | |
| if isinstance(ma_entry, dict): | |
| player_ma = ma_entry.get("value") | |
| self._upsert_player_lookup( | |
| player_id, | |
| team_id=team_id, | |
| player_name=player_name, | |
| skills=player_skills, | |
| skill_ids=player_skill_ids, | |
| number=player_number, | |
| characteristics=player_characteristics, | |
| ma=player_ma, | |
| ) | |
| # Add induced/star players that are not always present in Rosters. | |
| replay = jsonData.get("Replay", {}) | |
| replay_steps = replay.get("ReplayStep", []) | |
| if isinstance(replay_steps, dict): | |
| replay_steps = [replay_steps] | |
| for step_data in replay_steps: | |
| if not isinstance(step_data, dict): | |
| continue | |
| # EventBuyMercenary can include selected star player details. | |
| buy_mercenary = step_data.get("EventBuyMercenary") | |
| if isinstance(buy_mercenary, dict): | |
| mercenary_id = buy_mercenary.get("MercenaryId") | |
| gamer_id = buy_mercenary.get("GamerId") | |
| mercenary_name = None | |
| mercenary_number = None | |
| team_inducements_data = buy_mercenary.get("TeamInducementsData") | |
| if isinstance(team_inducements_data, dict): | |
| categories = team_inducements_data.get("InducementsCategories", {}).get("InducementsCategory", []) | |
| if not isinstance(categories, list): | |
| categories = [categories] | |
| for category in categories: | |
| if not isinstance(category, dict): | |
| continue | |
| players_node = category.get("Players") or {} | |
| if not isinstance(players_node, dict): | |
| continue | |
| player_data = players_node.get("PlayerData") | |
| if isinstance(player_data, dict): | |
| mercenary_name = player_data.get("Name", mercenary_name) | |
| mercenary_number = player_data.get("Number", mercenary_number) | |
| break | |
| self._upsert_player_lookup( | |
| mercenary_id, | |
| team_id=gamer_id, | |
| player_name=mercenary_name, | |
| number=mercenary_number, | |
| ) | |
| # BoardState often has all currently active players, including induced stars. | |
| board_state = step_data.get("BoardState") | |
| if not isinstance(board_state, dict): | |
| continue | |
| team_states = board_state.get("ListTeams", {}).get("TeamState", []) | |
| if not isinstance(team_states, list): | |
| team_states = [team_states] | |
| for team_idx, team_state in enumerate(team_states): | |
| if not isinstance(team_state, dict): | |
| continue | |
| players_state = team_state.get("ListPitchPlayers", {}).get("PlayerState", []) | |
| if not isinstance(players_state, list): | |
| players_state = [players_state] | |
| for player_state in players_state: | |
| if not isinstance(player_state, dict): | |
| continue | |
| player_id = player_state.get("Id") | |
| player_data = player_state.get("Data", {}) | |
| name = player_data.get("Name") if isinstance(player_data, dict) else None | |
| number = player_data.get("Number") if isinstance(player_data, dict) else None | |
| skill_ids = self._extract_player_skill_ids(player_data) if isinstance(player_data, dict) else [] | |
| skills = self._extract_player_skills(player_data) if isinstance(player_data, dict) else [] | |
| self._upsert_player_lookup( | |
| player_id, | |
| team_id=team_idx, | |
| player_name=name, | |
| skills=skills, | |
| skill_ids=skill_ids, | |
| number=number, | |
| ) | |
| # Populate per-team inducement lists from the pre-match phase. | |
| self._extract_team_inducements(replay_steps) | |
| def _ensure_list(self, value): | |
| """Normalize a value to list form for replay fields that are variably shaped.""" | |
| if value is None: | |
| return [] | |
| if isinstance(value, list): | |
| return value | |
| return [value] | |
| def normalize_replay_step_shapes(self, replay_steps): | |
| """Normalize replay step payload shapes in-place for consistent downstream parsing.""" | |
| for step_data in replay_steps: | |
| if not isinstance(step_data, dict): | |
| continue | |
| # Normalize TeamState to always be a list. | |
| board_state = step_data.get("BoardState") | |
| if isinstance(board_state, dict): | |
| list_teams = board_state.get("ListTeams") | |
| if isinstance(list_teams, dict) and "TeamState" in list_teams: | |
| list_teams["TeamState"] = self._ensure_list(list_teams.get("TeamState")) | |
| # Normalize EventExecuteSequence and nested StepResult/StringMessage fields. | |
| if "EventExecuteSequence" in step_data: | |
| step_data["EventExecuteSequence"] = self._ensure_list(step_data.get("EventExecuteSequence")) | |
| for sequence_item in step_data["EventExecuteSequence"]: | |
| if not isinstance(sequence_item, dict): | |
| continue | |
| sequence = sequence_item.get("Sequence") | |
| if not isinstance(sequence, dict) or "StepResult" not in sequence: | |
| continue | |
| sequence["StepResult"] = self._ensure_list(sequence.get("StepResult")) | |
| for step_result in sequence["StepResult"]: | |
| if not isinstance(step_result, dict): | |
| continue | |
| results = step_result.get("Results") | |
| if isinstance(results, dict) and "StringMessage" in results: | |
| results["StringMessage"] = self._ensure_list(results.get("StringMessage")) | |
| def extract_player_from_event_execute_sequence(self, exec_seq): | |
| """Extract player ID from an EventExecuteSequence.""" | |
| # Replay payloads can provide EventExecuteSequence as either a list or a dict. | |
| sequence_items = [] | |
| if isinstance(exec_seq, list): | |
| sequence_items = exec_seq | |
| elif isinstance(exec_seq, dict): | |
| sequence_items = [exec_seq] | |
| for seq_item in sequence_items: | |
| if not isinstance(seq_item, dict) or "Sequence" not in seq_item: | |
| continue | |
| sequence = seq_item["Sequence"] | |
| if not isinstance(sequence, dict) or "StepResult" not in sequence: | |
| continue | |
| step_results = sequence["StepResult"] | |
| if isinstance(step_results, dict): | |
| step_results = [step_results] | |
| if not isinstance(step_results, list): | |
| continue | |
| for step_result in step_results: | |
| if not isinstance(step_result, dict): | |
| continue | |
| step = step_result.get("Step") | |
| if isinstance(step, dict) and step.get("Name") == "PlayerStep" and "MessageData" in step: | |
| message_data = step["MessageData"] | |
| if isinstance(message_data, str): | |
| try: | |
| player_step_data = xmltodict.parse(message_data) | |
| return player_step_data["PlayerStep"]["PlayerId"] | |
| except (ExpatError, TypeError, KeyError): | |
| pass | |
| return None | |
| def extract_kick_off_event(self, data, game_turn): | |
| """Extract a kickoff event from data if EventKickOffTable is present.""" | |
| if "EventKickOffTable" not in data: | |
| return None | |
| # Get the EventKickOffTable data | |
| kickoff_table = data["EventKickOffTable"] | |
| # Extract player info from EventExecuteSequence | |
| player_id = None | |
| player_name = None | |
| team_id = None | |
| exec_seq = data.get("EventExecuteSequence") | |
| if exec_seq: | |
| player_id = self.extract_player_from_event_execute_sequence(exec_seq) | |
| # Look up player name and team ID | |
| if isinstance(player_id, str): | |
| lookup = self._safe_player_lookup(player_id) | |
| player_name = lookup.get('name', 'Not found') | |
| team_id = lookup.get('team_id', 'Not found') | |
| # Find following Event* | |
| event_keys = [k for k in data.keys() if k.startswith("Event")] | |
| next_event = None | |
| next_event_data = None | |
| if "EventKickOffTable" in event_keys: | |
| idx = event_keys.index("EventKickOffTable") | |
| if idx + 1 < len(event_keys): | |
| next_event = event_keys[idx + 1] | |
| next_event_data = data.get(next_event) | |
| # Build kickoff-specific context used by classification/reporting rules. | |
| kickoff_context = { | |
| "secret_weapon_eligible_counts": {"0": 0, "1": 0}, | |
| "secret_weapon_eligible_player_ids": {"0": [], "1": []}, | |
| "team_game_turns": {}, | |
| "weather_total_before": None, | |
| "weather_dice_before": [], | |
| } | |
| board_state = data.get("BoardState") | |
| if isinstance(board_state, dict): | |
| last_weather_roll = board_state.get("LastWeatherRoll") | |
| if isinstance(last_weather_roll, dict): | |
| weather_dice = last_weather_roll.get("Die") | |
| if weather_dice is None: | |
| weather_dice = (last_weather_roll.get("Dice") or {}).get("Die") | |
| if weather_dice is None: | |
| weather_dice = [] | |
| if not isinstance(weather_dice, list): | |
| weather_dice = [weather_dice] | |
| parsed_weather_dice = [] | |
| for die in weather_dice: | |
| if isinstance(die, dict): | |
| raw_value = die.get("Value") | |
| else: | |
| raw_value = die | |
| try: | |
| parsed_weather_dice.append(int(raw_value)) | |
| except (TypeError, ValueError): | |
| continue | |
| if parsed_weather_dice: | |
| kickoff_context["weather_dice_before"] = parsed_weather_dice | |
| kickoff_context["weather_total_before"] = sum(parsed_weather_dice) | |
| secret_weapon_ids = board_state.get("ListSecretWeapons", {}).get("ListSecretWeaponsItem", []) | |
| if secret_weapon_ids is None: | |
| secret_weapon_ids = [] | |
| if not isinstance(secret_weapon_ids, list): | |
| secret_weapon_ids = [secret_weapon_ids] | |
| secret_weapon_ids = set(str(pid) for pid in secret_weapon_ids if pid is not None) | |
| team_states = board_state.get("ListTeams", {}).get("TeamState", []) | |
| if not isinstance(team_states, list): | |
| team_states = [team_states] | |
| for team_idx, team_state in enumerate(team_states[:2]): | |
| if not isinstance(team_state, dict): | |
| continue | |
| tid = str(team_idx) | |
| kickoff_context["team_game_turns"][tid] = team_state.get("GameTurn") | |
| players = team_state.get("ListPitchPlayers", {}).get("PlayerState", []) | |
| if not isinstance(players, list): | |
| players = [players] | |
| for player in players: | |
| if not isinstance(player, dict): | |
| continue | |
| pid = str(player.get("Id")) if player.get("Id") is not None else None | |
| if not pid or pid not in secret_weapon_ids: | |
| continue | |
| cell = player.get("Cell") if isinstance(player.get("Cell"), dict) else {} | |
| cell_x = str(cell.get("X")) if cell.get("X") is not None else None | |
| cell_y = str(cell.get("Y")) if cell.get("Y") is not None else None | |
| is_on_pitch = not (cell_x == "-1" and cell_y == "-1") | |
| if is_on_pitch: | |
| kickoff_context["secret_weapon_eligible_counts"][tid] += 1 | |
| kickoff_context["secret_weapon_eligible_player_ids"][tid].append(pid) | |
| # Create and return the KickOffEvent | |
| kick_off = KickOffEvent( | |
| player_id=player_id, | |
| player_name=player_name, | |
| team_id=team_id, | |
| game_turn=game_turn, | |
| kickoff_table=kickoff_table, | |
| next_event=next_event, | |
| next_event_data=next_event_data, | |
| kickoff_context=kickoff_context, | |
| ) | |
| return kick_off | |
| def extract_touchdown_event(self, data): | |
| """Extract touchdown details from a replay step if present.""" | |
| touchdown_data = data.get("EventTouchdown") | |
| if not isinstance(touchdown_data, dict): | |
| return None | |
| player_id = touchdown_data.get("PlayerId") | |
| lookup = self._safe_player_lookup(player_id) | |
| return { | |
| 'player_id': player_id, | |
| 'player_name': lookup.get('name', 'Unknown'), | |
| 'team_id': lookup.get('team_id', 'Unknown'), | |
| 'source': 'EventTouchdown', | |
| } | |
| def _infer_touchdown_from_score_updates(self, score_updates, extracted_player_id=None): | |
| """Fallback touchdown inference when EventTouchdown is missing in a scoring step.""" | |
| if not isinstance(score_updates, list): | |
| return None | |
| scoring_updates = [] | |
| for update in score_updates: | |
| if not isinstance(update, dict): | |
| continue | |
| team_id = update.get("team_id") | |
| points = update.get("points") | |
| if team_id is None: | |
| continue | |
| try: | |
| points_int = int(str(points)) | |
| except (TypeError, ValueError): | |
| continue | |
| if points_int > 0: | |
| scoring_updates.append({"team_id": str(team_id), "points": points_int}) | |
| if not scoring_updates: | |
| return None | |
| inferred_team_id = scoring_updates[0]["team_id"] | |
| inferred_player_id = None | |
| inferred_player_name = "Unknown" | |
| if extracted_player_id is not None: | |
| lookup = self._safe_player_lookup(extracted_player_id) | |
| lookup_team_id = str(lookup.get("team_id")) if lookup.get("team_id") is not None else None | |
| if lookup_team_id == inferred_team_id: | |
| inferred_player_id = extracted_player_id | |
| inferred_player_name = lookup.get("name", "Unknown") | |
| self._emit_processing_warning( | |
| f"[touchdown] inferred touchdown from EventScore fallback for team {inferred_team_id} (no EventTouchdown payload in step)", | |
| key=("touchdown-score-fallback", inferred_team_id), | |
| ) | |
| return { | |
| "player_id": inferred_player_id, | |
| "player_name": inferred_player_name, | |
| "team_id": inferred_team_id, | |
| "source": "EventScoreFallback", | |
| } | |
| def _build_touchdown_roll_events(self): | |
| """Create synthetic touchdown marker events for detailed roll reporting.""" | |
| return build_touchdown_roll_events(self) | |
| def extract_score_from_event(self, event_data): | |
| """Extract score information from EventScore.""" | |
| # EventScore typically has Team and Points fields | |
| if isinstance(event_data, dict): | |
| team_id = event_data.get('Team', event_data.get('TeamId')) | |
| points = event_data.get('Points', 1) | |
| return team_id, points | |
| return None, 0 | |
| def _parse_message_data_xml(self, message_data): | |
| """Parse message data that may be plain XML or base64-encoded XML.""" | |
| if not isinstance(message_data, str): | |
| return None | |
| # Try direct XML parse first (common in current workflow). | |
| try: | |
| return xmltodict.parse(message_data) | |
| except (ExpatError, TypeError): | |
| pass | |
| # Fallback: payloads in some parser modes can be base64-encoded once or twice. | |
| for _ in range(2): | |
| try: | |
| message_data = base64.b64decode(message_data).decode("utf-8", errors="replace") | |
| except (binascii.Error, UnicodeDecodeError, ValueError): | |
| return None | |
| try: | |
| return xmltodict.parse(message_data) | |
| except (ExpatError, TypeError): | |
| continue | |
| return None | |
| def _extract_dice_values(self, dice_node): | |
| """Normalize Dice.Die payloads into a list of integer values.""" | |
| if not isinstance(dice_node, dict): | |
| return [] | |
| dies = dice_node.get("Die") | |
| if dies is None: | |
| return [] | |
| if not isinstance(dies, list): | |
| dies = [dies] | |
| values = [] | |
| for die in dies: | |
| if isinstance(die, dict) and "Value" in die: | |
| try: | |
| value = die.get("Value") | |
| if value is not None: | |
| values.append(int(value)) | |
| except (TypeError, ValueError): | |
| pass | |
| return values | |
| def _normalize_block_dice_values(self, dice_values): | |
| """Map BB3 block-face encoding (0..4) to Calculator.calc_blocks codes.""" | |
| if not isinstance(dice_values, list): | |
| return [] | |
| normalized = [] | |
| for value in dice_values: | |
| normalized_die = get_normalized_block_face(value) | |
| if normalized_die is None: | |
| continue | |
| normalized.append(normalized_die) | |
| return normalized | |
| def _extract_modifier_values(self, modifiers_node): | |
| """Extract integer modifier values from Modifiers payload.""" | |
| if not isinstance(modifiers_node, dict): | |
| return [] | |
| modifier_node = modifiers_node.get("Modifier") | |
| if modifier_node is None: | |
| return [] | |
| if not isinstance(modifier_node, list): | |
| modifier_node = [modifier_node] | |
| values = [] | |
| for modifier in modifier_node: | |
| if isinstance(modifier, dict) and "Value" in modifier: | |
| try: | |
| value = modifier.get("Value") | |
| if value is not None: | |
| values.append(int(value)) | |
| except (TypeError, ValueError): | |
| pass | |
| return values | |
| def _extract_player_id_from_step_message(self, step_message): | |
| """Extract PlayerId from parsed Step.MessageData payload.""" | |
| if not isinstance(step_message, dict) or len(step_message) == 0: | |
| return None | |
| root_key = next(iter(step_message.keys())) | |
| root = step_message.get(root_key) | |
| if not isinstance(root, dict): | |
| return None | |
| return root.get("PlayerId") | |
| def _extract_ball_carrier_id_from_step(self, step_data): | |
| """Return ball carrier player id for the replay step, when available.""" | |
| if not isinstance(step_data, dict): | |
| return None | |
| board_state = step_data.get("BoardState") | |
| if not isinstance(board_state, dict): | |
| return None | |
| ball_state = board_state.get("Ball") | |
| if not isinstance(ball_state, dict): | |
| return None | |
| is_held = ball_state.get("IsHeld") | |
| if str(is_held).strip().lower() not in ("1", "true", "yes"): | |
| return None | |
| carrier_id = ball_state.get("Carrier") | |
| if carrier_id in (None, "", "-1"): | |
| return None | |
| return carrier_id | |
| def _extract_bool_like(self, value): | |
| """Convert common replay truthy/falsey encodings to bool or None.""" | |
| if isinstance(value, bool): | |
| return value | |
| normalized = str(value).strip().lower() | |
| if normalized in ("1", "true", "yes"): | |
| return True | |
| if normalized in ("0", "false", "no"): | |
| return False | |
| return None | |
| def _infer_block_is_blitzing(self, payload_root, step_message): | |
| """Infer whether current block belongs to a Blitz action. | |
| Prefer explicit replay markers only; avoid speculative heuristics that may | |
| mislabel ordinary blocks. | |
| """ | |
| explicit_keys = ( | |
| "IsBlitz", | |
| "IsBlitzing", | |
| "Blitz", | |
| "Blitzing", | |
| "IsBlitzAction", | |
| ) | |
| if isinstance(payload_root, dict): | |
| for key in explicit_keys: | |
| if key in payload_root: | |
| parsed = self._extract_bool_like(payload_root.get(key)) | |
| if parsed is not None: | |
| return parsed | |
| if isinstance(step_message, dict) and len(step_message) > 0: | |
| root_key = next(iter(step_message.keys())) | |
| root = step_message.get(root_key) | |
| if isinstance(root, dict): | |
| for key in explicit_keys: | |
| if key in root: | |
| parsed = self._extract_bool_like(root.get(key)) | |
| if parsed is not None: | |
| return parsed | |
| action_like = root.get("ActionType") or root.get("Action") | |
| if isinstance(action_like, str) and "blitz" in action_like.lower(): | |
| return True | |
| return False | |
| def _label_ball_bounce_events(self, roll_events): | |
| """Classify BallStep events as 'ball' category with context from originating player.""" | |
| if not isinstance(roll_events, list): | |
| return roll_events | |
| # Find all bounce events and try to backfill from previous roll. | |
| for i, event in enumerate(roll_events): | |
| if (event.get('step_name') == 'BallStep' and | |
| event.get('player_name') == 'Unknown' and | |
| event.get('player_id') is None): | |
| # This is a ball bounce. Try to find the originating player. | |
| step_number = event.get('step_number') | |
| roll_ordinal = event.get('roll_ordinal_in_step') or 0 | |
| # Look for a previous roll in the same step. | |
| origin_player = None | |
| origin_team_id = None | |
| for j in range(i - 1, -1, -1): | |
| prev_event = roll_events[j] | |
| if str(prev_event.get('step_number')) != str(step_number): | |
| break | |
| if int(prev_event.get('roll_ordinal_in_step') or 0) < roll_ordinal: | |
| prev_player = prev_event.get('player_name', '') | |
| if prev_player and prev_player != 'Unknown': | |
| origin_player = prev_player | |
| origin_team_id = prev_event.get('team_id') | |
| break | |
| # Update ball bounce classification and semantics. | |
| if origin_player: | |
| event['player_name'] = f"Ball Bounce (from {origin_player})" | |
| else: | |
| event['player_name'] = "Ball Bounce" | |
| # Set team_id to originating player's team (so dice_roller resolves correctly). | |
| if origin_team_id is not None: | |
| event['team_id'] = origin_team_id | |
| # Set roll category to 'ball' instead of 'action'. | |
| event['roll_category'] = 'ball' | |
| # Set result to neutral. | |
| event['result_value'] = 0 | |
| event['result_classification'] = 'neutral' | |
| event['report_result_classification'] = 'neutral' | |
| # Set probabilities: 100% neutral, 0% success/fail. | |
| event['probability_success'] = 0.0 | |
| event['probability_neutral'] = 1.0 | |
| event['probability_fail'] = 0.0 | |
| return roll_events | |
| def _parse_string_messages(self, string_messages): | |
| """Parse step-result StringMessage payloads once and preserve replay order.""" | |
| parsed = [] | |
| if not isinstance(string_messages, list): | |
| return parsed | |
| for string_message_index, result in enumerate(string_messages, start=1): | |
| if not isinstance(result, dict): | |
| continue | |
| payload = self._parse_message_data_xml(result.get("MessageData")) | |
| if not isinstance(payload, dict) or len(payload) == 0: | |
| continue | |
| payload_type = next(iter(payload.keys())) | |
| payload_root = payload.get(payload_type) | |
| if not isinstance(payload_root, dict): | |
| continue | |
| parsed.append( | |
| { | |
| "string_message_index": string_message_index, | |
| "result": result, | |
| "result_name": result.get("Name"), | |
| "payload": payload, | |
| "payload_type": payload_type, | |
| "payload_root": payload_root, | |
| } | |
| ) | |
| return parsed | |
| def extract_roll_events_from_step(self, step_data, game_turn, forced_blitz_player_id=None, step_number=None): | |
| """Compatibility entrypoint that delegates to dedicated step extractor.""" | |
| return self.step_roll_extractor.extract_roll_events_from_step( | |
| step_data, | |
| game_turn, | |
| forced_blitz_player_id=forced_blitz_player_id, | |
| step_number=step_number, | |
| ) | |
| def _extract_roll_events_from_step_impl(self, step_data, game_turn, forced_blitz_player_id=None, step_number=None): | |
| """Extract all non-kickoff dice roll events from a replay step.""" | |
| roll_events = [] | |
| is_kickoff_step = isinstance(step_data, dict) and "EventKickOffTable" in step_data | |
| step_ball_carrier_id = self._extract_ball_carrier_id_from_step(step_data) | |
| roll_ordinal_in_step = 0 | |
| roll_type_ordinal_in_step = defaultdict(int) | |
| # Step-scoped context for better Blitz block detection. | |
| step_blitz_players = set() | |
| step_stood_up_players = set() | |
| step_special_skill_usages = [] | |
| step_consumed_special_usage_indexes = set() | |
| step_foul_player_id = None | |
| step_foul_target_id = None | |
| step_foul_emitted = False | |
| # Some replay steps carry roll payloads as top-level EventRoll/ResultRoll | |
| # (with optional EventSkillUsage), either with or without | |
| # EventExecuteSequence. | |
| top_level_parsed_messages = [] | |
| top_event_skill_usage_raw = step_data.get("EventSkillUsage") if isinstance(step_data, dict) else None | |
| top_event_roll_raw = step_data.get("EventRoll") if isinstance(step_data, dict) else None | |
| top_result_roll_raw = step_data.get("ResultRoll") if isinstance(step_data, dict) else None | |
| top_event_skill_usages = self._ensure_list(top_event_skill_usage_raw) | |
| for usage in top_event_skill_usages: | |
| if not isinstance(usage, dict): | |
| continue | |
| top_level_parsed_messages.append( | |
| { | |
| "string_message_index": len(top_level_parsed_messages) + 1, | |
| "result": {"Name": "ResultSkillUsage", "MessageData": ""}, | |
| "result_name": "ResultSkillUsage", | |
| "payload_type": "ResultSkillUsage", | |
| "payload_root": usage, | |
| } | |
| ) | |
| top_event_rolls = self._ensure_list(top_event_roll_raw) | |
| top_result_rolls = self._ensure_list(top_result_roll_raw) | |
| top_level_rolls = [] | |
| for roll in top_event_rolls: | |
| if isinstance(roll, dict): | |
| top_level_rolls.append(roll) | |
| for roll in top_result_rolls: | |
| if isinstance(roll, dict): | |
| top_level_rolls.append(roll) | |
| for roll in top_level_rolls: | |
| top_level_parsed_messages.append( | |
| { | |
| "string_message_index": len(top_level_parsed_messages) + 1, | |
| "result": {"Name": "ResultRoll", "MessageData": ""}, | |
| "result_name": "ResultRoll", | |
| "payload_type": "ResultRoll", | |
| "payload_root": roll, | |
| } | |
| ) | |
| def _event_signature(event): | |
| if not isinstance(event, dict): | |
| return None | |
| return ( | |
| str(event.get("roll_type") or "-"), | |
| str(event.get("difficulty") or "-"), | |
| tuple(event.get("dice_values") or []), | |
| str(event.get("player_id") or "-"), | |
| str(event.get("team_id") or "-"), | |
| ) | |
| exec_sequences = step_data.get("EventExecuteSequence") | |
| if isinstance(exec_sequences, list): | |
| for sequence_index, seq_item in enumerate(exec_sequences, start=1): | |
| if not isinstance(seq_item, dict): | |
| continue | |
| sequence = seq_item.get("Sequence") | |
| if not isinstance(sequence, dict): | |
| continue | |
| step_results = sequence.get("StepResult") | |
| if not isinstance(step_results, list): | |
| continue | |
| for step_result_index, step_result in enumerate(step_results, start=1): | |
| if not isinstance(step_result, dict): | |
| continue | |
| step_node = step_result.get("Step") | |
| step_name = step_node.get("Name") if isinstance(step_node, dict) else None | |
| step_message = None | |
| actor_player_id = None | |
| step_type = None | |
| if isinstance(step_node, dict) and isinstance(step_node.get("MessageData"), str): | |
| step_message = self._parse_message_data_xml(step_node.get("MessageData")) | |
| actor_player_id = self._extract_player_id_from_step_message(step_message) | |
| if isinstance(step_message, dict) and len(step_message) > 0: | |
| root_key = next(iter(step_message.keys())) | |
| root = step_message.get(root_key) | |
| if isinstance(root, dict): | |
| step_type = root.get("StepType") | |
| # Observed in BB3: StepType 7 appears when a prone player stands up. | |
| if str(step_type) == "7" and actor_player_id is not None: | |
| step_stood_up_players.add(str(actor_player_id)) | |
| actor_lookup = self._safe_player_lookup(actor_player_id) | |
| actor_player_name = actor_lookup.get("name", "Unknown") | |
| actor_team_id = actor_lookup.get("team_id", "Unknown") | |
| actor_skills = actor_lookup.get("skills", []) | |
| defender_player_id = None | |
| defender_player_name = None | |
| defender_skills = [] | |
| if isinstance(step_message, dict) and len(step_message) > 0: | |
| root_key = next(iter(step_message.keys())) | |
| root = step_message.get(root_key) | |
| if isinstance(root, dict): | |
| defender_player_id = root.get("TargetId") | |
| # TargetId of '-1' (or any non-positive value) is a sentinel meaning "no target". | |
| try: | |
| _did_int = int(str(defender_player_id)) if defender_player_id is not None else -1 | |
| except (TypeError, ValueError): | |
| _did_int = -1 | |
| if _did_int > 0: | |
| defender_lookup = self._safe_player_lookup(defender_player_id) | |
| defender_player_name = defender_lookup.get("name", "Unknown") | |
| defender_skills = defender_lookup.get("skills", []) | |
| else: | |
| defender_player_id = None | |
| results = step_result.get("Results") | |
| if not isinstance(results, dict): | |
| continue | |
| string_messages = results.get("StringMessage") | |
| parsed_messages = self._parse_string_messages(string_messages) | |
| if len(parsed_messages) == 0: | |
| continue | |
| step_foul_player_id, step_foul_target_id = self._collect_step_sidechannel_hints( | |
| parsed_messages, | |
| actor_player_id, | |
| defender_player_id, | |
| step_blitz_players, | |
| step_special_skill_usages, | |
| step_foul_player_id, | |
| step_foul_target_id, | |
| ) | |
| extracted_events, roll_ordinal_in_step, step_foul_emitted = self._extract_roll_events_from_string_messages( | |
| parsed_messages, | |
| game_turn=game_turn, | |
| step_number=step_number, | |
| sequence_index=sequence_index, | |
| step_result_index=step_result_index, | |
| step_name=step_name, | |
| step_type=step_type, | |
| step_message=step_message, | |
| actor_player_id=actor_player_id, | |
| actor_player_name=actor_player_name, | |
| actor_team_id=actor_team_id, | |
| actor_skills=actor_skills, | |
| defender_player_id=defender_player_id, | |
| defender_player_name=defender_player_name, | |
| defender_skills=defender_skills, | |
| forced_blitz_player_id=forced_blitz_player_id, | |
| step_ball_carrier_id=step_ball_carrier_id, | |
| is_kickoff_step=is_kickoff_step, | |
| step_blitz_players=step_blitz_players, | |
| step_stood_up_players=step_stood_up_players, | |
| step_special_skill_usages=step_special_skill_usages, | |
| step_consumed_special_usage_indexes=step_consumed_special_usage_indexes, | |
| step_foul_player_id=step_foul_player_id, | |
| step_foul_target_id=step_foul_target_id, | |
| step_foul_emitted=step_foul_emitted, | |
| roll_ordinal_in_step=roll_ordinal_in_step, | |
| roll_type_ordinal_in_step=roll_type_ordinal_in_step, | |
| ) | |
| roll_events.extend(extracted_events) | |
| if len(top_level_parsed_messages) > 0: | |
| actor_player_id = None | |
| if len(top_level_rolls) > 0 and isinstance(top_level_rolls[0], dict): | |
| actor_player_id = top_level_rolls[0].get("PlayerId") | |
| if actor_player_id is None and len(top_event_skill_usages) > 0 and isinstance(top_event_skill_usages[0], dict): | |
| actor_player_id = top_event_skill_usages[0].get("PlayerId") | |
| actor_lookup = self._safe_player_lookup(actor_player_id) | |
| actor_player_name = actor_lookup.get("name", "Unknown") | |
| actor_team_id = actor_lookup.get("team_id") | |
| if actor_team_id is None and len(top_level_rolls) > 0 and isinstance(top_level_rolls[0], dict): | |
| actor_team_id = top_level_rolls[0].get("TeamId") | |
| actor_team_id = actor_team_id if actor_team_id is not None else "Unknown" | |
| actor_skills = actor_lookup.get("skills", []) | |
| step_foul_player_id, step_foul_target_id = self._collect_step_sidechannel_hints( | |
| top_level_parsed_messages, | |
| actor_player_id, | |
| None, | |
| step_blitz_players, | |
| step_special_skill_usages, | |
| step_foul_player_id, | |
| step_foul_target_id, | |
| ) | |
| extracted_top_level_events, roll_ordinal_in_step, step_foul_emitted = self._extract_roll_events_from_string_messages( | |
| top_level_parsed_messages, | |
| game_turn=game_turn, | |
| step_number=step_number, | |
| sequence_index=0, | |
| step_result_index=0, | |
| step_name="TopLevelEventRoll", | |
| step_type=top_level_rolls[0].get("StepType") if len(top_level_rolls) > 0 and isinstance(top_level_rolls[0], dict) else None, | |
| step_message=None, | |
| actor_player_id=actor_player_id, | |
| actor_player_name=actor_player_name, | |
| actor_team_id=actor_team_id, | |
| actor_skills=actor_skills, | |
| defender_player_id=None, | |
| defender_player_name=None, | |
| defender_skills=[], | |
| forced_blitz_player_id=forced_blitz_player_id, | |
| step_ball_carrier_id=step_ball_carrier_id, | |
| is_kickoff_step=is_kickoff_step, | |
| step_blitz_players=step_blitz_players, | |
| step_stood_up_players=step_stood_up_players, | |
| step_special_skill_usages=step_special_skill_usages, | |
| step_consumed_special_usage_indexes=step_consumed_special_usage_indexes, | |
| step_foul_player_id=step_foul_player_id, | |
| step_foul_target_id=step_foul_target_id, | |
| step_foul_emitted=step_foul_emitted, | |
| roll_ordinal_in_step=roll_ordinal_in_step, | |
| roll_type_ordinal_in_step=roll_type_ordinal_in_step, | |
| ) | |
| existing_signatures = { | |
| sig for sig in (_event_signature(event) for event in roll_events) if sig is not None | |
| } | |
| for event in extracted_top_level_events: | |
| sig = _event_signature(event) | |
| if sig is None or sig in existing_signatures: | |
| continue | |
| roll_events.append(event) | |
| existing_signatures.add(sig) | |
| if len(step_special_skill_usages) > 0: | |
| for usage_index, usage in enumerate(step_special_skill_usages): | |
| if usage_index in step_consumed_special_usage_indexes: | |
| continue | |
| if str(usage.get("skill_label") or "") != "Chainsaw": | |
| continue | |
| self._emit_processing_warning( | |
| ( | |
| f"Chainsaw usage on step {step_number if step_number is not None else '?'} " | |
| "was detected but no linked armour roll was found in the same step." | |
| ), | |
| key=("chainsaw-unlinked", step_number, usage_index), | |
| ) | |
| # Post-process: identify and label ball bounce events. | |
| roll_events = self._label_ball_bounce_events(roll_events) | |
| return roll_events | |
| def _collect_step_sidechannel_hints( | |
| self, | |
| parsed_messages, | |
| actor_player_id, | |
| defender_player_id, | |
| step_blitz_players, | |
| step_special_skill_usages, | |
| step_foul_player_id, | |
| step_foul_target_id, | |
| ): | |
| """Collect non-roll hints (blitz/foul/special skill usage) from parsed messages.""" | |
| for parsed in parsed_messages: | |
| if not isinstance(parsed, dict): | |
| continue | |
| maybe_type = parsed.get("payload_type") | |
| maybe_root = parsed.get("payload_root") | |
| if not isinstance(maybe_root, dict): | |
| continue | |
| if maybe_type == "ResultUseAction": | |
| action_value = maybe_root.get("Action") | |
| action_text = str(action_value).strip().lower() | |
| is_blitz_action = action_text == "3" or "blitz" in action_text | |
| if is_blitz_action and actor_player_id is not None: | |
| step_blitz_players.add(str(actor_player_id)) | |
| if action_text == "6" and actor_player_id is not None: | |
| step_foul_player_id = actor_player_id | |
| step_foul_target_id = defender_player_id | |
| continue | |
| if maybe_type == "ResultSkillUsage": | |
| skill_raw = maybe_root.get("Skill") | |
| skill_label = self._classify_special_skill_usage(skill_raw) | |
| used_value = str(maybe_root.get("Used") or "").strip().lower() | |
| is_used = used_value in ("", "1", "true", "yes") | |
| if skill_label and is_used: | |
| step_special_skill_usages.append({ | |
| "skill_label": skill_label, | |
| "skill_raw": skill_raw, | |
| "player_id": ( | |
| maybe_root.get("PlayerId") | |
| or maybe_root.get("Id") | |
| or maybe_root.get("SourceId") | |
| or maybe_root.get("AttackerId") | |
| or actor_player_id | |
| ), | |
| "target_id": ( | |
| maybe_root.get("TargetId") | |
| or maybe_root.get("Target") | |
| or maybe_root.get("VictimId") | |
| or defender_player_id | |
| ), | |
| "raw": maybe_root, | |
| }) | |
| return step_foul_player_id, step_foul_target_id | |
| def _resolve_string_message_roll_root(self, payload_root): | |
| if isinstance(payload_root.get("Dice"), dict): | |
| return payload_root | |
| roll_infos = payload_root.get("RollInfos") | |
| if isinstance(roll_infos, dict) and isinstance(roll_infos.get("Dice"), dict): | |
| return roll_infos | |
| # QuestionApothecaryCasualtyChoice carries the accepted reroll under NewRoll. | |
| new_roll = payload_root.get("NewRoll") | |
| if isinstance(new_roll, dict) and isinstance(new_roll.get("Dice"), dict): | |
| return new_roll | |
| return None | |
| def _classify_string_message_roll(self, payload_type, result_name, step_name, roll_root): | |
| roll_type_str = str(roll_root.get("RollType")) if roll_root.get("RollType") is not None else "" | |
| payload_lower = str(payload_type or "").lower() | |
| result_lower = str(result_name or "").lower() | |
| step_name_lower = str(step_name or "").lower() | |
| if payload_type == "QuestionBlockDice": | |
| return "block" | |
| if payload_type == "QuestionChooseDice": | |
| # Pro-driven block resolution commonly arrives as QuestionChooseDice | |
| # with block faces and an attacker choice instead of QuestionBlockDice. | |
| if roll_root.get("AttackerChoice") is not None: | |
| return "block" | |
| if payload_type == "ResultInjuryRoll": | |
| return "injury" | |
| if ( | |
| "casualty" in payload_lower | |
| or "casualty" in result_lower | |
| or "casualty" in step_name_lower | |
| or ( | |
| roll_type_str == "12" | |
| and payload_type in ("QuestionApothecaryCasualtyUsage", "ResultCasualtyRoll") | |
| ) | |
| ): | |
| return "casualty" | |
| if ( | |
| "korecovery" in payload_lower | |
| or "korecovery" in result_lower | |
| or ( | |
| roll_type_str == "11" | |
| and payload_type == "QuestionKORecovery" | |
| ) | |
| ): | |
| return "ko_recovery_injury" | |
| if roll_root.get("Difficulty") is not None: | |
| return "armour" if roll_type_str == "10" else "action" | |
| return "other" | |
| def _resolve_block_string_message_roll( | |
| self, | |
| *, | |
| dice_values, | |
| payload_root, | |
| roll_root, | |
| step_message, | |
| actor_player_id, | |
| actor_skills, | |
| defender_player_id, | |
| defender_skills, | |
| forced_blitz_player_id, | |
| step_ball_carrier_id, | |
| step_blitz_players, | |
| step_stood_up_players, | |
| ): | |
| def _resolve_attacker_choice(root, actor_pid): | |
| if not isinstance(root, dict): | |
| return True, "default_attacker" | |
| attacker_choice_raw = root.get("AttackerChoice") | |
| if attacker_choice_raw is not None: | |
| return str(attacker_choice_raw) == "1", "attacker_choice" | |
| defender_choice_raw = root.get("DefenderChoice") | |
| if defender_choice_raw is not None: | |
| return str(defender_choice_raw) != "1", "defender_choice" | |
| gamer_raw = root.get("GamerId") | |
| if gamer_raw is not None and actor_pid is not None: | |
| chooser_team_id = self._normalize_team_id(gamer_raw) | |
| actor_team_id = self._normalize_team_id(self._safe_player_lookup(actor_pid).get("team_id")) | |
| if chooser_team_id in ("0", "1") and actor_team_id in ("0", "1"): | |
| return chooser_team_id == actor_team_id, "gamer_id" | |
| return True, "default_attacker" | |
| def _parse_dice_outcomes(root): | |
| if not isinstance(root, dict): | |
| return [] | |
| node = root.get("DiceOutcomes") | |
| if not isinstance(node, dict): | |
| return [] | |
| item = node.get("DiceOutcomesItem") | |
| if item is None: | |
| return [] | |
| values = item if isinstance(item, list) else [item] | |
| parsed = [] | |
| for value in values: | |
| parsed_val = self._to_int(value, default=None) | |
| if parsed_val is None: | |
| return [] | |
| parsed.append(parsed_val) | |
| return parsed | |
| def _select_block_die_metadata(raw_dice_values, root, attacker_choose): | |
| if not isinstance(raw_dice_values, list) or len(raw_dice_values) == 0: | |
| return { | |
| "selected_block_die_index": None, | |
| "selected_block_die_value": None, | |
| "selected_block_die_outcome": None, | |
| "selected_block_die_source": None, | |
| } | |
| if len(raw_dice_values) == 1: | |
| return { | |
| "selected_block_die_index": 0, | |
| "selected_block_die_value": raw_dice_values[0], | |
| "selected_block_die_outcome": None, | |
| "selected_block_die_source": "single_die", | |
| } | |
| outcome_values = _parse_dice_outcomes(root) | |
| if len(outcome_values) == len(raw_dice_values): | |
| selected_outcome = max(outcome_values) if attacker_choose else min(outcome_values) | |
| selected_index = outcome_values.index(selected_outcome) | |
| return { | |
| "selected_block_die_index": selected_index, | |
| "selected_block_die_value": raw_dice_values[selected_index], | |
| "selected_block_die_outcome": selected_outcome, | |
| "selected_block_die_source": "dice_outcomes_attacker_choice", | |
| } | |
| return { | |
| "selected_block_die_index": None, | |
| "selected_block_die_value": None, | |
| "selected_block_die_outcome": None, | |
| "selected_block_die_source": None, | |
| } | |
| block_dice_values = self._normalize_block_dice_values(dice_values) | |
| attacker_chooses, attacker_choice_source = _resolve_attacker_choice( | |
| roll_root if isinstance(roll_root, dict) else payload_root, | |
| actor_player_id, | |
| ) | |
| attacker_choice = 1 if attacker_chooses else 0 | |
| attacker_is_blitzing = self._infer_block_is_blitzing(payload_root, step_message) | |
| if (not attacker_is_blitzing) and actor_player_id is not None and str(actor_player_id) in step_blitz_players: | |
| attacker_is_blitzing = True | |
| if (not attacker_is_blitzing) and actor_player_id is not None and str(actor_player_id) in step_stood_up_players: | |
| attacker_is_blitzing = True | |
| if (not attacker_is_blitzing) and forced_blitz_player_id is not None: | |
| attacker_is_blitzing = str(actor_player_id) == str(forced_blitz_player_id) | |
| target_has_ball = False | |
| for possession_key in ("TargetHasBall", "DefenderHasBall", "HasBall", "IsBallCarrier"): | |
| if possession_key in payload_root: | |
| possession_raw = payload_root.get(possession_key) | |
| target_has_ball = str(possession_raw).strip().lower() in ("1", "true", "yes") | |
| break | |
| if not target_has_ball and defender_player_id is not None and step_ball_carrier_id is not None: | |
| target_has_ball = str(defender_player_id) == str(step_ball_carrier_id) | |
| block_result, p_success, p_neutral, p_fail = self.calculator.calc_blocks( | |
| block_dice_values, | |
| actor_skills, | |
| defender_skills, | |
| isAttackerChoose=attacker_choice, | |
| defenderHasBall=target_has_ball, | |
| attackerIsBlitzing=attacker_is_blitzing, | |
| ) | |
| selected_die_meta = _select_block_die_metadata( | |
| dice_values, | |
| roll_root if isinstance(roll_root, dict) else payload_root, | |
| bool(attacker_choice), | |
| ) | |
| return { | |
| 'roll_category': 'block', | |
| 'result_value': block_result, | |
| 'probability_success': p_success, | |
| 'probability_neutral': p_neutral, | |
| 'probability_fail': p_fail, | |
| 'target_has_ball': target_has_ball, | |
| 'attacker_is_blitzing': attacker_is_blitzing, | |
| 'armour_holds': None, | |
| 'selected_block_die_index': selected_die_meta.get('selected_block_die_index'), | |
| 'selected_block_die_value': selected_die_meta.get('selected_block_die_value'), | |
| 'selected_block_die_outcome': selected_die_meta.get('selected_block_die_outcome'), | |
| 'selected_block_die_source': selected_die_meta.get('selected_block_die_source'), | |
| 'block_choice_source': attacker_choice_source, | |
| } | |
| def _resolve_injury_string_message_roll(self, roll_root, modifier_values, defender_skills): | |
| injury_outcome = self._to_int(roll_root.get("Outcome"), default=0) | |
| modifier_total = sum(modifier_values) if modifier_values else 0 | |
| defender_skill_names = [str(skill).lower() for skill in defender_skills] | |
| has_stunty = "stunty" in defender_skill_names | |
| has_thick_skull = "thick skull" in defender_skill_names | |
| injury_result, p_success, p_neutral, p_fail = self.calculator.calc_injury( | |
| injury_outcome, | |
| modifierVal=modifier_total, | |
| is_stunty=has_stunty, | |
| is_thick_skull=has_thick_skull, | |
| ) | |
| return { | |
| 'roll_category': 'injury', | |
| 'result_value': injury_result, | |
| 'probability_success': p_success, | |
| 'probability_neutral': p_neutral, | |
| 'probability_fail': p_fail, | |
| 'target_has_ball': False, | |
| 'attacker_is_blitzing': False, | |
| 'armour_holds': None, | |
| } | |
| def _build_special_usage_roll_events( | |
| self, | |
| *, | |
| game_turn, | |
| step_number, | |
| sequence_index, | |
| step_result_index, | |
| string_message_index, | |
| step_name, | |
| step_type, | |
| actor_player_id, | |
| actor_player_name, | |
| actor_team_id, | |
| defender_player_name, | |
| defender_skills, | |
| step_special_skill_usages, | |
| step_consumed_special_usage_indexes, | |
| preferred_usage_index=None, | |
| roll_ordinal_in_step, | |
| roll_type_ordinal_in_step, | |
| ): | |
| if len(step_special_skill_usages) == 0: | |
| return [], roll_ordinal_in_step, roll_type_ordinal_in_step | |
| matched_usage_index = None | |
| matched_usage = None | |
| actor_key = str(actor_player_id) if actor_player_id is not None else None | |
| if isinstance(preferred_usage_index, int): | |
| if 0 <= preferred_usage_index < len(step_special_skill_usages): | |
| if preferred_usage_index not in step_consumed_special_usage_indexes: | |
| preferred_usage = step_special_skill_usages[preferred_usage_index] | |
| if isinstance(preferred_usage, dict): | |
| matched_usage_index = preferred_usage_index | |
| matched_usage = preferred_usage | |
| if matched_usage is None: | |
| for usage_index, usage in enumerate(step_special_skill_usages): | |
| if usage_index in step_consumed_special_usage_indexes: | |
| continue | |
| usage_target = usage.get("target_id") | |
| if actor_key is not None and usage_target is not None and str(usage_target) == actor_key: | |
| matched_usage_index = usage_index | |
| matched_usage = usage | |
| break | |
| if matched_usage is None: | |
| for usage_index, usage in enumerate(step_special_skill_usages): | |
| if usage_index in step_consumed_special_usage_indexes: | |
| continue | |
| matched_usage_index = usage_index | |
| matched_usage = usage | |
| break | |
| if not isinstance(matched_usage, dict): | |
| return [], roll_ordinal_in_step, roll_type_ordinal_in_step | |
| step_consumed_special_usage_indexes.add(matched_usage_index) | |
| usage_label = str(matched_usage.get("skill_label") or "SkillAction") | |
| usage_player_id = matched_usage.get("player_id") | |
| usage_target_id = matched_usage.get("target_id") | |
| if usage_target_id is None: | |
| usage_target_id = actor_player_id | |
| if usage_label == "Chainsaw" and actor_key is not None: | |
| usage_target = matched_usage.get("target_id") | |
| if usage_target is None or str(usage_target) != actor_key: | |
| self._emit_processing_warning( | |
| ( | |
| f"Chainsaw usage on step {step_number if step_number is not None else '?'} " | |
| f"did not explicitly target armour victim {actor_key}; linked by fallback." | |
| ), | |
| key=("chainsaw-fallback-link", step_number, actor_key), | |
| ) | |
| if usage_label == "Chainsaw" and usage_player_id is None: | |
| self._emit_processing_warning( | |
| ( | |
| f"Chainsaw usage on step {step_number if step_number is not None else '?'} " | |
| "is missing source player id in payload." | |
| ), | |
| key=("chainsaw-missing-source", step_number), | |
| ) | |
| usage_player_lookup = self._safe_player_lookup(usage_player_id) | |
| usage_target_lookup = self._safe_player_lookup(usage_target_id) | |
| usage_team_id = usage_player_lookup.get("team_id", actor_team_id) | |
| usage_roll_type_key = str(usage_label).lower() | |
| usage_player_skills = usage_player_lookup.get("skills", []) | |
| if not isinstance(usage_player_skills, list): | |
| usage_player_skills = [] | |
| usage_target_skills = usage_target_lookup.get("skills", defender_skills) | |
| if not isinstance(usage_target_skills, list): | |
| usage_target_skills = defender_skills if isinstance(defender_skills, list) else [] | |
| roll_ordinal_in_step += 1 | |
| roll_type_ordinal_in_step[usage_roll_type_key] += 1 | |
| synthetic_event = { | |
| 'game_turn': game_turn, | |
| 'player_id': usage_player_id, | |
| 'player_name': usage_player_lookup.get("name", actor_player_name), | |
| 'team_id': usage_team_id, | |
| 'dice_roller': self._resolve_dice_roller(usage_team_id, "action"), | |
| 'player_skills': usage_player_skills, | |
| 'target_player_id': usage_target_id, | |
| 'target_player_name': usage_target_lookup.get("name", defender_player_name), | |
| 'target_player_skills': usage_target_skills, | |
| 'target_has_ball': False, | |
| 'attacker_is_blitzing': False, | |
| 'is_synthetic_skill_action': True, | |
| 'synthetic_action_label': usage_label, | |
| 'step_name': step_name, | |
| 'step_type': step_type, | |
| 'sequence_index': sequence_index, | |
| 'step_result_index': step_result_index, | |
| 'string_message_index': string_message_index, | |
| 'roll_ordinal_in_step': roll_ordinal_in_step, | |
| 'roll_type_ordinal_in_step': roll_type_ordinal_in_step[usage_roll_type_key], | |
| 'result_name': "ResultSkillUsage", | |
| 'payload_type': "ResultSkillUsage", | |
| 'roll_category': "action", | |
| 'dice_values': [], | |
| 'dice_total': 0, | |
| 'difficulty': None, | |
| 'roll_type': usage_roll_type_key, | |
| 'outcome': None, | |
| 'modifier_values': [], | |
| 'result_value': 1, | |
| 'result_classification': "success", | |
| 'report_result_classification': "success", | |
| 'armour_holds': None, | |
| 'probability_success': 1.0, | |
| 'probability_neutral': 0.0, | |
| 'probability_fail': 0.0, | |
| 'raw': matched_usage.get("raw") if isinstance(matched_usage, dict) else {}, | |
| } | |
| if usage_label == "Stab": | |
| synthetic_event['is_stab_action'] = True | |
| synthetic_event['is_marker_only_action'] = True | |
| synthetic_event['result_value'] = None | |
| synthetic_event['result_classification'] = "unknown" | |
| synthetic_event['report_result_classification'] = "unknown" | |
| synthetic_event['probability_success'] = None | |
| synthetic_event['probability_neutral'] = None | |
| synthetic_event['probability_fail'] = None | |
| synthetic_event['exclude_from_surprise'] = True | |
| if usage_label == "Chainsaw": | |
| synthetic_event['is_chainsaw_action'] = True | |
| synthetic_event['is_marker_only_action'] = True | |
| synthetic_event['result_value'] = None | |
| synthetic_event['result_classification'] = "unknown" | |
| synthetic_event['report_result_classification'] = "unknown" | |
| synthetic_event['probability_success'] = None | |
| synthetic_event['probability_neutral'] = None | |
| synthetic_event['probability_fail'] = None | |
| synthetic_event['exclude_from_surprise'] = True | |
| return [synthetic_event], roll_ordinal_in_step, roll_type_ordinal_in_step | |
| def _build_foul_action_roll_event( | |
| self, | |
| *, | |
| game_turn, | |
| sequence_index, | |
| step_result_index, | |
| string_message_index, | |
| step_name, | |
| step_type, | |
| actor_player_name, | |
| actor_team_id, | |
| defender_player_name, | |
| defender_skills, | |
| step_foul_player_id, | |
| step_foul_target_id, | |
| roll_ordinal_in_step, | |
| roll_type_ordinal_in_step, | |
| ): | |
| foul_player_lookup = self._safe_player_lookup(step_foul_player_id) | |
| foul_target_lookup = self._safe_player_lookup(step_foul_target_id) | |
| foul_team_id = foul_player_lookup.get("team_id", actor_team_id) | |
| foul_player_skills = foul_player_lookup.get("skills", []) | |
| if not isinstance(foul_player_skills, list): | |
| foul_player_skills = [] | |
| foul_target_skills = foul_target_lookup.get("skills", defender_skills) | |
| if not isinstance(foul_target_skills, list): | |
| foul_target_skills = defender_skills if isinstance(defender_skills, list) else [] | |
| roll_ordinal_in_step += 1 | |
| roll_type_ordinal_in_step["foul"] += 1 | |
| return { | |
| 'game_turn': game_turn, | |
| 'player_id': step_foul_player_id, | |
| 'player_name': foul_player_lookup.get("name", actor_player_name), | |
| 'team_id': foul_team_id, | |
| 'dice_roller': self._resolve_dice_roller(foul_team_id, "action"), | |
| 'player_skills': foul_player_skills, | |
| 'target_player_id': step_foul_target_id, | |
| 'target_player_name': foul_target_lookup.get("name", defender_player_name), | |
| 'target_player_skills': foul_target_skills, | |
| 'target_has_ball': False, | |
| 'attacker_is_blitzing': False, | |
| 'is_synthetic_skill_action': True, | |
| 'synthetic_action_label': 'Foul', | |
| 'is_foul_action': True, | |
| 'step_name': step_name, | |
| 'step_type': step_type, | |
| 'sequence_index': sequence_index, | |
| 'step_result_index': step_result_index, | |
| 'string_message_index': string_message_index, | |
| 'roll_ordinal_in_step': roll_ordinal_in_step, | |
| 'roll_type_ordinal_in_step': roll_type_ordinal_in_step["foul"], | |
| 'result_name': 'ResultUseAction', | |
| 'payload_type': 'ResultUseAction', | |
| 'roll_category': 'action', | |
| 'dice_values': [], | |
| 'dice_total': 0, | |
| 'difficulty': None, | |
| 'roll_type': 'foul', | |
| 'outcome': None, | |
| 'modifier_values': [], | |
| 'result_value': 1, | |
| 'result_classification': 'success', | |
| 'report_result_classification': 'success', | |
| 'armour_holds': None, | |
| 'probability_success': 1.0, | |
| 'probability_neutral': 0.0, | |
| 'probability_fail': 0.0, | |
| 'exclude_from_surprise': True, | |
| 'raw': {'synthetic': 'foul_action', 'action': '6'}, | |
| }, roll_ordinal_in_step, roll_type_ordinal_in_step | |
| def _resolve_difficulty_string_message_roll( | |
| self, | |
| *, | |
| dice_values, | |
| roll_root, | |
| game_turn, | |
| step_number, | |
| sequence_index, | |
| step_result_index, | |
| string_message_index, | |
| step_name, | |
| step_type, | |
| actor_player_id, | |
| actor_player_name, | |
| actor_team_id, | |
| defender_player_name, | |
| defender_skills, | |
| step_special_skill_usages, | |
| step_consumed_special_usage_indexes, | |
| step_foul_player_id, | |
| step_foul_target_id, | |
| step_foul_emitted, | |
| roll_ordinal_in_step, | |
| roll_type_ordinal_in_step, | |
| ): | |
| difficulty_int = self._to_int(roll_root.get("Difficulty"), default=0) | |
| roll_type_str = str(roll_root.get("RollType")) if roll_root.get("RollType") is not None else "" | |
| if roll_type_str == "10": | |
| synthetic_events, roll_ordinal_in_step, roll_type_ordinal_in_step = self._build_special_usage_roll_events( | |
| game_turn=game_turn, | |
| step_number=step_number, | |
| sequence_index=sequence_index, | |
| step_result_index=step_result_index, | |
| string_message_index=string_message_index, | |
| step_name=step_name, | |
| step_type=step_type, | |
| actor_player_id=actor_player_id, | |
| actor_player_name=actor_player_name, | |
| actor_team_id=actor_team_id, | |
| defender_player_name=defender_player_name, | |
| defender_skills=defender_skills, | |
| step_special_skill_usages=step_special_skill_usages, | |
| step_consumed_special_usage_indexes=step_consumed_special_usage_indexes, | |
| preferred_usage_index=None, | |
| roll_ordinal_in_step=roll_ordinal_in_step, | |
| roll_type_ordinal_in_step=roll_type_ordinal_in_step, | |
| ) | |
| if step_foul_player_id is not None and not step_foul_emitted: | |
| step_foul_emitted = True | |
| foul_event, roll_ordinal_in_step, roll_type_ordinal_in_step = self._build_foul_action_roll_event( | |
| game_turn=game_turn, | |
| sequence_index=sequence_index, | |
| step_result_index=step_result_index, | |
| string_message_index=string_message_index, | |
| step_name=step_name, | |
| step_type=step_type, | |
| actor_player_name=actor_player_name, | |
| actor_team_id=actor_team_id, | |
| defender_player_name=defender_player_name, | |
| defender_skills=defender_skills, | |
| step_foul_player_id=step_foul_player_id, | |
| step_foul_target_id=step_foul_target_id, | |
| roll_ordinal_in_step=roll_ordinal_in_step, | |
| roll_type_ordinal_in_step=roll_type_ordinal_in_step, | |
| ) | |
| synthetic_events.append(foul_event) | |
| is_success, p_success, p_fail = self.calculator.calc_armour(dice_values, difficulty_int) | |
| return { | |
| 'roll_category': 'armour', | |
| 'result_value': 1 if is_success else -1, | |
| 'probability_success': p_fail, | |
| 'probability_neutral': 0.0, | |
| 'probability_fail': p_success, | |
| 'target_has_ball': False, | |
| 'attacker_is_blitzing': False, | |
| 'armour_holds': bool(is_success), | |
| 'synthetic_events': synthetic_events, | |
| 'step_foul_emitted': step_foul_emitted, | |
| 'roll_ordinal_in_step': roll_ordinal_in_step, | |
| 'roll_type_ordinal_in_step': roll_type_ordinal_in_step, | |
| } | |
| # Chainsaw kickback uses RollType=67 (2+ D6). Emit pending Chainsaw marker | |
| # before the kickback row so display ordering matches replay semantics. | |
| if roll_type_str == "67": | |
| synthetic_events = [] | |
| chainsaw_usage_idx = next( | |
| ( | |
| idx for idx, u in enumerate(step_special_skill_usages) | |
| if str(u.get("skill_label") or "") == "Chainsaw" | |
| and idx not in step_consumed_special_usage_indexes | |
| ), | |
| None, | |
| ) | |
| if chainsaw_usage_idx is not None: | |
| chainsaw_synthetic_events, roll_ordinal_in_step, roll_type_ordinal_in_step = self._build_special_usage_roll_events( | |
| game_turn=game_turn, | |
| step_number=step_number, | |
| sequence_index=sequence_index, | |
| step_result_index=step_result_index, | |
| string_message_index=string_message_index, | |
| step_name=step_name, | |
| step_type=step_type, | |
| actor_player_id=actor_player_id, | |
| actor_player_name=actor_player_name, | |
| actor_team_id=actor_team_id, | |
| defender_player_name=defender_player_name, | |
| defender_skills=defender_skills, | |
| step_special_skill_usages=step_special_skill_usages, | |
| step_consumed_special_usage_indexes=step_consumed_special_usage_indexes, | |
| preferred_usage_index=chainsaw_usage_idx, | |
| roll_ordinal_in_step=roll_ordinal_in_step, | |
| roll_type_ordinal_in_step=roll_type_ordinal_in_step, | |
| ) | |
| synthetic_events.extend(chainsaw_synthetic_events) | |
| if step_foul_player_id is not None and not step_foul_emitted: | |
| step_foul_emitted = True | |
| foul_event, roll_ordinal_in_step, roll_type_ordinal_in_step = self._build_foul_action_roll_event( | |
| game_turn=game_turn, | |
| sequence_index=sequence_index, | |
| step_result_index=step_result_index, | |
| string_message_index=string_message_index, | |
| step_name=step_name, | |
| step_type=step_type, | |
| actor_player_name=actor_player_name, | |
| actor_team_id=actor_team_id, | |
| defender_player_name=defender_player_name, | |
| defender_skills=defender_skills, | |
| step_foul_player_id=step_foul_player_id, | |
| step_foul_target_id=step_foul_target_id, | |
| roll_ordinal_in_step=roll_ordinal_in_step, | |
| roll_type_ordinal_in_step=roll_type_ordinal_in_step, | |
| ) | |
| is_success, p_success, p_fail = self.calculator.calc_action(dice_values, difficulty_int) | |
| return { | |
| 'roll_category': 'action', | |
| 'result_value': 1 if is_success else -1, | |
| 'probability_success': p_success, | |
| 'probability_neutral': 0.0, | |
| 'probability_fail': p_fail, | |
| 'target_has_ball': False, | |
| 'attacker_is_blitzing': False, | |
| 'armour_holds': None, | |
| 'synthetic_events': synthetic_events + [foul_event], | |
| 'step_foul_emitted': step_foul_emitted, | |
| 'roll_ordinal_in_step': roll_ordinal_in_step, | |
| 'roll_type_ordinal_in_step': roll_type_ordinal_in_step, | |
| } | |
| is_success, p_success, p_fail = self.calculator.calc_action(dice_values, difficulty_int) | |
| return { | |
| 'roll_category': 'action', | |
| 'result_value': 1 if is_success else -1, | |
| 'probability_success': p_success, | |
| 'probability_neutral': 0.0, | |
| 'probability_fail': p_fail, | |
| 'target_has_ball': False, | |
| 'attacker_is_blitzing': False, | |
| 'armour_holds': None, | |
| 'synthetic_events': synthetic_events, | |
| 'step_foul_emitted': step_foul_emitted, | |
| 'roll_ordinal_in_step': roll_ordinal_in_step, | |
| 'roll_type_ordinal_in_step': roll_type_ordinal_in_step, | |
| } | |
| is_success, p_success, p_fail = self.calculator.calc_action(dice_values, difficulty_int) | |
| return { | |
| 'roll_category': 'action', | |
| 'result_value': 1 if is_success else -1, | |
| 'probability_success': p_success, | |
| 'probability_neutral': 0.0, | |
| 'probability_fail': p_fail, | |
| 'target_has_ball': False, | |
| 'attacker_is_blitzing': False, | |
| 'armour_holds': None, | |
| 'synthetic_events': [], | |
| 'step_foul_emitted': step_foul_emitted, | |
| 'roll_ordinal_in_step': roll_ordinal_in_step, | |
| 'roll_type_ordinal_in_step': roll_type_ordinal_in_step, | |
| } | |
| def _build_roll_event_from_string_message( | |
| self, | |
| *, | |
| game_turn, | |
| actor_player_id, | |
| actor_player_name, | |
| actor_team_id, | |
| actor_skills, | |
| defender_player_id, | |
| defender_player_name, | |
| defender_skills, | |
| step_name, | |
| step_type, | |
| sequence_index, | |
| step_result_index, | |
| string_message_index, | |
| roll_ordinal_in_step, | |
| roll_type_ordinal_in_step, | |
| result_name, | |
| payload_type, | |
| roll_category, | |
| dice_values, | |
| roll_root, | |
| modifier_values, | |
| result_value, | |
| result_classification, | |
| report_result_classification, | |
| armour_holds, | |
| probability_success, | |
| probability_neutral, | |
| probability_fail, | |
| target_has_ball, | |
| attacker_is_blitzing, | |
| action_type=None, | |
| ): | |
| normalized_actor_skills = actor_skills if isinstance(actor_skills, list) else [] | |
| normalized_defender_skills = defender_skills if isinstance(defender_skills, list) else [] | |
| return { | |
| 'game_turn': game_turn, | |
| 'player_id': actor_player_id, | |
| 'player_name': actor_player_name, | |
| 'team_id': actor_team_id, | |
| 'dice_roller': self._resolve_dice_roller(actor_team_id, roll_category), | |
| 'player_skills': normalized_actor_skills, | |
| 'target_player_id': defender_player_id, | |
| 'target_player_name': defender_player_name, | |
| 'target_player_skills': normalized_defender_skills, | |
| 'target_has_ball': target_has_ball, | |
| 'attacker_is_blitzing': attacker_is_blitzing, | |
| 'step_name': step_name, | |
| 'step_type': step_type, | |
| 'sequence_index': sequence_index, | |
| 'step_result_index': step_result_index, | |
| 'string_message_index': string_message_index, | |
| 'roll_ordinal_in_step': roll_ordinal_in_step, | |
| 'roll_type_ordinal_in_step': roll_type_ordinal_in_step, | |
| 'result_name': result_name, | |
| 'payload_type': payload_type, | |
| 'roll_category': roll_category, | |
| 'dice_values': dice_values, | |
| 'dice_total': sum(dice_values), | |
| 'difficulty': roll_root.get('Difficulty'), | |
| 'roll_type': roll_root.get('RollType'), | |
| 'outcome': roll_root.get('Outcome'), | |
| 'modifier_values': modifier_values, | |
| 'result_value': result_value, | |
| 'result_classification': result_classification, | |
| 'report_result_classification': report_result_classification, | |
| 'armour_holds': armour_holds, | |
| 'probability_success': probability_success, | |
| 'probability_neutral': probability_neutral, | |
| 'probability_fail': probability_fail, | |
| 'raw': roll_root, | |
| **({"action_type": action_type} if action_type is not None else {}), | |
| } | |
| def _extract_roll_events_from_string_messages( | |
| self, | |
| parsed_messages, | |
| *, | |
| game_turn, | |
| step_number, | |
| sequence_index, | |
| step_result_index, | |
| step_name, | |
| step_type, | |
| step_message, | |
| actor_player_id, | |
| actor_player_name, | |
| actor_team_id, | |
| actor_skills, | |
| defender_player_id, | |
| defender_player_name, | |
| defender_skills, | |
| forced_blitz_player_id, | |
| step_ball_carrier_id, | |
| is_kickoff_step, | |
| step_blitz_players, | |
| step_stood_up_players, | |
| step_special_skill_usages, | |
| step_consumed_special_usage_indexes, | |
| step_foul_player_id, | |
| step_foul_target_id, | |
| step_foul_emitted, | |
| roll_ordinal_in_step, | |
| roll_type_ordinal_in_step, | |
| ): | |
| """Extract roll events from parsed StepResult string messages, preserving replay order.""" | |
| roll_events = [] | |
| for parsed in parsed_messages: | |
| if not isinstance(parsed, dict): | |
| continue | |
| string_message_index = parsed.get("string_message_index") | |
| result = parsed.get("result") | |
| result_name = parsed.get("result_name") | |
| payload_type = parsed.get("payload_type") | |
| payload_root = parsed.get("payload_root") | |
| if not isinstance(payload_root, dict): | |
| continue | |
| roll_root = self._resolve_string_message_roll_root(payload_root) | |
| if roll_root is None: | |
| continue | |
| dice_values = self._extract_dice_values(roll_root.get("Dice")) | |
| if len(dice_values) == 0: | |
| continue | |
| if payload_type == "EventKickOffTable": | |
| continue | |
| modifier_values = self._extract_modifier_values(roll_root.get("Modifiers")) | |
| roll_family = self._classify_string_message_roll(payload_type, result_name, step_name, roll_root) | |
| roll_resolution = { | |
| 'roll_category': 'other', | |
| 'probability_success': None, | |
| 'probability_neutral': None, | |
| 'probability_fail': None, | |
| 'result_value': 0, | |
| 'target_has_ball': False, | |
| 'attacker_is_blitzing': False, | |
| 'armour_holds': None, | |
| } | |
| if roll_family == "block": | |
| roll_resolution = self._resolve_block_string_message_roll( | |
| dice_values=dice_values, | |
| payload_root=payload_root, | |
| roll_root=roll_root, | |
| step_message=step_message, | |
| actor_player_id=actor_player_id, | |
| actor_skills=actor_skills, | |
| defender_player_id=defender_player_id, | |
| defender_skills=defender_skills, | |
| forced_blitz_player_id=forced_blitz_player_id, | |
| step_ball_carrier_id=step_ball_carrier_id, | |
| step_blitz_players=step_blitz_players, | |
| step_stood_up_players=step_stood_up_players, | |
| ) | |
| elif roll_family in ("injury", "ko_recovery_injury"): | |
| roll_resolution = self._resolve_injury_string_message_roll( | |
| roll_root, | |
| modifier_values, | |
| defender_skills, | |
| ) | |
| elif roll_family == "casualty": | |
| die_val = dice_values[0] if dice_values else 0 | |
| cas_result, cas_p_success, cas_p_neutral, cas_p_fail = self.calculator.calc_casualty(die_val) | |
| roll_resolution['roll_category'] = 'casualty' | |
| roll_resolution['result_value'] = cas_result | |
| roll_resolution['probability_success'] = cas_p_success | |
| roll_resolution['probability_neutral'] = cas_p_neutral | |
| roll_resolution['probability_fail'] = cas_p_fail | |
| elif roll_family in ("armour", "action"): | |
| roll_resolution = self._resolve_difficulty_string_message_roll( | |
| dice_values=dice_values, | |
| roll_root=roll_root, | |
| game_turn=game_turn, | |
| step_number=step_number, | |
| sequence_index=sequence_index, | |
| step_result_index=step_result_index, | |
| string_message_index=string_message_index, | |
| step_name=step_name, | |
| step_type=step_type, | |
| actor_player_id=actor_player_id, | |
| actor_player_name=actor_player_name, | |
| actor_team_id=actor_team_id, | |
| defender_player_name=defender_player_name, | |
| defender_skills=defender_skills, | |
| step_special_skill_usages=step_special_skill_usages, | |
| step_consumed_special_usage_indexes=step_consumed_special_usage_indexes, | |
| step_foul_player_id=step_foul_player_id, | |
| step_foul_target_id=step_foul_target_id, | |
| step_foul_emitted=step_foul_emitted, | |
| roll_ordinal_in_step=roll_ordinal_in_step, | |
| roll_type_ordinal_in_step=roll_type_ordinal_in_step, | |
| ) | |
| roll_events.extend(roll_resolution.get('synthetic_events', [])) | |
| step_foul_emitted = roll_resolution.get('step_foul_emitted', step_foul_emitted) | |
| roll_ordinal_in_step = roll_resolution.get('roll_ordinal_in_step', roll_ordinal_in_step) | |
| roll_type_ordinal_in_step = roll_resolution.get('roll_type_ordinal_in_step', roll_type_ordinal_in_step) | |
| roll_category = roll_resolution['roll_category'] | |
| probability_success = roll_resolution['probability_success'] | |
| probability_neutral = roll_resolution['probability_neutral'] | |
| probability_fail = roll_resolution['probability_fail'] | |
| result_value = roll_resolution['result_value'] | |
| target_has_ball = roll_resolution['target_has_ball'] | |
| attacker_is_blitzing = roll_resolution['attacker_is_blitzing'] | |
| armour_holds = roll_resolution['armour_holds'] | |
| result_classification = "neutral" | |
| report_result_classification = "neutral" | |
| if result_value == 1: | |
| result_classification = "success" | |
| elif result_value == -1: | |
| result_classification = "fail" | |
| roll_type_value = roll_root.get("RollType") | |
| roll_type_key = str(roll_type_value) if roll_type_value is not None else "-" | |
| roll_ordinal_in_step += 1 | |
| roll_type_ordinal_in_step[roll_type_key] += 1 | |
| report_result_classification = result_classification | |
| if roll_category == "armour": | |
| if result_classification == "success": | |
| report_result_classification = "fail" | |
| elif result_classification == "fail": | |
| report_result_classification = "success" | |
| event_actor_player_id = actor_player_id | |
| event_actor_player_name = actor_player_name | |
| event_actor_team_id = actor_team_id | |
| event_actor_skills = actor_skills | |
| if step_name == "TopLevelEventRoll" and payload_type == "ResultRoll": | |
| roll_player_id = roll_root.get("PlayerId") | |
| if roll_player_id is not None: | |
| lookup = self._safe_player_lookup(roll_player_id) | |
| event_actor_player_id = roll_player_id | |
| event_actor_player_name = lookup.get("name", actor_player_name) | |
| event_actor_team_id = lookup.get("team_id", actor_team_id) | |
| event_actor_skills = lookup.get("skills", actor_skills) | |
| elif roll_root.get("TeamId") is not None: | |
| event_actor_team_id = roll_root.get("TeamId") | |
| roll_event = self._build_roll_event_from_string_message( | |
| game_turn=game_turn, | |
| actor_player_id=event_actor_player_id, | |
| actor_player_name=event_actor_player_name, | |
| actor_team_id=event_actor_team_id, | |
| actor_skills=event_actor_skills, | |
| defender_player_id=defender_player_id, | |
| defender_player_name=defender_player_name, | |
| defender_skills=defender_skills, | |
| step_name=step_name, | |
| step_type=step_type, | |
| sequence_index=sequence_index, | |
| step_result_index=step_result_index, | |
| string_message_index=string_message_index, | |
| roll_ordinal_in_step=roll_ordinal_in_step, | |
| roll_type_ordinal_in_step=roll_type_ordinal_in_step[roll_type_key], | |
| result_name=result_name, | |
| payload_type=payload_type, | |
| roll_category=roll_category, | |
| dice_values=dice_values, | |
| roll_root=roll_root, | |
| modifier_values=modifier_values, | |
| result_value=result_value, | |
| result_classification=result_classification, | |
| report_result_classification=report_result_classification, | |
| armour_holds=armour_holds, | |
| probability_success=probability_success, | |
| probability_neutral=probability_neutral, | |
| probability_fail=probability_fail, | |
| target_has_ball=target_has_ball, | |
| attacker_is_blitzing=attacker_is_blitzing, | |
| action_type="APO" if payload_type == "QuestionApothecaryCasualtyChoice" else None, | |
| ) | |
| # Armour rows emitted right after an attack should inherit attacker and | |
| # target identity from that attack, rather than step actor fallback. | |
| if roll_category == "armour": | |
| current_victim_id = roll_event.get("player_id") | |
| source_attack_event = self._find_nearest_armour_source_event( | |
| roll_events, | |
| len(roll_events), | |
| current_victim_id, | |
| ) | |
| if source_attack_event is not None: | |
| roll_event["player_id"] = source_attack_event.get("player_id") | |
| roll_event["player_name"] = source_attack_event.get("player_name") | |
| roll_event["team_id"] = source_attack_event.get("team_id") | |
| roll_event["dice_roller"] = source_attack_event.get("dice_roller") | |
| roll_event["player_skills"] = source_attack_event.get("player_skills") or [] | |
| roll_event["target_player_id"] = source_attack_event.get("target_player_id") | |
| roll_event["target_player_name"] = source_attack_event.get("target_player_name") | |
| roll_event["target_player_skills"] = source_attack_event.get("target_player_skills") or [] | |
| roll_event["armour_source_category"] = str(source_attack_event.get("roll_category") or "").lower() | |
| roll_event["armour_source_action_label"] = str( | |
| source_attack_event.get("synthetic_action_label") | |
| or source_attack_event.get("display_action_type") | |
| or source_attack_event.get("action_type") | |
| or "" | |
| ).strip() | |
| if roll_category == "block": | |
| for key in ( | |
| "selected_block_die_index", | |
| "selected_block_die_value", | |
| "selected_block_die_outcome", | |
| "selected_block_die_source", | |
| "block_choice_source", | |
| ): | |
| if key in roll_resolution: | |
| roll_event[key] = roll_resolution.get(key) | |
| is_trivial_kickoff_followup = ( | |
| payload_type == "ResultRoll" | |
| and result_name == "ResultRoll" | |
| and roll_category == "action" | |
| and str(roll_root.get('Difficulty')) == "0" | |
| and str(roll_root.get('RollType')) in ("25", "26") | |
| and step_name in ("PlayerStep", "BallStep") | |
| and str(step_type) in ("3", "10") | |
| and probability_success == 1.0 | |
| and probability_fail == 0.0 | |
| ) | |
| if is_trivial_kickoff_followup: | |
| roll_event['exclude_from_detailed_report'] = True | |
| roll_event['exclude_from_surprise'] = True | |
| # KO recovery is modeled explicitly from EventRoll as synthetic | |
| # KO-RECOV rows. Keep those as the single source of truth in | |
| # reports by suppressing duplicate top-level ResultRoll rows. | |
| is_duplicate_ko_recovery_resultroll = ( | |
| step_name == "TopLevelEventRoll" | |
| and payload_type == "ResultRoll" | |
| and result_name == "ResultRoll" | |
| and str(roll_root.get("RollType")) == "13" | |
| ) | |
| if is_duplicate_ko_recovery_resultroll: | |
| roll_event['exclude_from_detailed_report'] = True | |
| roll_event['exclude_from_surprise'] = True | |
| # Ejection (Argue/Bribe) rolls are modeled explicitly from | |
| # QuestionBribeUsage + ResultRoll in synthetic extraction. | |
| # Suppress the raw top-level ResultRoll rows to avoid duplicate, | |
| # less-informative action rows in detailed report output. | |
| is_duplicate_ejection_resultroll = ( | |
| step_name == "TopLevelEventRoll" | |
| and payload_type == "ResultRoll" | |
| and result_name == "ResultRoll" | |
| and str(roll_root.get("RollType")) in ("30", "21", "29") | |
| ) | |
| if is_duplicate_ejection_resultroll: | |
| roll_event['exclude_from_detailed_report'] = True | |
| roll_event['exclude_from_surprise'] = True | |
| roll_events.append(roll_event) | |
| return roll_events, roll_ordinal_in_step, step_foul_emitted | |
| def _infer_blitz_players_in_step(self, step_data): | |
| """Infer player ids that are blitzing in this replay step. | |
| Heuristic used: | |
| - same player has a movement step (StepType=1) followed by a block step | |
| (StepType=6) in the same replay step. | |
| """ | |
| blitz_players = set() | |
| seen_move_by_player = set() | |
| exec_sequences = step_data.get("EventExecuteSequence") | |
| if not isinstance(exec_sequences, list): | |
| return blitz_players | |
| for seq_item in exec_sequences: | |
| if not isinstance(seq_item, dict): | |
| continue | |
| sequence = seq_item.get("Sequence") | |
| if not isinstance(sequence, dict): | |
| continue | |
| step_results = sequence.get("StepResult") | |
| if not isinstance(step_results, list): | |
| continue | |
| for step_result in step_results: | |
| if not isinstance(step_result, dict): | |
| continue | |
| step_node = step_result.get("Step") | |
| if not isinstance(step_node, dict) or not isinstance(step_node.get("MessageData"), str): | |
| continue | |
| step_message = self._parse_message_data_xml(step_node.get("MessageData")) | |
| if not isinstance(step_message, dict) or len(step_message) == 0: | |
| continue | |
| root_key = next(iter(step_message.keys())) | |
| root = step_message.get(root_key) | |
| if not isinstance(root, dict): | |
| continue | |
| player_id = root.get("PlayerId") | |
| step_type = str(root.get("StepType")) if root.get("StepType") is not None else "" | |
| if player_id is None: | |
| continue | |
| if step_type == "1": | |
| seen_move_by_player.add(str(player_id)) | |
| elif step_type == "6" and str(player_id) in seen_move_by_player: | |
| blitz_players.add(str(player_id)) | |
| return blitz_players | |
| def _build_ejection_roll_events(self, replay_steps): | |
| """Scan all replay steps for secret-weapon / foul ejection sequences. | |
| Trigger signal | |
| -------------- | |
| A top-level ``QuestionBribeUsage`` key in a step marks the start of an | |
| ejection decision. The fields we care about: | |
| GamerId – which team owns the threatened player | |
| PlayerId – the player at risk | |
| Reason – "1" = secret weapon (end-of-half), "2" = foul | |
| ArgueAvailable – "1" if the team coach can argue | |
| Resolution signal | |
| ----------------- | |
| The **same** step (e.g. an end-of-half step where multiple things happen | |
| at once) or the **immediately following** step carries the outcome as one | |
| or more top-level ``ResultRoll`` entries plus optional ``ResultPlayerSentOff``. | |
| Known RollTypes observed in the data | |
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| - "30" (confirmed): Argue the Call | |
| - "21" (confirmed): Bribe | |
| - "29" (legacy/unknown): recognised ejection roll type (kept distinct, | |
| not mapped to Argue) | |
| A missing ``ResultPlayerSentOff`` with a present ``ResultRoll`` means the | |
| roll succeeded and the player was reprieved. If there is no ``ResultRoll`` | |
| at all after a ``QuestionBribeUsage`` the player was ejected automatically | |
| (coach was sent off – no option available). | |
| Each roll is emitted as a separate event with its own ``roll_category``, | |
| probability, and outcome so it integrates naturally with the existing roll | |
| reporting pipeline. | |
| """ | |
| return build_ejection_roll_events(self, replay_steps) | |
| def _build_officious_ref_roll_events(self, replay_steps): | |
| """Extract the follow-up send-off die from EventOfficiousRef. | |
| Event 11 has two distinct random stages: | |
| 1. Kickoff event selection + fan-factor roll-off (handled by kickoff reporting) | |
| 2. Independent D6 send-off check for the chosen player (handled here) | |
| This function emits stage (2) as a separate roll event so detailed roll | |
| reporting can show it alongside other independent rolls without folding | |
| it into the kickoff pass/fail classification. | |
| """ | |
| return build_officious_ref_roll_events(self, replay_steps) | |
| def _reset_processing_state(self): | |
| """Reset per-run processor state used during replay parsing.""" | |
| pass | |
| def _build_roll_event_category_index(self): | |
| """Build a per-category lookup over game_state.roll_events. | |
| Stores ``self._events_by_cat``: a dict mapping roll_category string → | |
| list of event dicts. The lists hold references (not copies), so | |
| in-place mutations to individual events are immediately visible. | |
| Must be rebuilt after any operation that replaces game_state.roll_events | |
| entirely (e.g. the _drop_* helpers in skill_transition.py). | |
| """ | |
| index: dict = {} | |
| for event in (getattr(self.game_state, "roll_events", None) or ()): | |
| if isinstance(event, dict): | |
| cat = event.get("roll_category") or "" | |
| bucket = index.get(cat) | |
| if bucket is None: | |
| index[cat] = [event] | |
| else: | |
| bucket.append(event) | |
| self._events_by_cat = index | |
| def _prepare_replay_steps(self, jsonData): | |
| """Extract and normalize replay step payloads from replay JSON.""" | |
| replay = jsonData.get("Replay", {}) | |
| replay_steps = replay.get("ReplayStep", []) | |
| if isinstance(replay_steps, dict): | |
| replay_steps = [replay_steps] | |
| self.normalize_replay_step_shapes(replay_steps) | |
| self.game_state.raw_replay_steps = replay_steps | |
| return replay_steps | |
| def _append_post_step_synthetic_rolls(self, replay_steps): | |
| """Append synthetic roll events that are derived after primary step parsing.""" | |
| synthetic_ko_rolls = self._build_ko_recovery_roll_events(replay_steps) | |
| for synthetic_roll in synthetic_ko_rolls: | |
| self.game_state.add_roll_event(synthetic_roll) | |
| ejection_rolls = self._build_ejection_roll_events(replay_steps) | |
| for ejection_roll in ejection_rolls: | |
| self.game_state.add_roll_event(ejection_roll) | |
| officious_ref_roll_events = self._build_officious_ref_roll_events(replay_steps) | |
| for officious_ref_roll in officious_ref_roll_events: | |
| self.game_state.add_roll_event(officious_ref_roll) | |
| synthetic_touchdown_rolls = self._build_touchdown_roll_events() | |
| for synthetic_touchdown_roll in synthetic_touchdown_rolls: | |
| self.game_state.add_roll_event(synthetic_touchdown_roll) | |
| self._assign_missing_roll_ordinals() | |
| def _assign_missing_roll_ordinals(self): | |
| """Assign in-step roll ordinals for events that do not already carry them.""" | |
| events = getattr(self.game_state, "roll_events", None) | |
| if not isinstance(events, list) or not events: | |
| return | |
| max_ordinal_by_step = defaultdict(int) | |
| max_type_ordinal_by_step = defaultdict(lambda: defaultdict(int)) | |
| for event in events: | |
| if not isinstance(event, dict): | |
| continue | |
| step_number = event.get("step_number") | |
| if step_number is None: | |
| continue | |
| try: | |
| step_key = int(step_number) | |
| except (TypeError, ValueError): | |
| continue | |
| existing_ordinal = event.get("roll_ordinal_in_step") | |
| if isinstance(existing_ordinal, int): | |
| if existing_ordinal > max_ordinal_by_step[step_key]: | |
| max_ordinal_by_step[step_key] = existing_ordinal | |
| roll_type_key = str(event.get("roll_type") or "") | |
| existing_type_ordinal = event.get("roll_type_ordinal_in_step") | |
| if isinstance(existing_type_ordinal, int) and roll_type_key: | |
| if existing_type_ordinal > max_type_ordinal_by_step[step_key][roll_type_key]: | |
| max_type_ordinal_by_step[step_key][roll_type_key] = existing_type_ordinal | |
| for event in events: | |
| if not isinstance(event, dict): | |
| continue | |
| if isinstance(event.get("roll_ordinal_in_step"), int): | |
| continue | |
| step_number = event.get("step_number") | |
| if step_number is None: | |
| continue | |
| try: | |
| step_key = int(step_number) | |
| except (TypeError, ValueError): | |
| continue | |
| max_ordinal_by_step[step_key] += 1 | |
| event["roll_ordinal_in_step"] = max_ordinal_by_step[step_key] | |
| roll_type_key = str(event.get("roll_type") or "") | |
| if roll_type_key: | |
| max_type_ordinal_by_step[step_key][roll_type_key] += 1 | |
| event["roll_type_ordinal_in_step"] = max_type_ordinal_by_step[step_key][roll_type_key] | |
| def _is_failed_pro_action_roll(self, event): | |
| """Return True for action rolls that represent a failed Pro test.""" | |
| if not isinstance(event, dict): | |
| return False | |
| if str(event.get("roll_category") or "") != "action": | |
| return False | |
| if str(event.get("roll_type") or "") != PRO_RULE.get("roll_type", ""): | |
| return False | |
| if str(event.get("result_classification") or "") != "fail": | |
| return False | |
| return self._event_player_has_skill_id(event, PRO_RULE.get("skill_id", "50")) | |
| def _is_successful_pro_action_roll(self, event): | |
| """Return True for action rolls that represent a successful Pro test.""" | |
| if not isinstance(event, dict): | |
| return False | |
| if str(event.get("roll_category") or "") != "action": | |
| return False | |
| if str(event.get("roll_type") or "") != PRO_RULE.get("roll_type", ""): | |
| return False | |
| if str(event.get("result_classification") or "") != "success": | |
| return False | |
| return self._event_player_has_skill_id(event, PRO_RULE.get("skill_id", "50")) | |
| def _is_any_pro_action_roll(self, event): | |
| """Return True for any Pro test roll (success or fail).""" | |
| return self._is_successful_pro_action_roll(event) or self._is_failed_pro_action_roll(event) | |
| def _extract_pro_reroll_index_from_block_event(self, event): | |
| """Return rerolled die index from explicit block reroll payload metadata.""" | |
| if not isinstance(event, dict): | |
| return None | |
| if str(event.get("roll_category") or "") != "block": | |
| return None | |
| raw = event.get("raw") if isinstance(event.get("raw"), dict) else {} | |
| candidates = [] | |
| direct_item = raw.get("RolledDiceIndexesItem") | |
| if direct_item is not None: | |
| candidates.append(direct_item) | |
| rolled_indexes = raw.get("RolledDiceIndexes") | |
| if isinstance(rolled_indexes, dict): | |
| nested_item = rolled_indexes.get("RolledDiceIndexesItem") | |
| if nested_item is None: | |
| nested_item = rolled_indexes.get("Item") | |
| if nested_item is not None: | |
| candidates.append(nested_item) | |
| for candidate in candidates: | |
| values = candidate if isinstance(candidate, list) else [candidate] | |
| for value in values: | |
| parsed = self._to_int(value) | |
| if parsed is not None: | |
| return parsed | |
| return None | |
| def _extract_rolled_dice_indexes_from_block_event(self, event): | |
| """Return all rerolled dice indexes declared in block payload metadata.""" | |
| if not isinstance(event, dict): | |
| return [] | |
| if str(event.get("roll_category") or "") != "block": | |
| return [] | |
| raw = event.get("raw") if isinstance(event.get("raw"), dict) else {} | |
| candidates = [] | |
| direct_item = raw.get("RolledDiceIndexesItem") | |
| if direct_item is not None: | |
| candidates.append(direct_item) | |
| rolled_indexes = raw.get("RolledDiceIndexes") | |
| if isinstance(rolled_indexes, dict): | |
| nested_item = rolled_indexes.get("RolledDiceIndexesItem") | |
| if nested_item is None: | |
| nested_item = rolled_indexes.get("Item") | |
| if nested_item is not None: | |
| candidates.append(nested_item) | |
| parsed_values = [] | |
| for candidate in candidates: | |
| values = candidate if isinstance(candidate, list) else [candidate] | |
| for value in values: | |
| parsed = self._to_int(value) | |
| if parsed is not None: | |
| parsed_values.append(parsed) | |
| # Preserve order while deduplicating. | |
| deduped = [] | |
| seen = set() | |
| for value in parsed_values: | |
| if value in seen: | |
| continue | |
| deduped.append(value) | |
| seen.add(value) | |
| return deduped | |
| def _extract_brawler_reroll_indexes_from_block_event(self, event): | |
| """Return Brawler-eligible die indexes from block payload metadata.""" | |
| if not isinstance(event, dict): | |
| return [] | |
| if str(event.get("roll_category") or "") != "block": | |
| return [] | |
| raw = event.get("raw") if isinstance(event.get("raw"), dict) else {} | |
| brawler_node = raw.get("BrawlerDiceIndexes") | |
| if not isinstance(brawler_node, dict): | |
| return [] | |
| candidate = brawler_node.get("BrawlerDiceIndexesItem") | |
| if candidate is None: | |
| candidate = brawler_node.get("Item") | |
| if candidate is None: | |
| return [] | |
| values = candidate if isinstance(candidate, list) else [candidate] | |
| indexes = [] | |
| for value in values: | |
| parsed = self._to_int(value) | |
| if parsed is not None: | |
| indexes.append(parsed) | |
| return indexes | |
| def _annotate_pro_not_tested(self): | |
| """Annotate Pro decision status for blocks by players with Pro.""" | |
| return self.skill_transition_backend.annotate_pro_not_tested() | |
| def _annotate_brawler_usage(self): | |
| """Annotate whether Brawler was offered/used on eligible block events.""" | |
| return self.skill_transition_backend.annotate_brawler_usage() | |
| def _annotate_pro_reroll_indexes(self): | |
| """Annotate successful Pro action rows with explicit rerolled die index.""" | |
| return self.skill_transition_backend.annotate_pro_reroll_indexes() | |
| def _get_attacker_choice_from_block(self, block_event): | |
| """Extract AttackerChoice from block event raw data (defaults to True/1).""" | |
| if not isinstance(block_event, dict): | |
| return True | |
| raw = block_event.get("raw") if isinstance(block_event.get("raw"), dict) else {} | |
| return str(raw.get("AttackerChoice", "1")) == "1" | |
| def _append_event_note(self, event, note_text): | |
| """Append a short note to event['notes'] without duplicates.""" | |
| if not isinstance(event, dict): | |
| return | |
| note = str(note_text or "").strip() | |
| if note == "": | |
| return | |
| existing = str(event.get("notes") or "").strip() | |
| if existing in ("", "-"): | |
| event["notes"] = note | |
| return | |
| parts = [part.strip() for part in existing.split(";") if part.strip()] | |
| if note in parts: | |
| return | |
| parts.append(note) | |
| event["notes"] = "; ".join(parts) | |
| def _calculate_block_pro_block_probs(self, block1, block2, pro_reroll_index): | |
| """Calculate and assign probabilities for block2 in a block-pro-block sequence. | |
| Logic: | |
| - Block1 outcome determined using non-pro-reroll dice | |
| - Block2 probabilities calculated using only the single pro-reroll die | |
| - Final probabilities combine these based on block1's outcome | |
| If block1 is fail: | |
| P(final = success/neutral/fail) = P(block2 result) | |
| If block1 is neutral: | |
| P(final = success) = P(block2 = success) | |
| P(final = neutral) = P(block2 = neutral) + P(block2 = fail) | |
| P(final = fail) = 0 | |
| If block1 is success: | |
| P(final = success) = 1.0 | |
| P(final = neutral/fail) = 0 | |
| """ | |
| # Get dice from both blocks | |
| block1_dice = block1.get("dice_values") | |
| block2_dice = block2.get("dice_values") | |
| if not isinstance(block1_dice, list) or not isinstance(block2_dice, list): | |
| return | |
| # Validate pro_reroll_index | |
| pro_reroll_index = self._to_int(pro_reroll_index) | |
| if pro_reroll_index is None or pro_reroll_index < 0 or pro_reroll_index >= len(block1_dice): | |
| return | |
| if pro_reroll_index >= len(block2_dice): | |
| return | |
| # Map replay block-face encoding (0..4) to calculator faces (1..6) | |
| # before evaluating synthetic block outcomes. | |
| block1_dice_normalized = self._normalize_block_dice_values(block1_dice) | |
| block2_dice_normalized = self._normalize_block_dice_values(block2_dice) | |
| if len(block1_dice_normalized) != len(block1_dice): | |
| return | |
| if len(block2_dice_normalized) != len(block2_dice): | |
| return | |
| # Get common parameters from block1 | |
| raw_attacker_skills = block1.get("player_skills", []) | |
| raw_defender_skills = block1.get("target_player_skills", []) | |
| attacker_skills = raw_attacker_skills if isinstance(raw_attacker_skills, list) else [] | |
| defender_skills = raw_defender_skills if isinstance(raw_defender_skills, list) else [] | |
| attacker_choose = self._get_attacker_choice_from_block(block1) | |
| defender_has_ball = block1.get("target_has_ball", False) | |
| attacker_is_blitzing = block1.get("attacker_is_blitzing", False) | |
| probs = self.calculator.calc_rerolled_block_followup_probabilities( | |
| block1_dice_normalized, | |
| block2_dice_normalized, | |
| pro_reroll_index, | |
| attacker_skills, | |
| defender_skills, | |
| isAttackerChoose=attacker_choose, | |
| defenderHasBall=defender_has_ball, | |
| attackerIsBlitzing=attacker_is_blitzing, | |
| ) | |
| if probs is None: | |
| return | |
| p_final_success, p_final_neutral, p_final_fail = probs | |
| # Overwrite block2 probabilities with calculated conditional probabilities | |
| block2["probability_success"] = p_final_success | |
| block2["probability_neutral"] = p_final_neutral | |
| block2["probability_fail"] = p_final_fail | |
| block2["is_pro_adjusted_block"] = True | |
| self._append_event_note(block2, "pro update to block") | |
| def _calculate_block_brawler_block_probs(self, block1, block2, brawler_reroll_index): | |
| """Calculate and assign probabilities for block2 in a block-brawler(block) sequence. | |
| Same combination logic as successful Pro block reroll handling: | |
| - Evaluate block1 using all non-rerolled dice | |
| - Evaluate block2 as one-die block from the rerolled Brawler die | |
| - Combine based on block1 status (fail/neutral/success) | |
| """ | |
| block1_dice = block1.get("dice_values") | |
| block2_dice = block2.get("dice_values") | |
| if not isinstance(block1_dice, list) or not isinstance(block2_dice, list): | |
| return | |
| brawler_reroll_index = self._to_int(brawler_reroll_index) | |
| if brawler_reroll_index is None or brawler_reroll_index < 0 or brawler_reroll_index >= len(block1_dice): | |
| return | |
| if brawler_reroll_index >= len(block2_dice): | |
| return | |
| block1_dice_normalized = self._normalize_block_dice_values(block1_dice) | |
| block2_dice_normalized = self._normalize_block_dice_values(block2_dice) | |
| if len(block1_dice_normalized) != len(block1_dice): | |
| return | |
| if len(block2_dice_normalized) != len(block2_dice): | |
| return | |
| raw_attacker_skills = block1.get("player_skills", []) | |
| raw_defender_skills = block1.get("target_player_skills", []) | |
| attacker_skills = raw_attacker_skills if isinstance(raw_attacker_skills, list) else [] | |
| defender_skills = raw_defender_skills if isinstance(raw_defender_skills, list) else [] | |
| attacker_choose = self._get_attacker_choice_from_block(block1) | |
| defender_has_ball = block1.get("target_has_ball", False) | |
| attacker_is_blitzing = block1.get("attacker_is_blitzing", False) | |
| probs = self.calculator.calc_rerolled_block_followup_probabilities( | |
| block1_dice_normalized, | |
| block2_dice_normalized, | |
| brawler_reroll_index, | |
| attacker_skills, | |
| defender_skills, | |
| isAttackerChoose=attacker_choose, | |
| defenderHasBall=defender_has_ball, | |
| attackerIsBlitzing=attacker_is_blitzing, | |
| ) | |
| if probs is None: | |
| return | |
| p_final_success, p_final_neutral, p_final_fail = probs | |
| block2["probability_success"] = p_final_success | |
| block2["probability_neutral"] = p_final_neutral | |
| block2["probability_fail"] = p_final_fail | |
| block2["is_brawler_adjusted_block"] = True | |
| self._append_event_note(block2, "brawler-adjusted block") | |
| def _calculate_block_pro_block_probs_ottd(self, block1, block2, pro_reroll_index): | |
| """OTTD-mode variant of block-pro-block conditional probability update.""" | |
| block1_dice = block1.get("dice_values") | |
| block2_dice = block2.get("dice_values") | |
| if not isinstance(block1_dice, list) or not isinstance(block2_dice, list): | |
| return | |
| pro_reroll_index = self._to_int(pro_reroll_index) | |
| if pro_reroll_index is None or pro_reroll_index < 0 or pro_reroll_index >= len(block1_dice): | |
| return | |
| if pro_reroll_index >= len(block2_dice): | |
| return | |
| block1_dice_normalized = self._normalize_block_dice_values(block1_dice) | |
| block2_dice_normalized = self._normalize_block_dice_values(block2_dice) | |
| if len(block1_dice_normalized) != len(block1_dice): | |
| return | |
| if len(block2_dice_normalized) != len(block2_dice): | |
| return | |
| raw_attacker_skills = block1.get("player_skills", []) | |
| raw_defender_skills = block1.get("target_player_skills", []) | |
| attacker_skills = raw_attacker_skills if isinstance(raw_attacker_skills, list) else [] | |
| defender_skills = raw_defender_skills if isinstance(raw_defender_skills, list) else [] | |
| attacker_choose = self._get_attacker_choice_from_block(block1) | |
| defender_has_ball = block1.get("target_has_ball", False) | |
| attacker_is_blitzing = block1.get("attacker_is_blitzing", False) | |
| probs = self.calculator.calc_rerolled_block_followup_probabilities_ottd( | |
| block1_dice_normalized, | |
| block2_dice_normalized, | |
| pro_reroll_index, | |
| attacker_skills, | |
| defender_skills, | |
| isAttackerChoose=attacker_choose, | |
| defenderHasBall=defender_has_ball, | |
| attackerIsBlitzing=attacker_is_blitzing, | |
| ) | |
| if probs is None: | |
| return | |
| p_final_success, p_final_neutral, p_final_fail = probs | |
| block2["probability_success"] = p_final_success | |
| block2["probability_neutral"] = p_final_neutral | |
| block2["probability_fail"] = p_final_fail | |
| block2["is_pro_adjusted_block"] = True | |
| self._append_event_note(block2, "pro update to block") | |
| def _calculate_block_brawler_block_probs_ottd(self, block1, block2, brawler_reroll_index): | |
| """OTTD-mode variant of block-brawler(block) conditional probability update.""" | |
| block1_dice = block1.get("dice_values") | |
| block2_dice = block2.get("dice_values") | |
| if not isinstance(block1_dice, list) or not isinstance(block2_dice, list): | |
| return | |
| brawler_reroll_index = self._to_int(brawler_reroll_index) | |
| if brawler_reroll_index is None or brawler_reroll_index < 0 or brawler_reroll_index >= len(block1_dice): | |
| return | |
| if brawler_reroll_index >= len(block2_dice): | |
| return | |
| block1_dice_normalized = self._normalize_block_dice_values(block1_dice) | |
| block2_dice_normalized = self._normalize_block_dice_values(block2_dice) | |
| if len(block1_dice_normalized) != len(block1_dice): | |
| return | |
| if len(block2_dice_normalized) != len(block2_dice): | |
| return | |
| raw_attacker_skills = block1.get("player_skills", []) | |
| raw_defender_skills = block1.get("target_player_skills", []) | |
| attacker_skills = raw_attacker_skills if isinstance(raw_attacker_skills, list) else [] | |
| defender_skills = raw_defender_skills if isinstance(raw_defender_skills, list) else [] | |
| attacker_choose = self._get_attacker_choice_from_block(block1) | |
| defender_has_ball = block1.get("target_has_ball", False) | |
| attacker_is_blitzing = block1.get("attacker_is_blitzing", False) | |
| probs = self.calculator.calc_rerolled_block_followup_probabilities_ottd( | |
| block1_dice_normalized, | |
| block2_dice_normalized, | |
| brawler_reroll_index, | |
| attacker_skills, | |
| defender_skills, | |
| isAttackerChoose=attacker_choose, | |
| defenderHasBall=defender_has_ball, | |
| attackerIsBlitzing=attacker_is_blitzing, | |
| ) | |
| if probs is None: | |
| return | |
| p_final_success, p_final_neutral, p_final_fail = probs | |
| block2["probability_success"] = p_final_success | |
| block2["probability_neutral"] = p_final_neutral | |
| block2["probability_fail"] = p_final_fail | |
| block2["is_brawler_adjusted_block"] = True | |
| self._append_event_note(block2, "brawler-adjusted block") | |
| def _get_ottd_mode_context_for_event(self, event): | |
| """Return (evaluation, mode) for an event when OTTD mode applies to its turn/team.""" | |
| if not isinstance(event, dict): | |
| return None | |
| turn_num = self._to_int(event.get("game_turn"), default=None) | |
| if turn_num is None: | |
| return None | |
| evaluations = getattr(self.game_state, "ottd_evaluations", {}) or {} | |
| evaluation = evaluations.get(turn_num) | |
| if evaluation is None: | |
| evaluation = evaluations.get(str(turn_num)) | |
| if not isinstance(evaluation, dict): | |
| return None | |
| if not bool(evaluation.get("final_decision")): | |
| return None | |
| mode = evaluation.get("ottd_mode") if isinstance(evaluation.get("ottd_mode"), dict) else {} | |
| if not bool(mode.get("enabled")): | |
| return None | |
| receiving_team_id = self._normalize_team_id(evaluation.get("receiving_team_id")) | |
| if self._normalize_team_id(event.get("team_id")) != receiving_team_id: | |
| return None | |
| step_number = self._to_int(event.get("step_number"), default=None) | |
| mode_on_step = self._to_int(mode.get("mode_on_step"), default=None) | |
| turned_off_step = self._to_int(mode.get("turned_off_step"), default=None) | |
| if step_number is None or mode_on_step is None: | |
| return None | |
| if step_number < mode_on_step: | |
| return None | |
| if turned_off_step is not None and step_number >= turned_off_step: | |
| return None | |
| return evaluation, mode | |
| def _is_ottd_mode_active_for_event(self, event): | |
| """Return True when an event falls in an active OTTD mode window.""" | |
| return self._get_ottd_mode_context_for_event(event) is not None | |
| def _is_ottd_mode_active_for_block_event(self, event): | |
| """Return True when a block event falls in an active OTTD mode window.""" | |
| if str(event.get("roll_category") or "").lower() != "block": | |
| return False | |
| return self._is_ottd_mode_active_for_event(event) | |
| def _append_ottd_mode_notes_for_event(self, event, mode): | |
| """Append OTTD mode notes for active-window rows.""" | |
| if not isinstance(event, dict): | |
| return | |
| self._append_event_note(event, "OTTD mode") | |
| def _append_ottd_mode_before_step_boundary_note(self, event): | |
| """Annotate the first non-OTTD step (the turned-off step itself) for all timing modes.""" | |
| if not isinstance(event, dict): | |
| return | |
| turn_num = self._to_int(event.get("game_turn"), default=None) | |
| if turn_num is None: | |
| return | |
| evaluations = getattr(self.game_state, "ottd_evaluations", {}) or {} | |
| evaluation = evaluations.get(turn_num) | |
| if evaluation is None: | |
| evaluation = evaluations.get(str(turn_num)) | |
| if not isinstance(evaluation, dict) or not bool(evaluation.get("final_decision")): | |
| return | |
| mode = evaluation.get("ottd_mode") if isinstance(evaluation.get("ottd_mode"), dict) else {} | |
| if not bool(mode.get("enabled")) or not bool(mode.get("turned_off")): | |
| return | |
| boundary_step = self._to_int(mode.get("turned_off_step"), default=None) | |
| step_number = self._to_int(event.get("step_number"), default=None) | |
| if boundary_step is None or step_number is None or step_number != boundary_step: | |
| return | |
| receiving_team_id = self._normalize_team_id(evaluation.get("receiving_team_id")) | |
| if self._normalize_team_id(event.get("team_id")) != receiving_team_id: | |
| return | |
| criterion = str(mode.get("turned_off_criterion") or "").strip() | |
| reason = str(mode.get("turned_off_reason") or "").strip() | |
| if criterion and reason: | |
| self._append_event_note(event, f"OTTD mode off from this step ({criterion}: {reason})") | |
| elif criterion: | |
| self._append_event_note(event, f"OTTD mode off from this step ({criterion})") | |
| elif reason: | |
| self._append_event_note(event, f"OTTD mode off from this step ({reason})") | |
| def _apply_ottd_mode_block_probability_overrides(self): | |
| """Apply OTTD-specific block result/probability model during active mode windows.""" | |
| events = getattr(self.game_state, "roll_events", None) | |
| if not isinstance(events, list) or len(events) == 0: | |
| return | |
| # Mark all events that occur while OTTD mode is active. | |
| # Block rows receive additional probability/classification overrides below. | |
| for event in events: | |
| if not isinstance(event, dict): | |
| continue | |
| event["ottd_mode_active"] = False | |
| self._append_ottd_mode_before_step_boundary_note(event) | |
| mode_context = self._get_ottd_mode_context_for_event(event) | |
| if mode_context is None: | |
| continue | |
| _, mode = mode_context | |
| event["ottd_mode_active"] = True | |
| self._append_ottd_mode_notes_for_event(event, mode) | |
| if str(event.get("roll_category") or "").lower() != "block": | |
| continue | |
| dice_values = event.get("dice_values") | |
| if not isinstance(dice_values, list) or len(dice_values) == 0: | |
| continue | |
| normalized_dice = self._normalize_block_dice_values(dice_values) | |
| if len(normalized_dice) != len(dice_values): | |
| continue | |
| raw_attacker_skills = event.get("player_skills", []) | |
| raw_defender_skills = event.get("target_player_skills", []) | |
| attacker_skills = raw_attacker_skills if isinstance(raw_attacker_skills, list) else [] | |
| defender_skills = raw_defender_skills if isinstance(raw_defender_skills, list) else [] | |
| attacker_choose = self._get_attacker_choice_from_block(event) | |
| defender_has_ball = bool(event.get("target_has_ball", False)) | |
| attacker_is_blitzing = bool(event.get("attacker_is_blitzing", False)) | |
| result, p_success, p_neutral, p_fail = self.calculator.calc_blocks_ottd( | |
| normalized_dice, | |
| attacker_skills, | |
| defender_skills, | |
| isAttackerChoose=attacker_choose, | |
| defenderHasBall=defender_has_ball, | |
| attackerIsBlitzing=attacker_is_blitzing, | |
| ) | |
| event["result_value"] = result | |
| if result == 1: | |
| event["result_classification"] = "success" | |
| event["report_result_classification"] = "success" | |
| else: | |
| event["result_classification"] = "fail" | |
| event["report_result_classification"] = "fail" | |
| event["probability_success"] = p_success | |
| event["probability_neutral"] = p_neutral | |
| event["probability_fail"] = p_fail | |
| # Build activation groups for OTTD-active rows only. | |
| activation_rows = defaultdict(list) | |
| for idx, event in enumerate(events): | |
| if not isinstance(event, dict): | |
| continue | |
| if not bool(event.get("ottd_mode_active")): | |
| continue | |
| category = str(event.get("roll_category") or "") | |
| if category not in ("block", "action", "other"): | |
| continue | |
| player_name = str(event.get("player_name") or "") | |
| if player_name in ("", "Unknown"): | |
| continue | |
| activation_key = ( | |
| str(event.get("game_turn")), | |
| str(event.get("team_id")), | |
| str(event.get("player_id")), | |
| player_name, | |
| ) | |
| activation_rows[activation_key].append(idx) | |
| # OTTD Pro sequence adjustment: block -> Pro(test) -> block. | |
| for row_indexes in activation_rows.values(): | |
| row_count = len(row_indexes) | |
| if row_count < 3: | |
| continue | |
| for pos, block1_index in enumerate(row_indexes): | |
| block1 = events[block1_index] | |
| if str(block1.get("roll_category") or "") != "block": | |
| continue | |
| scan_pos = pos + 1 | |
| pro_event_pos = None | |
| while scan_pos < row_count: | |
| candidate = events[row_indexes[scan_pos]] | |
| if self._is_successful_pro_action_roll(candidate): | |
| if pro_event_pos is None: | |
| pro_event_pos = scan_pos | |
| scan_pos += 1 | |
| else: | |
| break | |
| if pro_event_pos is None: | |
| continue | |
| pro_event = events[row_indexes[pro_event_pos]] | |
| pro_reroll_index = pro_event.get("pro_reroll_index") | |
| if pro_reroll_index is None: | |
| continue | |
| block2_pos = None | |
| while scan_pos < row_count: | |
| candidate = events[row_indexes[scan_pos]] | |
| if str(candidate.get("roll_category") or "") == "block": | |
| block2_pos = scan_pos | |
| break | |
| scan_pos += 1 | |
| if block2_pos is None: | |
| continue | |
| block2 = events[row_indexes[block2_pos]] | |
| self._calculate_block_pro_block_probs_ottd(block1, block2, pro_reroll_index) | |
| if bool(block2.get("ottd_mode_active")): | |
| self._append_event_note(block2, "OTTD mode") | |
| # OTTD Pro available but not tested. | |
| for event in events: | |
| if not isinstance(event, dict): | |
| continue | |
| if not bool(event.get("ottd_mode_active")): | |
| continue | |
| if str(event.get("roll_category") or "") != "block": | |
| continue | |
| if not bool(event.get("pro_not_tested")): | |
| continue | |
| dice_values = event.get("dice_values") | |
| if not isinstance(dice_values, list) or len(dice_values) == 0: | |
| continue | |
| normalized_dice = self._normalize_block_dice_values(dice_values) | |
| if len(normalized_dice) != len(dice_values): | |
| continue | |
| raw_attacker_skills = event.get("player_skills", []) | |
| raw_defender_skills = event.get("target_player_skills", []) | |
| attacker_skills = raw_attacker_skills if isinstance(raw_attacker_skills, list) else [] | |
| defender_skills = raw_defender_skills if isinstance(raw_defender_skills, list) else [] | |
| attacker_choose = self._get_attacker_choice_from_block(event) | |
| defender_has_ball = bool(event.get("target_has_ball", False)) | |
| attacker_is_blitzing = bool(event.get("attacker_is_blitzing", False)) | |
| probs = self.calculator.calc_blocks_with_pro_ottd( | |
| normalized_dice, | |
| attacker_skills, | |
| defender_skills, | |
| isAttackerChoose=attacker_choose, | |
| defenderHasBall=defender_has_ball, | |
| attackerIsBlitzing=attacker_is_blitzing, | |
| ) | |
| if probs is None: | |
| continue | |
| p_success, p_neutral, p_fail = probs | |
| event["probability_success"] = p_success | |
| event["probability_neutral"] = p_neutral | |
| event["probability_fail"] = p_fail | |
| event["is_pro_adjusted_block"] = True | |
| self._append_event_note(event, "pro-adjusted (skill available; not tested)") | |
| self._append_event_note(event, "OTTD mode") | |
| # OTTD Brawler sequence and offered-not-used adjustment. | |
| active_block_events = [ | |
| event | |
| for event in events | |
| if isinstance(event, dict) | |
| and bool(event.get("ottd_mode_active")) | |
| and str(event.get("roll_category") or "") == "block" | |
| ] | |
| source_by_step = {} | |
| for event in active_block_events: | |
| if str(event.get("brawler_status") or "") != "used": | |
| continue | |
| step_number = event.get("step_number") | |
| if isinstance(step_number, int): | |
| source_by_step[step_number] = event | |
| for event in active_block_events: | |
| if not bool(event.get("is_brawler_reroll_block")): | |
| continue | |
| source_step = event.get("brawler_source_step") | |
| source_block = source_by_step.get(source_step) | |
| if not isinstance(source_block, dict): | |
| continue | |
| brawler_reroll_index = source_block.get("brawler_used_index") | |
| self._calculate_block_brawler_block_probs_ottd(source_block, event, brawler_reroll_index) | |
| self._append_event_note(event, "OTTD mode") | |
| for event in active_block_events: | |
| if bool(event.get("is_brawler_reroll_block")): | |
| continue | |
| brawler_status = str(event.get("brawler_status") or "") | |
| if not brawler_status.startswith("offered_not_used"): | |
| continue | |
| dice_values = event.get("dice_values") | |
| if not isinstance(dice_values, list) or len(dice_values) == 0: | |
| continue | |
| normalized_dice = self._normalize_block_dice_values(dice_values) | |
| if len(normalized_dice) != len(dice_values): | |
| continue | |
| raw_attacker_skills = event.get("player_skills", []) | |
| raw_defender_skills = event.get("target_player_skills", []) | |
| attacker_skills = raw_attacker_skills if isinstance(raw_attacker_skills, list) else [] | |
| defender_skills = raw_defender_skills if isinstance(raw_defender_skills, list) else [] | |
| attacker_choose = self._get_attacker_choice_from_block(event) | |
| defender_has_ball = bool(event.get("target_has_ball", False)) | |
| attacker_is_blitzing = bool(event.get("attacker_is_blitzing", False)) | |
| probs = self.calculator.calc_blocks_with_brawler_ottd( | |
| normalized_dice, | |
| attacker_skills, | |
| defender_skills, | |
| isAttackerChoose=attacker_choose, | |
| defenderHasBall=defender_has_ball, | |
| attackerIsBlitzing=attacker_is_blitzing, | |
| ) | |
| if probs is None: | |
| continue | |
| p_success, p_neutral, p_fail = probs | |
| event["probability_success"] = p_success | |
| event["probability_neutral"] = p_neutral | |
| event["probability_fail"] = p_fail | |
| event["is_brawler_adjusted_block"] = True | |
| self._append_event_note(event, "brawler-adjusted block (not used)") | |
| self._append_event_note(event, "OTTD mode") | |
| def _enrich_block_pro_block_probabilities(self): | |
| """Enrich conditional probabilities for second block in block-pro-block sequences. | |
| Two patterns handled: | |
| 1. block-pro(test)-block sequences: Calculate conditional probabilities for second block | |
| 2. Blocks by Pro players where Pro was NOT tested: Apply Pro adjustment to first block | |
| """ | |
| events = getattr(self.game_state, "roll_events", None) | |
| if not isinstance(events, list) or len(events) == 0: | |
| return | |
| # Group events by player activation | |
| activation_rows = defaultdict(list) | |
| for idx, event in enumerate(events): | |
| if not isinstance(event, dict): | |
| continue | |
| category = str(event.get("roll_category") or "") | |
| if category not in ("block", "action", "other"): | |
| continue | |
| player_name = str(event.get("player_name") or "") | |
| if player_name in ("", "Unknown"): | |
| continue | |
| activation_key = ( | |
| str(event.get("game_turn")), | |
| str(event.get("team_id")), | |
| str(event.get("player_id")), | |
| player_name, | |
| ) | |
| activation_rows[activation_key].append(idx) | |
| # Process each activation for block-pro-block patterns | |
| for row_indexes in activation_rows.values(): | |
| row_count = len(row_indexes) | |
| if row_count < 3: # Need at least: block, pro, block | |
| continue | |
| for pos, block1_index in enumerate(row_indexes): | |
| block1 = events[block1_index] | |
| if str(block1.get("roll_category") or "") != "block": | |
| continue | |
| # Scan for successful Pro test(s) following this block | |
| scan_pos = pos + 1 | |
| pro_event_pos = None | |
| while scan_pos < row_count: | |
| candidate = events[row_indexes[scan_pos]] | |
| if self._is_successful_pro_action_roll(candidate): | |
| if pro_event_pos is None: | |
| pro_event_pos = scan_pos | |
| scan_pos += 1 | |
| else: | |
| break | |
| if pro_event_pos is None: | |
| continue | |
| # Get pro_reroll_index from Pro event | |
| pro_event = events[row_indexes[pro_event_pos]] | |
| pro_reroll_index = pro_event.get("pro_reroll_index") | |
| if pro_reroll_index is None: | |
| continue | |
| # Find second block following the Pro test(s) | |
| block2_pos = None | |
| while scan_pos < row_count: | |
| candidate = events[row_indexes[scan_pos]] | |
| if str(candidate.get("roll_category") or "") == "block": | |
| block2_pos = scan_pos | |
| break | |
| scan_pos += 1 | |
| if block2_pos is None: | |
| continue | |
| block2 = events[row_indexes[block2_pos]] | |
| # Calculate and assign conditional probabilities for block2 | |
| self._calculate_block_pro_block_probs(block1, block2, pro_reroll_index) | |
| # === PATTERN 2: Pro available but not tested === | |
| # Apply Pro adjustment to blocks where player has Pro but didn't test it | |
| for event in events: | |
| if not isinstance(event, dict): | |
| continue | |
| if str(event.get("roll_category") or "") != "block": | |
| continue | |
| # Only process blocks marked as pro_not_tested | |
| if not bool(event.get("pro_not_tested")): | |
| continue | |
| dice_values = event.get("dice_values") | |
| if not isinstance(dice_values, list) or len(dice_values) == 0: | |
| continue | |
| normalized_dice = self._normalize_block_dice_values(dice_values) | |
| if len(normalized_dice) != len(dice_values): | |
| continue | |
| raw_attacker_skills = event.get("player_skills", []) | |
| raw_defender_skills = event.get("target_player_skills", []) | |
| attacker_skills = raw_attacker_skills if isinstance(raw_attacker_skills, list) else [] | |
| defender_skills = raw_defender_skills if isinstance(raw_defender_skills, list) else [] | |
| attacker_choose = self._get_attacker_choice_from_block(event) | |
| defender_has_ball = bool(event.get("target_has_ball", False)) | |
| attacker_is_blitzing = bool(event.get("attacker_is_blitzing", False)) | |
| probs = self.calculator.calc_blocks_with_pro( | |
| normalized_dice, | |
| attacker_skills, | |
| defender_skills, | |
| isAttackerChoose=attacker_choose, | |
| defenderHasBall=defender_has_ball, | |
| attackerIsBlitzing=attacker_is_blitzing, | |
| ) | |
| if probs is None: | |
| continue | |
| p_success, p_neutral, p_fail = probs | |
| event["probability_success"] = p_success | |
| event["probability_neutral"] = p_neutral | |
| event["probability_fail"] = p_fail | |
| event["is_pro_adjusted_block"] = True | |
| self._append_event_note(event, "pro-adjusted (skill available; not tested)") | |
| def _enrich_block_brawler_block_probabilities(self): | |
| """Enrich probabilities for Brawler-eligible blocks where Brawler was not used. | |
| Rules: | |
| - Only apply when brawler_status indicates offered-but-not-used. | |
| - Do not apply on blitz blocks. | |
| - Do not apply when both-down is already in success faces (handled by calculator guardrail). | |
| - If Brawler was used, leave probabilities unchanged. | |
| """ | |
| block_events = getattr(self, "_events_by_cat", {}).get("block", ()) | |
| if not block_events: | |
| return | |
| # First handle block->brawler(block) sequences where Brawler is used. | |
| source_by_step = {} | |
| for event in block_events: | |
| if str(event.get("brawler_status") or "") != "used": | |
| continue | |
| step_number = event.get("step_number") | |
| if isinstance(step_number, int): | |
| source_by_step[step_number] = event | |
| for event in block_events: | |
| if not bool(event.get("is_brawler_reroll_block")): | |
| continue | |
| source_step = event.get("brawler_source_step") | |
| source_block = source_by_step.get(source_step) | |
| if not isinstance(source_block, dict): | |
| continue | |
| brawler_reroll_index = source_block.get("brawler_used_index") | |
| self._calculate_block_brawler_block_probs(source_block, event, brawler_reroll_index) | |
| for event in block_events: | |
| # Rerolled block rows are handled by the Brawler-used branch above. | |
| if bool(event.get("is_brawler_reroll_block")): | |
| continue | |
| brawler_status = str(event.get("brawler_status") or "") | |
| if not brawler_status.startswith("offered_not_used"): | |
| continue | |
| # Guardrail: Brawler is not legal on blitz blocks. | |
| if bool(event.get("attacker_is_blitzing")): | |
| continue | |
| dice_values = event.get("dice_values") | |
| if not isinstance(dice_values, list) or len(dice_values) == 0: | |
| continue | |
| normalized_dice = self._normalize_block_dice_values(dice_values) | |
| if len(normalized_dice) != len(dice_values): | |
| continue | |
| raw_attacker_skills = event.get("player_skills", []) | |
| raw_defender_skills = event.get("target_player_skills", []) | |
| attacker_skills = raw_attacker_skills if isinstance(raw_attacker_skills, list) else [] | |
| defender_skills = raw_defender_skills if isinstance(raw_defender_skills, list) else [] | |
| attacker_choose = self._get_attacker_choice_from_block(event) | |
| defender_has_ball = bool(event.get("target_has_ball", False)) | |
| probs = self.calculator.calc_blocks_with_brawler( | |
| normalized_dice, | |
| attacker_skills, | |
| defender_skills, | |
| isAttackerChoose=attacker_choose, | |
| defenderHasBall=defender_has_ball, | |
| attackerIsBlitzing=False, | |
| ) | |
| if probs is None: | |
| continue | |
| p_success, p_neutral, p_fail = probs | |
| event["probability_success"] = p_success | |
| event["probability_neutral"] = p_neutral | |
| event["probability_fail"] = p_fail | |
| event["is_brawler_adjusted_block"] = True | |
| self._append_event_note(event, "brawler-adjusted block (not used)") | |
| def _block_repeat_signature(self, event): | |
| """Build a stable signature used to match repeated Pro-fail block restatements.""" | |
| if not isinstance(event, dict): | |
| return None | |
| raw = event.get("raw") if isinstance(event.get("raw"), dict) else {} | |
| return ( | |
| str(event.get("player_id")), | |
| str(event.get("target_player_id")), | |
| tuple(event.get("dice_values") if isinstance(event.get("dice_values"), list) else ()), | |
| str(raw.get("AttackerChoice") if raw.get("AttackerChoice") is not None else "1"), | |
| ) | |
| def _drop_repeat_block_after_failed_pro(self): | |
| """Drop duplicate block restatements after failed Pro test sequences.""" | |
| return self.skill_transition_backend.drop_repeat_block_after_failed_pro() | |
| def _drop_duplicate_block_after_successful_pro(self): | |
| """Drop duplicate block restatements after successful Pro reroll declarations.""" | |
| return self.skill_transition_backend.drop_duplicate_block_after_successful_pro() | |
| def process_replay(self, jsonData): | |
| """Main method to process the entire replay and build game state.""" | |
| # Pipeline contract (canonical processing order): | |
| # Stage 1: Parse replay payload into canonical step/roll event structures. | |
| # Stage 2: Annotate semantic markers on canonical events (no probability writes). | |
| # Stage 3: Enrich probability fields on already-annotated canonical events. | |
| # Stage 4: Finalize state for downstream report/stats consumers. | |
| # | |
| # Side-effect boundaries: | |
| # - Parse: may create/update game_state steps/events and metadata snapshots. | |
| # - Annotate: may add semantic status/notes/flags to existing events. | |
| # - Enrich: may write probability_* fields and adjusted-* flags. | |
| # - Finalize: no new semantic derivation; expose stable game_state to readers. | |
| # Stage 0: Reset per-run state and prepare replay inputs. | |
| self._reset_processing_state() | |
| # Build the player lookup first | |
| self.build_player_lookup(jsonData) | |
| replay_steps = self._prepare_replay_steps(jsonData) | |
| self._annotate_player_lookup_kickoff_los(replay_steps) | |
| # Stage 1: Parse replay steps into canonical step + roll-event streams. | |
| run_parse_stage(self, replay_steps) | |
| # Stage 1b: Append canonical synthetic rows derived from parsed replay context. | |
| run_post_parse_synthetic_stage(self, replay_steps) | |
| # Stage 2: Semantic annotation (status/flags/notes only; no generic probability writes). | |
| run_annotation_stage(self, replay_steps) | |
| # Stage 3: Probability enrichment on the canonical annotated event stream. | |
| run_probability_enrichment_stage(self) | |
| # Stage 4: Finalized canonical game_state is now available to report/stats readers. | |
| # Stage 5: Stamp step-turn counter game_turn onto all roll events. | |
| # After this, event["game_turn"] is the step-turn counter value for all | |
| # events with a step_number in the gt_map. Downstream report/stats code | |
| # no longer needs to apply any post-hoc game_turn correction. | |
| run_step_turn_stage(self) | |
| # Stage 6: Detect structural OTTD attempt turns. | |
| # Must run after step-turn stamping so kickoff event game_turn values | |
| # are the final canonical turn numbers. | |
| self._detect_ottd_attempt_turns() | |
| # Stage 7: Override block probabilities/classification in active OTTD mode windows. | |
| self._apply_ottd_mode_block_probability_overrides() | |
| def get_game_state(self): | |
| """Return the processed game state.""" | |
| return self.game_state | |