330 lines
12 KiB
Python
330 lines
12 KiB
Python
"""MTG-specific deck line extensions."""
|
|
|
|
from odoo import api, fields, models
|
|
from odoo.exceptions import UserError
|
|
|
|
MTG_DECK_LEGALITY_SELECTION = [
|
|
("unknown", "Unknown"),
|
|
("legal", "Legal"),
|
|
("restricted", "Restricted"),
|
|
("not_legal", "Not Legal"),
|
|
("banned", "Banned"),
|
|
]
|
|
|
|
|
|
class MvdTcgDeckLine(models.Model):
|
|
"""Expose MTG card metadata directly on deck lines."""
|
|
|
|
_inherit = "mvd.tcg.deck.line"
|
|
|
|
mtg_set_id = fields.Many2one(
|
|
related="card_id.mtg_set_id",
|
|
string="Set",
|
|
readonly=True,
|
|
store=True,
|
|
index=True,
|
|
)
|
|
mtg_collector_number = fields.Char(
|
|
related="card_id.mtg_collector_number",
|
|
string="Collector Number",
|
|
readonly=True,
|
|
store=True,
|
|
index=True,
|
|
)
|
|
mtg_mana_cost = fields.Char(
|
|
related="card_id.mtg_mana_cost",
|
|
string="Mana Cost",
|
|
readonly=True,
|
|
store=True,
|
|
)
|
|
mtg_mana_value = fields.Float(
|
|
related="card_id.mtg_mana_value",
|
|
string="Mana Value",
|
|
readonly=True,
|
|
store=True,
|
|
index=True,
|
|
)
|
|
mtg_type_line = fields.Char(
|
|
related="card_id.mtg_type_line",
|
|
string="Type Line",
|
|
readonly=True,
|
|
)
|
|
mtg_primary_card_type_id = fields.Many2one(
|
|
"mvd.tcg.mtg.card.type",
|
|
string="Primary Card Type",
|
|
compute="_compute_mtg_primary_card_type",
|
|
store=True,
|
|
readonly=True,
|
|
index=True,
|
|
)
|
|
mtg_legality_status = fields.Selection(
|
|
selection=MTG_DECK_LEGALITY_SELECTION,
|
|
string="Legality Status",
|
|
compute="_compute_mtg_rule_flags",
|
|
store=True,
|
|
readonly=True,
|
|
index=True,
|
|
)
|
|
mtg_legality_ok = fields.Boolean(
|
|
string="Legality OK",
|
|
compute="_compute_mtg_rule_flags",
|
|
store=True,
|
|
readonly=True,
|
|
)
|
|
mtg_color_identity_violation = fields.Boolean(
|
|
string="Color Identity Violation",
|
|
compute="_compute_mtg_rule_flags",
|
|
store=True,
|
|
readonly=True,
|
|
)
|
|
mtg_singleton_violation = fields.Boolean(
|
|
string="Singleton Violation",
|
|
compute="_compute_mtg_rule_flags",
|
|
store=True,
|
|
readonly=True,
|
|
)
|
|
mtg_issue_count = fields.Integer(
|
|
string="Issue Count",
|
|
compute="_compute_mtg_rule_flags",
|
|
store=True,
|
|
readonly=True,
|
|
)
|
|
|
|
@api.depends(
|
|
"card_id.mtg_card_type_ids",
|
|
"card_id.mtg_card_type_ids.sequence",
|
|
"card_id.mtg_card_type_ids.name",
|
|
)
|
|
def _compute_mtg_primary_card_type(self):
|
|
"""Expose one stable primary card type for grouping and sorting.
|
|
|
|
Returns:
|
|
None: The compute updates records in place.
|
|
"""
|
|
for line in self:
|
|
card_type = line.card_id.mtg_card_type_ids.sorted(
|
|
key=lambda current_type: (
|
|
current_type.sequence,
|
|
current_type.name or "",
|
|
current_type.id,
|
|
)
|
|
)[:1]
|
|
line.mtg_primary_card_type_id = card_type
|
|
|
|
@api.depends(
|
|
"board_id.code",
|
|
"board_id.include_in_total",
|
|
"deck_id.is_mtg_deck",
|
|
"deck_id.mtg_format_id",
|
|
"deck_id.mtg_format_id.code",
|
|
"deck_id.mtg_color_identity_signature",
|
|
"deck_id.line_ids.quantity",
|
|
"deck_id.line_ids.board_id",
|
|
"deck_id.line_ids.board_id.code",
|
|
"deck_id.line_ids.board_id.include_in_total",
|
|
"deck_id.line_ids.card_id",
|
|
"deck_id.line_ids.card_id.mtg_oracle_id",
|
|
"deck_id.line_ids.card_id.mtg_type_line",
|
|
"deck_id.line_ids.card_id.mtg_oracle_text",
|
|
"card_id.mtg_oracle_id",
|
|
"card_id.mtg_color_identity_signature",
|
|
"card_id.mtg_legality_ids.status",
|
|
"card_id.mtg_legality_ids.format_code",
|
|
)
|
|
def _compute_mtg_rule_flags(self):
|
|
"""Compute line-level MTG rule signals for the current deck context.
|
|
|
|
Returns:
|
|
None: The compute updates records in place.
|
|
"""
|
|
singleton_violation_by_line = {}
|
|
legality_status_by_line = {}
|
|
legality_ok_by_line = {}
|
|
color_violation_by_line = {}
|
|
|
|
mtg_decks = self.mapped("deck_id").filtered("is_mtg_deck")
|
|
for deck in mtg_decks:
|
|
format_code = deck.mtg_format_id.code or ""
|
|
commander_identity = set(deck.mtg_color_identity_signature or "")
|
|
singleton_groups = []
|
|
candidate_lines = deck.line_ids.filtered(
|
|
lambda line: line.card_id
|
|
and line.card_id.game_id.code == "mtg"
|
|
and line.board_id.include_in_total
|
|
)
|
|
candidate_entries = []
|
|
for line in candidate_lines:
|
|
card = line.card_id.with_context(lang="en_US")
|
|
if self._mtg_is_singleton_exempt(card):
|
|
continue
|
|
singleton_aliases = set(card.mtg_get_singleton_key_aliases())
|
|
if not singleton_aliases:
|
|
singleton_aliases = {card.external_ref or str(card.id)}
|
|
candidate_entries.append(
|
|
{
|
|
"line": line,
|
|
"aliases": singleton_aliases,
|
|
}
|
|
)
|
|
|
|
remaining_entries = list(candidate_entries)
|
|
while remaining_entries:
|
|
seed_entry = remaining_entries.pop(0)
|
|
grouped_lines = [seed_entry["line"]]
|
|
grouped_aliases = set(seed_entry["aliases"])
|
|
did_merge = True
|
|
while did_merge:
|
|
did_merge = False
|
|
unmatched_entries = []
|
|
for entry in remaining_entries:
|
|
if grouped_aliases & entry["aliases"]:
|
|
grouped_lines.append(entry["line"])
|
|
grouped_aliases |= entry["aliases"]
|
|
did_merge = True
|
|
else:
|
|
unmatched_entries.append(entry)
|
|
remaining_entries = unmatched_entries
|
|
singleton_groups.append(grouped_lines)
|
|
|
|
for grouped_lines in singleton_groups:
|
|
total_quantity = sum(line.quantity for line in grouped_lines)
|
|
has_violation = total_quantity > 1
|
|
for line in grouped_lines:
|
|
singleton_violation_by_line[line.id] = has_violation
|
|
|
|
for line in deck.line_ids:
|
|
if not line.card_id or line.card_id.game_id.code != "mtg":
|
|
legality_status_by_line[line.id] = "unknown"
|
|
legality_ok_by_line[line.id] = True
|
|
color_violation_by_line[line.id] = False
|
|
continue
|
|
|
|
legality_status = self._mtg_get_card_legality_status(
|
|
line.card_id,
|
|
format_code,
|
|
)
|
|
legality_status_by_line[line.id] = legality_status
|
|
legality_ok_by_line[line.id] = legality_status not in {
|
|
"banned",
|
|
"not_legal",
|
|
}
|
|
if legality_status == "restricted" and line.quantity > 1:
|
|
legality_ok_by_line[line.id] = False
|
|
|
|
line_identity = set(line.card_id.mtg_color_identity_signature or "")
|
|
color_violation_by_line[line.id] = bool(
|
|
commander_identity
|
|
and line.board_id.code != "command_zone"
|
|
and line_identity - commander_identity
|
|
)
|
|
|
|
for line in self:
|
|
legality_status = legality_status_by_line.get(line.id, "unknown")
|
|
legality_ok = legality_ok_by_line.get(line.id, True)
|
|
color_violation = color_violation_by_line.get(line.id, False)
|
|
singleton_violation = singleton_violation_by_line.get(line.id, False)
|
|
|
|
line.mtg_legality_status = legality_status
|
|
line.mtg_legality_ok = legality_ok
|
|
line.mtg_color_identity_violation = color_violation
|
|
line.mtg_singleton_violation = singleton_violation
|
|
line.mtg_issue_count = sum(
|
|
bool(flag)
|
|
for flag in (
|
|
not legality_ok,
|
|
color_violation,
|
|
singleton_violation,
|
|
)
|
|
)
|
|
|
|
@api.model
|
|
def _mtg_get_card_legality_status(self, card, format_code):
|
|
"""Return the legality status of one card for one MTG format.
|
|
|
|
Args:
|
|
card: MTG card record.
|
|
format_code: Canonical MTG format code such as ``commander``.
|
|
|
|
Returns:
|
|
str: One status from ``MTG_DECK_LEGALITY_SELECTION``.
|
|
"""
|
|
if not card or not format_code:
|
|
return "unknown"
|
|
legality = card.mtg_legality_ids.filtered(
|
|
lambda current_legality: current_legality.format_code == format_code
|
|
)[:1]
|
|
return legality.status if legality else "unknown"
|
|
|
|
@api.model
|
|
def _mtg_is_singleton_exempt(self, card):
|
|
"""Return whether a card should bypass singleton checks.
|
|
|
|
Args:
|
|
card: MTG card record in a stable language context.
|
|
|
|
Returns:
|
|
bool: ``True`` when the card can legally appear multiple times.
|
|
"""
|
|
return bool(card and card.mtg_allows_unlimited_copies())
|
|
|
|
def _mvd_tcg_validate_move_to_board(self, target_board):
|
|
"""Block duplicate MTG singleton cards during board moves."""
|
|
self.ensure_one()
|
|
result = super()._mvd_tcg_validate_move_to_board(target_board)
|
|
deck = self.deck_id
|
|
if (
|
|
not deck.is_mtg_deck
|
|
or not target_board.include_in_total
|
|
or not deck._mtg_format_enforces_singleton()
|
|
):
|
|
return result
|
|
|
|
english_card = self.card_id.with_context(lang="en_US")
|
|
conflict_lines = deck._mtg_get_singleton_conflict_lines(
|
|
english_card,
|
|
excluding_line=self,
|
|
)
|
|
total_quantity = self.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. "
|
|
"Moving this line would conflict with: %(cards)s"
|
|
)
|
|
% {"cards": conflicting_names or english_card.display_name}
|
|
)
|
|
|
|
def action_move_to_mtg_command_zone(self):
|
|
"""Move the current line to the MTG command zone."""
|
|
self.ensure_one()
|
|
return self._mvd_tcg_move_to_board("command_zone")
|
|
|
|
def action_move_to_mtg_mainboard(self):
|
|
"""Move the current line to the MTG mainboard."""
|
|
self.ensure_one()
|
|
return self._mvd_tcg_move_to_board("mainboard")
|
|
|
|
def action_move_to_mtg_sideboard(self):
|
|
"""Move the current line to the MTG sideboard."""
|
|
self.ensure_one()
|
|
return self._mvd_tcg_move_to_board("sideboard")
|
|
|
|
def action_move_to_mtg_maybeboard(self):
|
|
"""Move the current line to the MTG maybeboard."""
|
|
self.ensure_one()
|
|
return self._mvd_tcg_move_to_board("maybeboard")
|
|
|
|
def _mvd_tcg_get_card_form_view_xmlid(self):
|
|
"""Prefer the MTG-specific card form for MTG deck lines.
|
|
|
|
Returns:
|
|
str: Stable XMLID for the preferred card form view.
|
|
"""
|
|
self.ensure_one()
|
|
if self.game_id.code == "mtg":
|
|
return "mvd_tcg_mtg.mvd_tcg_mtg_card_view_form"
|
|
return super()._mvd_tcg_get_card_form_view_xmlid()
|