Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c3589b3
docs(decks): spec and plan for deck checks (#258)
plainprogrammer Nov 12, 2026
b2ee0f1
feat(decks): add Catalog::DeckChecks and its owned-plus-proxy check (…
plainprogrammer Nov 12, 2026
5362d86
feat(decks): size results for the deck checks (#258)
plainprogrammer Nov 12, 2026
afd799f
feat(decks): copy-limit results for the deck checks (#258)
plainprogrammer Nov 12, 2026
7988bb6
feat(decks): Magic's deck rules for the deck checks (#258)
plainprogrammer Nov 12, 2026
eb7156f
test(decks): Magic deck checks end to end through the seam (#258)
plainprogrammer Nov 12, 2026
4c5097e
feat(decks): Check deck on the deck page (#258)
plainprogrammer Nov 12, 2026
81c5cf9
test(decks): the check runs no statement of its own (#258)
plainprogrammer Nov 12, 2026
8d2fba5
feat(design): the DeckCheck pattern for the check results (#258)
plainprogrammer Nov 12, 2026
d0acb71
test(decks): the check's focus, Back and phone width in the browser (…
plainprogrammer Nov 12, 2026
4ca7014
fix(design): rename the DeckCheck classes to c-deckcheck, off Form's …
plainprogrammer Nov 12, 2026
452cc14
fix(design): leave zone heads alone; test the results section's own w…
plainprogrammer Nov 12, 2026
62e1bc8
test(decks): make the browser examples fail when their selectors matc…
plainprogrammer Nov 12, 2026
ec3260d
docs(decks): screenshots and walkthrough for the deck checks (#258)
plainprogrammer Nov 12, 2026
32b86d9
docs(decks): document the deck checks in the area doc, CLAUDE.md and …
plainprogrammer Nov 12, 2026
03e52d7
docs(decks): verification record for the deck checks (#258)
plainprogrammer Nov 12, 2026
848c654
test(decks): prove the Magic copy, size and split ACs with the spec's…
plainprogrammer Nov 12, 2026
4144d1f
docs(decks): verification record names the new proofs (#258)
plainprogrammer Nov 12, 2026
56b26fb
fix(decks): the core never counts Considering for a size or copy rule…
plainprogrammer Nov 12, 2026
178a00e
docs(decks): say why Check deck turns prefetch off (#258)
plainprogrammer Nov 12, 2026
8c64f8f
docs(decks): record the implementation review and close its follow-up…
plainprogrammer Nov 12, 2026
1257c4c
docs(architecture): distil spec 079 into collection-and-lots
plainprogrammer Nov 12, 2026
01b3efb
docs(specs): mark spec 079 frozen by the collection-and-lots area doc…
plainprogrammer Nov 12, 2026
5c0b27b
docs(decks): record the distillation of spec 079 in verification.md (…
plainprogrammer Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions app/assets/stylesheets/collector/additions.css
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,27 @@ body:has(.c-locations) .c-appbar__back { min-height:var(--touch-floor); min-widt
.c-zone .c-section__actions { margin-left:auto; }
}

/* ---------- DeckCheck (spec 079): the Check deck results between the deck page's head and its zones ---------- */
/* A row is a status word with its icon and a sentence; the word and the icon say pass or fault, so colour is never the only
signal (decision 8). A row breaks inside a word: a card or zone name may be 60 characters with no space (AC-6.4). The
class is `c-deckcheck`, distinct from Form's `c-check` checkbox row. The 12px icon takes the 2.5 stroke, as a badge's
does (Iconography). */
.c-deckcheck { margin-top:var(--space-6); display:flex; flex-direction:column; gap:var(--space-3); min-width:0; }
.c-deckcheck__title:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius-sm); }
.c-deckcheck__summary { margin:0; font:400 13px/18px var(--font-mono); color:var(--ink-muted); }
.c-deckcheck__list { list-style:none; margin:0; padding:0; background:var(--surface-raised); border:1px solid var(--line); border-radius:var(--radius-md); }
.c-deckcheck__row { display:grid; grid-template-columns:minmax(0, 1fr); gap:var(--space-1); padding:var(--space-3) var(--space-4); border-top:1px solid var(--line); overflow-wrap:anywhere; }
.c-deckcheck__row:first-child { border-top:0; }
.c-deckcheck__status { display:inline-flex; align-items:center; gap:var(--space-2); font:600 13px/18px var(--font-sans); color:var(--ink); }
.c-deckcheck__status svg { width:12px; height:12px; flex:none; stroke-width:2.5; }
.c-deckcheck__row--pass .c-deckcheck__status svg { color:var(--success); }
.c-deckcheck__row--fault .c-deckcheck__status svg { color:var(--warning); }
.c-deckcheck__text { font:400 15px/22px var(--font-sans); color:var(--ink); }
@container page (min-width: 640px) {
.c-deckcheck__row { grid-template-columns:11rem minmax(0, 1fr); align-items:baseline; gap:var(--space-4); }
.c-deckcheck__row--plain .c-deckcheck__text { grid-column:1 / -1; }
}

/* ---------- Deck page zones (spec 060): a table per zone, grouped rows, one shared column layout ---------- */
/* From 640px of the table's container every zone table has the same fixed widths, so Cost, Owned, Proxy, Total and the
menu sit at one x in every zone (decision 25). th is border-box: a width is the whole column, padding included. A pip is
Expand Down
17 changes: 17 additions & 0 deletions app/controllers/decks/checks_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Check deck (spec 079): the deck page with its checks' results. A read-only request, so the link to it needs no form and the
# result is never stored: any later request of the deck page has none (decision 7). The deck comes from the signed-in
# account's decks, so another account's is a 404 (`FindsDeck`). It builds the page and the shape panel as
# `DecksController#show` does and adds one report; the report reads the loaded rows and runs no statement of its own (AC-5.1).
class Decks::ChecksController < ApplicationController
include FindsDeck

before_action :set_deck

# Renders the deck page with `@check` set.
def show
@page = Deck::Page.new(@deck)
@shape = Catalog::DeckShape.answer(@deck.collectible_type, @page.zones)
@check = Catalog::DeckChecks.report(@deck.collectible_type, @deck.format, @page.zones)
render "decks/show"
end
end
176 changes: 176 additions & 0 deletions app/models/catalog/deck_checks.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# What a deck page's Check deck answers (spec 079): the deck's size and copy limits judged by the collectible's own rules, and
# every card with owned copies and proxies in one zone, which needs no rule and runs for every deck. Like
# `Catalog::DeckShape`, an optional answer on the collecting vocabulary (`Catalog.collecting_for`), so the core counts and
# words the results and never names a collectible, a format or a zone key but `Catalog::Formats::CONSIDERING`.
#
# An extension contract: a vocabulary that has rules for a format provides
# * `deck_rules(format_key)` returning {Rules}, or nil for a format it has no rules for (the deck's stored key, registered or not).
# * `Rules#sizes`: {SizeRule}s in the order they are shown. A rule holds the `zone_keys` it counts (the extension names them;
# the core matches each to a zone row's key) and one of three shapes: `min` only, `max` only, or `min == max` (an exact
# size); a range with two different bounds is not offered. The extension supplies no words.
# * `Rules#copies`: a {CopyRule} or nil. Its `zone_keys` are counted together; `limit_for` is called with one counted printing
# (a `Catalog::Entry` whose `extension` may be nil) and returns a {Limit} (`count`, and a `reason` sentence part such as
# "restricted to 1", or nil) or nil for a card that is never limited. For a printing with no extension record it returns
# the format's ordinary limit. It reads loaded records only, never a query.
# The core counts copies by catalog identity over every counted printing and holds a card to the lowest number its printings
# answer; nil is above every number, so a card is unlimited only when every counted printing answers nil, and the reason is
# the one given with the lowest number (the first printing in page order on a tie). A card of another collectible than the
# deck's is left out of every check. A size or copy rule counts only the zones it names, and the core never counts
# Considering for one, even when a rule names its key (decision 3): such a rule still names it in a size line's subject.
# The owned-plus-proxy check judges every zone, Considering included (decision 5).
# A vocabulary without `deck_rules`, a nil answer, a type no longer registered and a deck whose format is no longer registered
# have no size or copy rules.
# @see file:collection-and-lots.md
module Catalog::DeckChecks
# One size rule: the zone keys it counts together and a minimum, a maximum, or both equal (an exact size).
SizeRule = Data.define(:zone_keys, :min, :max)
# A card's limit: the most copies a deck may hold, and an optional sentence part that says why ("restricted to 1").
Limit = Data.define(:count, :reason)
# The copy rule: the zone keys counted together and a callable from a loaded printing to a {Limit} or nil.
CopyRule = Data.define(:zone_keys, :limit_for)
# What a vocabulary answers for a format: its size rules and its copy rule (nil for none).
Rules = Data.define(:sizes, :copies)
# One line of a report: a status word (nil for a line that is no check), its sentence, and whether it is a finding.
Line = Data.define(:status, :sentence, :fault)
# The results of one check request, in the order they are shown.
Report = Data.define(:lines) do
# The number of findings.
def faults = lines.count(&:fault)

# The summary line: "3 findings", "1 finding" or "No findings".
def summary = faults.zero? ? "No findings" : "#{faults} #{faults == 1 ? 'finding' : 'findings'}"
end
# A row of the deck's collectible with its zone and the zone's position in page order.
# @api private
Counted = Data.define(:zone, :position, :row)

# The report for a deck.
#
# @param deck_type [String] the deck's collectible type
# @param format_key [String] the deck's stored format key, registered or not
# @param zones [Array<Deck::Page::Zone>] `Deck::Page#zones`; read from memory, no statement runs
# @return [Report] never nil, and never raises for an unregistered type or format
def self.report(deck_type, format_key, zones)
counted = counted_rows(deck_type, zones)
rules = rules_for(deck_type, format_key)
lines = rules ? rule_lines(rules, deck_type, format_key, zones, counted) : [ no_rules_line(deck_type, format_key) ]
Report.new(lines: lines + split_lines(counted))
end

# @api private
def self.rules_for(deck_type, format_key)
return unless Catalog.collecting.key?(deck_type)

vocabulary = Catalog.collecting_for(deck_type)
vocabulary.deck_rules(format_key) if vocabulary.respond_to?(:deck_rules)
end
private_class_method :rules_for

# Every row of the deck's collectible with its zone's position; another collectible's rows are left out (decision 4).
# @api private
def self.counted_rows(deck_type, zones)
zones.each_with_index.flat_map do |zone, position|
zone.groups.flat_map(&:rows).select { it.entry.collectible_type == deck_type }.map { Counted.new(zone:, position:, row: it) }
end
end
private_class_method :counted_rows

# The rows a size or copy rule counts: those in a zone the rule names, never Considering (decision 3), so a vocabulary that
# lists Considering among a rule's zones still has none of its cards counted.
# @api private
def self.in_zones(rule, counted)
counted.select { rule.zone_keys.include?(it.zone.location.zone) && it.zone.location.zone != Catalog::Formats::CONSIDERING }
end
private_class_method :in_zones

# @api private
def self.rule_lines(rules, deck_type, format_key, zones, counted)
sizes = rules.sizes.map { size_line(it, deck_type, format_key, zones, counted) }
sizes + (rules.copies ? copy_lines(rules.copies, counted) : [])
end
private_class_method :rule_lines

# The copy check (decisions 1, 3, 4): copies by catalog identity over the rule's zones, owned plus proxy; one finding per
# card over its limit, ordered by the first zone that holds it, then its name, then its identity; or one pass line. A card
# is held to the lowest number its printings answer; `min_by` keeps the first printing in page order on a tie.
# @api private
def self.copy_lines(rule, counted)
in_rule = in_zones(rule, counted)
findings = in_rule.group_by { it.row.entry.catalog_identity_id }.filter_map do |identity_id, cards|
copies = cards.sum { it.row.total }
limit = cards.map { it.row.entry }.uniq.filter_map { rule.limit_for.call(it) }.min_by(&:count)
next if limit.nil? || copies <= limit.count

[ cards.map(&:position).min, cards.first.row.entry.name, identity_id, copies, limit ]
end
return [ Line.new(status: "Pass", sentence: "No card is over its limit.", fault: false) ] if findings.empty?

findings.sort_by { it.first(3) }.map do |_position, name, _identity_id, copies, limit|
Line.new(status: "Over the limit", sentence: "#{name}: #{counted_words(copies, 'copy')}; #{limit.reason || "the limit is #{limit.count}"}.", fault: true)
end
end
private_class_method :copy_lines

# One size result (decision 6). Zones are named in the rule's key order: by the zone row's shown name, else by the format's
# registration, else by the key.
# @api private
def self.size_line(rule, deck_type, format_key, zones, counted)
total = in_zones(rule, counted).sum { it.row.total }
subject = rule.zone_keys.map { zone_name(it, deck_type, format_key, zones) }.join(" + ")
status = if rule.min && total < rule.min then "Short" elsif rule.max && total > rule.max then "Over" else "Pass" end
format_name = Catalog::Formats.name_for(deck_type, format_key)
Line.new(status:, sentence: "#{subject}: #{counted_words(total, 'card')}; #{format_name} #{size_need(rule)}.", fault: status != "Pass")
end
private_class_method :size_line

# The rule's requirement in words: "needs at least 60", "allows at most 15" or "needs exactly 100".
# @api private
def self.size_need(rule)
return "needs exactly #{delimited(rule.min)}" if rule.min && rule.min == rule.max
return "needs at least #{delimited(rule.min)}" if rule.min

"allows at most #{delimited(rule.max)}"
end
private_class_method :size_need

# @api private
def self.zone_name(key, deck_type, format_key, zones)
zones.find { it.location.zone == key }&.name || Catalog::Formats.zones_for(deck_type, format_key)&.find { it.key == key }&.name || key
end
private_class_method :zone_name

# "1 card", "5 copies": the count with delimiters and the singular for one.
# @api private
def self.counted_words(number, word) = "#{delimited(number)} #{word.pluralize(number)}"
private_class_method :counted_words

# @api private
def self.no_rules_line(deck_type, format_key)
Line.new(status: nil, sentence: "#{Catalog::Formats.name_for(deck_type, format_key)} has no rules to check.", fault: false)
end
private_class_method :no_rules_line

# A count with delimiters ("1,234"), as the deck page's heads and spec 069's panel write one.
# @api private
def self.delimited(number) = ActiveSupport::NumberHelper.number_to_delimited(number)
private_class_method :delimited

# The owned-plus-proxy check (decision 5): each zone alone, any printing of the card; findings ordered by the zone's
# position, then the card's name, then its identity; or one pass line.
# @api private
def self.split_lines(counted)
findings = counted.group_by { [ it.position, it.row.entry.catalog_identity_id ] }.filter_map do |(position, identity_id), cards|
owned = cards.sum { it.row.owned }
proxy = cards.sum { it.row.proxy }
next unless owned.positive? && proxy.positive?

[ position, cards.first.row.entry.name, identity_id, cards.first.zone.name, owned, proxy ]
end
return [ Line.new(status: "Pass", sentence: "No card has both owned copies and proxies in one zone.", fault: false) ] if findings.empty?

findings.sort_by { it.first(3) }.map do |_position, name, _identity_id, zone_name, owned, proxy|
Line.new(status: "Owned and proxy", sentence: "#{name} in #{zone_name}: #{delimited(owned)} owned, #{delimited(proxy)} proxy.", fault: true)
end
end
private_class_method :split_lines
end
7 changes: 7 additions & 0 deletions app/models/mtg/collecting.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ def self.deck_shape(zones)
Catalog::DeckShape::Answer.new(partial: "mtg/deck_shapes/parts", figures:, scope: figures.scope, facts: figures.facts)
end

# The deck checks' rules for one format (spec 079, the extension side of `Catalog::DeckChecks`): size and copy limits.
# See `MTG::DeckRules`.
#
# @param format_key [String] the deck's stored format key
# @return [Catalog::DeckChecks::Rules, nil] nil for casual and for a key with no rules
def self.deck_rules(format_key) = MTG::DeckRules.for(format_key.to_s)

# A printing with no extension record or no face data is not judged: it has no card data to be a legal or a banned card,
# so it gets no marker (spec 066 AC-6.6).
# @api private
Expand Down
80 changes: 80 additions & 0 deletions app/models/mtg/deck_rules.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Magic's deck rules for the deck checks (spec 079 decision 3, the extension side of `Catalog::DeckChecks`): size and copy
# limits per format, and each printing's limit read from its first face's type line, its rules text and its legalities.
# Reads the loaded printing record only, so no query runs per card.
class MTG::DeckRules
# The formats that share one rule set: the main zone's minimum, the sideboard's maximum and a limit of four.
CONSTRUCTED = %w[standard pioneer modern legacy vintage pauper].freeze
# A constructed deck holds at least this many cards in its main zone (spec 079 decision 3).
MAIN_MINIMUM = 60
# A constructed deck's sideboard holds at most this many cards.
SIDEBOARD_MAXIMUM = 15
# The copies of one card a constructed deck may hold (a basic land and the cards that say otherwise excepted).
CONSTRUCTED_COPIES = 4
# The copies of one card a Commander deck may hold: the singleton rule.
COMMANDER_COPIES = 1
# A Commander deck's commander and main zones hold exactly this many cards together.
COMMANDER_SIZE = 100
# Rules text of a card a deck may hold any number of: Scryfall's own words (confirmed in verification.md, T0.2).
ANY_NUMBER = /a deck can have any number of cards named/i
# Rules text of a card a deck may hold up to a stated number of; the number is the word after "up to".
STATED_NUMBER = /a deck can have up to (\w+) cards named/i
# The number words the stated limits use (Seven Dwarves: seven; Nazgûl: nine).
NUMBER_WORDS = %w[two three four five six seven eight nine ten].each_with_index.to_h { |word, index| [ word, index + 2 ] }.freeze

# The rules of one format.
#
# @param format_key [String] the deck's stored format key
# @return [Catalog::DeckChecks::Rules, nil] nil for casual and for a key the collectible has no rules for
def self.for(format_key)
return commander if format_key == "commander"

constructed(format_key) if CONSTRUCTED.include?(format_key)
end

# A printing's limit in a format. Order matters: a basic land and any-number text are exempt first, then a stated number,
# then Vintage's restriction, then the ordinary limit. A printing with no card data is never exempt and never restricted.
#
# @param entry [Catalog::Entry] a printing, its `extension` loaded or nil
# @param format_key [String]
# @param ordinary [Integer] the format's ordinary limit
# @return [Catalog::DeckChecks::Limit, nil] nil for a card that is never limited
def self.limit_for(entry, format_key, ordinary)
printing = entry.extension
return Catalog::DeckChecks::Limit.new(count: ordinary, reason: nil) if printing.nil?

faces = Array(printing.faces)
return if basic_land?(faces.first)

text = faces.filter_map { it["oracle_text"] }.join("\n")
return if text.match?(ANY_NUMBER)

stated = NUMBER_WORDS[text[STATED_NUMBER, 1].to_s.downcase]
return Catalog::DeckChecks::Limit.new(count: stated, reason: nil) if stated
return Catalog::DeckChecks::Limit.new(count: 1, reason: "restricted to 1") if format_key == "vintage" && printing.legalities.to_h["vintage"] == "restricted"

Catalog::DeckChecks::Limit.new(count: ordinary, reason: nil)
end

# @api private
def self.basic_land?(face) = face&.dig("type_line").to_s.split("—").first.to_s.split.include?("Basic")
private_class_method :basic_land?

# @api private
def self.constructed(format_key)
Catalog::DeckChecks::Rules.new(
sizes: [ Catalog::DeckChecks::SizeRule.new(zone_keys: %w[main], min: MAIN_MINIMUM, max: nil),
Catalog::DeckChecks::SizeRule.new(zone_keys: %w[sideboard], min: nil, max: SIDEBOARD_MAXIMUM) ],
copies: Catalog::DeckChecks::CopyRule.new(zone_keys: %w[main sideboard], limit_for: ->(entry) { limit_for(entry, format_key, CONSTRUCTED_COPIES) })
)
end
private_class_method :constructed

# @api private
def self.commander
Catalog::DeckChecks::Rules.new(
sizes: [ Catalog::DeckChecks::SizeRule.new(zone_keys: %w[commander main], min: COMMANDER_SIZE, max: COMMANDER_SIZE) ],
copies: Catalog::DeckChecks::CopyRule.new(zone_keys: %w[commander main], limit_for: ->(entry) { limit_for(entry, "commander", COMMANDER_COPIES) })
)
end
private_class_method :commander
end
14 changes: 14 additions & 0 deletions app/views/decks/_check.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<section class="c-deckcheck" aria-labelledby="deck-check-title">
<h2 class="c-section__title c-deckcheck__title" id="deck-check-title" tabindex="-1" autofocus>Deck check</h2>
<p class="c-deckcheck__summary"><%= check.summary %></p>
<ul class="c-deckcheck__list">
<% check.lines.each do |line| %>
<li class="c-deckcheck__row<%= line.status ? (line.fault ? " c-deckcheck__row--fault" : " c-deckcheck__row--pass") : " c-deckcheck__row--plain" %>">
<% if line.status %>
<span class="c-deckcheck__status"><%= render(line.fault ? "icons/alert" : "icons/check") %><%= line.status %></span>
<% end %>
<span class="c-deckcheck__text"><%= line.sentence %></span>
</li>
<% end %>
</ul>
</section>
Loading
Loading