"""MTG-specific deck extensions.""" import html import re from odoo import _, api, fields, models from odoo.exceptions import UserError class MvdTcgDeck(models.Model): """Extend neutral decks with MTG-oriented overview fields.""" _inherit = "mvd.tcg.deck" _MTG_COLOR_PIP_ORDER = ("W", "U", "B", "R", "G", "C") _MTG_COLOR_PIP_LABELS = { "W": "White", "U": "Blue", "B": "Black", "R": "Red", "G": "Green", "C": "Colorless", } _MTG_COLOR_IDENTITY_NAME_MAP = { "C": "Colorless", "W": "White", "U": "Blue", "B": "Black", "R": "Red", "G": "Green", "WU": "Azorius", "WB": "Orzhov", "WR": "Boros", "WG": "Selesnya", "UB": "Dimir", "UR": "Izzet", "UG": "Simic", "BR": "Rakdos", "BG": "Golgari", "RG": "Gruul", "WUB": "Esper", "UBR": "Grixis", "BRG": "Jund", "WRG": "Naya", "WUG": "Bant", "WBR": "Mardu", "WUR": "Jeskai", "WBG": "Abzan", "URG": "Temur", "UBG": "Sultai", "WUBR": "Yore-Tiller", "WUBG": "Witch-Maw", "WURG": "Ink-Treader", "WBRG": "Dune-Brood", "UBRG": "Glint-Eye", "WUBRG": "Five-Color", } _MTG_BOARD_FIELD_MAP = { "command_zone": { "board": "mtg_command_zone_board_id", "count": "mtg_command_zone_count", "lines": "mtg_command_zone_line_ids", }, "mainboard": { "board": "mtg_mainboard_board_id", "count": "mtg_mainboard_count", "lines": "mtg_mainboard_line_ids", }, "sideboard": { "board": "mtg_sideboard_board_id", "count": "mtg_sideboard_count", "lines": "mtg_sideboard_line_ids", }, "maybeboard": { "board": "mtg_maybeboard_board_id", "count": "mtg_maybeboard_count", "lines": "mtg_maybeboard_line_ids", }, } _MTG_COMMANDER_STYLE_FORMATS = frozenset( {"commander", "duel", "paupercommander", "predh"} ) _MTG_BRAWL_STYLE_FORMATS = frozenset({"brawl", "standardbrawl"}) _MTG_SIDEBOARD_FORMATS = frozenset( {"standard", "pioneer", "modern", "legacy", "pauper", "vintage", "premodern", "oldschool"} ) _MTG_SINGLETON_FORMATS = _MTG_COMMANDER_STYLE_FORMATS | _MTG_BRAWL_STYLE_FORMATS is_mtg_deck = fields.Boolean(compute="_compute_is_mtg_deck") mtg_format_id = fields.Many2one( "mvd.tcg.mtg.format", string="Format", ondelete="restrict", ) mtg_command_zone_board_id = fields.Many2one( "mvd.tcg.deck.board", string="Command Zone Board", compute="_compute_mtg_boards", readonly=True, store=True, ) mtg_mainboard_board_id = fields.Many2one( "mvd.tcg.deck.board", string="Mainboard", compute="_compute_mtg_boards", readonly=True, store=True, ) mtg_sideboard_board_id = fields.Many2one( "mvd.tcg.deck.board", string="Sideboard", compute="_compute_mtg_boards", readonly=True, store=True, ) mtg_maybeboard_board_id = fields.Many2one( "mvd.tcg.deck.board", string="Maybeboard", compute="_compute_mtg_boards", readonly=True, store=True, ) mtg_command_zone_count = fields.Integer( string="Command Zone Count", compute="_compute_mtg_boards", store=True, ) mtg_mainboard_count = fields.Integer( string="Mainboard Count", compute="_compute_mtg_boards", store=True, ) mtg_sideboard_count = fields.Integer( string="Sideboard Count", compute="_compute_mtg_boards", store=True, ) mtg_maybeboard_count = fields.Integer( string="Maybeboard Count", compute="_compute_mtg_boards", store=True, ) mtg_command_zone_line_ids = fields.One2many( "mvd.tcg.deck.line", string="Command Zone Cards", compute="_compute_mtg_board_line_ids", inverse="_inverse_mtg_command_zone_line_ids", readonly=False, ) mtg_mainboard_line_ids = fields.One2many( "mvd.tcg.deck.line", string="Mainboard Cards", compute="_compute_mtg_board_line_ids", inverse="_inverse_mtg_mainboard_line_ids", readonly=False, ) mtg_sideboard_line_ids = fields.One2many( "mvd.tcg.deck.line", string="Sideboard Cards", compute="_compute_mtg_board_line_ids", inverse="_inverse_mtg_sideboard_line_ids", readonly=False, ) mtg_maybeboard_line_ids = fields.One2many( "mvd.tcg.deck.line", string="Maybeboard Cards", compute="_compute_mtg_board_line_ids", inverse="_inverse_mtg_maybeboard_line_ids", readonly=False, ) mtg_commander_card_id = fields.Many2one( "mvd.tcg.card", string="Commander", compute="_compute_mtg_header", readonly=True, ) mtg_commander_image = fields.Image( string="Commander Image", compute="_compute_mtg_header", readonly=True, ) mtg_color_identity_ids = fields.Many2many( "mvd.tcg.mtg.color", compute="_compute_mtg_overview", string="Color Identity", ) mtg_color_identity_signature = fields.Char( string="Color Identity Signature", compute="_compute_mtg_overview", readonly=True, ) mtg_color_identity_name = fields.Char( compute="_compute_mtg_overview", string="Color Identity Name", readonly=True, ) mtg_average_mana_value = fields.Float( string="Average Mana Value", compute="_compute_mtg_overview", readonly=True, digits=(16, 2), ) mtg_land_count = fields.Integer( string="Lands", compute="_compute_mtg_overview", readonly=True, ) mtg_creature_count = fields.Integer( string="Creatures", compute="_compute_mtg_overview", readonly=True, ) mtg_artifact_count = fields.Integer( string="Artifacts", compute="_compute_mtg_overview", readonly=True, ) mtg_enchantment_count = fields.Integer( string="Enchantments", compute="_compute_mtg_overview", readonly=True, ) mtg_planeswalker_count = fields.Integer( string="Planeswalkers", compute="_compute_mtg_overview", readonly=True, ) mtg_instant_count = fields.Integer( string="Instants", compute="_compute_mtg_overview", readonly=True, ) mtg_sorcery_count = fields.Integer( string="Sorceries", compute="_compute_mtg_overview", readonly=True, ) mtg_expected_mainboard_size = fields.Integer( string="Expected Mainboard Size", compute="_compute_mtg_rule_hints", readonly=True, ) mtg_expected_sideboard_size = fields.Integer( string="Expected Sideboard Size", compute="_compute_mtg_rule_hints", readonly=True, ) mtg_expected_command_zone_size = fields.Integer( string="Expected Command Zone Size", compute="_compute_mtg_rule_hints", readonly=True, ) mtg_mainboard_size_ok = fields.Boolean( string="Mainboard Size OK", compute="_compute_mtg_rule_hints", ) mtg_sideboard_size_ok = fields.Boolean( string="Sideboard Size OK", compute="_compute_mtg_rule_hints", ) mtg_command_zone_size_ok = fields.Boolean( string="Command Zone Size OK", compute="_compute_mtg_rule_hints", ) mtg_color_identity_ok = fields.Boolean( string="Color Identity OK", compute="_compute_mtg_rule_hints", ) mtg_singleton_ok = fields.Boolean( string="Singleton OK", compute="_compute_mtg_rule_hints", ) mtg_legality_ok = fields.Boolean( string="Legality OK", compute="_compute_mtg_rule_hints", ) mtg_commander_eligibility_ok = fields.Boolean( string="Commander Eligibility OK", compute="_compute_mtg_rule_hints", ) mtg_off_color_card_count = fields.Integer( string="Off-Color Cards", compute="_compute_mtg_rule_hints", ) mtg_duplicate_card_count = fields.Integer( string="Duplicate Cards", compute="_compute_mtg_rule_hints", ) mtg_illegal_card_count = fields.Integer( string="Illegal Cards", compute="_compute_mtg_rule_hints", ) mtg_restricted_card_count = fields.Integer( string="Restricted Cards", compute="_compute_mtg_rule_hints", ) mtg_issue_line_count = fields.Integer( string="Issue Lines", compute="_compute_mtg_rule_hints", ) mtg_rule_warning_count = fields.Integer( string="Rule Warnings", compute="_compute_mtg_rule_hints", ) mtg_rule_summary = fields.Html( string="Rule Summary", compute="_compute_mtg_rule_hints", readonly=True, ) mtg_tagged_line_count = fields.Integer( string="Tagged Cards", compute="_compute_mtg_analysis_panels", readonly=True, ) mtg_untagged_line_count = fields.Integer( string="Untagged Cards", compute="_compute_mtg_analysis_panels", readonly=True, ) mtg_role_coverage_ratio = fields.Float( string="Role Coverage", compute="_compute_mtg_analysis_panels", readonly=True, digits=(16, 2), ) mtg_mana_curve_html = fields.Html( string="Mana Curve", compute="_compute_mtg_analysis_panels", sanitize_attributes=False, sanitize_form=False, readonly=True, ) mtg_type_breakdown_html = fields.Html( string="Type Composition", compute="_compute_mtg_analysis_panels", sanitize_attributes=False, sanitize_form=False, readonly=True, ) mtg_role_breakdown_html = fields.Html( string="Role Breakdown", compute="_compute_mtg_analysis_panels", sanitize_attributes=False, sanitize_form=False, readonly=True, ) mtg_color_pip_breakdown_html = fields.Html( string="Color Pips", compute="_compute_mtg_analysis_panels", sanitize_attributes=False, sanitize_form=False, readonly=True, ) @api.depends("game_id.code") def _compute_is_mtg_deck(self): """Flag whether the current deck belongs to the MTG game adapter.""" for deck in self: deck.is_mtg_deck = deck.game_id.code == "mtg" def _mtg_get_format_code(self): """Return the normalized MTG format code for the current deck. Returns: str: Lowercase MTG format code, or an empty string when unset. """ self.ensure_one() return (self.mtg_format_id.code or "").strip().lower() @classmethod def _mtg_is_commander_style_format_code(cls, format_code): """Return whether one format behaves like a Commander-style format. Args: format_code: Candidate MTG format code. Returns: bool: ``True`` for Commander-style formats. """ return (format_code or "").strip().lower() in cls._MTG_COMMANDER_STYLE_FORMATS @classmethod def _mtg_is_brawl_style_format_code(cls, format_code): """Return whether one format behaves like a Brawl-style format. Args: format_code: Candidate MTG format code. Returns: bool: ``True`` for Brawl-style formats. """ return (format_code or "").strip().lower() in cls._MTG_BRAWL_STYLE_FORMATS @classmethod def _mtg_format_enforces_singleton_code(cls, format_code): """Return whether one format enforces singleton deckbuilding. Args: format_code: Candidate MTG format code. Returns: bool: ``True`` when the format enforces singleton deckbuilding. """ return (format_code or "").strip().lower() in cls._MTG_SINGLETON_FORMATS @classmethod def _mtg_get_format_profile(cls, format_code): """Return the consolidated MTG policy profile for one format. Args: format_code: Candidate MTG format code. Returns: dict: Consolidated size expectations and rule flags. """ normalized_format_code = (format_code or "").strip().lower() commander_style = cls._mtg_is_commander_style_format_code(normalized_format_code) brawl_style = cls._mtg_is_brawl_style_format_code(normalized_format_code) profile = { "code": normalized_format_code, "commander_style": commander_style, "brawl_style": brawl_style, "singleton": cls._mtg_format_enforces_singleton_code(normalized_format_code), "requires_commander": commander_style or brawl_style, "mainboard": 0, "sideboard": 0, "command_zone": 0, } if commander_style: profile.update({"mainboard": 99, "command_zone": 1}) elif brawl_style: profile.update({"mainboard": 59, "command_zone": 1}) elif normalized_format_code in cls._MTG_SIDEBOARD_FORMATS: profile.update({"mainboard": 60, "sideboard": 15}) return profile @api.depends( "board_ids", "board_ids.code", "board_ids.total_card_count", "board_ids.line_ids.quantity", ) def _compute_mtg_boards(self): """Resolve canonical MTG boards and their card counts.""" for deck in self: for board_code, field_map in self._MTG_BOARD_FIELD_MAP.items(): board = deck._mvd_tcg_get_board_by_code(board_code) deck[field_map["board"]] = board deck[field_map["count"]] = board.total_card_count if board else 0 @api.depends( "board_ids.code", "board_ids.line_ids.sequence", "board_ids.line_ids.card_id", "board_ids.line_ids.card_id.image_1920", ) def _compute_mtg_header(self): """Pick the primary MTG commander-style header card.""" for deck in self: commander_line = deck.mtg_command_zone_line_ids.sorted( key=lambda line: (line.sequence, line.id) )[:1] commander_card = commander_line.card_id if commander_line else False deck.mtg_commander_card_id = commander_card deck.mtg_commander_image = ( commander_card._mvd_tcg_get_deck_image_binary() if commander_card else False ) @api.depends( "mtg_command_zone_board_id", "mtg_command_zone_board_id.line_ids", "mtg_mainboard_board_id", "mtg_mainboard_board_id.line_ids", "mtg_sideboard_board_id", "mtg_sideboard_board_id.line_ids", "mtg_maybeboard_board_id", "mtg_maybeboard_board_id.line_ids", ) def _compute_mtg_board_line_ids(self): """Expose canonical MTG board lines directly on the deck form.""" for deck in self: for field_map in self._MTG_BOARD_FIELD_MAP.values(): deck[field_map["lines"]] = deck[field_map["board"]].line_ids def _inverse_mtg_board_line_ids(self, board_code, field_name): """Propagate deferred x2many edits back to one canonical MTG board. Args: board_code: Stable logical board code such as ``mainboard``. field_name: Deck field name that currently mirrors the board lines. """ for deck in self: board = deck._mvd_tcg_get_board_by_code(board_code) if not board: continue desired_lines = deck[field_name] current_lines = board.line_ids removed_lines = current_lines - desired_lines added_lines = desired_lines - current_lines if added_lines: added_lines.write({"board_id": board.id}) if removed_lines: removed_lines.unlink() def _inverse_mtg_command_zone_line_ids(self): """Apply deferred Command Zone x2many edits.""" self._inverse_mtg_board_line_ids("command_zone", "mtg_command_zone_line_ids") def _inverse_mtg_mainboard_line_ids(self): """Apply deferred Mainboard x2many edits.""" self._inverse_mtg_board_line_ids("mainboard", "mtg_mainboard_line_ids") def _inverse_mtg_sideboard_line_ids(self): """Apply deferred Sideboard x2many edits.""" self._inverse_mtg_board_line_ids("sideboard", "mtg_sideboard_line_ids") def _inverse_mtg_maybeboard_line_ids(self): """Apply deferred Maybeboard x2many edits.""" self._inverse_mtg_board_line_ids("maybeboard", "mtg_maybeboard_line_ids") @api.depends( "board_ids.code", "line_ids.quantity", "line_ids.board_id.include_in_total", "line_ids.card_id", "line_ids.card_id.mtg_mana_value", "line_ids.card_id.mtg_card_type_ids", "line_ids.card_id.mtg_card_type_ids.code", "line_ids.card_id.mtg_color_identity_ids", "line_ids.card_id.mtg_color_identity_ids.sequence", "line_ids.card_id.mtg_color_identity_ids.code", ) def _compute_mtg_overview(self): """Compute MTG deck statistics and color identity signals.""" type_field_map = { "artifact": "mtg_artifact_count", "creature": "mtg_creature_count", "enchantment": "mtg_enchantment_count", "instant": "mtg_instant_count", "land": "mtg_land_count", "planeswalker": "mtg_planeswalker_count", "sorcery": "mtg_sorcery_count", } for deck in self: for field_name in type_field_map.values(): deck[field_name] = 0 included_lines = deck.line_ids.filtered( lambda line: line.board_id.include_in_total and line.card_id.game_id.code == "mtg" ) mana_lines = included_lines.filtered( lambda line: "land" not in set(line.card_id.mtg_card_type_ids.mapped("code")) ) total_quantity = sum(mana_lines.mapped("quantity")) total_mana_value = sum( line.quantity * line.card_id.mtg_mana_value for line in mana_lines ) deck.mtg_average_mana_value = ( total_mana_value / total_quantity if total_quantity else 0.0 ) for line in included_lines: type_codes = set(line.card_id.mtg_card_type_ids.mapped("code")) for type_code, field_name in type_field_map.items(): if type_code in type_codes: deck[field_name] += line.quantity identity_cards = deck.mtg_command_zone_line_ids.mapped("card_id") or included_lines.mapped("card_id") colors = identity_cards.mapped("mtg_color_identity_ids").sorted( key=lambda color: (color.sequence, color.code or "", color.id) ) deck.mtg_color_identity_ids = colors deck.mtg_color_identity_signature = "".join( (color.code or "").strip().upper() for color in colors ) or False deck.mtg_color_identity_name = deck._mtg_get_color_identity_name( deck.mtg_color_identity_signature ) @api.depends( "mtg_format_id", "mtg_format_id.code", "mtg_mainboard_count", "mtg_sideboard_count", "mtg_command_zone_count", "board_ids.line_ids.card_id", "line_ids.quantity", "line_ids.mtg_color_identity_violation", "line_ids.mtg_singleton_violation", "line_ids.mtg_legality_ok", "line_ids.mtg_legality_status", "line_ids.mtg_issue_count", "line_ids.card_id", "line_ids.card_id.mtg_color_identity_signature", "line_ids.card_id.mtg_type_line", "line_ids.card_id.mtg_oracle_text", "mtg_color_identity_signature", ) def _compute_mtg_rule_hints(self): """Compute lightweight MTG rule hints for the current deck.""" for deck in self: format_code = deck._mtg_get_format_code() format_profile = deck._mtg_get_format_profile(format_code) expected_mainboard = format_profile["mainboard"] expected_sideboard = format_profile["sideboard"] expected_command_zone = format_profile["command_zone"] deck.mtg_expected_mainboard_size = expected_mainboard deck.mtg_expected_sideboard_size = expected_sideboard deck.mtg_expected_command_zone_size = expected_command_zone deck.mtg_mainboard_size_ok = ( deck.mtg_mainboard_count == expected_mainboard if expected_mainboard else True ) deck.mtg_sideboard_size_ok = ( deck.mtg_sideboard_count <= expected_sideboard if expected_sideboard else True ) deck.mtg_command_zone_size_ok = ( deck.mtg_command_zone_count == expected_command_zone if expected_command_zone else True ) issue_lines = deck.line_ids.filtered( lambda line: line.board_id.include_in_total and line.mtg_issue_count ) off_color_lines = issue_lines.filtered("mtg_color_identity_violation") singleton_lines = issue_lines.filtered("mtg_singleton_violation") illegal_lines = issue_lines.filtered(lambda line: not line.mtg_legality_ok) restricted_lines = illegal_lines.filtered( lambda line: line.mtg_legality_status == "restricted" ) banned_or_not_legal_lines = illegal_lines - restricted_lines commander_lines = deck.mtg_command_zone_line_ids.filtered("card_id") commander_eligibility_ok = True if format_profile["requires_commander"]: commander_eligibility_ok = bool(commander_lines) and all( deck._mtg_is_commander_eligible_card(line.card_id, format_code) for line in commander_lines ) deck.mtg_off_color_card_count = len(off_color_lines) deck.mtg_duplicate_card_count = len(singleton_lines) deck.mtg_illegal_card_count = len(banned_or_not_legal_lines) deck.mtg_restricted_card_count = len(restricted_lines) deck.mtg_issue_line_count = len(issue_lines) deck.mtg_color_identity_ok = not off_color_lines deck.mtg_singleton_ok = not singleton_lines deck.mtg_legality_ok = not illegal_lines deck.mtg_commander_eligibility_ok = commander_eligibility_ok warning_messages = [] if expected_command_zone and not deck.mtg_command_zone_size_ok: warning_messages.append( _( "Command Zone should contain exactly %(count)s card(s)." ) % {"count": expected_command_zone} ) if expected_mainboard and not deck.mtg_mainboard_size_ok: warning_messages.append( _( "Mainboard currently has %(current)s cards, expected %(expected)s." ) % { "current": deck.mtg_mainboard_count, "expected": expected_mainboard, } ) if expected_sideboard and not deck.mtg_sideboard_size_ok: warning_messages.append( _( "Sideboard currently has %(current)s cards, maximum is %(expected)s." ) % { "current": deck.mtg_sideboard_count, "expected": expected_sideboard, } ) if format_profile["requires_commander"] and not commander_eligibility_ok: warning_messages.append( _( "Current Command Zone cards are not valid commander choices for the selected format." ) ) if off_color_lines: warning_messages.append( _("%(count)s line(s) exceed the current commander color identity.") % {"count": len(off_color_lines)} ) if singleton_lines and format_profile["singleton"]: warning_messages.append( _( "%(count)s line(s) violate singleton rules. Basic lands and cards with explicit unlimited-copy text are ignored." ) % {"count": len(singleton_lines)} ) if banned_or_not_legal_lines: warning_messages.append( _("%(count)s line(s) are banned or not legal in %(format)s.") % { "count": len(banned_or_not_legal_lines), "format": deck.mtg_format_id.display_name or format_code, } ) if restricted_lines: warning_messages.append( _("%(count)s restricted line(s) exceed one copy in %(format)s.") % { "count": len(restricted_lines), "format": deck.mtg_format_id.display_name or format_code, } ) deck.mtg_rule_warning_count = len(warning_messages) if warning_messages: deck.mtg_rule_summary = "" % "".join( f"
  • {html.escape(message)}
  • " for message in warning_messages ) else: deck.mtg_rule_summary = ( "

    Current deck structure matches the active Commander and format checks.

    " if format_code else "

    Select a format to enable MTG rule hints.

    " ) def _mtg_format_enforces_singleton(self): """Return whether the active MTG format enforces singleton deckbuilding. Returns: bool: ``True`` for Commander- and Brawl-style singleton formats. """ self.ensure_one() return self._mtg_get_format_profile(self._mtg_get_format_code())["singleton"] def _mtg_get_singleton_conflict_lines(self, card, excluding_line=False): """Return included MTG lines that conflict with the given singleton key. Args: card: MTG card record that should be checked. excluding_line: Optional deck line that should be ignored. Returns: mvd.tcg.deck.line: Conflicting included deck lines. """ self.ensure_one() english_card = card.with_context(lang="en_US") line_model = self.env["mvd.tcg.deck.line"] if line_model._mtg_is_singleton_exempt(english_card): return line_model singleton_aliases = set(english_card.mtg_get_singleton_key_aliases()) if not singleton_aliases: return line_model excluding_line_id = excluding_line.id if excluding_line else False conflict_lines = line_model for line in self.line_ids.filtered( lambda current_line: current_line.card_id and current_line.card_id.game_id.code == "mtg" and current_line.board_id.include_in_total and current_line.id != excluding_line_id ): current_aliases = set( line.card_id.with_context(lang="en_US").mtg_get_singleton_key_aliases() ) if singleton_aliases & current_aliases: conflict_lines |= line return conflict_lines def _mvd_tcg_validate_add_to_board( self, card, board, quantity=1, existing_line=False, ): """Block duplicate MTG singleton cards during add-to-deck flows.""" self.ensure_one() result = super()._mvd_tcg_validate_add_to_board( card, board, quantity=quantity, existing_line=existing_line, ) if ( not self.is_mtg_deck or not board.include_in_total or not self._mtg_format_enforces_singleton() ): return result english_card = card.with_context(lang="en_US") conflict_lines = self._mtg_get_singleton_conflict_lines( english_card, excluding_line=existing_line, ) target_quantity = quantity + (existing_line.quantity if existing_line else 0) total_quantity = target_quantity + sum(conflict_lines.mapped("quantity")) if total_quantity <= 1: return result conflicting_names = ", ".join(conflict_lines.mapped("card_id.display_name")[:3]) raise UserError( _( "Commander- and Brawl-style decks can include only one copy of the same card across all styles and printings. " "This card conflicts with: %(cards)s" ) % {"cards": conflicting_names or english_card.display_name} ) @api.depends( "line_ids.quantity", "line_ids.board_id.include_in_total", "line_ids.card_id", "line_ids.card_id.game_id.code", "line_ids.card_id.mtg_mana_value", "line_ids.card_id.mtg_mana_cost", "line_ids.card_id.mtg_card_type_ids.code", "line_ids.primary_role_id", "line_ids.primary_role_id.sequence", "line_ids.primary_role_id.name", "line_ids.role_ids", ) def _compute_mtg_analysis_panels(self): """Compute compact MTG analysis panels inspired by deckbuilder tools.""" type_field_map = ( ("Creature", "mtg_creature_count"), ("Land", "mtg_land_count"), ("Instant", "mtg_instant_count"), ("Sorcery", "mtg_sorcery_count"), ("Artifact", "mtg_artifact_count"), ("Enchantment", "mtg_enchantment_count"), ("Planeswalker", "mtg_planeswalker_count"), ) for deck in self: included_lines = deck.line_ids.filtered( lambda line: line.board_id.include_in_total and line.card_id.game_id.code == "mtg" ) tagged_lines = included_lines.filtered("role_ids") deck.mtg_tagged_line_count = len(tagged_lines) deck.mtg_untagged_line_count = len(included_lines) - len(tagged_lines) deck.mtg_role_coverage_ratio = ( (len(tagged_lines) / len(included_lines)) * 100.0 if included_lines else 0.0 ) curve_buckets = {str(index): 0 for index in range(0, 7)} curve_buckets["7+"] = 0 mana_lines = included_lines.filtered( lambda line: "land" not in set(line.card_id.mtg_card_type_ids.mapped("code")) ) for line in mana_lines: mana_value = int(line.card_id.mtg_mana_value or 0) bucket = str(mana_value) if mana_value < 7 else "7+" curve_buckets[bucket] += line.quantity role_buckets = [] for role in tagged_lines.mapped("primary_role_id").sorted( key=lambda role: (role.sequence, role.name or "", role.id) ): quantity = sum( tagged_lines.filtered(lambda line: line.primary_role_id == role).mapped( "quantity" ) ) role_buckets.append((role.name, quantity)) if deck.mtg_untagged_line_count: untagged_quantity = sum( included_lines.filtered(lambda line: not line.role_ids).mapped("quantity") ) role_buckets.append(("Unassigned", untagged_quantity)) type_buckets = [ (label, deck[field_name]) for label, field_name in type_field_map if deck[field_name] ] color_pips = {code: 0 for code in self._MTG_COLOR_PIP_ORDER} for line in mana_lines: for symbol in self._mtg_extract_color_pips(line.card_id.mtg_mana_cost or ""): color_pips[symbol] += line.quantity color_buckets = [ (self._MTG_COLOR_PIP_LABELS[code], color_pips[code]) for code in self._MTG_COLOR_PIP_ORDER if color_pips[code] ] deck.mtg_mana_curve_html = self._mtg_render_analysis_bar_list( items=[(label, count) for label, count in curve_buckets.items() if count], empty_message=_("Add cards to see the mana curve."), ) deck.mtg_type_breakdown_html = self._mtg_render_analysis_bar_list( items=type_buckets, empty_message=_("Add cards to see the type composition."), ) deck.mtg_role_breakdown_html = self._mtg_render_analysis_bar_list( items=role_buckets, empty_message=_("Run role analysis or tag cards to see role coverage."), ) deck.mtg_color_pip_breakdown_html = self._mtg_render_analysis_bar_list( items=color_buckets, empty_message=_("Colored mana symbols will appear here."), ) @classmethod def _mtg_render_analysis_bar_list( cls, items, empty_message, ): """Render one compact HTML bar-list for the MTG analysis page. Args: items: Sequence of ``(label, value)`` tuples. empty_message: Fallback message when no values are available. Returns: str: Safe HTML fragment for an Odoo ``html`` field. """ non_empty_items = [(label, value) for label, value in items if value] if not non_empty_items: return ( "

    %s

    " % html.escape(empty_message) ) max_value = max(value for _, value in non_empty_items) or 1 rows = [] for label, value in non_empty_items: width_ratio = max(8, round((value / max_value) * 100)) label_html = html.escape(label) rows.append( "
    " f"{label_html}" "
    " f"
    " "
    " f"{int(value)}" "
    " ) return "
    %s
    " % "".join(rows) @classmethod def _mtg_extract_color_pips(cls, mana_cost): """Extract colored mana pip codes from one MTG mana cost string. Args: mana_cost: Raw Scryfall-style mana cost like ``{2}{W/U}{W}``. Returns: list[str]: Ordered color symbols found in the mana cost. """ pips = [] for token in re.findall(r"\{([^}]+)\}", (mana_cost or "").upper()): for code in cls._MTG_COLOR_PIP_ORDER: if code in token: pips.append(code) return pips @classmethod def _mtg_get_color_identity_name(cls, signature): """Resolve one canonical MTG color signature to its deck-color name. Args: signature: Canonical MTG color signature such as ``WUR`` or ``UB``. Returns: str | bool: Canonical MTG deck-color name or ``False`` when empty. """ normalized_signature = (signature or "").strip().upper() if not normalized_signature: return False return cls._MTG_COLOR_IDENTITY_NAME_MAP.get( normalized_signature, normalized_signature, ) @api.model def _mtg_is_commander_eligible_card(self, card, format_code): """Return whether one card qualifies as a commander candidate. Args: card: MTG card record. format_code: Canonical MTG format code such as ``commander``. Returns: bool: ``True`` when the card can plausibly serve as commander. """ if not card: return False card_in_english = card.with_context(lang="en_US") type_line = (card_in_english.mtg_type_line or "").lower() oracle_text = (card_in_english.mtg_oracle_text or "").lower() is_legendary = "legendary" in type_line is_creature = "creature" in type_line is_planeswalker = "planeswalker" in type_line has_explicit_commander_text = "can be your commander" in oracle_text if self._mtg_get_format_profile(format_code)["brawl_style"]: return (is_legendary and (is_creature or is_planeswalker)) or ( has_explicit_commander_text ) return (is_legendary and is_creature) or has_explicit_commander_text def _mtg_action_open_board(self, board_code): """Open one MTG board line manager by its canonical code.""" self.ensure_one() board = self._mvd_tcg_get_board_by_code(board_code) if not board: self._mvd_tcg_seed_default_boards() board = self._mvd_tcg_get_board_by_code(board_code) return board.action_open_line_manager() if board else False def _mtg_action_add_to_board(self, board_code): """Open the add-to-deck wizard for one canonical MTG board.""" self.ensure_one() return self._mvd_tcg_action_open_add_to_board_wizard(board_code) def action_open_mtg_command_zone(self): """Open the MTG command zone board.""" self.ensure_one() return self._mtg_action_open_board("command_zone") def action_add_to_mtg_command_zone(self): """Open the add-to-deck wizard for the command zone.""" self.ensure_one() return self._mtg_action_add_to_board("command_zone") def action_open_mtg_mainboard(self): """Open the MTG mainboard.""" self.ensure_one() return self._mtg_action_open_board("mainboard") def action_add_to_mtg_mainboard(self): """Open the add-to-deck wizard for the mainboard.""" self.ensure_one() return self._mtg_action_add_to_board("mainboard") def action_open_mtg_sideboard(self): """Open the MTG sideboard.""" self.ensure_one() return self._mtg_action_open_board("sideboard") def action_add_to_mtg_sideboard(self): """Open the add-to-deck wizard for the sideboard.""" self.ensure_one() return self._mtg_action_add_to_board("sideboard") def action_open_mtg_maybeboard(self): """Open the MTG maybeboard.""" self.ensure_one() return self._mtg_action_open_board("maybeboard") def action_add_to_mtg_maybeboard(self): """Open the add-to-deck wizard for the maybeboard.""" self.ensure_one() return self._mtg_action_add_to_board("maybeboard")