👁️
1import { getPreset } from "@/lib/deck-validation/presets";
2
3export interface FormatGroup {
4 label: string;
5 formats: { value: string; label: string }[];
6}
7
8export type CommanderType =
9 | "commander"
10 | "oathbreaker"
11 | "brawl"
12 | "pauper"
13 | null;
14
15export interface FormatInfo {
16 deckSize: number | "variable";
17 singleton: boolean;
18 commanderType: CommanderType;
19 hasSignatureSpell: boolean;
20 hasSideboard: boolean;
21 tagline: string;
22 isCube: boolean;
23 supportsAlchemy: boolean;
24}
25
26/**
27 * Short taglines describing each format's identity.
28 * Focused on what makes formats unique, not rules details.
29 */
30const FORMAT_TAGLINES: Record<string, string> = {
31 // Constructed
32 standard: "Rotating · Recent sets",
33 pioneer: "Non-rotating · 2012+",
34 modern: "Non-rotating · 2003+",
35 legacy: "Eternal · All sets",
36 vintage: "Eternal · Power allowed",
37 pauper: "Commons only",
38
39 // Commander
40 commander: "Multiplayer · Casual",
41 duel: "1v1 · Competitive",
42 paupercommander: "Commons + uncommon commander",
43 predh: "Pre-2020 cards only",
44 oathbreaker: "Planeswalker + signature spell",
45
46 // Brawl
47 brawl: "Arena · Historic card pool",
48 standardbrawl: "Arena · Standard card pool",
49
50 // Arena
51 historic: "Arena · Non-rotating",
52 timeless: "Arena · No bans",
53 alchemy: "Arena · Digital cards",
54 gladiator: "Arena · 100-card singleton",
55
56 // Retro
57 premodern: "1995–2003 cards",
58 oldschool: "1993–1994 cards",
59
60 // Limited
61 draft: "40-card · Drafted cards + basic lands",
62
63 // Other
64 penny: "Budget · Rotating cheapest",
65 cube: "Draft · Custom card pool",
66
67 // Casual
68 kitchentable: "Anything goes",
69};
70
71/**
72 * Get stable format info that won't change with rules updates.
73 * Used for UI display (deck size badges, singleton indicators, etc.)
74 */
75export function getFormatInfo(format: string): FormatInfo {
76 // Special case: Cube
77 if (format === "cube") {
78 return {
79 deckSize: "variable",
80 singleton: true,
81 commanderType: null,
82 hasSignatureSpell: false,
83 hasSideboard: false,
84 tagline: FORMAT_TAGLINES.cube,
85 isCube: true,
86 supportsAlchemy: false,
87 };
88 }
89
90 const preset = getPreset(format);
91 if (!preset) {
92 return {
93 deckSize: 60,
94 singleton: false,
95 commanderType: null,
96 hasSignatureSpell: false,
97 hasSideboard: true,
98 tagline: "",
99 isCube: false,
100 supportsAlchemy: false,
101 };
102 }
103
104 const { rules, config } = preset;
105 const rulesSet = new Set(rules);
106
107 const deckSize = config.deckSize ?? config.minDeckSize ?? 60;
108 const singleton = rulesSet.has("singleton");
109 const hasSideboard = (config.sideboardSize ?? 0) > 0;
110 const hasSignatureSpell = rulesSet.has("signatureSpell");
111
112 // Determine commander type
113 let commanderType: CommanderType = null;
114 if (rulesSet.has("commanderPlaneswalker")) {
115 commanderType = "oathbreaker";
116 } else if (rulesSet.has("commanderRequired")) {
117 if (format === "brawl" || format === "standardbrawl") {
118 commanderType = "brawl";
119 } else if (format === "paupercommander") {
120 commanderType = "pauper";
121 } else {
122 commanderType = "commander";
123 }
124 }
125
126 return {
127 deckSize,
128 singleton,
129 commanderType,
130 hasSignatureSpell,
131 hasSideboard,
132 tagline: FORMAT_TAGLINES[format] ?? "",
133 isCube: false,
134 supportsAlchemy: config.supportsAlchemy ?? false,
135 };
136}
137
138export const FORMAT_GROUPS: FormatGroup[] = [
139 {
140 label: "Constructed",
141 formats: [
142 { value: "standard", label: "Standard" },
143 { value: "pioneer", label: "Pioneer" },
144 { value: "modern", label: "Modern" },
145 { value: "legacy", label: "Legacy" },
146 { value: "vintage", label: "Vintage" },
147 { value: "pauper", label: "Pauper" },
148 ],
149 },
150 {
151 label: "Commander",
152 formats: [
153 { value: "commander", label: "Commander" },
154 { value: "duel", label: "Duel Commander" },
155 { value: "paupercommander", label: "Pauper Commander" },
156 { value: "predh", label: "PreDH" },
157 { value: "oathbreaker", label: "Oathbreaker" },
158 ],
159 },
160 {
161 label: "Brawl",
162 formats: [
163 { value: "brawl", label: "Brawl" },
164 { value: "standardbrawl", label: "Standard Brawl" },
165 ],
166 },
167 {
168 label: "Arena",
169 formats: [
170 { value: "historic", label: "Historic" },
171 { value: "timeless", label: "Timeless" },
172 { value: "alchemy", label: "Alchemy" },
173 { value: "gladiator", label: "Gladiator" },
174 ],
175 },
176 {
177 label: "Retro",
178 formats: [
179 { value: "premodern", label: "Premodern" },
180 { value: "oldschool", label: "Old School" },
181 ],
182 },
183 {
184 label: "Limited",
185 formats: [{ value: "draft", label: "Draft" }],
186 },
187 {
188 label: "Other",
189 formats: [
190 { value: "penny", label: "Penny Dreadful" },
191 { value: "cube", label: "Cube" },
192 { value: "kitchentable", label: "Kitchen Table" },
193 ],
194 },
195];
196
197const FORMAT_DISPLAY_NAMES: Record<string, string> = Object.fromEntries(
198 FORMAT_GROUPS.flatMap((group) =>
199 group.formats.map((fmt) => [fmt.value, fmt.label]),
200 ),
201);
202
203export function formatDisplayName(format: string | undefined): string {
204 if (!format) return "";
205 return FORMAT_DISPLAY_NAMES[format] ?? format;
206}
207
208/**
209 * Deck characteristics for format suggestion
210 */
211export interface DeckCharacteristics {
212 deckSize: number;
213 hasCommander: boolean;
214 /** Formats where error cards are legal (from card legalities). Used to boost matching formats. */
215 errorLegalFormats: string[];
216}
217
218// Pre-computed format info for all formats (avoids repeated getFormatInfo calls)
219const ALL_FORMATS: Array<{ id: string; info: FormatInfo }> =
220 FORMAT_GROUPS.flatMap((group) =>
221 group.formats.map((fmt) => ({
222 id: fmt.value,
223 info: getFormatInfo(fmt.value),
224 })),
225 );
226
227/**
228 * Suggest formats that match the deck's characteristics better than the current format.
229 * Returns format IDs sorted by relevance (max 3).
230 *
231 * Scoring:
232 * - +20 per occurrence in errorLegalFormats (formats where error cards are legal)
233 * - +50 for commander support (when deck has commander)
234 * - -20 for commander format when deck has no commander (might be missing markup)
235 * - +50 for exact deck size match (within 5%)
236 * - +30 for close deck size (within 20%)
237 * - +10 for somewhat close deck size (within 50%)
238 *
239 * Exclusions:
240 * - Formats without commander support when deck has commander
241 * - Cube (too specific, user knows if they're building a cube)
242 *
243 * Falls back to Kitchen Table if no other formats match.
244 */
245export function suggestFormats(
246 characteristics: DeckCharacteristics,
247 currentFormat: string,
248): string[] {
249 const { deckSize, hasCommander, errorLegalFormats } = characteristics;
250
251 // Count occurrences of each format in error legalities
252 const legalFormatCounts = new Map<string, number>();
253 for (const fmt of errorLegalFormats) {
254 legalFormatCounts.set(fmt, (legalFormatCounts.get(fmt) || 0) + 1);
255 }
256
257 const candidates: Array<{ format: string; score: number }> = [];
258
259 for (const { id, info } of ALL_FORMATS) {
260 if (id === currentFormat) continue;
261
262 // Skip cube (too specific) and kitchentable (handled as fallback)
263 if (info.isCube || id === "kitchentable") continue;
264
265 let score = 0;
266
267 // Boost formats where error cards are legal (+20 per card)
268 const legalCount = legalFormatCounts.get(id) || 0;
269 score += legalCount * 20;
270
271 // Commander handling:
272 // - Deck HAS commander but format doesn't support: hard exclude
273 // - Deck has NO commander but format expects one: penalty (might just be missing markup)
274 // - Both match: boost
275 if (hasCommander && info.commanderType === null) {
276 continue; // Can't use commander in non-commander format
277 }
278
279 if (hasCommander && info.commanderType !== null) {
280 score += 50;
281 } else if (!hasCommander && info.commanderType !== null) {
282 score -= 20; // Penalty for missing commander, but don't exclude
283 }
284
285 // Deck size matching - bigger boost for exact match
286 const expectedSize = info.deckSize === "variable" ? null : info.deckSize;
287 if (expectedSize) {
288 const sizeDiff = Math.abs(deckSize - expectedSize);
289 if (sizeDiff <= expectedSize * 0.05) {
290 // Exact or near-exact match (within 5%)
291 score += 50;
292 } else if (sizeDiff <= expectedSize * 0.2) {
293 // Close match (within 20%)
294 score += 30;
295 } else if (sizeDiff <= expectedSize * 0.5) {
296 // Somewhat close (within 50%)
297 score += 10;
298 }
299 }
300
301 if (score > 0) {
302 candidates.push({ format: id, score });
303 }
304 }
305
306 const results = candidates
307 .sort((a, b) => b.score - a.score)
308 .slice(0, 3)
309 .map((c) => c.format);
310
311 // Fall back to Kitchen Table if nothing else matches
312 if (results.length === 0 && currentFormat !== "kitchentable") {
313 return ["kitchentable"];
314 }
315
316 return results;
317}
318
319/**
320 * Format a list of format suggestions as a readable string.
321 * e.g., ["brawl", "standardbrawl"] → "Brawl or Standard Brawl"
322 */
323export function formatSuggestionList(formats: string[]): string {
324 if (formats.length === 0) return "";
325 if (formats.length === 1) return formatDisplayName(formats[0]);
326 if (formats.length === 2) {
327 return `${formatDisplayName(formats[0])} or ${formatDisplayName(formats[1])}`;
328 }
329 const last = formats[formats.length - 1];
330 const rest = formats.slice(0, -1);
331 return `${rest.map(formatDisplayName).join(", ")}, or ${formatDisplayName(last)}`;
332}