"""Magic: The Gathering card extensions for the TCG suite.""" import re from odoo import api, fields, models COLLECTOR_NUMBER_PATTERN = re.compile(r"^(\d+)(.*)$") class MvdTcgCard(models.Model): """Extend neutral card references with MTG-specific metadata.""" _inherit = "mvd.tcg.card" is_mtg_game = fields.Boolean(compute="_compute_is_mtg_game") mtg_set_id = fields.Many2one( "mvd.tcg.mtg.set", string="Set", index=True, ondelete="restrict", ) mtg_set_code = fields.Char( related="mtg_set_id.code", string="Set Code", store=True, index=True, ) mtg_rarity_id = fields.Many2one( "mvd.tcg.mtg.rarity", string="Rarity", index=True, ondelete="restrict", ) mtg_rarity_code = fields.Char( related="mtg_rarity_id.code", string="Rarity Code", store=True, index=True, ) mtg_collector_number = fields.Char(string="Collector Number", index=True) mtg_collector_sort_key = fields.Char( string="Collector Sort Key", compute="_compute_mtg_collector_sort_key", store=True, index=True, ) mtg_oracle_id = fields.Char(string="Oracle ID", index=True) mtg_layout = fields.Char(string="Layout", index=True) mtg_mana_cost = fields.Char(string="Mana Cost") mtg_mana_value = fields.Float(string="Mana Value") mtg_type_line = fields.Char(string="Type Line", translate=True) mtg_oracle_text = fields.Text(string="Oracle Text", translate=True) mtg_flavor_text = fields.Text(string="Flavor Text", translate=True) mtg_color_ids = fields.Many2many( "mvd.tcg.mtg.color", "mvd_tcg_card_mtg_color_rel", "card_id", "color_id", string="Colors", ) mtg_color_identity_ids = fields.Many2many( "mvd.tcg.mtg.color", "mvd_tcg_card_mtg_color_identity_rel", "card_id", "color_id", string="Color Identity", ) mtg_color_identity_signature = fields.Char( string="Color Identity Signature", compute="_compute_mtg_color_identity_signature", store=True, index=True, help="Ordered MTG color identity signature, for example W, UB or WUBRG.", ) mtg_card_type_ids = fields.Many2many( "mvd.tcg.mtg.card.type", "mvd_tcg_card_mtg_card_type_rel", "card_id", "type_id", string="Card Types", ) mtg_keyword_ids = fields.Many2many( "mvd.tcg.mtg.keyword", "mvd_tcg_card_mtg_keyword_rel", "card_id", "keyword_id", string="Keywords", ) mtg_game_platform_ids = fields.Many2many( "mvd.tcg.mtg.platform", "mvd_tcg_card_mtg_platform_rel", "card_id", "platform_id", string="Games", ) mtg_finish_ids = fields.Many2many( "mvd.tcg.mtg.finish", "mvd_tcg_card_mtg_finish_rel", "card_id", "finish_id", string="Finishes", ) mtg_power = fields.Char(string="Power") mtg_toughness = fields.Char(string="Toughness") mtg_loyalty = fields.Char(string="Loyalty") mtg_artist = fields.Char(string="Artist", index="trigram") mtg_color_signature = fields.Char( string="Color Signature", compute="_compute_mtg_color_signature", store=True, index=True, help="Ordered MTG color signature, for example W, UB or WUBRG.", ) mtg_face_ids = fields.One2many("mvd.tcg.mtg.card.face", "card_id", string="Faces") mtg_face_count = fields.Integer( string="Face Count", compute="_compute_mtg_face_count", store=True, ) mtg_legality_ids = fields.One2many( "mvd.tcg.mtg.card.legality", "card_id", string="Legalities", ) mtg_is_token = fields.Boolean(string="Token") mtg_is_reprint = fields.Boolean(string="Reprint") mtg_is_promo = fields.Boolean(string="Promo") mtg_is_digital = fields.Boolean(string="Digital") mtg_is_full_art = fields.Boolean(string="Full Art") mtg_is_textless = fields.Boolean(string="Textless") mtg_set_released_on = fields.Date( related="mtg_set_id.released_on", string="Released On", store=True, index=True, ) mtg_set_type = fields.Char( related="mtg_set_id.set_type", string="Set Type", store=True, index=True, ) _mtg_set_collector_unique = models.Constraint( "UNIQUE (mtg_set_id, mtg_collector_number)", "The collector number must be unique inside an MTG set.", ) @api.depends("game_id.code") def _compute_is_mtg_game(self): """Flag whether the current card belongs to the MTG adapter.""" for card in self: card.is_mtg_game = card.game_id.code == "mtg" @api.depends("mtg_collector_number") def _compute_mtg_collector_sort_key(self): """Build a stable natural-sort key for MTG collector numbers. Returns: None: The compute updates records in place. """ for card in self: collector_number = (card.mtg_collector_number or "").strip().lower() if not collector_number: card.mtg_collector_sort_key = "99999999:" continue match = COLLECTOR_NUMBER_PATTERN.match(collector_number) if match: numeric_part, suffix = match.groups() card.mtg_collector_sort_key = ( f"{int(numeric_part):08d}:{suffix.strip()}" ) continue card.mtg_collector_sort_key = f"99999999:{collector_number}" @api.depends("mtg_color_ids", "mtg_color_ids.sequence", "mtg_color_ids.code") def _compute_mtg_color_signature(self): """Build the canonical MTG color signature from selected colors. Returns: None: The compute updates records in place. """ for card in self: colors = card.mtg_color_ids.sorted( key=lambda color: (color.sequence, color.code or "", color.id) ) card.mtg_color_signature = "".join( (color.code or "").strip().upper() for color in colors ) or False @api.depends( "mtg_color_identity_ids", "mtg_color_identity_ids.sequence", "mtg_color_identity_ids.code", ) def _compute_mtg_color_identity_signature(self): """Build the canonical MTG color identity signature. Returns: None: The compute updates records in place. """ for card in self: colors = card.mtg_color_identity_ids.sorted( key=lambda color: (color.sequence, color.code or "", color.id) ) card.mtg_color_identity_signature = "".join( (color.code or "").strip().upper() for color in colors ) or False @api.depends("mtg_face_ids") def _compute_mtg_face_count(self): """Compute how many explicit faces are linked to each card. Returns: None: The compute updates records in place. """ face_data = self.env["mvd.tcg.mtg.card.face"]._read_group( [("card_id", "in", self.ids)], ["card_id"], ["__count"], ) counts_by_card = {record.id: count for record, count in face_data} for card in self: card.mtg_face_count = counts_by_card.get(card.id, 0) def action_open_mtg_set(self): """Open the linked MTG set in form view. Returns: dict: Window action for the linked MTG set. """ self.ensure_one() if not self.mtg_set_id: return False return { "type": "ir.actions.act_window", "name": self.mtg_set_id.display_name, "res_model": "mvd.tcg.mtg.set", "view_mode": "form", "res_id": self.mtg_set_id.id, "target": "current", } def mtg_get_rules_sections(self): """Return one face-aware MTG rules structure for the current card. Returns: list[dict[str, object]]: Ordered rules sections. Multi-face cards return one section per face, while single-face cards return one fallback section from the card header fields. """ self.ensure_one() face_sections = [] for face in self.mtg_face_ids.sorted(lambda current_face: (current_face.sequence, current_face.id)): section = { "sequence": face.sequence, "name": face.name or False, "mana_cost": face.mana_cost or False, "type_line": face.type_line or False, "oracle_text": face.oracle_text or False, "flavor_text": face.flavor_text or False, "power": face.power or False, "toughness": face.toughness or False, "loyalty": face.loyalty or False, } if any(section.values()): face_sections.append(section) if face_sections: return face_sections return [ { "sequence": 10, "name": self.name or False, "mana_cost": self.mtg_mana_cost or False, "type_line": self.mtg_type_line or False, "oracle_text": self.mtg_oracle_text or False, "flavor_text": self.mtg_flavor_text or False, "power": self.mtg_power or False, "toughness": self.mtg_toughness or False, "loyalty": self.mtg_loyalty or False, } ] def mtg_get_rules_summary(self): """Return one analysis-friendly MTG rules summary. Returns: str | bool: Single-face cards return their oracle text directly. Multi-face cards return a combined face-aware rules summary. """ self.ensure_one() rule_sections = self.mtg_get_rules_sections() if len(rule_sections) <= 1: return rule_sections and rule_sections[0]["oracle_text"] or False rendered_sections = [] for section in rule_sections: section_lines = [] heading_parts = [section["name"]] if section["mana_cost"]: heading_parts.append(section["mana_cost"]) heading = " ".join(part for part in heading_parts if part) if heading: section_lines.append(heading) if section["type_line"]: section_lines.append(section["type_line"]) if section["oracle_text"]: section_lines.append(section["oracle_text"]) if section_lines: rendered_sections.append("\n".join(section_lines)) return "\n\n//\n\n".join(rendered_sections) or False def mtg_get_singleton_key_aliases(self): """Return stable MTG identity aliases across printings and styles. Returns: tuple[str, ...]: Ordered identity aliases. Cards with a missing Oracle ID still expose rules-based fallbacks that can match styled variants of the same card. """ self.ensure_one() english_card = self.with_context(lang="en_US") aliases = [] oracle_id = (english_card.mtg_oracle_id or "").strip() if oracle_id: aliases.append(f"oracle:{oracle_id}") rules_summary = re.sub( r"\s+", " ", (english_card.mtg_get_rules_summary() or "").strip(), ).strip() if rules_summary: aliases.append(f"rules:{rules_summary}") deduplicated_oracle_chunks = [] for section in english_card.mtg_get_rules_sections(): oracle_text = re.sub( r"\s+", " ", (section["oracle_text"] or "").strip(), ).strip() if oracle_text and oracle_text not in deduplicated_oracle_chunks: deduplicated_oracle_chunks.append(oracle_text) if deduplicated_oracle_chunks: aliases.append(f"oracletext:{' // '.join(deduplicated_oracle_chunks)}") face_names = " // ".join( section["name"] for section in english_card.mtg_get_rules_sections() if section["name"] ) fallback_name = face_names or (english_card.name or "").strip() fallback_type = (english_card.mtg_type_line or "").strip() fallback_cost = (english_card.mtg_mana_cost or "").strip() if any((fallback_name, fallback_type, fallback_cost)): aliases.append(f"fallback:{fallback_name}|{fallback_type}|{fallback_cost}") deduplicated_aliases = [] for alias in aliases: if alias and alias not in deduplicated_aliases: deduplicated_aliases.append(alias) return tuple(deduplicated_aliases) def mtg_get_singleton_key(self): """Return the primary MTG singleton key for the current card. Returns: str | bool: First stable singleton alias, if any. """ self.ensure_one() aliases = self.mtg_get_singleton_key_aliases() return aliases[0] if aliases else False def mtg_allows_unlimited_copies(self): """Return whether one MTG card bypasses singleton restrictions. Returns: bool: ``True`` for basic lands and cards with explicit unlimited- copy rules text. """ self.ensure_one() english_card = self.with_context(lang="en_US") combined_type_line = " ".join( section["type_line"] for section in english_card.mtg_get_rules_sections() if section["type_line"] ).lower() rules_summary = (english_card.mtg_get_rules_summary() or "").lower() is_basic_land = "land" in combined_type_line and "basic" in combined_type_line allows_unlimited_copies = ( "a deck can have any number of cards named" in rules_summary ) return is_basic_land or allows_unlimited_copies @api.model def _mtg_exact_color_signature_from_ids(self, color_ids): """Build the canonical color signature for selected color records. Args: color_ids: MTG color record ids from a filter domain. Returns: str | bool: Canonical signature like ``WB`` or ``False`` when invalid. """ normalized_ids = [int(color_id) for color_id in color_ids or [] if color_id] if not normalized_ids: return False colors = self.env["mvd.tcg.mtg.color"].browse(normalized_ids).exists().sorted( key=lambda color: (color.sequence, color.code or "", color.id) ) if len(colors) != len(set(normalized_ids)): return False return "".join((color.code or "").strip().upper() for color in colors) @api.model def _mtg_transform_exact_color_domain(self, domain): """Rewrite MTG color filters into exact color-signature filters. Args: domain: Original ORM domain. Returns: list: Domain with exact MTG color matching where applicable. """ domain_object = fields.Domain(domain or []) selected_color_ids = [] for condition in domain_object.iter_conditions(): if condition.field_expr != "mtg_color_ids": continue if condition.operator == "=" and condition.value: color_id = int(condition.value) if color_id not in selected_color_ids: selected_color_ids.append(color_id) continue if condition.operator == "in" and isinstance(condition.value, (list, tuple)): for color_id in condition.value: normalized_id = int(color_id) if normalized_id not in selected_color_ids: selected_color_ids.append(normalized_id) if not selected_color_ids: return domain exact_signature = self._mtg_exact_color_signature_from_ids(selected_color_ids) if not exact_signature: return list(fields.Domain.FALSE) remaining_domain = domain_object.map_conditions( lambda condition: ( fields.Domain.TRUE if condition.field_expr == "mtg_color_ids" else fields.Domain(condition) ) ).optimize(self) exact_domain = fields.Domain( [("mtg_color_signature", "=", exact_signature)] ) return list(fields.Domain.AND([remaining_domain, exact_domain]).optimize(self)) @api.model def _search( self, domain, offset=0, limit=None, order=None, *, active_test=True, bypass_access=False, ): """Apply exact MTG color matching for the Magic card action. Args: domain: ORM domain for the current search. offset: Search offset. limit: Optional maximal number of records. order: SQL order clause. active_test: Whether active records should be filtered implicitly. bypass_access: Whether access rules should be bypassed. Returns: Query: Matching search query. """ if self.env.context.get("mvd_mtg_exact_color_filter"): domain = self._mtg_transform_exact_color_domain(domain) return super()._search( domain, offset=offset, limit=limit, order=order, active_test=active_test, bypass_access=bypass_access, )