diff --git a/db/migrate/20260611120000_add_max_3_point_agendas_to_restrictions.rb b/db/migrate/20260611120000_add_max_3_point_agendas_to_restrictions.rb new file mode 100644 index 00000000..c94ebf4e --- /dev/null +++ b/db/migrate/20260611120000_add_max_3_point_agendas_to_restrictions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddMax3PointAgendasToRestrictions < ActiveRecord::Migration[8.1] + def change + add_column :restrictions, :max_3_point_agendas, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index e07575cb..bbf64d45 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_05_22_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_06_11_120000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -277,6 +277,7 @@ t.datetime "created_at", null: false t.text "date_start", null: false t.string "format_id" + t.integer "max_3_point_agendas" t.text "name", null: false t.integer "point_limit" t.datetime "updated_at", null: false diff --git a/lib/deck_validator.rb b/lib/deck_validator.rb index 8f1af8b6..9a6f52e9 100644 --- a/lib/deck_validator.rb +++ b/lib/deck_validator.rb @@ -91,9 +91,36 @@ def valid? # rubocop:disable Metrics/MethodLength # Validate against Restriction next if v.restriction_id.nil? + restriction = @restrictions[v.restriction_id] r = @unified_restrictions[v.restriction_id] Rails.logger.error "Restriction is #{r.inspect}" + # Check for deckbuilding restrictions (currently just the 3+ point agenda limit in startup). + if @deck['side_id'] == 'corp' + max_three_point_agendas = restriction.max_3_point_agendas + unless max_three_point_agendas.nil? + three_point_agendas = @deck['cards'].filter_map do |card_id, quantity| + card = @cards[card_id] + next unless card.card_type_id == 'agenda' && card.agenda_points.to_i >= 3 + + [card_id, quantity] + end + agenda_count = three_point_agendas.sum { |_card_id, quantity| quantity } + + if agenda_count > max_three_point_agendas + v.add_error( + format( + 'Startup Corp decks may not include more than %d agenda cards with a printed agenda point value of 3 or greater, but deck has %d: %s.', # rubocop:disable Layout/LineLength + limit: max_three_point_agendas, + count: agenda_count, + cards: three_point_agendas.map { |card_id, quantity| "#{card_id} (#{quantity})" }.join(', ') + ) + ) + @validation_errors = true + end + end + end + # Check for banned cards. ([@deck['identity_card_id']] + @deck['cards'].keys).each do |card_id| next unless r.key?(card_id) && r[card_id].is_banned diff --git a/lib/tasks/cards.rake b/lib/tasks/cards.rake index 58799022..c83ad299 100644 --- a/lib/tasks/cards.rake +++ b/lib/tasks/cards.rake @@ -758,6 +758,7 @@ namespace :cards do name: m['name'], date_start: m['date_start'], point_limit: m['point_limit'], + max_3_point_agendas: m['max_3_point_agendas'], format_id: m['format_id'] ) end diff --git a/spec/libraries/deck_validation_spec.rb b/spec/libraries/deck_validation_spec.rb index b50d56b8..e238c43e 100644 --- a/spec/libraries/deck_validation_spec.rb +++ b/spec/libraries/deck_validation_spec.rb @@ -49,6 +49,19 @@ end end + context 'with startup format only' do + subject(:v) { described_class.new({ 'label' => 'expand startup format', 'format_id' => 'startup' }) } + + it 'expands fields' do + expect(v.label).to eq('expand startup format') + expect(v.format_id).to eq('startup') + expect(v.card_pool_id).to eq('startup_02') + expect(v.restriction_id).to eq('startup_banlist') + expect(v.snapshot_id).to eq('startup_02') + expect(v).to be_valid + end + end + context 'with card pool only' do subject(:v) { described_class.new(card_pool_only) } diff --git a/spec/libraries/deck_validator_spec.rb b/spec/libraries/deck_validator_spec.rb index 783b1efc..97620098 100644 --- a/spec/libraries/deck_validator_spec.rb +++ b/spec/libraries/deck_validator_spec.rb @@ -435,6 +435,23 @@ def set_card_quantity(deck, card_id, quantity) new_deck end + def replace_validation(deck, validation) + new_deck = deck.deep_dup + new_deck['validations'] = [validation] + new_deck + end + + def validate_for_format(deck, format_id) + replace_validation( + deck, + { + 'label' => "#{format_id.capitalize} validation.", + 'basic_deckbuilding_rules' => true, + 'format_id' => format_id + } + ) + end + def add_out_of_faction_agenda(deck) new_deck = deck.deep_dup new_deck['cards'].delete('send_a_message') @@ -758,6 +775,64 @@ def add_out_of_faction_agenda(deck) expect(v.errors).to include('Snapshot `snapshot_3030` does not exist.') end + it 'applies startup restriction bans like standard restriction bans' do + deck = validate_for_format(good_asa_group, 'startup') + v = described_class.new(deck) + expect(v).not_to be_valid + expect(v.validations.size).to eq(deck['validations'].size) + expect(v.validations[0].errors).to include('Card `hedge_fund` is banned in restriction `startup_banlist`.') + end + + it 'fails validation for startup corp deck with too many 3+ point agendas' do + deck = validate_for_format(good_asa_group, 'startup') + v = described_class.new(deck) + expect(v).not_to be_valid + expect(v.validations.size).to eq(deck['validations'].size) + expect(v.validations[0].errors).to include( + 'Startup Corp decks may not include more than 4 agenda cards with a printed agenda point value of 3 or greater, but deck has 5: ikawah_project (3), send_a_message (2).' # rubocop:disable Layout/LineLength + ) + end + + it 'uses the agenda cap from the startup restriction' do + deck = replace_validation( + good_asa_group, + { + 'label' => 'Startup validation.', + 'basic_deckbuilding_rules' => true, + 'card_pool_id' => 'startup_02', + 'restriction_id' => 'startup_three_point_agenda_limit' + } + ) + v = described_class.new(deck) + expect(v).not_to be_valid + expect(v.validations.size).to eq(deck['validations'].size) + expect(v.validations[0].errors).to include( + 'Startup Corp decks may not include more than 3 agenda cards with a printed agenda point value of 3 or greater, but deck has 5: ikawah_project (3), send_a_message (2).' # rubocop:disable Layout/LineLength + ) + end + + it 'allows startup corp deck at the 3+ point agenda limit' do + deck = validate_for_format(set_card_quantity(good_asa_group, 'send_a_message', 1), 'startup') + v = described_class.new(deck) + expect(v.validations.size).to eq(deck['validations'].size) + expect(v.validations[0].errors).not_to include(a_string_matching(/Startup Corp decks may not include/)) + end + + it 'does not apply startup agenda cap to snapshots without a restriction' do + deck = validate_for_format(good_asa_group, 'startup') + deck['validations'][0]['snapshot_id'] = 'startup_01' + v = described_class.new(deck) + expect(v.validations.size).to eq(deck['validations'].size) + expect(v.validations[0].errors).not_to include(a_string_matching(/Startup Corp decks may not include/)) + end + + it 'does not apply startup agenda cap outside startup validations' do + deck = validate_for_format(good_asa_group, 'standard') + v = described_class.new(deck) + expect(v.validations.size).to eq(deck['validations'].size) + expect(v.validations[0].errors).not_to include(a_string_matching(/Startup Corp decks may not include/)) + end + it 'fails validation for cards not in specified card pool' do deck = good_asa_group.deep_dup # Test fixture standard_02 is not a full representation of standard. diff --git a/test/fixtures/restrictions.yml b/test/fixtures/restrictions.yml index 557eb205..4841148c 100644 --- a/test/fixtures/restrictions.yml +++ b/test/fixtures/restrictions.yml @@ -14,6 +14,24 @@ eternal_points_list: created_at: <%= Time.utc(2022, 12, 8, 12, 0).to_fs(:db) %> updated_at: <%= Time.utc(2022, 12, 8, 12, 0).to_fs(:db) %> +startup_banlist: + id: startup_banlist + name: "Startup Banlist" + format_id: "startup" + date_start: 2026-04-17 + max_3_point_agendas: 4 + created_at: <%= Time.utc(2022, 12, 8, 12, 0).to_fs(:db) %> + updated_at: <%= Time.utc(2022, 12, 8, 12, 0).to_fs(:db) %> + +startup_three_point_agenda_limit: + id: startup_three_point_agenda_limit + name: "Startup 3 Point Agenda Limit" + format_id: "startup" + date_start: 2026-03-03 + max_3_point_agendas: 3 + created_at: <%= Time.utc(2022, 12, 8, 12, 0).to_fs(:db) %> + updated_at: <%= Time.utc(2022, 12, 8, 12, 0).to_fs(:db) %> + standard_restricted: id: standard_restricted name: "Standard Restricted List" diff --git a/test/fixtures/restrictions_cards_banned.yml b/test/fixtures/restrictions_cards_banned.yml index 409d69c7..eac466f6 100644 --- a/test/fixtures/restrictions_cards_banned.yml +++ b/test/fixtures/restrictions_cards_banned.yml @@ -4,3 +4,8 @@ standard_banlist_trieste_model_bioroids: created_at: <%= Time.utc(2022, 12, 8, 12, 0).to_fs(:db) %> updated_at: <%= Time.utc(2022, 12, 8, 12, 0).to_fs(:db) %> +startup_banlist_hedge_fund: + restriction_id: startup_banlist + card_id: hedge_fund + created_at: <%= Time.utc(2022, 12, 8, 12, 0).to_fs(:db) %> + updated_at: <%= Time.utc(2022, 12, 8, 12, 0).to_fs(:db) %> diff --git a/test/fixtures/snapshots.yml b/test/fixtures/snapshots.yml index 6aa369ae..8a9fe386 100644 --- a/test/fixtures/snapshots.yml +++ b/test/fixtures/snapshots.yml @@ -60,6 +60,7 @@ startup_02: id: startup_02 format_id: startup card_pool_id: startup_02 + restriction_id: startup_banlist date_start: 2022-09-01 active: true created_at: <%= Time.utc(2022, 12, 8, 12, 0).to_fs(:db) %>