at dev 332 lines 9.0 kB view raw
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}