From 5d4a312621e72ad24b4ca43b5f56d3408e16887f Mon Sep 17 00:00:00 2001 From: Marc Wempe Date: Fri, 3 Apr 2026 23:08:58 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20Initialize=20module=20repository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 + __init__.py | 2 + __manifest__.py | 30 + i18n/de.po | 726 ++++++++++++ models/__init__.py | 3 + models/mvd_tcg_deck.py | 1065 ++++++++++++++++++ models/mvd_tcg_deck_line.py | 329 ++++++ models/mvd_tcg_game.py | 30 + report/__init__.py | 1 + report/mvd_tcg_mtg_deck_report_templates.xml | 220 ++++ views/menu_views.xml | 21 + views/mvd_tcg_mtg_card_views.xml | 28 + views/mvd_tcg_mtg_deck_views.xml | 433 +++++++ 13 files changed, 2896 insertions(+) create mode 100644 .gitignore create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 i18n/de.po create mode 100644 models/__init__.py create mode 100644 models/mvd_tcg_deck.py create mode 100644 models/mvd_tcg_deck_line.py create mode 100644 models/mvd_tcg_game.py create mode 100644 report/__init__.py create mode 100644 report/mvd_tcg_mtg_deck_report_templates.xml create mode 100644 views/menu_views.xml create mode 100644 views/mvd_tcg_mtg_card_views.xml create mode 100644 views/mvd_tcg_mtg_deck_views.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c5f867 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +.DS_Store +.pytest_cache/ +.ruff_cache/ +*.log +*.swp +*~ diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..bf588bc --- /dev/null +++ b/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import report diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..26f7ea0 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,30 @@ +{ + "name": "MVD TCG MTG Deck", + "summary": "Magic-specific deckbuilding rules, analytics, and deck views", + "version": "19.0.10.5.0", + "description": """ +Magic: The Gathering deckbuilding extension for the MVD TCG suite. + +This module turns the neutral deck layer into an MTG-focused deckbuilder with: +- Command Zone, Mainboard, Sideboard, and Maybeboard workflows +- Commander and format-aware rule hints +- mana cost, legality, and type signals on deck lines +- analysis panels such as mana curve, role coverage, type mix, and color pips +- MTG-specific report additions and deck views + +It is the main gameplay-oriented deckbuilding module for MTG before commerce +and collection features are added. +""", + "category": "Tools", + "author": "Mantjeverse Digital", + "license": "LGPL-3", + "depends": ["mvd_tcg_deck", "mvd_tcg_mtg"], + "data": [ + "views/menu_views.xml", + "views/mvd_tcg_mtg_deck_views.xml", + "views/mvd_tcg_mtg_card_views.xml", + "report/mvd_tcg_mtg_deck_report_templates.xml", + ], + "application": False, + "installable": True, +} diff --git a/i18n/de.po b/i18n/de.po new file mode 100644 index 0000000..a1a3c15 --- /dev/null +++ b/i18n/de.po @@ -0,0 +1,726 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mvd_tcg_mtg_deck +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0+e-20260324\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-04-03 05:05+0000\n" +"PO-Revision-Date: 2026-04-03 05:05+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Language: de\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: mvd_tcg_mtg_deck +#. odoo-python +#: code:addons/mvd_tcg_mtg_deck/models/mvd_tcg_deck.py:0 +msgid "%(count)s line(s) are banned or not legal in %(format)s." +msgstr "%(count)s Zeile(n) sind in %(format)s gebannt oder nicht legal." + +#. module: mvd_tcg_mtg_deck +#. odoo-python +#: code:addons/mvd_tcg_mtg_deck/models/mvd_tcg_deck.py:0 +msgid "%(count)s line(s) exceed the current commander color identity." +msgstr "%(count)s Zeile(n) überschreiten die aktuelle Commander-Farbidentität." + +#. module: mvd_tcg_mtg_deck +#. odoo-python +#: code:addons/mvd_tcg_mtg_deck/models/mvd_tcg_deck.py:0 +msgid "" +"%(count)s line(s) violate singleton rules. Basic lands and cards with " +"explicit unlimited-copy text are ignored." +msgstr "" +"%(count)s Zeile(n) verletzen die Singleton-Regeln. Standardländer und Karten " +"mit explizit unbegrenzter Kopienanzahl werden ignoriert." + +#. module: mvd_tcg_mtg_deck +#. odoo-python +#: code:addons/mvd_tcg_mtg_deck/models/mvd_tcg_deck.py:0 +msgid "%(count)s restricted line(s) exceed one copy in %(format)s." +msgstr "%(count)s eingeschränkte Zeile(n) überschreiten ein Exemplar in %(format)s." + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid " · " +msgstr " · " + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_line_view_form_mtg_inherit +msgid "Command" +msgstr "Command" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_line_view_form_mtg_inherit +msgid "Mainboard" +msgstr "Mainboard" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_line_view_form_mtg_inherit +msgid "Maybeboard" +msgstr "Maybeboard" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_line_view_form_mtg_inherit +msgid "Sideboard" +msgstr "Sideboard" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_view_form_mtg_inherit +msgid "Cards" +msgstr "Karten" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid "Average Mana Value:" +msgstr "Durchschnittlicher Manawert:" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid "Commander:" +msgstr "Commander:" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid "Deck Size:" +msgstr "Deckgröße:" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid "Format:" +msgstr "Format:" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid "Identity:" +msgstr "Identität:" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid "Rule Warnings:" +msgstr "Regelhinweise:" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_view_form_mtg_inherit +msgid "Add Card" +msgstr "Karte hinzufügen" + +#. module: mvd_tcg_mtg_deck +#. odoo-python +#: code:addons/mvd_tcg_mtg_deck/models/mvd_tcg_deck.py:0 +msgid "Add cards to see the mana curve." +msgstr "Füge Karten hinzu, um die Manakurve zu sehen." + +#. module: mvd_tcg_mtg_deck +#. odoo-python +#: code:addons/mvd_tcg_mtg_deck/models/mvd_tcg_deck.py:0 +msgid "Add cards to see the type composition." +msgstr "Füge Karten hinzu, um die Typverteilung zu sehen." + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_mtg_card_view_form_deck_inherit +msgid "Add to Deck" +msgstr "Zum Deck hinzufügen" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_view_form_mtg_inherit +msgid "Analysis" +msgstr "Analyse" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_view_form_mtg_inherit +msgid "Average Mana Value" +msgstr "Durchschnittlicher Manawert" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields.selection,name:mvd_tcg_mtg_deck.selection__mvd_tcg_deck_line__mtg_legality_status__banned +msgid "Banned" +msgstr "Gebannt" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_line_view_form_mtg_inherit +msgid "Card Snapshot" +msgstr "Karten-Snapshot" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck_line__mtg_collector_number +msgid "Collector Number" +msgstr "Sammlernummer" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_color_identity_ids +msgid "Color Identity" +msgstr "Farbidentität" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid "Color Identity & Curve" +msgstr "Farbidentität & Kurve" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_color_identity_name +msgid "Color Identity Name" +msgstr "Name der Farbidentität" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_view_form_mtg_inherit +msgid "Color Pips" +msgstr "Farb-Pips" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid "Color identity not available" +msgstr "Farbidentität nicht verfügbar" + +#. module: mvd_tcg_mtg_deck +#. odoo-python +#: code:addons/mvd_tcg_mtg_deck/models/mvd_tcg_deck.py:0 +msgid "Colored mana symbols will appear here." +msgstr "Farbige Manasymbole erscheinen hier." + +#. module: mvd_tcg_mtg_deck +#. odoo-python +#: code:addons/mvd_tcg_mtg_deck/models/mvd_tcg_game.py:0 +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_view_form_mtg_inherit +msgid "Command Zone" +msgstr "Command Zone" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_command_zone_line_ids +msgid "Command Zone Cards" +msgstr "Command-Zone-Karten" + +#. module: mvd_tcg_mtg_deck +#. odoo-python +#: code:addons/mvd_tcg_mtg_deck/models/mvd_tcg_deck.py:0 +msgid "Command Zone should contain exactly %(count)s card(s)." +msgstr "Die Command Zone muss genau %(count)s Karte(n) enthalten." + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid "Commander Snapshot" +msgstr "Commander-Snapshot" + +#. module: mvd_tcg_mtg_deck +#. odoo-python +#: code:addons/mvd_tcg_mtg_deck/models/mvd_tcg_deck_line.py:0 +msgid "" +"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" +msgstr "" +"Commander- und Brawl-Decks dürfen dieselbe Karte über alle Varianten und " +"Drucke hinweg nur einmal enthalten. Das Verschieben dieser Zeile würde " +"kollidieren mit: %(cards)s" + +#. module: mvd_tcg_mtg_deck +#. odoo-python +#: code:addons/mvd_tcg_mtg_deck/models/mvd_tcg_deck.py:0 +msgid "" +"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" +msgstr "" +"Commander- und Brawl-Decks dürfen dieselbe Karte über alle Varianten und " +"Drucke hinweg nur einmal enthalten. Diese Karte kollidiert mit: %(cards)s" + +#. module: mvd_tcg_mtg_deck +#. odoo-python +#: code:addons/mvd_tcg_mtg_deck/models/mvd_tcg_deck.py:0 +msgid "" +"Current Command Zone cards are not valid commander choices for the selected " +"format." +msgstr "Die aktuellen Command-Zone-Karten sind für das gewählte Format keine gültigen Commander." + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_view_form_mtg_inherit +msgid "Deck Metrics" +msgstr "Deck-Metriken" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_view_form_mtg_inherit +msgid "Deck Snapshot" +msgstr "Deck-Snapshot" + +#. module: mvd_tcg_mtg_deck +#: model:ir.actions.act_window,name:mvd_tcg_mtg_deck.mvd_tcg_mtg_deck_action +#: model:ir.ui.menu,name:mvd_tcg_mtg_deck.mvd_tcg_mtg_decks_menu +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_mtg_card_view_form_deck_inherit +msgid "Decks" +msgstr "Decks" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__display_name +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck_line__display_name +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_game__display_name +msgid "Display Name" +msgstr "Anzeigename" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_format_id +msgid "Format" +msgstr "Format" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid "Format & Commander" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__id +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck_line__id +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_game__id +msgid "ID" +msgstr "ID" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_line_view_search_mtg_inherit +msgid "Illegal" +msgstr "Illegal" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__is_mtg_deck +msgid "Is Mtg Deck" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_line_view_search_mtg_inherit +msgid "Issues" +msgstr "Probleme" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields.selection,name:mvd_tcg_mtg_deck.selection__mvd_tcg_deck_line__mtg_legality_status__legal +msgid "Legal" +msgstr "Legal" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_view_form_mtg_inherit +msgid "Mainboard" +msgstr "Mainboard" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_mainboard_line_ids +msgid "Mainboard Cards" +msgstr "Mainboard-Karten" + +#. module: mvd_tcg_mtg_deck +#. odoo-python +#: code:addons/mvd_tcg_mtg_deck/models/mvd_tcg_deck.py:0 +msgid "Mainboard currently has %(current)s cards, expected %(expected)s." +msgstr "Das Mainboard enthält aktuell %(current)s Karten, erwartet werden %(expected)s." + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck_line__mtg_mana_cost +msgid "Mana Cost" +msgstr "Manakosten" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_view_form_mtg_inherit +msgid "Mana Curve" +msgstr "Manakurve" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck_line__mtg_mana_value +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_line_view_search_mtg_inherit +msgid "Mana Value" +msgstr "Manawert" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_view_form_mtg_inherit +msgid "Manage Lines" +msgstr "Zeilen verwalten" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_view_form_mtg_inherit +msgid "Maybeboard" +msgstr "Maybeboard" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_maybeboard_line_ids +msgid "Maybeboard Cards" +msgstr "Maybeboard-Karten" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_artifact_count +msgid "Artifacts" +msgstr "Artefakte" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_average_mana_value +msgid "Average Mana Value" +msgstr "Durchschnittlicher Manawert" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_color_identity_ok +msgid "Color Identity OK" +msgstr "Farbidentität OK" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_color_identity_signature +msgid "Color Identity Signature" +msgstr "Farbidentitäts-Signatur" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck_line__mtg_color_identity_violation +msgid "Color Identity Violation" +msgstr "Verstoß gegen Farbidentität" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_color_pip_breakdown_html +msgid "Color Pips" +msgstr "Farb-Pips" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_command_zone_board_id +msgid "Command Zone Board" +msgstr "Command-Zone-Board" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_command_zone_count +msgid "Command Zone Count" +msgstr "Anzahl Command Zone" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_command_zone_size_ok +msgid "Command Zone Size OK" +msgstr "Größe der Command Zone OK" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_commander_card_id +msgid "Commander" +msgstr "Commander" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_commander_eligibility_ok +msgid "Commander Eligibility OK" +msgstr "Commander-Eignung OK" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_commander_image +msgid "Commander Image" +msgstr "Commander-Bild" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_creature_count +msgid "Creatures" +msgstr "Kreaturen" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_duplicate_card_count +msgid "Duplicate Cards" +msgstr "Doppelte Karten" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_enchantment_count +msgid "Enchantments" +msgstr "Verzauberungen" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_expected_command_zone_size +msgid "Expected Command Zone Size" +msgstr "Erwartete Größe der Command Zone" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_expected_mainboard_size +msgid "Expected Mainboard Size" +msgstr "Erwartete Mainboard-Größe" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_expected_sideboard_size +msgid "Expected Sideboard Size" +msgstr "Erwartete Sideboard-Größe" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_illegal_card_count +msgid "Illegal Cards" +msgstr "Illegale Karten" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_instant_count +msgid "Instants" +msgstr "Spontanzauber" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck_line__mtg_issue_count +msgid "Issue Count" +msgstr "Anzahl Probleme" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_issue_line_count +msgid "Issue Lines" +msgstr "Problemzeilen" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_land_count +msgid "Lands" +msgstr "Länder" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_legality_ok +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck_line__mtg_legality_ok +msgid "Legality OK" +msgstr "Legalität OK" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck_line__mtg_legality_status +msgid "Legality Status" +msgstr "Legalitätsstatus" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_mainboard_board_id +msgid "Mainboard" +msgstr "Mainboard" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_mainboard_count +msgid "Mainboard Count" +msgstr "Anzahl Mainboard" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_mainboard_size_ok +msgid "Mainboard Size OK" +msgstr "Mainboard-Größe OK" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_mana_curve_html +msgid "Mana Curve" +msgstr "Manakurve" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_maybeboard_board_id +msgid "Maybeboard" +msgstr "Maybeboard" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_maybeboard_count +msgid "Maybeboard Count" +msgstr "Anzahl Maybeboard" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_off_color_card_count +msgid "Off-Color Cards" +msgstr "Karten außerhalb der Farbidentität" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_planeswalker_count +msgid "Planeswalkers" +msgstr "Planeswalker" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck_line__mtg_primary_card_type_id +msgid "Primary Card Type" +msgstr "Primärer Kartentyp" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_restricted_card_count +msgid "Restricted Cards" +msgstr "Eingeschränkte Karten" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_role_breakdown_html +msgid "Role Breakdown" +msgstr "Rollenübersicht" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_role_coverage_ratio +msgid "Role Coverage" +msgstr "Rollenabdeckung" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_rule_summary +msgid "Rule Summary" +msgstr "Regelzusammenfassung" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_rule_warning_count +msgid "Rule Warnings" +msgstr "Regelhinweise" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_sideboard_board_id +msgid "Sideboard" +msgstr "Sideboard" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_sideboard_count +msgid "Sideboard Count" +msgstr "Anzahl Sideboard" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_sideboard_size_ok +msgid "Sideboard Size OK" +msgstr "Sideboard-Größe OK" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_singleton_ok +msgid "Singleton OK" +msgstr "Singleton OK" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck_line__mtg_singleton_violation +msgid "Singleton Violation" +msgstr "Singleton-Verstoß" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_sorcery_count +msgid "Sorceries" +msgstr "Hexereien" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_tagged_line_count +msgid "Tagged Cards" +msgstr "Getaggte Karten" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_type_breakdown_html +msgid "Type Composition" +msgstr "Typverteilung" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_untagged_line_count +msgid "Untagged Cards" +msgstr "Ungetaggte Karten" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid "No MTG rule notes are currently available." +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid "No commander detected" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid "No format selected" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields.selection,name:mvd_tcg_mtg_deck.selection__mvd_tcg_deck_line__mtg_legality_status__not_legal +msgid "Not Legal" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_line_view_search_mtg_inherit +msgid "Off Color" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields.selection,name:mvd_tcg_mtg_deck.selection__mvd_tcg_deck_line__mtg_legality_status__restricted +msgid "Restricted" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_view_form_mtg_inherit +msgid "Role Breakdown" +msgstr "Rollenverteilung" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_view_form_mtg_inherit +msgid "Role Coverage %" +msgstr "Rollenabdeckung %" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid "Rule Notes" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#. odoo-python +#: code:addons/mvd_tcg_mtg_deck/models/mvd_tcg_deck.py:0 +msgid "Run role analysis or tag cards to see role coverage." +msgstr "Führe eine Rollen-Analyse aus oder tagge Karten, um die Rollenabdeckung zu sehen." + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck_line__mtg_set_id +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_line_view_search_mtg_inherit +msgid "Set" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_view_form_mtg_inherit +msgid "Sideboard" +msgstr "Sideboard" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck__mtg_sideboard_line_ids +msgid "Sideboard Cards" +msgstr "Sideboard-Karten" + +#. module: mvd_tcg_mtg_deck +#. odoo-python +#: code:addons/mvd_tcg_mtg_deck/models/mvd_tcg_deck.py:0 +msgid "Sideboard currently has %(current)s cards, maximum is %(expected)s." +msgstr "Das Sideboard enthält aktuell %(current)s Karten, maximal erlaubt sind %(expected)s." + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_line_view_search_mtg_inherit +msgid "Singleton" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model,name:mvd_tcg_mtg_deck.model_mvd_tcg_deck +msgid "TCG Deck" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model,name:mvd_tcg_mtg_deck.model_mvd_tcg_deck_line +msgid "TCG Deck Line" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model,name:mvd_tcg_mtg_deck.model_mvd_tcg_game +msgid "TCG Game" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_view_form_mtg_inherit +msgid "Tagged Cards" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_line_view_search_mtg_inherit +msgid "Type" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_view_form_mtg_inherit +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid "Type Composition" +msgstr "Typverteilung" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields,field_description:mvd_tcg_mtg_deck.field_mvd_tcg_deck_line__mtg_type_line +msgid "Type Line" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model:ir.model.fields.selection,name:mvd_tcg_mtg_deck.selection__mvd_tcg_deck_line__mtg_legality_status__unknown +msgid "Unknown" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.mvd_tcg_deck_view_form_mtg_inherit +msgid "Untagged Cards" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid "command zone" +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid "" +"mainboard\n" +" · " +msgstr "" + +#. module: mvd_tcg_mtg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_mtg_deck.report_mvd_tcg_deck_document_mtg_inherit +msgid "sideboard" +msgstr "" diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..9941843 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,3 @@ +from . import mvd_tcg_game +from . import mvd_tcg_deck +from . import mvd_tcg_deck_line diff --git a/models/mvd_tcg_deck.py b/models/mvd_tcg_deck.py new file mode 100644 index 0000000..7461a64 --- /dev/null +++ b/models/mvd_tcg_deck.py @@ -0,0 +1,1065 @@ +"""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") diff --git a/models/mvd_tcg_deck_line.py b/models/mvd_tcg_deck_line.py new file mode 100644 index 0000000..648b8ae --- /dev/null +++ b/models/mvd_tcg_deck_line.py @@ -0,0 +1,329 @@ +"""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() diff --git a/models/mvd_tcg_game.py b/models/mvd_tcg_game.py new file mode 100644 index 0000000..4b85836 --- /dev/null +++ b/models/mvd_tcg_game.py @@ -0,0 +1,30 @@ +"""MTG-specific deck defaults.""" + +from odoo import _, models + + +class MvdTcgGame(models.Model): + """Add MTG-specific default deck boards.""" + + _inherit = "mvd.tcg.game" + + def _mvd_tcg_get_default_deck_board_templates(self): + """Extend default deck boards for MTG decks. + + Returns: + list[dict[str, object]]: Ordered board configuration dictionaries. + """ + boards = super()._mvd_tcg_get_default_deck_board_templates() + self.ensure_one() + if self.code != "mtg": + return boards + + return [ + { + "name": _("Command Zone"), + "code": "command_zone", + "sequence": 5, + "include_in_total": True, + }, + *boards, + ] diff --git a/report/__init__.py b/report/__init__.py new file mode 100644 index 0000000..cb8701f --- /dev/null +++ b/report/__init__.py @@ -0,0 +1 @@ +# MTG deck report customizations are template-only for now. diff --git a/report/mvd_tcg_mtg_deck_report_templates.xml b/report/mvd_tcg_mtg_deck_report_templates.xml new file mode 100644 index 0000000..a1eebd2 --- /dev/null +++ b/report/mvd_tcg_mtg_deck_report_templates.xml @@ -0,0 +1,220 @@ + + + + diff --git a/views/menu_views.xml b/views/menu_views.xml new file mode 100644 index 0000000..30227c9 --- /dev/null +++ b/views/menu_views.xml @@ -0,0 +1,21 @@ + + + + Decks + mvd.tcg.deck + kanban,list,form + + + + + + + + diff --git a/views/mvd_tcg_mtg_card_views.xml b/views/mvd_tcg_mtg_card_views.xml new file mode 100644 index 0000000..0d64024 --- /dev/null +++ b/views/mvd_tcg_mtg_card_views.xml @@ -0,0 +1,28 @@ + + + + mvd.tcg.mtg.card.view.form.deck.inherit + mvd.tcg.card + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +