From 25210b1dafdadffaeca414e6599b2af94c7b1f3a Mon Sep 17 00:00:00 2001 From: kayorga Date: Mon, 23 Feb 2026 17:50:17 +0100 Subject: [PATCH 01/11] draft deck limits --- assets/css/style.css | 10 ++++++++++ assets/js/app.deck.js | 6 ++++++ assets/js/ui.deckedit.js | 41 +++++++++++++++++++++++++++++----------- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/assets/css/style.css b/assets/css/style.css index b3cfcaa2..aa3cab1e 100755 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -208,6 +208,16 @@ 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; +} + /** Description panel **/ div#description img, div#description-preview img { diff --git a/assets/js/app.deck.js b/assets/js/app.deck.js index db016a31..7c383433 100755 --- a/assets/js/app.deck.js +++ b/assets/js/app.deck.js @@ -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': { 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)); From f58227046389cdeb9cf5acaa865ac6ac36ca2a45 Mon Sep 17 00:00:00 2001 From: kayorga Date: Mon, 23 Feb 2026 22:29:12 +0100 Subject: [PATCH 02/11] agenda validation WIP --- src/Classes/SlotCollectionDecorator.php | 14 +++++++ src/Classes/SlotCollectionInterface.php | 7 ++++ src/Services/DeckValidationHelper.php | 55 +++++++++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/src/Classes/SlotCollectionDecorator.php b/src/Classes/SlotCollectionDecorator.php index f4fcae9a..8ba40278 100644 --- a/src/Classes/SlotCollectionDecorator.php +++ b/src/Classes/SlotCollectionDecorator.php @@ -272,6 +272,20 @@ public function filterByFaction($faction_code) return new SlotCollectionDecorator(new ArrayCollection($slots)); } + /** + * @inheritdoc + */ + public function excludeByFaction($faction_code) + { + $slots = []; + foreach ($this->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/Services/DeckValidationHelper.php b/src/Services/DeckValidationHelper.php index 754c6803..d879c47e 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 } @@ -237,6 +245,13 @@ 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 '00364': // Pass Beneath the Shadow + case '00365': // Seeking Fortunes + case '00366': // Join Forces + case '00367': // Desperate Hope + return $card->getPack()->getCode() === 'ToJ'; } return false; } @@ -303,6 +318,14 @@ 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 '00364': // Pass Beneath the Shadow + case '00365': // Seeking Fortunes + case '00366': // Join Forces + case '00367': // Desperate Hope default: return true; } @@ -743,4 +766,36 @@ 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 validatePassBeneathTheShadow(SlotCollectionInterface $slots, FactionInterface $faction): bool + { + // TODO + return true; + } } From 90bc7f44286cad836e170eaf5c88af64348a88b2 Mon Sep 17 00:00:00 2001 From: kayorga Date: Tue, 24 Feb 2026 17:42:10 +0100 Subject: [PATCH 03/11] agenda validation --- assets/js/app.deck.js | 87 +++++++++++++++++++++++---- src/Entity/Card.php | 6 +- src/Entity/CardInterface.php | 6 +- src/Services/DeckValidationHelper.php | 38 +++++++++--- tests/Entity/CardTest.php | 2 +- 5 files changed, 111 insertions(+), 28 deletions(-) diff --git a/assets/js/app.deck.js b/assets/js/app.deck.js index 7c383433..f074d0c4 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"); @@ -1033,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) ]; }; @@ -1071,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 @@ -1351,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) { @@ -1445,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; }; @@ -1516,7 +1568,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 @@ -1550,7 +1602,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': @@ -1566,6 +1618,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/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/Services/DeckValidationHelper.php b/src/Services/DeckValidationHelper.php index d879c47e..45b113ba 100755 --- a/src/Services/DeckValidationHelper.php +++ b/src/Services/DeckValidationHelper.php @@ -146,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) { @@ -200,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())) { @@ -247,11 +247,15 @@ protected function isCardAllowedByAgenda(CardInterface $agenda, CardInterface $c return false; 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 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; } @@ -322,9 +326,10 @@ protected function validateAgenda( 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 '00366': // Join Forces case '00367': // Desperate Hope default: return true; @@ -793,9 +798,26 @@ public function validateUnknownAndUnknowable(SlotCollectionInterface $slots, Fac return count($minorFactions) <= 2; } - public function validatePassBeneathTheShadow(SlotCollectionInterface $slots, FactionInterface $faction): bool + public function validateJoinForces(SlotCollectionInterface $slots, FactionInterface $faction): bool { - // TODO - return true; + $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/tests/Entity/CardTest.php b/tests/Entity/CardTest.php index 133e3ef7..b6053b5d 100644 --- a/tests/Entity/CardTest.php +++ b/tests/Entity/CardTest.php @@ -41,6 +41,6 @@ public function testHasShadowKeyword($text, $shadowKeyword, $hasKeyword) { $card = new Card(); $card->setText($text); - $this->assertEquals($card->hasShadowKeyword($shadowKeyword), $hasKeyword); + $this->assertEquals($card->hasKeyword($shadowKeyword), $hasKeyword); } } From 21fde09aa9b2493b96e5c029146605e67b757624 Mon Sep 17 00:00:00 2001 From: kayorga Date: Tue, 24 Feb 2026 18:12:08 +0100 Subject: [PATCH 04/11] card modal --- assets/js/app.card_modal.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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) { From 81f883a856089ea973c4ce61627901e5b76e12f0 Mon Sep 17 00:00:00 2001 From: kayorga Date: Wed, 25 Feb 2026 08:33:13 +0100 Subject: [PATCH 05/11] card modal dropdown styling --- assets/css/style.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/assets/css/style.css b/assets/css/style.css index aa3cab1e..fc572f4d 100755 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -217,6 +217,13 @@ ol { 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, From 4c8e394c74d927e548114e546cc8889220b2aae4 Mon Sep 17 00:00:00 2001 From: kayorga Date: Wed, 25 Feb 2026 10:33:09 +0100 Subject: [PATCH 06/11] draft faq page --- src/Controller/DefaultController.php | 21 +++++++++++++++++++++ templates/base.html.twig | 1 + translations/messages.de.yml | 1 + translations/messages.en.yml | 1 + translations/messages.es.yml | 1 + 5 files changed, 25 insertions(+) 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/templates/base.html.twig b/templates/base.html.twig index 90d0088f..7655a277 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -62,6 +62,7 @@