Files
mvd_tcg_mtg_deck/models/mvd_tcg_deck.py
2026-04-03 23:08:58 +02:00

1066 lines
39 KiB
Python

"""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 = "<ul>%s</ul>" % "".join(
f"<li>{html.escape(message)}</li>" for message in warning_messages
)
else:
deck.mtg_rule_summary = (
"<p>Current deck structure matches the active Commander and format checks.</p>"
if format_code
else "<p>Select a format to enable MTG rule hints.</p>"
)
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 (
"<p class='text-muted mb-0'>%s</p>"
% 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(
"<div class='d-flex align-items-center gap-2 py-1'>"
f"<span class='text-muted small flex-shrink-0' style='min-width: 7rem;'>{label_html}</span>"
"<div class='progress flex-grow-1' style='height: 0.5rem;'>"
f"<div class='progress-bar' role='progressbar' aria-valuenow='{int(value)}' aria-valuemin='0' aria-valuemax='{int(max_value)}' style='width:{width_ratio}%;'></div>"
"</div>"
f"<span class='small fw-semibold text-end flex-shrink-0' style='min-width: 2rem;'>{int(value)}</span>"
"</div>"
)
return "<div class='d-flex flex-column gap-1'>%s</div>" % "".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")