diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..1d9f0113 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,14 @@ +FROM mcr.microsoft.com/devcontainers/php:7.4 + +# Remove Yarn repo, then install Node.js 18 +#RUN rm -f /etc/apt/sources.list.d/*yarn* && \ +# curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \ +# apt-get update && \ +# apt-get install -y nodejs npm && \ +# docker-php-ext-install pdo_mysql && \ +# rm -rf /var/lib/apt/lists/* + +# Install Node.js 18 using official binary distribution +RUN curl -fsSL https://nodejs.org/dist/v18.19.1/node-v18.19.1-linux-x64.tar.xz | tar -xJ -C /usr/local --strip-components=1 && \ + docker-php-ext-install pdo_mysql && \ + rm -rf /var/lib/apt/lists/* \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..2eb27ca5 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,18 @@ +{ + "name": "ThronesDB", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspace", + "forwardPorts": [8000, 3306], + "customizations": { + "vscode": { + "extensions": [ + "felixbecker.php-debug", + "ms-vscode.php-tools", + "eamodio.gitlens" + ] + } + }, + "postCreateCommand": "bash .devcontainer/post-create.sh", + "postStartCommand": "bash .devcontainer/post-start.sh" +} \ No newline at end of file diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 00000000..99725042 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.9' +services: + app: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + volumes: + - ..:/workspace:cached + environment: + DATABASE_URL: mysql://app:password@db:3306/thronesdb + depends_on: + - db + + db: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: thronesdb + MYSQL_USER: app + MYSQL_PASSWORD: password + ports: + - "3307:3306" \ No newline at end of file diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100644 index 00000000..9bcd3576 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,55 @@ +#!/bin/bash +set -e + +echo "Waiting for MySQL to be ready..." +for i in {1..30}; do + if mysqladmin ping -h db -u app -ppassword &> /dev/null; then + echo "MySQL is ready!" + break + fi + echo "Waiting... attempt $i/30" + sleep 2 +done + +echo "Installing PHP dependencies..." +composer install + +echo "Installing Node dependencies..." +npm install + +echo "Creating database..." +php bin/console doctrine:database:create --if-not-exists + +echo "Running migrations..." +php bin/console doctrine:migrations:migrate + +echo "Loading fixtures..." +php bin/console doctrine:fixtures:load --env=dev + +echo "Cloning json data repository..." +if [ ! -d "/workspace/throneteki-json-data" ]; then + git clone https://github.com/kayorga/throneteki-json-data.git -b draft throneteki-json-data +fi + +echo "Importing json data..." +php bin/console app:import:std /workspace/throneteki-json-data + +echo "Importing restriction lists..." +php bin/console app:restrictions:import /workspace/throneteki-json-data + +echo "Activating restriction list..." +php bin/console app:restrictions:activate + +echo "Dumping translations..." +php bin/console bazinga:js-translation:dump assets/js + +echo "Dumping routes..." +php bin/console fos:js-routing:dump --target=public/js/fos_js_routes.js + +echo "Building assets..." +npx gulp + +echo "Clearing cache..." +php bin/console cache:clear --env=dev + +echo "Setup complete!" \ No newline at end of file diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh new file mode 100644 index 00000000..7b358953 --- /dev/null +++ b/.devcontainer/post-start.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +echo "Creating user..." +php bin/console fos:user:create dev dev@thronesdb.com password123 --no-interaction + +echo "Activating user..." +php bin/console fos:user:activate dev + +echo "Promoting user..." +php bin/console fos:user:promote --super dev + +echo "Setup complete!" \ No newline at end of file diff --git a/.gitignore b/.gitignore index b43beffa..87d1996a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ # ignore FOS JS routing output /public/js/fos_js_routes.js +/web/js/fos_js_routes.js # ignore node modules /node_modules diff --git a/assets/css/style.css b/assets/css/style.css index b3cfcaa2..fc572f4d 100755 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -208,6 +208,23 @@ ol { height: 70px; } +.qty-select { + width: auto; + display: inline-block; + padding: 2px 6px; + font-size: 12px; + height: 24px; + line-height: 1.5; + min-width: 69px; +} +.modal .qty-select { + font-size: 14px; + padding: 6px 12px; + min-width: 90px; + height: auto; + line-height: 1.5; +} + /** Description panel **/ div#description img, div#description-preview img { diff --git a/assets/js/app.card_modal.js b/assets/js/app.card_modal.js index 02663f16..cd9fa453 100755 --- a/assets/js/app.card_modal.js +++ b/assets/js/app.card_modal.js @@ -52,11 +52,19 @@ var qtyelt = modal.find('.modal-qty'); if(qtyelt) { - var qty = ''; - for(var i = 0; i <= card.maxqty; i++) { - qty += ''; + var qtyHtml = ''; + if(card.maxqty > 3) { + qtyHtml = ''; + } else { + for(var i = 0; i <= card.maxqty; i++) { + qtyHtml += ''; + } } - qtyelt.html(qty); + qtyelt.html(qtyHtml); qtyelt.find('label').each(function (index, element) { diff --git a/assets/js/app.deck.js b/assets/js/app.deck.js index db016a31..028bcfc5 100755 --- a/assets/js/app.deck.js +++ b/assets/js/app.deck.js @@ -64,14 +64,14 @@ } /* - * Checks a given card's text has the "Shadow" keyword. + * Checks a given card's text has the given keyword. * @param {Object} card - * @param {String} shadow The i18n'ed word "Shadow". + * @param {String} keyword The i18n'ed word for a keyword. * @returns {boolean} */ - var card_has_shadow_keyword = function(card, shadow) { - // "Shadow ().", with being either digits or the letter "X" - var regex = new RegExp(shadow + ' \\(([0-9]+|X)\\)\\.'); + var card_has_keyword = function(card, keyword) { + // "Keyword ().", with being either digits or the letter "X" + var regex = new RegExp(keyword + ' \\(([0-9]+|X)\\)\\.'); var text = card.text || ''; // check if first line in the card text has that keyword. var textLines = text.split("\n"); @@ -627,6 +627,12 @@ { nb_packs[card.pack_code] = Math.max(nb_packs[card.pack_code] || 0, card.indeck / card.quantity); }); + + // for draft packs, do not calculate pack number + ['ToJ', 'VDS'].forEach(function(pack_code){ + nb_packs[pack_code] = 1; + }); + var pack_codes = _.uniq(_.pluck(cards, 'pack_code')); var packs = app.data.packs.find({ 'code': { @@ -1027,12 +1033,6 @@ { return [ '00030', // The King's Voice (VHotK) - '00362', // Sealing the Pact (ToJ) - '00363', // Unknown and Unknowable (ToJ) - '00364', // Pass Beneath a Shadow (ToJ) - '00365', // Seeking Fortunes (ToJ) - '00366', // Join Forces (ToJ) - '00367', // Desperate Hope (ToJ) ]; }; @@ -1065,6 +1065,8 @@ expectedMinCardCount = 100; } else if (agenda && agenda.code === '21030') { expectedPlotDeckSize = 10; + } else if (agenda && ['00362', '00363', '00364', '00365', '00366', '00367'].indexOf(agenda.code) > -1) { + expectedMinCardCount = 40; } }); // exactly 7 plots @@ -1345,6 +1347,56 @@ })) >= 12; } + var validate_sealing_the_pact = function() { + var outOfFactionCards = deck.get_cards( + null, + {faction_code: {$nin: [deck.get_faction_code(), 'neutral']}}, + ); + var minorFactions = []; + outOfFactionCards.forEach(function(card) { + if (!minorFactions.includes(card.faction_code)) { + minorFactions.push(card.faction_code); + } + }); + return minorFactions.length <= 1; + } + + var validate_unknown_and_unknowable = function() { + var outOfFactionCards = deck.get_cards( + null, + {faction_code: {$nin: [deck.get_faction_code(), 'neutral']}}, + ); + var minorFactions = []; + outOfFactionCards.forEach(function(card) { + if (!minorFactions.includes(card.faction_code)) { + minorFactions.push(card.faction_code); + } + }); + return minorFactions.length <= 2; + } + + var validate_join_forces = function() { + var outOfFactionCards = deck.get_cards( + null, + {faction_code: {$nin: [deck.get_faction_code(), 'neutral']}}, + ); + var commonTraits = []; + outOfFactionCards.forEach(function(card) { + var traits = card.traits.split('.') + .map(function(trait) { return trait.trim(); }) + .filter(function(trait) { return trait.length > 0; }); + if (commonTraits.length === 0) { + commonTraits = traits; + } else { + commonTraits = _.intersection(commonTraits, traits); + if (commonTraits.length === 0) { + return; + } + } + }); + return commonTraits.length > 0; + } + switch(agenda.code) { case '01027': if(deck.get_nb_cards(deck.get_cards(null, {type_code: {$in: ['character', 'attachment', 'location', 'event']}, faction_code: 'neutral'})) > 15) { @@ -1439,6 +1491,12 @@ return validate_sight_of_the_three_eyed_crow(); case '27620': return validate_streets_of_kings_landing(); + case '00362': + return validate_sealing_the_pact(); + case '00363': + return validate_unknown_and_unknowable(); + case '00366': + return validate_join_forces(); } return true; }; @@ -1500,6 +1558,23 @@ { var agendas = deck.get_agendas(); var agendaCodes = agendas.map(function(agenda) { return agenda.code }); + + // non-ToJ cards with a ToJ agenda => no + var tojAgendaCodes = ['00362', '00363', '00364', '00365', '00366', '00367']; + if(card.pack_code !== 'ToJ' && agendaCodes.some(function(code) { return tojAgendaCodes.includes(code) })) { + return false; + } + + // variant draw deck cards with a non-variant agenda => no + var variantAgendaCodes = ['00001', '00002', '00003', '00004', '00030']; + var variantPackCodes = ['VDS', 'ToJ']; + if (variantPackCodes.includes(card.pack_code) && agendaCodes.every( + function(code) { + return !variantAgendaCodes.includes(code) && !tojAgendaCodes.includes(code); + })) { + return false; + } + // neutral card => yes, unless the agenda is redesigned "Sea of Blood" and the card is an event. if(card.faction_code === 'neutral') { return !(-1 !== agendaCodes.indexOf('17149') && card.type_code === 'event'); @@ -1510,7 +1585,7 @@ return true; // out-of-house and loyal => no - if(card.is_loyal) + if(card.is_loyal && card.pack_code !== 'ToJ') return false; // agenda => yes @@ -1544,7 +1619,7 @@ return card.type_code === 'character' && card.traits.indexOf(Translator.trans('card.traits.maester')) !== -1; case '13079': // Kingdom of Shadows (BtRK) case '17148': // Kingdom of Shadows (R) - return card.type_code === 'character' && card_has_shadow_keyword(card, Translator.trans('card.keywords.shadow')); + return card.type_code === 'character' && card_has_keyword(card, Translator.trans('card.keywords.shadow')); case '13099': return card.type_code === 'character' && card.traits.indexOf(Translator.trans('card.traits.kingsguard')) !== -1; case '17150': @@ -1560,6 +1635,15 @@ return card.type_code === 'location' && card.traits.indexOf(Translator.trans('card.traits.warship')) !== -1; case '27619': return card.type_code === 'character' && card.traits.indexOf(Translator.trans('card.traits.guard')) !== -1; + case '00362': // Sealing the Pact + case '00363': // Unknown and Unknowable + case '00366': // Join Forces + case '00367': // Desperate Hope + return card.pack_code === 'ToJ'; + case '00364': // Pass Beneath the Shadow + return card.pack_code === 'ToJ' && card_has_keyword(card, Translator.trans('card.keywords.shadow')); + case '00365': // Seeking Fortunes + return card.pack_code === 'ToJ' && card_has_keyword(card, Translator.trans('keyword.bestow.name')); } }; })(app.deck = {}, jQuery); diff --git a/assets/js/ui.deckedit.js b/assets/js/ui.deckedit.js index b34e4dbc..e30c59bd 100755 --- a/assets/js/ui.deckedit.js +++ b/assets/js/ui.deckedit.js @@ -47,7 +47,7 @@ { app.data.cards.find().forEach(function (record) { - var max_qty = Math.min(3, record.deck_limit); + var max_qty = record.deck_limit; if(record.pack_code === 'Core') max_qty = Math.min(max_qty, record.quantity * app.config.get('core-set')); app.data.cards.updateById(record.code, { @@ -372,6 +372,12 @@ $(element).prop('checked', true).closest('label').addClass('active'); } }); + + // for cards with deck limit > 3, update the dropdown value instead + var select = row.find('select.qty-select'); + if(select.length) { + select.val(quantity); + } }); }; @@ -451,7 +457,7 @@ }); $('#config-options').on('change', 'input', ui.on_config_change); - $('#collection').on('change', 'input[type=radio]', ui.on_list_quantity_change); + $('#collection').on('change', 'input[type=radio], select.qty-select', ui.on_list_quantity_change); $('#restricted_lists').on('change', 'input[type=radio]', ui.on_rl_change); $('#cardModal').on('keypress', function (event) @@ -555,16 +561,28 @@ */ ui.build_row = function build_row(card) { - var radios = '', radioTpl = _.template( - '' - ); + var radios; - for(var i = 0; i <= card.maxqty; i++) { - radios += radioTpl({ - i: i, - active: (i === card.indeck ? ' active' : ''), - card: card - }); + // if there are more than three copies allowed, show a dropdown instead of + // a button group. this makes the UI more compact for large limits. + if (card.maxqty > 3) { + radios = ''; + } else { + var radioTpl = _.template( + '' + ); + radios = ''; + for (var i = 0; i <= card.maxqty; i++) { + radios += radioTpl({ + i: i, + active: (i === card.indeck ? ' active' : ''), + card: card + }); + } } var html = DisplayColumnsTpl({ @@ -640,6 +658,7 @@ } } ); + row.find('select.qty-select').val(card.indeck); row.find('.rl-labels').html(app.deck.get_card_labels(card)); diff --git a/assets/js/ui.deckinit.js b/assets/js/ui.deckinit.js index 8e57f9eb..ac52c0c9 100644 --- a/assets/js/ui.deckinit.js +++ b/assets/js/ui.deckinit.js @@ -43,24 +43,31 @@ function refresh_agendas() { var rl = app.data.getBestSelectedRestrictedList(); - if (! rl) { - $('.agenda').removeClass('hidden'); - return; - } + var mode = $('input[name=build_mode]:checked').val(); - $('.agenda').each(function (index, elem) { - var $elem = $(elem); - var code = $(elem).attr('data-card-code'); - if (-1 === rl.contents.joust.banned.indexOf(code) || -1 === rl.contents.melee.banned.indexOf(code)) { - $elem.removeClass('hidden'); - } else { - $elem.addClass('hidden'); - $('input', $elem).each(function(idx, input) { - var $input = $(input); - $input.prop('checked', false); - }); + if (mode === 'variant') { + $('.agenda.constructed').addClass('hidden'); + $('.agenda.variant').removeClass('hidden'); + } else { + $('.agenda.variant').addClass('hidden'); + $('.agenda.constructed').removeClass('hidden'); + if (! rl) { + return; } - }); + $('.agenda.constructed:visible').each(function (index, elem) { + var $elem = $(elem); + var code = $(elem).attr('data-card-code'); + if (-1 === rl.contents.joust.banned.indexOf(code) || -1 === rl.contents.melee.banned.indexOf(code)) { + $elem.removeClass('hidden'); + } else { + $elem.addClass('hidden'); + $('input', $elem).each(function(idx, input) { + var $input = $(input); + $input.prop('checked', false); + }); + } + }); + } if (! $('.agenda input:checked').length) { $('.no-agenda input[type="radio"]').prop('checked', true); @@ -73,6 +80,7 @@ */ ui.setup_event_handlers = function setup_event_handlers() { $('#restricted_lists').on('change', on_rl_change); + $('input[name=build_mode]').on('change', refresh_agendas); }; /** diff --git a/config/packages/dev/jms_i18n_routing.yaml b/config/packages/dev/jms_i18n_routing.yaml new file mode 100644 index 00000000..309ad46a --- /dev/null +++ b/config/packages/dev/jms_i18n_routing.yaml @@ -0,0 +1,4 @@ +# JMSI18nRoutingBundle Configuration +jms_i18n_routing: + strategy: prefix_except_default + redirect_to_host: false \ No newline at end of file diff --git a/public/router.php b/public/router.php new file mode 100644 index 00000000..844f3aaa --- /dev/null +++ b/public/router.php @@ -0,0 +1,10 @@ +slots as $slot) { + if ($slot->getCard()->getFaction()->getCode() !== $faction_code) { + $slots[] = $slot; + } + } + return new SlotCollectionDecorator(new ArrayCollection($slots)); + } + /** * @inheritdoc */ diff --git a/src/Classes/SlotCollectionInterface.php b/src/Classes/SlotCollectionInterface.php index 093b3566..ee84fd1b 100755 --- a/src/Classes/SlotCollectionInterface.php +++ b/src/Classes/SlotCollectionInterface.php @@ -92,6 +92,13 @@ public function getContent(); */ public function filterByFaction($faction_code); + /** + * Returns only any cards that don't match the given faction. + * @param string $faction_code + * @return SlotCollectionDecorator + */ + public function excludeByFaction($faction_code); + /** * Returns only cards that match the given type. * @param string $type_code diff --git a/src/Controller/BuilderController.php b/src/Controller/BuilderController.php index 4c04c629..39e0cf7a 100755 --- a/src/Controller/BuilderController.php +++ b/src/Controller/BuilderController.php @@ -49,21 +49,21 @@ class BuilderController extends AbstractController { /** - * @const EXCLUDED_AGENDAS Codes of agendas that should not be available for selection in the new deck wizard. + * @const VARIANT_AGENDAS Codes of agendas that should not be available for selection under "constructed" in the new deck wizard. * @todo Hardwiring those is good enough for now, rethink this if/as this list grows [ST 2019/04/04] */ - protected const EXCLUDED_AGENDAS = [ - '00001', // The Power of Wealth (VDS) - '00002', // Protectors of the Realm (VDS) - '00003', // Treaty (VDS) - '00004', // Uniting the Seven Kingdoms (VDS) - "00030", // The King's Voice (VHotK) + protected const VARIANT_AGENDAS = [ '00362', // Sealing the Pact (ToJ) '00363', // Unknown and Unknowable (ToJ) '00364', // Pass Beneath a Shadow (ToJ) '00365', // Seeking Fortunes (ToJ) '00366', // Join Forces (ToJ) '00367', // Desperate Hope (ToJ) + '00001', // The Power of Wealth (VDS) + '00002', // Protectors of the Realm (VDS) + '00003', // Treaty (VDS) + '00004', // Uniting the Seven Kingdoms (VDS) + "00030", // The King's Voice (VHotK) ]; /** @@ -82,7 +82,11 @@ public function buildformAction(int $cacheExpiration, TranslatorInterface $trans $em = $this->getDoctrine()->getManager(); $factions = $em->getRepository(Faction::class)->findPrimaries(); - $agendas = $em->getRepository(Card::class)->getAgendasForNewDeckWizard(self::EXCLUDED_AGENDAS); + $agendas = $em->getRepository(Card::class)->getAgendasForNewDeckWizard(self::VARIANT_AGENDAS); + $variantAgendas = []; + if (count(self::VARIANT_AGENDAS)) { + $variantAgendas = $em->getRepository(Card::class)->getVariantAgendasForNewDeckWizard(self::VARIANT_AGENDAS); + } return $this->render( 'Builder/initbuild.html.twig', @@ -90,6 +94,7 @@ public function buildformAction(int $cacheExpiration, TranslatorInterface $trans 'pagetitle' => $translator->trans('decks.form.new'), 'factions' => $factions, 'agendas' => $agendas, + 'variantAgendas' => $variantAgendas, ], $response ); diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index dd5c3e00..09a9128a 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -154,6 +154,27 @@ public function faqAction(int $cacheExpiration, TranslatorInterface $translator) return $response; } + /** + * @Route("/draft", name="draft", methods={"GET"}) + * @param int $cacheExpiration + * @param TranslatorInterface $translator + * @return Response + */ + public function draftAction(int $cacheExpiration, TranslatorInterface $translator) + { + $response = new Response(); + $response->setPublic(); + $response->setMaxAge($cacheExpiration); + + $page = $this->renderView( + 'Default/draft.html.twig', + array("pagetitle" => $translator->trans("nav.draft"), "pagedescription" => "Draft F.A.Q") + ); + $response->setContent($page); + + return $response; + } + /** * @Route("/syntax", name="syntax", methods={"GET"}) * @param int $cacheExpiration diff --git a/src/Entity/Card.php b/src/Entity/Card.php index 96d9954e..da2c0ef8 100755 --- a/src/Entity/Card.php +++ b/src/Entity/Card.php @@ -822,10 +822,10 @@ public function setImageUrl(string $imageUrl = null) /** * @inheritdoc */ - public function hasShadowKeyword($shadow): bool + public function hasKeyword($keyword): bool { - // "Shadow ().", with being either digits or the letter "X" - $regex = "/${shadow} \\(([0-9]+|X)\\)\\./"; + // "Keyword ().", with being either digits or the letter "X" + $regex = "/${keyword} \\(([0-9]+|X)\\)\\./"; // check if first line in the card text has that keyword. $textLines = explode("\n", $this->getText()); diff --git a/src/Entity/CardInterface.php b/src/Entity/CardInterface.php index 2cdb98c4..87200485 100644 --- a/src/Entity/CardInterface.php +++ b/src/Entity/CardInterface.php @@ -348,9 +348,9 @@ public function getImageUrl(); public function setImageUrl(string $imageUrl = null); /** - * Checks if this card has the "Shadow" keyword. - * @param string $shadow The keyword "Shadow" in whatever language. + * Checks if this card has the given keyword. + * @param string $keyword The keyword in whatever language. * @return bool */ - public function hasShadowKeyword($shadow): bool; + public function hasKeyword($keyword): bool; } diff --git a/src/Repository/CardRepository.php b/src/Repository/CardRepository.php index 3e649ad4..4a6d9d9d 100644 --- a/src/Repository/CardRepository.php +++ b/src/Repository/CardRepository.php @@ -135,7 +135,7 @@ public function findTraits() } /** - * Retrieves all agendas eligible for deck building. + * Retrieves all agendas eligible for constructed deck building. * @param array $excludedAgendas a list of codes of agendas to exclude. * @return array */ @@ -157,4 +157,28 @@ public function getAgendasForNewDeckWizard($excludedAgendas = array()): array return $qb->getQuery()->getResult(); } + + /** + * Retrieves all agendas eligible for variant game modes deck building. + * @param array $variantAgendas a list of codes of agendas to include. + * @return array + */ + public function getVariantAgendasForNewDeckWizard($variantAgendas = array()): array + { + $qb = $this->createQueryBuilder('c') + ->select('c, p') + ->join('c.pack', 'p') + ->join('c.type', 't') + ->where('t.code = :type') + ->orderBy('p.code', 'ASC'); + + $qb->setParameter(':type', 'agenda'); + + if (! empty($variantAgendas)) { + $qb->andWhere($qb->expr()->in('c.code', ':codes')); + $qb->setParameter(':codes', $variantAgendas, Connection::PARAM_STR_ARRAY); + } + + return $qb->getQuery()->getResult(); + } } diff --git a/src/Services/DeckValidationHelper.php b/src/Services/DeckValidationHelper.php index 754c6803..45b113ba 100755 --- a/src/Services/DeckValidationHelper.php +++ b/src/Services/DeckValidationHelper.php @@ -67,6 +67,14 @@ public function findProblem(CommonDeckInterface $deck) case '21030': // Battle of the Trident $expectedPlotDeckSize = 10; break; + case '00362': // Sealing the Pact + case '00363': // Unknown and Unknowable + case '00364': // Pass Beneath the Shadow + case '00365': // Seeking Fortunes + case '00366': // Join Forces + case '00367': // Desperate Hope + $expectedMinCardCount = 40; + break; default: // do nothing here } @@ -138,7 +146,7 @@ public function canIncludeCard(CommonDeckInterface $deck, CardInterface $card): if ($card->getFaction()->getCode() === $deck->getFaction()->getCode()) { return true; } - if ($card->getIsLoyal()) { + if ($card->getIsLoyal() && $card->getPack()->getCode() !== 'ToJ') { return false; } foreach ($deck->getSlots()->getAgendas() as $slot) { @@ -192,7 +200,7 @@ protected function isCardAllowedByAgenda(CardInterface $agenda, CardInterface $c case '13079': // Kingdom of Shadows (BtRK) case '17148': // Kingdom of Shadows (R) $langKey = $this->translator->trans('card.keywords.shadow'); - return $card->getType()->getCode() === 'character' && $card->hasShadowKeyword($langKey); + return $card->getType()->getCode() === 'character' && $card->hasKeyword($langKey); case '13099': // The White Book $trait = $this->translator->trans('card.traits.kingsguard'); if (preg_match("/$trait\\./", $card->getTraits())) { @@ -237,6 +245,17 @@ protected function isCardAllowedByAgenda(CardInterface $agenda, CardInterface $c return $card->getType()->getCode() === 'character'; } return false; + case '00362': // Sealing the Pact + case '00363': // Unknown and Unknowable + case '00366': // Join Forces + case '00367': // Desperate Hope + return $card->getPack()->getCode() === 'ToJ'; + case '00364': // Pass Beneath the Shadow + $langKey = $this->translator->trans('card.keywords.shadow'); + return $card->getPack()->getCode() === 'ToJ' && $card->hasKeyword($langKey); + case '00365': // Seeking Fortunes + $langKey = $this->translator->trans('keyword.bestow.name'); + return $card->getPack()->getCode() === 'ToJ' && $card->hasKeyword($langKey); } return false; } @@ -303,6 +322,15 @@ protected function validateAgenda( return $this->validateSightOfTheThreeEyedCrow($slots); case '27620': return $this->validateStreetsOfKingsLanding($slots); + case '00362': // Sealing the Pact + return $this->validateSealingThePact($slots, $faction); + case '00363': // Unknown and Unknowable + return $this->validateUnknownAndUnknowable($slots, $faction); + case '00366': // Join Forces + return $this->validateJoinForces($slots, $faction); + case '00364': // Pass Beneath the Shadow + case '00365': // Seeking Fortunes + case '00367': // Desperate Hope default: return true; } @@ -743,4 +771,53 @@ public function validateStreetsOfKingsLanding(SlotCollectionInterface $slots): b return true; } + + public function validateSealingThePact(SlotCollectionInterface $slots, FactionInterface $faction): bool + { + $outOfFactionCards = $slots->excludeByFaction($faction->getCode())->excludeByFaction('neutral'); + + $minorFactions = []; + foreach ($outOfFactionCards->getSlots() as $slot) { + $minorFactions[] = $slot->getCard()->getFaction()->getCode(); + } + $minorFactions = array_unique($minorFactions); + + return count($minorFactions) <= 1; + } + + public function validateUnknownAndUnknowable(SlotCollectionInterface $slots, FactionInterface $faction): bool + { + $outOfFactionCards = $slots->excludeByFaction($faction->getCode())->excludeByFaction('neutral'); + + $minorFactions = []; + foreach ($outOfFactionCards->getSlots() as $slot) { + $minorFactions[] = $slot->getCard()->getFaction()->getCode(); + } + $minorFactions = array_unique($minorFactions); + + return count($minorFactions) <= 2; + } + + public function validateJoinForces(SlotCollectionInterface $slots, FactionInterface $faction): bool + { + $outOfFactionCards = $slots->excludeByFaction($faction->getCode())->excludeByFaction('neutral'); + + $commonTraits = []; + foreach ($outOfFactionCards->getSlots() as $slot) { + $traits = $slot->getCard()->getTraits(); + $traits = array_filter( + array_map('trim', explode('.', $traits)) + ); + if (!$commonTraits) { + $commonTraits = $traits; + } else { + $commonTraits = array_intersect($commonTraits, $traits); + if (!$commonTraits) { + return false; + } + } + } + + return count($commonTraits) > 0; + } } diff --git a/templates/Builder/initbuild.html.twig b/templates/Builder/initbuild.html.twig index 9ce6bcd0..c96f243e 100755 --- a/templates/Builder/initbuild.html.twig +++ b/templates/Builder/initbuild.html.twig @@ -32,6 +32,16 @@

{{ 'decks.build.choose.agenda' | trans }}

+
+ + +
  • @@ -41,7 +51,15 @@
  • {% for agenda in agendas %} - + {% endfor %} + {% for agenda in variantAgendas %} +