1066 lines
39 KiB
Python
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")
|