@domainellipticlanguage/mtg-crucible 0.2.0 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +137 -386
  2. package/dist/constants/constants/index.d.ts +3 -0
  3. package/dist/constants/constants/index.d.ts.map +1 -0
  4. package/dist/constants/constants/index.js +14 -0
  5. package/dist/constants/constants/index.js.map +1 -0
  6. package/dist/constants/index.d.ts +3 -0
  7. package/dist/constants/index.d.ts.map +1 -0
  8. package/dist/constants/index.js +14 -0
  9. package/dist/constants/index.js.map +1 -0
  10. package/dist/constants/types.d.ts +166 -0
  11. package/dist/constants/types.d.ts.map +1 -0
  12. package/dist/constants/types.js +19 -0
  13. package/dist/constants/types.js.map +1 -0
  14. package/dist/helpers.d.ts +0 -6
  15. package/dist/helpers.d.ts.map +1 -1
  16. package/dist/helpers.js +0 -54
  17. package/dist/helpers.js.map +1 -1
  18. package/dist/index.d.ts +5 -3
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +23 -10
  21. package/dist/index.js.map +1 -1
  22. package/dist/parser/index.d.ts +4 -0
  23. package/dist/parser/index.d.ts.map +1 -0
  24. package/dist/parser/index.js +28 -0
  25. package/dist/parser/index.js.map +1 -0
  26. package/dist/parser/layout.d.ts +1355 -0
  27. package/dist/parser/layout.d.ts.map +1 -0
  28. package/dist/parser/layout.js +339 -0
  29. package/dist/parser/layout.js.map +1 -0
  30. package/dist/parser/parser/index.d.ts +4 -0
  31. package/dist/parser/parser/index.d.ts.map +1 -0
  32. package/dist/parser/parser/index.js +28 -0
  33. package/dist/parser/parser/index.js.map +1 -0
  34. package/dist/parser/parser.d.ts +30 -0
  35. package/dist/parser/parser.d.ts.map +1 -0
  36. package/dist/parser/parser.js +1321 -0
  37. package/dist/parser/parser.js.map +1 -0
  38. package/dist/parser/types.d.ts +166 -0
  39. package/dist/parser/types.d.ts.map +1 -0
  40. package/dist/parser/types.js +19 -0
  41. package/dist/parser/types.js.map +1 -0
  42. package/dist/parser.d.ts.map +1 -1
  43. package/dist/parser.js +64 -66
  44. package/dist/parser.js.map +1 -1
  45. package/dist/react/react/MtgCard.d.ts +2 -2
  46. package/dist/react/react/MtgCard.d.ts.map +1 -1
  47. package/dist/react/react/MtgCard.js +15 -15
  48. package/dist/react/react/MtgCard.js.map +1 -1
  49. package/dist/react/react/index.d.ts +1 -1
  50. package/dist/react/react/index.d.ts.map +1 -1
  51. package/dist/react/types.d.ts +21 -15
  52. package/dist/react/types.d.ts.map +1 -1
  53. package/dist/react/types.js +16 -0
  54. package/dist/react/types.js.map +1 -1
  55. package/dist/renderers/render.d.ts.map +1 -1
  56. package/dist/renderers/render.js +4 -19
  57. package/dist/renderers/render.js.map +1 -1
  58. package/dist/types.d.ts +21 -15
  59. package/dist/types.d.ts.map +1 -1
  60. package/dist/types.js +16 -0
  61. package/dist/types.js.map +1 -1
  62. package/package.json +12 -2
@@ -0,0 +1,1321 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deriveFrameColor = deriveFrameColor;
4
+ exports.parseAbilities = parseAbilities;
5
+ exports.formatAbilities = formatAbilities;
6
+ exports.parseCard = parseCard;
7
+ exports.formatCard = formatCard;
8
+ exports.getOracleText = getOracleText;
9
+ exports.toScryfallJson = toScryfallJson;
10
+ exports.toScryfallText = toScryfallText;
11
+ exports.getParsedAbilities = getParsedAbilities;
12
+ exports.resolveTemplate = resolveTemplate;
13
+ exports.getArtDimensions = getArtDimensions;
14
+ exports.inferLinkType = inferLinkType;
15
+ exports.computeRotations = computeRotations;
16
+ const layout_1 = require("./layout");
17
+ const MANA_COST_REGEX = /^(.+?)\s+((?:\{[^}]+\})+)$/;
18
+ const ART_REGEX = /^Art URL:\s*(https?:\/\/\S+)$/i;
19
+ const ART_DESCRIPTION_REGEX = /^Art Description:\s*(.+)$/i;
20
+ const RARITY_REGEX = /^Rarity:\s*(common|uncommon|rare|mythic(?:\s+rare)?)$/i;
21
+ const ARTIST_REGEX = /^Artist:\s*(.+)$/i;
22
+ const SET_REGEX = /^Set:\s*([A-Za-z0-9]+)$/i;
23
+ const COLLECTOR_REGEX = /^Collector(?:\s+(?:Number|No\.?))?:\s*(.+)$/i;
24
+ const DESIGNER_REGEX = /^Designer:\s*(.+)$/i;
25
+ const COLOR_INDICATOR_REGEX = /^Color Indicator:\s*(.+)$/i;
26
+ const ACCENT_REGEX = /^Accent:\s*(.+)$/i;
27
+ const FRAME_REGEX = /^Frame Color:\s*(.+)$/i;
28
+ const FRAME_EFFECT_REGEX = /^Frame Effect:\s*(.+)$/i;
29
+ const NAME_LINE_REGEX = /^Name Line:\s*(.+)$/i;
30
+ const TYPE_LINE_COLOR_REGEX = /^Type Line Color:\s*(.+)$/i;
31
+ const PT_BOX_COLOR_REGEX = /^PT Box Color:\s*(.+)$/i;
32
+ const PT_REGEX = /^([*\d+]+)\/([*\d+]+)$/;
33
+ const LOYALTY_REGEX = /^Loyalty:\s*(\S+)$/i;
34
+ const DEFENSE_REGEX = /^Defense:\s*(\S+)$/i;
35
+ const PW_ABILITY_REGEX = /^([+\-\u2212]?\d+):\s*(.+)$/;
36
+ // TODO make this more general - any IVXL
37
+ const SAGA_CHAPTER_REGEX = /^((?:I{1,3}|IV|V|VI)(?:\s*,\s*(?:I{1,3}|IV|V|VI))*)\s*[—–-]\s*(.+)$/;
38
+ const CLASS_LEVEL_REGEX = /^((?:\{[^}]+\})+):\s*(Level\s+\d+)$/;
39
+ const FLAVOR_TEXT_REGEX = /^Flavor Text:\s*(.+)$/i;
40
+ const ZERO_WIDTH_REGEX = /[\u200B-\u200D\uFEFF]/g;
41
+ const SUPERTYPES = new Set(['legendary', 'basic', 'snow', 'world']);
42
+ const TYPES = new Set(['creature', 'instant', 'sorcery', 'enchantment', 'artifact', 'planeswalker', 'land', 'battle']);
43
+ const COLOR_ALIASES = {
44
+ w: 'white', white: 'white',
45
+ u: 'blue', blue: 'blue',
46
+ b: 'black', black: 'black',
47
+ r: 'red', red: 'red',
48
+ g: 'green', green: 'green',
49
+ };
50
+ const FRAME_ALIASES = {
51
+ ...COLOR_ALIASES,
52
+ colorless: 'colorless', c: 'colorless',
53
+ artifact: 'artifact', a: 'artifact',
54
+ // TODO make multicolored be the canonical version
55
+ multicolor: 'multicolor', multi: 'multicolor', gold: 'multicolor', m: 'multicolor', multicolored: 'multicolor',
56
+ vehicle: 'vehicle', v: 'vehicle',
57
+ land: 'land', l: 'land',
58
+ };
59
+ /** Split "Green, Artifact, and Green" or "wubrg" into individual tokens. */
60
+ function tokenizeColorList(input) {
61
+ // Split on commas and whitespace, then filter out "and" and empty strings
62
+ const raw = input.split(/[,\s]+/).map(t => t.trim().toLowerCase()).filter(t => t && t !== 'and');
63
+ // Expand shorthand like "wubrg" — if a token is 2+ chars and every char is a known single-letter alias
64
+ const expanded = [];
65
+ for (const token of raw) {
66
+ if (token.length >= 2 && [...token].every(ch => FRAME_ALIASES[ch])) {
67
+ expanded.push(...[...token]);
68
+ }
69
+ else {
70
+ expanded.push(token);
71
+ }
72
+ }
73
+ return expanded;
74
+ }
75
+ function parseFrameTokens(input) {
76
+ const tokens = tokenizeColorList(input);
77
+ if (tokens.length === 1) {
78
+ return FRAME_ALIASES[tokens[0]];
79
+ }
80
+ const parsed = [];
81
+ for (const raw of tokens) {
82
+ const fc = FRAME_ALIASES[raw];
83
+ if (fc)
84
+ parsed.push(fc);
85
+ }
86
+ return parsed.length > 0 ? parsed : undefined;
87
+ }
88
+ const FRAME_EFFECT_ALIASES = {
89
+ normal: 'normal',
90
+ nyx: 'nyx',
91
+ snow: 'snow',
92
+ devoid: 'devoid',
93
+ };
94
+ function parseAccentTokens(input) {
95
+ const tokens = tokenizeColorList(input);
96
+ if (tokens.length === 1) {
97
+ return FRAME_ALIASES[tokens[0]];
98
+ }
99
+ const parsed = [];
100
+ for (const raw of tokens) {
101
+ const ac = FRAME_ALIASES[raw];
102
+ if (ac)
103
+ parsed.push(ac);
104
+ }
105
+ return parsed.length > 0 ? parsed : undefined;
106
+ }
107
+ function parseFrameEffectTokens(input) {
108
+ const tokens = input.split(/[,\s]+/).map(t => t.trim().toLowerCase()).filter(t => t && t !== 'and');
109
+ if (tokens.length === 1) {
110
+ return FRAME_EFFECT_ALIASES[tokens[0]];
111
+ }
112
+ const parsed = [];
113
+ for (const raw of tokens) {
114
+ const fe = FRAME_EFFECT_ALIASES[raw];
115
+ if (fe)
116
+ parsed.push(fe);
117
+ }
118
+ return parsed.length > 0 ? parsed : undefined;
119
+ }
120
+ function stripZeroWidth(text) {
121
+ return text.replace(ZERO_WIDTH_REGEX, '');
122
+ }
123
+ function normalizeManaSymbols(value) {
124
+ if (!value)
125
+ return value;
126
+ return value.replace(/\{([^}]+)\}/g, (_, inner) => `{${inner.trim().toUpperCase()}}`);
127
+ }
128
+ function normalizeLines(text) {
129
+ return text
130
+ .replace(/\r\n?/g, '\n')
131
+ .split('\n')
132
+ .map(line => stripZeroWidth(line).trim())
133
+ .filter(line => line.length > 0);
134
+ }
135
+ function parseColorIndicator(raw) {
136
+ const tokens = tokenizeColorList(raw);
137
+ if (tokens.length === 0)
138
+ return undefined;
139
+ const colors = [];
140
+ for (const token of tokens) {
141
+ const color = COLOR_ALIASES[token];
142
+ if (color && !colors.includes(color))
143
+ colors.push(color);
144
+ }
145
+ return colors.length > 0 ? colors : undefined;
146
+ }
147
+ function romanToNumber(roman) {
148
+ switch (roman.trim()) {
149
+ case 'I': return 1;
150
+ case 'II': return 2;
151
+ case 'III': return 3;
152
+ case 'IV': return 4;
153
+ case 'V': return 5;
154
+ case 'VI': return 6;
155
+ default: return parseInt(roman) || 0;
156
+ }
157
+ }
158
+ function parseTypeLine(typeLine) {
159
+ if (!typeLine)
160
+ return { supertypes: [], types: [], subtypes: [] };
161
+ const [left, right] = typeLine.split(/\s+[—–-]\s+|\s*[—–]\s*/);
162
+ const subtypes = right ? right.split(/\s+/) : [];
163
+ const supertypes = [];
164
+ const types = [];
165
+ for (const word of left.split(/\s+/)) {
166
+ const lower = word.toLowerCase();
167
+ if (SUPERTYPES.has(lower))
168
+ supertypes.push(lower);
169
+ else if (TYPES.has(lower))
170
+ types.push(lower);
171
+ }
172
+ return { supertypes, types, subtypes };
173
+ }
174
+ const MANA_COLOR_MAP = { W: 'white', U: 'blue', B: 'black', R: 'red', G: 'green' };
175
+ const WUBRG = ['W', 'U', 'B', 'R', 'G'];
176
+ function extractManaColors(manaCost) {
177
+ const colors = new Set();
178
+ const symbols = manaCost?.match(/\{([^}]+)\}/g) || [];
179
+ for (const sym of symbols) {
180
+ const inner = sym.slice(1, -1).toUpperCase();
181
+ for (const c of WUBRG) {
182
+ if (inner.includes(c))
183
+ colors.add(c);
184
+ }
185
+ }
186
+ return colors;
187
+ }
188
+ /** Return true if any mana symbol is hybrid between the two colors. */
189
+ function hasHybridMana(manaCost, colors) {
190
+ if (!manaCost || colors.size !== 2)
191
+ return false;
192
+ const [c1, c2] = [...colors];
193
+ const symbols = manaCost.match(/\{([^}]+)\}/g) || [];
194
+ for (const sym of symbols) {
195
+ const inner = sym.slice(1, -1).toUpperCase();
196
+ if (inner.includes('/') && inner.includes(c1) && inner.includes(c2))
197
+ return true;
198
+ }
199
+ return false;
200
+ }
201
+ /** Return colors sorted in WUBRG order as Color[]. */
202
+ function colorsInOrder(colors) {
203
+ return [...colors]
204
+ .sort((a, b) => WUBRG.indexOf(a) - WUBRG.indexOf(b))
205
+ .map(c => MANA_COLOR_MAP[c]);
206
+ }
207
+ const LAND_TYPE_COLORS = {
208
+ plains: 'W', island: 'U', swamp: 'B', mountain: 'R', forest: 'G',
209
+ };
210
+ /** Extract colors a land produces from basic land subtypes and "Add {X}" abilities. */
211
+ function extractProducedColors(subtypes, oracleText) {
212
+ const colors = new Set();
213
+ if (subtypes) {
214
+ for (const st of subtypes) {
215
+ const c = LAND_TYPE_COLORS[st.toLowerCase()];
216
+ if (c)
217
+ colors.add(c);
218
+ }
219
+ }
220
+ if (oracleText) {
221
+ // "mana of any color" → all five colors (gold frame)
222
+ if (/mana of any color/i.test(oracleText)) {
223
+ for (const c of WUBRG)
224
+ colors.add(c);
225
+ }
226
+ // Find "Add ..." clauses (up to period/newline), extract {W}/{U}/{B}/{R}/{G} symbols
227
+ for (const m of oracleText.matchAll(/[Aa]dd [^.\n]*/g)) {
228
+ for (const sym of m[0].matchAll(/\{([WUBRG])\}/gi)) {
229
+ const c = sym[1].toUpperCase();
230
+ if (WUBRG.includes(c))
231
+ colors.add(c);
232
+ }
233
+ }
234
+ }
235
+ return colors;
236
+ }
237
+ /** Convert a set of color letters to an accent value (scalar, array, or 'multicolor'). */
238
+ function colorsToAccent(colors) {
239
+ if (colors.size === 0)
240
+ return undefined;
241
+ if (colors.size === 1)
242
+ return MANA_COLOR_MAP[[...colors][0]];
243
+ if (colors.size === 2)
244
+ return colorsInOrder(colors);
245
+ return 'multicolor';
246
+ }
247
+ function deriveFrameColor(card) {
248
+ // Effective colors: from mana cost, or fall back to color indicator
249
+ const colors = extractManaColors(card.manaCost);
250
+ const fromIndicator = colors.size === 0 && card.colorIndicator && card.colorIndicator.length > 0;
251
+ if (fromIndicator) {
252
+ for (const [letter, name] of Object.entries(MANA_COLOR_MAP)) {
253
+ if (card.colorIndicator.includes(name))
254
+ colors.add(letter);
255
+ }
256
+ }
257
+ const twoColors = colors.size === 2 ? colorsInOrder(colors) : undefined;
258
+ // Dual frames for hybrid mana only
259
+ const isDualFrame = twoColors !== undefined && hasHybridMana(card.manaCost, colors);
260
+ const accent = colorsToAccent(colors);
261
+ // 1. Vehicle subtype
262
+ if (card.subtypes?.some(s => s.toLowerCase() === 'vehicle'))
263
+ return { frameColor: 'vehicle' };
264
+ // 2. Land type — accent from produced colors, then card colors fallback
265
+ if (card.types?.includes('land')) {
266
+ const produced = extractProducedColors(card.subtypes, card.abilitiesText);
267
+ const landAccent = colorsToAccent(produced);
268
+ if (landAccent)
269
+ return { frameColor: 'land', accentColor: landAccent };
270
+ if (accent)
271
+ return { frameColor: 'land', accentColor: accent };
272
+ return { frameColor: 'land' };
273
+ }
274
+ // 3. Artifact type
275
+ if (card.types?.includes('artifact')) {
276
+ return accent ? { frameColor: 'artifact', accentColor: accent } : { frameColor: 'artifact' };
277
+ }
278
+ // 4. Devoid — colorless frame and accent
279
+ const isDevoid = card.abilitiesText?.toLowerCase().includes('devoid');
280
+ if (isDevoid)
281
+ return { frameColor: 'colorless', accentColor: 'colorless' };
282
+ // 5. Normal cards
283
+ if (colors.size === 0)
284
+ return { frameColor: 'colorless' };
285
+ if (colors.size === 1)
286
+ return { frameColor: MANA_COLOR_MAP[[...colors][0]] };
287
+ if (isDualFrame)
288
+ return { frameColor: twoColors, accentColor: twoColors };
289
+ if (twoColors)
290
+ return { frameColor: 'multicolor', accentColor: twoColors };
291
+ return { frameColor: 'multicolor' };
292
+ }
293
+ function numberToRoman(n) {
294
+ switch (n) {
295
+ case 1: return 'I';
296
+ case 2: return 'II';
297
+ case 3: return 'III';
298
+ case 4: return 'IV';
299
+ case 5: return 'V';
300
+ case 6: return 'VI';
301
+ default: return String(n);
302
+ }
303
+ }
304
+ /** Parse raw ability text into structured form. */
305
+ function parseAbilities(text, kind) {
306
+ const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0);
307
+ if (lines.length === 0)
308
+ return {};
309
+ if (kind === 'planeswalker') {
310
+ const loyaltyAbilities = [];
311
+ for (const line of lines) {
312
+ const m = line.match(PW_ABILITY_REGEX);
313
+ if (m) {
314
+ loyaltyAbilities.push({ cost: m[1].replace(/\u2212/g, '-'), text: m[2] });
315
+ }
316
+ else {
317
+ loyaltyAbilities.push({ cost: '', text: line });
318
+ }
319
+ }
320
+ return { structuredAbilities: { kind: 'planeswalker', loyaltyAbilities } };
321
+ }
322
+ if (kind === 'saga') {
323
+ const chapters = [];
324
+ const unstructured = [];
325
+ for (const line of lines) {
326
+ const m = line.match(SAGA_CHAPTER_REGEX);
327
+ if (m) {
328
+ const chapterNumbers = m[1].split(',').map(r => romanToNumber(r.trim()));
329
+ chapters.push({ chapterNumbers, text: m[2].trim() });
330
+ }
331
+ else {
332
+ unstructured.push(line);
333
+ }
334
+ }
335
+ const result = { structuredAbilities: { kind: 'saga', chapters } };
336
+ if (unstructured.length > 0)
337
+ result.unstructuredAbilities = unstructured;
338
+ return result;
339
+ }
340
+ if (kind === 'class') {
341
+ const classLevels = [];
342
+ let pending = { level: 1, cost: '', textLines: [] };
343
+ let haveExplicitLevel = false;
344
+ const pushPending = () => {
345
+ const text = pending.textLines.join('\n').trim();
346
+ classLevels.push({
347
+ level: pending.level,
348
+ cost: normalizeManaSymbols(pending.cost) ?? '',
349
+ text,
350
+ });
351
+ };
352
+ for (const line of lines) {
353
+ const levelMatch = line.match(CLASS_LEVEL_REGEX);
354
+ if (levelMatch) {
355
+ if (haveExplicitLevel || pending.textLines.length > 0)
356
+ pushPending();
357
+ haveExplicitLevel = true;
358
+ pending = {
359
+ level: parseInt(levelMatch[2].replace(/\D/g, ''), 10) || pending.level + 1,
360
+ cost: levelMatch[1],
361
+ textLines: [],
362
+ };
363
+ }
364
+ else {
365
+ pending.textLines.push(line);
366
+ }
367
+ }
368
+ if (haveExplicitLevel || pending.textLines.length > 0)
369
+ pushPending();
370
+ // Extract reminder text from level 1 — lines wrapped in *(...)* are italic reminder text
371
+ const unstructured = [];
372
+ if (classLevels.length > 0 && classLevels[0].level === 1 && classLevels[0].text) {
373
+ const level0Lines = classLevels[0].text.split('\n');
374
+ const reminderLines = [];
375
+ const abilityLines = [];
376
+ for (const line of level0Lines) {
377
+ const trimmed = line.trim();
378
+ // Match reminder text: either *(...)* or bare (...)
379
+ if (reminderLines.length === 0 && abilityLines.length === 0 && (/^\*\(.*\)\*$/.test(trimmed) || /^\(.*\)$/.test(trimmed))) {
380
+ reminderLines.push(trimmed.replace(/^\*|\*$/g, ''));
381
+ }
382
+ else {
383
+ abilityLines.push(line);
384
+ }
385
+ }
386
+ if (reminderLines.length > 0) {
387
+ unstructured.push(...reminderLines);
388
+ classLevels[0].text = abilityLines.join('\n');
389
+ }
390
+ }
391
+ const result = { structuredAbilities: { kind: 'class', classLevels } };
392
+ if (unstructured.length > 0)
393
+ result.unstructuredAbilities = unstructured;
394
+ return result;
395
+ }
396
+ if (kind === 'case') {
397
+ let toSolve = '';
398
+ let solved = '';
399
+ const unstructured = [];
400
+ for (const line of lines) {
401
+ const toSolveMatch = line.match(/^To solve\s*[—–-]\s*(.+)$/i);
402
+ const solvedMatch = line.match(/^Solved\s*[—–-]\s*(.+)$/i);
403
+ if (toSolveMatch) {
404
+ toSolve = toSolveMatch[1].trim();
405
+ }
406
+ else if (solvedMatch) {
407
+ solved = solvedMatch[1].trim();
408
+ }
409
+ else {
410
+ unstructured.push(line);
411
+ }
412
+ }
413
+ const result = {
414
+ structuredAbilities: { kind: 'case', caseConditions: { toSolve, solved } },
415
+ };
416
+ if (unstructured.length > 0)
417
+ result.unstructuredAbilities = unstructured;
418
+ return result;
419
+ }
420
+ // Detect prototype from body text: "Prototype {cost} — P/T (...)"
421
+ const PROTO_REGEX = /^Prototype\s+((?:\{[^}]+\})+)\s*[—–-]\s*(\d+)\/(\d+)/i;
422
+ for (let i = 0; i < lines.length; i++) {
423
+ const m = lines[i].match(PROTO_REGEX);
424
+ if (m) {
425
+ const unstructured = lines.filter((_, idx) => idx !== i);
426
+ const result = {
427
+ structuredAbilities: {
428
+ kind: 'prototype',
429
+ prototype: { manaCost: m[1], power: m[2], toughness: m[3] },
430
+ },
431
+ };
432
+ if (unstructured.length > 0)
433
+ result.unstructuredAbilities = unstructured;
434
+ return result;
435
+ }
436
+ }
437
+ // Detect mutate from body text: "Mutate {cost} (...)"
438
+ const MUTATE_REGEX = /^Mutate\s+((?:\{[^}]+\})+)/i;
439
+ for (let i = 0; i < lines.length; i++) {
440
+ const m = lines[i].match(MUTATE_REGEX);
441
+ if (m) {
442
+ const unstructured = lines.filter((_, idx) => idx !== i);
443
+ const result = {
444
+ structuredAbilities: { kind: 'mutate', mutateCost: m[1] },
445
+ };
446
+ if (unstructured.length > 0)
447
+ result.unstructuredAbilities = unstructured;
448
+ return result;
449
+ }
450
+ }
451
+ // Detect leveler from body text: "LEVEL N-N" or "LEVEL N+"
452
+ const LEVEL_HEADER_REGEX = /^LEVEL\s+(\d+)([+-])(\d*)$/i;
453
+ const levelLines = [];
454
+ const unstructuredLeveler = [];
455
+ let foundLevelHeader = false;
456
+ for (let i = 0; i < lines.length; i++) {
457
+ const lm = lines[i].match(LEVEL_HEADER_REGEX);
458
+ if (lm) {
459
+ foundLevelHeader = true;
460
+ const lo = parseInt(lm[1], 10);
461
+ const hi = lm[2] === '+' ? 99 : parseInt(lm[3], 10);
462
+ // Next lines: P/T then rules text (or rules then P/T)
463
+ let power = '0', toughness = '0', rulesText = '';
464
+ const remaining = [];
465
+ for (let j = i + 1; j < lines.length; j++) {
466
+ if (lines[j].match(LEVEL_HEADER_REGEX))
467
+ break;
468
+ remaining.push(lines[j]);
469
+ }
470
+ const ptIdx = remaining.findIndex(l => PT_REGEX.test(l));
471
+ if (ptIdx >= 0) {
472
+ const ptm = remaining[ptIdx].match(PT_REGEX);
473
+ power = ptm[1];
474
+ toughness = ptm[2];
475
+ rulesText = remaining.filter((_, idx) => idx !== ptIdx).join('\n').trim();
476
+ }
477
+ levelLines.push({ level: [lo, hi], rulesText, power, toughness });
478
+ // Skip the lines we consumed
479
+ i += remaining.length;
480
+ }
481
+ else if (!foundLevelHeader) {
482
+ unstructuredLeveler.push(lines[i]);
483
+ }
484
+ }
485
+ if (levelLines.length > 0) {
486
+ const result = {
487
+ structuredAbilities: { kind: 'leveler', creatureLevels: levelLines },
488
+ };
489
+ if (unstructuredLeveler.length > 0)
490
+ result.unstructuredAbilities = unstructuredLeveler;
491
+ return result;
492
+ }
493
+ // Default (standard): all lines are unstructured abilities
494
+ return { unstructuredAbilities: lines };
495
+ }
496
+ /** Format ParsedAbilities back into oracle text. */
497
+ function formatAbilities(abilities) {
498
+ const parts = [];
499
+ if (abilities.unstructuredAbilities && abilities.unstructuredAbilities.length > 0) {
500
+ parts.push(abilities.unstructuredAbilities.join('\n'));
501
+ }
502
+ const sa = abilities.structuredAbilities;
503
+ if (sa) {
504
+ switch (sa.kind) {
505
+ case 'planeswalker':
506
+ for (const a of sa.loyaltyAbilities) {
507
+ parts.push(a.cost ? `${a.cost}: ${a.text}` : a.text);
508
+ }
509
+ break;
510
+ case 'saga':
511
+ for (const ch of sa.chapters) {
512
+ const nums = ch.chapterNumbers.map(n => numberToRoman(n)).join(', ');
513
+ parts.push(`${nums} — ${ch.text}`);
514
+ }
515
+ break;
516
+ case 'class':
517
+ for (const lv of sa.classLevels) {
518
+ if (lv.cost)
519
+ parts.push(`${lv.cost}: Level ${lv.level}`);
520
+ if (lv.text)
521
+ parts.push(lv.text);
522
+ }
523
+ break;
524
+ case 'leveler':
525
+ for (const lv of sa.creatureLevels) {
526
+ parts.push(`Level ${lv.level.join('-')}: ${lv.rulesText} (${lv.power}/${lv.toughness})`);
527
+ }
528
+ break;
529
+ case 'case':
530
+ parts.push(`To solve: ${sa.caseConditions.toSolve}`);
531
+ parts.push(`Solved: ${sa.caseConditions.solved}`);
532
+ break;
533
+ case 'prototype':
534
+ parts.push(`Prototype ${sa.prototype.manaCost} — ${sa.prototype.power}/${sa.prototype.toughness}`);
535
+ break;
536
+ }
537
+ }
538
+ return parts.join('\n');
539
+ }
540
+ // Matches "----", "--transform--", "--modal_dfc--", etc.
541
+ const FACE_DELIMITER = /^-{2,}(\w+)?-{2,}$/;
542
+ const LINK_TYPE_ALIASES = {
543
+ transform: 'transform',
544
+ modal_dfc: 'modal_dfc',
545
+ mdfc: 'modal_dfc',
546
+ flip: 'flip',
547
+ split: 'split',
548
+ fuse: 'fuse',
549
+ adventure: 'adventure',
550
+ aftermath: 'aftermath',
551
+ };
552
+ function parseCard(text) {
553
+ // Split on delimiter for multi-face cards, capturing optional link type
554
+ let parsedLinkType;
555
+ const faces = text.split(/\n/).reduce((acc, line) => {
556
+ const m = line.trim().match(FACE_DELIMITER);
557
+ if (m) {
558
+ if (m[1])
559
+ parsedLinkType = LINK_TYPE_ALIASES[m[1].toLowerCase()];
560
+ acc.push([]);
561
+ }
562
+ else {
563
+ acc[acc.length - 1].push(line);
564
+ }
565
+ return acc;
566
+ }, [[]]);
567
+ if (faces.length > 1) {
568
+ const front = parseSingleFace(faces[0].join('\n'));
569
+ const back = parseSingleFace(faces[1].join('\n'));
570
+ front.linkedCard = back;
571
+ if (parsedLinkType)
572
+ front.linkType = parsedLinkType;
573
+ return front;
574
+ }
575
+ return parseSingleFace(text);
576
+ }
577
+ function parseSingleFace(text) {
578
+ const lines = normalizeLines(text || '');
579
+ // Line 1: Name and mana cost
580
+ let name = '';
581
+ let manaCost;
582
+ if (lines.length > 0) {
583
+ const nameMatch = lines[0].match(MANA_COST_REGEX);
584
+ if (nameMatch) {
585
+ name = nameMatch[1].trim();
586
+ manaCost = normalizeManaSymbols(nameMatch[2]);
587
+ }
588
+ else {
589
+ name = lines[0];
590
+ }
591
+ }
592
+ // Optional metadata lines between name and type (Art:, Rarity:)
593
+ let artUrl;
594
+ let artDescription;
595
+ let rarity;
596
+ let artist;
597
+ let setCode;
598
+ let collectorNumber;
599
+ let designer;
600
+ let colorIndicator;
601
+ let explicitAccent;
602
+ let explicitFrame;
603
+ let explicitFrameEffect;
604
+ let explicitNameLine;
605
+ let explicitTypeLine;
606
+ let explicitPtBox;
607
+ let flavorText;
608
+ let nextLine = 1;
609
+ while (nextLine < lines.length) {
610
+ const current = lines[nextLine];
611
+ const artMatch = current.match(ART_REGEX);
612
+ if (artMatch) {
613
+ artUrl = artMatch[1];
614
+ nextLine++;
615
+ continue;
616
+ }
617
+ const artDescMatch = current.match(ART_DESCRIPTION_REGEX);
618
+ if (artDescMatch) {
619
+ artDescription = artDescMatch[1].trim();
620
+ nextLine++;
621
+ continue;
622
+ }
623
+ const rarityMatch = current.match(RARITY_REGEX);
624
+ if (rarityMatch) {
625
+ const raw = rarityMatch[1].toLowerCase();
626
+ rarity = (raw === 'mythic rare' ? 'mythic' : raw);
627
+ nextLine++;
628
+ continue;
629
+ }
630
+ const artistMatch = current.match(ARTIST_REGEX);
631
+ if (artistMatch) {
632
+ artist = artistMatch[1].trim();
633
+ nextLine++;
634
+ continue;
635
+ }
636
+ const setMatch = current.match(SET_REGEX);
637
+ if (setMatch) {
638
+ setCode = setMatch[1].toUpperCase();
639
+ nextLine++;
640
+ continue;
641
+ }
642
+ const collectorMatch = current.match(COLLECTOR_REGEX);
643
+ if (collectorMatch) {
644
+ collectorNumber = collectorMatch[1].trim();
645
+ nextLine++;
646
+ continue;
647
+ }
648
+ const designerMatch = current.match(DESIGNER_REGEX);
649
+ if (designerMatch) {
650
+ designer = designerMatch[1].trim();
651
+ nextLine++;
652
+ continue;
653
+ }
654
+ const colorIndicatorMatch = current.match(COLOR_INDICATOR_REGEX);
655
+ if (colorIndicatorMatch) {
656
+ colorIndicator = parseColorIndicator(colorIndicatorMatch[1]) || colorIndicator;
657
+ nextLine++;
658
+ continue;
659
+ }
660
+ const accentMatch = current.match(ACCENT_REGEX);
661
+ if (accentMatch) {
662
+ const result = parseAccentTokens(accentMatch[1]);
663
+ if (result)
664
+ explicitAccent = result;
665
+ nextLine++;
666
+ continue;
667
+ }
668
+ const frameMatch = current.match(FRAME_REGEX);
669
+ if (frameMatch) {
670
+ const result = parseFrameTokens(frameMatch[1]);
671
+ if (result)
672
+ explicitFrame = result;
673
+ nextLine++;
674
+ continue;
675
+ }
676
+ const frameEffectMatch = current.match(FRAME_EFFECT_REGEX);
677
+ if (frameEffectMatch) {
678
+ const result = parseFrameEffectTokens(frameEffectMatch[1]);
679
+ if (result)
680
+ explicitFrameEffect = result;
681
+ nextLine++;
682
+ continue;
683
+ }
684
+ const nameLineMatch = current.match(NAME_LINE_REGEX);
685
+ if (nameLineMatch) {
686
+ const result = parseFrameTokens(nameLineMatch[1]);
687
+ if (result)
688
+ explicitNameLine = result;
689
+ nextLine++;
690
+ continue;
691
+ }
692
+ const typeLineMatch = current.match(TYPE_LINE_COLOR_REGEX);
693
+ if (typeLineMatch) {
694
+ const result = parseFrameTokens(typeLineMatch[1]);
695
+ if (result)
696
+ explicitTypeLine = result;
697
+ nextLine++;
698
+ continue;
699
+ }
700
+ const ptBoxMatch = current.match(PT_BOX_COLOR_REGEX);
701
+ if (ptBoxMatch) {
702
+ const result = parseFrameTokens(ptBoxMatch[1]);
703
+ if (result)
704
+ explicitPtBox = result;
705
+ nextLine++;
706
+ continue;
707
+ }
708
+ const flavorTextMatch = current.match(FLAVOR_TEXT_REGEX);
709
+ if (flavorTextMatch) {
710
+ flavorText = flavorTextMatch[1].trim();
711
+ nextLine++;
712
+ continue;
713
+ }
714
+ if (/^[A-Za-z][A-Za-z0-9\/\s]+:\s*/.test(current)) {
715
+ nextLine++;
716
+ continue;
717
+ }
718
+ break;
719
+ }
720
+ // Type line
721
+ const typeLine = lines[nextLine] ?? '';
722
+ const { supertypes, types, subtypes } = parseTypeLine(typeLine);
723
+ let body = lines.slice(nextLine + 1);
724
+ const lowerType = typeLine.toLowerCase();
725
+ // Determine ability kind from type line
726
+ let kind;
727
+ if (lowerType.includes('planeswalker'))
728
+ kind = 'planeswalker';
729
+ else if (lowerType.includes('class'))
730
+ kind = 'class';
731
+ else if (lowerType.includes('saga'))
732
+ kind = 'saga';
733
+ else if (lowerType.includes('case'))
734
+ kind = 'case';
735
+ // Extract stats from body lines before parsing abilities
736
+ let startingLoyalty;
737
+ let battleDefense;
738
+ let power;
739
+ let toughness;
740
+ // Extract metadata fields from body lines (allows them to appear anywhere)
741
+ {
742
+ const filtered = [];
743
+ const flavorParts = [];
744
+ for (const line of body) {
745
+ const flavorMatch = line.match(FLAVOR_TEXT_REGEX);
746
+ if (flavorMatch) {
747
+ flavorParts.push(flavorMatch[1].trim());
748
+ continue;
749
+ }
750
+ const artMatch = line.match(ART_REGEX);
751
+ if (artMatch) {
752
+ artUrl = artMatch[1];
753
+ continue;
754
+ }
755
+ const artDescMatch = line.match(ART_DESCRIPTION_REGEX);
756
+ if (artDescMatch) {
757
+ artDescription = artDescMatch[1].trim();
758
+ continue;
759
+ }
760
+ const rarityMatch = line.match(RARITY_REGEX);
761
+ if (rarityMatch) {
762
+ const raw = rarityMatch[1].toLowerCase();
763
+ rarity = (raw === 'mythic rare' ? 'mythic' : raw);
764
+ continue;
765
+ }
766
+ const artistMatch = line.match(ARTIST_REGEX);
767
+ if (artistMatch) {
768
+ artist = artistMatch[1].trim();
769
+ continue;
770
+ }
771
+ const setMatch = line.match(SET_REGEX);
772
+ if (setMatch) {
773
+ setCode = setMatch[1].toUpperCase();
774
+ continue;
775
+ }
776
+ const collectorMatch = line.match(COLLECTOR_REGEX);
777
+ if (collectorMatch) {
778
+ collectorNumber = collectorMatch[1].trim();
779
+ continue;
780
+ }
781
+ const designerMatch = line.match(DESIGNER_REGEX);
782
+ if (designerMatch) {
783
+ designer = designerMatch[1].trim();
784
+ continue;
785
+ }
786
+ const colorIndicatorMatch = line.match(COLOR_INDICATOR_REGEX);
787
+ if (colorIndicatorMatch) {
788
+ colorIndicator = parseColorIndicator(colorIndicatorMatch[1]) || colorIndicator;
789
+ continue;
790
+ }
791
+ const accentMatch = line.match(ACCENT_REGEX);
792
+ if (accentMatch) {
793
+ const result = parseAccentTokens(accentMatch[1]);
794
+ if (result)
795
+ explicitAccent = result;
796
+ continue;
797
+ }
798
+ const frameMatch = line.match(FRAME_REGEX);
799
+ if (frameMatch) {
800
+ const result = parseFrameTokens(frameMatch[1]);
801
+ if (result)
802
+ explicitFrame = result;
803
+ continue;
804
+ }
805
+ const frameEffectMatch = line.match(FRAME_EFFECT_REGEX);
806
+ if (frameEffectMatch) {
807
+ const result = parseFrameEffectTokens(frameEffectMatch[1]);
808
+ if (result)
809
+ explicitFrameEffect = result;
810
+ continue;
811
+ }
812
+ const nameLineMatch = line.match(NAME_LINE_REGEX);
813
+ if (nameLineMatch) {
814
+ const result = parseFrameTokens(nameLineMatch[1]);
815
+ if (result)
816
+ explicitNameLine = result;
817
+ continue;
818
+ }
819
+ const typeLineMatch = line.match(TYPE_LINE_COLOR_REGEX);
820
+ if (typeLineMatch) {
821
+ const result = parseFrameTokens(typeLineMatch[1]);
822
+ if (result)
823
+ explicitTypeLine = result;
824
+ continue;
825
+ }
826
+ const ptBoxMatch = line.match(PT_BOX_COLOR_REGEX);
827
+ if (ptBoxMatch) {
828
+ const result = parseFrameTokens(ptBoxMatch[1]);
829
+ if (result)
830
+ explicitPtBox = result;
831
+ continue;
832
+ }
833
+ const loyaltyMatch = line.match(LOYALTY_REGEX);
834
+ if (loyaltyMatch) {
835
+ startingLoyalty = loyaltyMatch[1];
836
+ continue;
837
+ }
838
+ const defenseMatch = line.match(DEFENSE_REGEX);
839
+ if (defenseMatch) {
840
+ battleDefense = defenseMatch[1];
841
+ continue;
842
+ }
843
+ filtered.push(line);
844
+ }
845
+ if (flavorParts.length > 0) {
846
+ flavorText = flavorParts.join('\n');
847
+ }
848
+ body = filtered;
849
+ }
850
+ // Default loyalty/defense if not found
851
+ if (kind === 'planeswalker' && !startingLoyalty)
852
+ startingLoyalty = '0';
853
+ if (lowerType.includes('battle') && !battleDefense)
854
+ battleDefense = '0';
855
+ // Standard cards: extract trailing P/T
856
+ if (!kind && !lowerType.includes('battle')) {
857
+ // P/T: last line matching N/N for creatures/vehicles
858
+ if ((lowerType.includes('creature') || lowerType.includes('vehicle')) && body.length > 0) {
859
+ const ptMatch = body[body.length - 1].match(PT_REGEX);
860
+ if (ptMatch) {
861
+ power = ptMatch[1];
862
+ toughness = ptMatch[2];
863
+ body = body.slice(0, -1);
864
+ }
865
+ }
866
+ }
867
+ // Parse abilities from remaining body lines
868
+ const abilities = body.length > 0 ? parseAbilities(body.join('\n'), kind) : undefined;
869
+ // Build card
870
+ const card = { name };
871
+ if (supertypes.length > 0)
872
+ card.supertypes = supertypes;
873
+ if (types.length > 0)
874
+ card.types = types;
875
+ if (subtypes.length > 0)
876
+ card.subtypes = subtypes;
877
+ if (manaCost)
878
+ card.manaCost = manaCost;
879
+ if (abilities)
880
+ card.abilities = abilities;
881
+ if (flavorText)
882
+ card.flavorText = flavorText;
883
+ if (power !== undefined)
884
+ card.power = power;
885
+ if (toughness !== undefined)
886
+ card.toughness = toughness;
887
+ if (startingLoyalty)
888
+ card.startingLoyalty = startingLoyalty;
889
+ if (battleDefense)
890
+ card.battleDefense = battleDefense;
891
+ if (artUrl)
892
+ card.artUrl = artUrl;
893
+ if (artDescription)
894
+ card.artDescription = artDescription;
895
+ if (rarity)
896
+ card.rarity = rarity;
897
+ if (artist)
898
+ card.artist = artist;
899
+ if (setCode)
900
+ card.setCode = setCode;
901
+ if (collectorNumber)
902
+ card.collectorNumber = collectorNumber;
903
+ if (designer)
904
+ card.designer = designer;
905
+ if (colorIndicator && colorIndicator.length > 0)
906
+ card.colorIndicator = colorIndicator;
907
+ if (explicitFrame)
908
+ card.frameColor = explicitFrame;
909
+ if (explicitFrameEffect)
910
+ card.frameEffect = explicitFrameEffect;
911
+ if (explicitAccent)
912
+ card.accentColor = explicitAccent;
913
+ if (explicitNameLine)
914
+ card.nameLineColor = explicitNameLine;
915
+ if (explicitTypeLine)
916
+ card.typeLineColor = explicitTypeLine;
917
+ if (explicitPtBox)
918
+ card.ptBoxColor = explicitPtBox;
919
+ return card;
920
+ }
921
+ /** Format a list as "Red", "Red and Blue", or "Red, Blue, and Green". */
922
+ function formatList(items) {
923
+ const capitalized = items.map(s => s.charAt(0).toUpperCase() + s.slice(1));
924
+ if (capitalized.length <= 1)
925
+ return capitalized[0] ?? '';
926
+ if (capitalized.length === 2)
927
+ return `${capitalized[0]} and ${capitalized[1]}`;
928
+ return capitalized.slice(0, -1).join(', ') + ', and ' + capitalized[capitalized.length - 1];
929
+ }
930
+ /** Format CardData back into Crucible extended text format (reverse of parseCard). */
931
+ function formatCard(card) {
932
+ const lines = [];
933
+ // Line 1: Name {ManaCost}
934
+ let nameLine = card.name ?? '';
935
+ if (card.manaCost)
936
+ nameLine += ` ${card.manaCost}`;
937
+ lines.push(nameLine);
938
+ if (card.colorIndicator && card.colorIndicator.length > 0) {
939
+ lines.push(`Color Indicator: ${formatList(card.colorIndicator)}`);
940
+ }
941
+ // Type line
942
+ lines.push(buildTypeLine(card));
943
+ // Abilities
944
+ const oracleText = getOracleText(card);
945
+ if (oracleText)
946
+ lines.push(oracleText);
947
+ // Stats that go before abilities for pw/battle, after for creatures
948
+ if (card.startingLoyalty)
949
+ lines.push(`Loyalty: ${card.startingLoyalty}`);
950
+ if (card.battleDefense)
951
+ lines.push(`Defense: ${card.battleDefense}`);
952
+ // P/T for creatures
953
+ if (card.power && card.toughness) {
954
+ lines.push(`${card.power}/${card.toughness}`);
955
+ }
956
+ if (card.rarity) {
957
+ const rarityDisplay = card.rarity === 'mythic' ? 'Mythic Rare' : card.rarity.charAt(0).toUpperCase() + card.rarity.slice(1);
958
+ lines.push(`Rarity: ${rarityDisplay}`);
959
+ }
960
+ // Flavor text
961
+ if (card.flavorText) {
962
+ for (const fl of card.flavorText.split('\n')) {
963
+ lines.push(`Flavor Text: ${fl}`);
964
+ }
965
+ }
966
+ // Metadata lines
967
+ if (card.artUrl)
968
+ lines.push(`Art URL: ${card.artUrl}`);
969
+ if (card.artDescription)
970
+ lines.push(`Art Description: ${card.artDescription}`);
971
+ if (card.artist)
972
+ lines.push(`Artist: ${card.artist}`);
973
+ if (card.setCode)
974
+ lines.push(`Set: ${card.setCode}`);
975
+ if (card.collectorNumber)
976
+ lines.push(`Collector Number: ${card.collectorNumber}`);
977
+ if (card.designer)
978
+ lines.push(`Designer: ${card.designer}`);
979
+ if (card.frameColor) {
980
+ const frames = Array.isArray(card.frameColor) ? card.frameColor : [card.frameColor];
981
+ lines.push(`Frame Color: ${formatList(frames)}`);
982
+ }
983
+ if (card.frameEffect) {
984
+ const effects = Array.isArray(card.frameEffect) ? card.frameEffect : [card.frameEffect];
985
+ if (effects.length > 0) {
986
+ lines.push(`Frame Effect: ${formatList(effects)}`);
987
+ }
988
+ }
989
+ if (card.accentColor) {
990
+ const accents = Array.isArray(card.accentColor) ? card.accentColor : [card.accentColor];
991
+ if (accents.length > 0) {
992
+ lines.push(`Accent: ${formatList(accents)}`);
993
+ }
994
+ }
995
+ if (card.ptBoxColor) {
996
+ const colors = Array.isArray(card.ptBoxColor) ? card.ptBoxColor : [card.ptBoxColor];
997
+ if (colors.length > 0) {
998
+ lines.push(`PT Box Color: ${formatList(colors)}`);
999
+ }
1000
+ }
1001
+ if (card.linkedCard) {
1002
+ lines.push(card.linkType ? `--${card.linkType}--` : '----');
1003
+ lines.push(formatCard(card.linkedCard));
1004
+ }
1005
+ return lines.join('\n');
1006
+ }
1007
+ // --- Scryfall conversion helpers ---
1008
+ const COLOR_TO_LETTER = {
1009
+ white: 'W',
1010
+ blue: 'U',
1011
+ black: 'B',
1012
+ red: 'R',
1013
+ green: 'G',
1014
+ };
1015
+ const MANA_COLOR_LETTERS = new Set(['W', 'U', 'B', 'R', 'G']);
1016
+ /** Extract colors from a mana cost string like "{2}{U}{R}" */
1017
+ function colorsFromManaCost(manaCost) {
1018
+ if (!manaCost)
1019
+ return [];
1020
+ const colors = [];
1021
+ const symbols = manaCost.match(/\{([^}]+)\}/g) || [];
1022
+ for (const sym of symbols) {
1023
+ const inner = sym.slice(1, -1).toUpperCase();
1024
+ for (const ch of inner) {
1025
+ if (MANA_COLOR_LETTERS.has(ch) && !colors.includes(ch)) {
1026
+ colors.push(ch);
1027
+ }
1028
+ }
1029
+ }
1030
+ const order = ['W', 'U', 'B', 'R', 'G'];
1031
+ return colors.sort((a, b) => order.indexOf(a) - order.indexOf(b));
1032
+ }
1033
+ /** Calculate converted mana cost from a mana cost string */
1034
+ function calcCmc(manaCost) {
1035
+ if (!manaCost)
1036
+ return 0;
1037
+ let total = 0;
1038
+ const symbols = manaCost.match(/\{([^}]+)\}/g) || [];
1039
+ for (const sym of symbols) {
1040
+ const inner = sym.slice(1, -1).toUpperCase();
1041
+ if (inner === 'X')
1042
+ continue;
1043
+ const num = parseInt(inner, 10);
1044
+ if (!isNaN(num)) {
1045
+ total += num;
1046
+ }
1047
+ else {
1048
+ total += 1;
1049
+ }
1050
+ }
1051
+ return total;
1052
+ }
1053
+ /** Build the type line string like "Legendary Creature — Human Wizard" */
1054
+ function buildTypeLine(card) {
1055
+ const parts = [];
1056
+ if (card.supertypes)
1057
+ parts.push(...card.supertypes.map(s => s.charAt(0).toUpperCase() + s.slice(1)));
1058
+ if (card.types)
1059
+ parts.push(...card.types.map(t => t.charAt(0).toUpperCase() + t.slice(1)));
1060
+ let line = parts.join(' ');
1061
+ if (card.subtypes && card.subtypes.length > 0) {
1062
+ line += ' \u2014 ' + card.subtypes.join(' ');
1063
+ }
1064
+ return line;
1065
+ }
1066
+ function getOracleText(card) {
1067
+ if (!card.abilities)
1068
+ return '';
1069
+ if (typeof card.abilities === 'string')
1070
+ return card.abilities;
1071
+ return formatAbilities(card.abilities);
1072
+ }
1073
+ /** Map LinkType to Scryfall layout string */
1074
+ function scryfallLayout(card) {
1075
+ if (card.linkType) {
1076
+ switch (card.linkType) {
1077
+ case 'transform': return 'transform';
1078
+ case 'modal_dfc': return 'modal_dfc';
1079
+ case 'flip': return 'flip';
1080
+ case 'split':
1081
+ case 'fuse': return 'split';
1082
+ case 'adventure': return 'adventure';
1083
+ case 'aftermath': return 'aftermath';
1084
+ }
1085
+ }
1086
+ return 'normal';
1087
+ }
1088
+ /** Build a Scryfall-like card face object */
1089
+ function buildScryfallFace(card) {
1090
+ const face = {};
1091
+ face.name = card.name ?? '';
1092
+ if (card.manaCost)
1093
+ face.mana_cost = card.manaCost;
1094
+ face.type_line = buildTypeLine(card);
1095
+ const oracleText = getOracleText(card);
1096
+ if (oracleText)
1097
+ face.oracle_text = oracleText;
1098
+ if (card.power)
1099
+ face.power = card.power;
1100
+ if (card.toughness)
1101
+ face.toughness = card.toughness;
1102
+ if (card.startingLoyalty)
1103
+ face.loyalty = card.startingLoyalty;
1104
+ if (card.battleDefense)
1105
+ face.defense = card.battleDefense;
1106
+ if (card.flavorText)
1107
+ face.flavor_text = card.flavorText;
1108
+ if (card.artist)
1109
+ face.artist = card.artist;
1110
+ if (card.colorIndicator) {
1111
+ face.color_indicator = card.colorIndicator.map(c => COLOR_TO_LETTER[c]);
1112
+ }
1113
+ if (card.artUrl) {
1114
+ face.image_uris = { art_crop: card.artUrl };
1115
+ }
1116
+ const colors = card.colorIndicator
1117
+ ? card.colorIndicator.map(c => COLOR_TO_LETTER[c])
1118
+ : colorsFromManaCost(card.manaCost);
1119
+ face.colors = colors;
1120
+ return face;
1121
+ }
1122
+ /** Convert CardData to a Scryfall-compatible JSON string */
1123
+ function toScryfallJson(card) {
1124
+ const obj = {};
1125
+ obj.layout = scryfallLayout(card);
1126
+ obj.name = card.name ?? '';
1127
+ if (card.linkedCard) {
1128
+ obj.name = `${card.name ?? ''} // ${card.linkedCard.name ?? ''}`;
1129
+ obj.card_faces = [buildScryfallFace(card), buildScryfallFace(card.linkedCard)];
1130
+ }
1131
+ else {
1132
+ Object.assign(obj, buildScryfallFace(card));
1133
+ }
1134
+ if (card.manaCost)
1135
+ obj.mana_cost = card.manaCost;
1136
+ obj.cmc = calcCmc(card.manaCost);
1137
+ obj.type_line = buildTypeLine(card);
1138
+ const colors = card.colorIndicator
1139
+ ? card.colorIndicator.map(c => COLOR_TO_LETTER[c])
1140
+ : colorsFromManaCost(card.manaCost);
1141
+ obj.colors = colors;
1142
+ obj.color_identity = colors;
1143
+ if (card.rarity)
1144
+ obj.rarity = card.rarity;
1145
+ if (card.setCode)
1146
+ obj.set = card.setCode.toLowerCase();
1147
+ if (card.collectorNumber)
1148
+ obj.collector_number = card.collectorNumber;
1149
+ return JSON.stringify(obj);
1150
+ }
1151
+ /** Format a single face as Scryfall spoiler text */
1152
+ function formatScryfallFaceText(card) {
1153
+ const lines = [];
1154
+ let nameLine = card.name ?? '';
1155
+ if (card.manaCost)
1156
+ nameLine += ` ${card.manaCost}`;
1157
+ lines.push(nameLine);
1158
+ lines.push(buildTypeLine(card));
1159
+ const oracleText = getOracleText(card);
1160
+ if (oracleText)
1161
+ lines.push(oracleText);
1162
+ if (card.power && card.toughness) {
1163
+ lines.push(`${card.power}/${card.toughness}`);
1164
+ }
1165
+ else if (card.startingLoyalty) {
1166
+ lines.push(`Loyalty: ${card.startingLoyalty}`);
1167
+ }
1168
+ else if (card.battleDefense) {
1169
+ lines.push(`Defense: ${card.battleDefense}`);
1170
+ }
1171
+ return lines.join('\n');
1172
+ }
1173
+ /** Convert CardData to Scryfall-style spoiler text */
1174
+ function toScryfallText(card) {
1175
+ const parts = [formatScryfallFaceText(card)];
1176
+ if (card.linkedCard) {
1177
+ parts.push('----');
1178
+ parts.push(formatScryfallFaceText(card.linkedCard));
1179
+ }
1180
+ return parts.join('\n');
1181
+ }
1182
+ /** Compute rotation steps for card face presentation */
1183
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1184
+ const TEMPLATE_CONFIGS = {
1185
+ standard: { layout: layout_1.STD_LAYOUT, w: layout_1.STD_W, h: layout_1.STD_H },
1186
+ planeswalker: { layout: layout_1.PW_LAYOUT, w: layout_1.PW_W, h: layout_1.PW_H },
1187
+ planeswalker_tall: { layout: layout_1.PW_TALL_LAYOUT, w: layout_1.PW_W, h: layout_1.PW_H },
1188
+ saga: { layout: layout_1.SAGA_LAYOUT, w: layout_1.PW_W, h: layout_1.PW_H },
1189
+ class: { layout: layout_1.CLASS_LAYOUT, w: layout_1.PW_W, h: layout_1.PW_H },
1190
+ battle: { layout: layout_1.BTL_LAYOUT, w: layout_1.BTL_W, h: layout_1.BTL_H },
1191
+ adventure: { layout: layout_1.ADV_LAYOUT, w: layout_1.PW_W, h: layout_1.PW_H },
1192
+ transform_front: { layout: layout_1.TF_FRONT_LAYOUT, w: layout_1.PW_W, h: layout_1.PW_H },
1193
+ transform_back: { layout: layout_1.TF_BACK_LAYOUT, w: layout_1.PW_W, h: layout_1.PW_H },
1194
+ mdfc_front: { layout: layout_1.MDFC_FRONT_LAYOUT, w: layout_1.PW_W, h: layout_1.PW_H },
1195
+ mdfc_back: { layout: layout_1.MDFC_BACK_LAYOUT, w: layout_1.PW_W, h: layout_1.PW_H },
1196
+ split: { layout: layout_1.SPLIT_RIGHT_LAYOUT, w: layout_1.PW_W, h: layout_1.PW_H, linkedLayout: layout_1.SPLIT_LEFT_LAYOUT },
1197
+ fuse: { layout: layout_1.SPLIT_RIGHT_LAYOUT, w: layout_1.PW_W, h: layout_1.PW_H, linkedLayout: layout_1.SPLIT_LEFT_LAYOUT },
1198
+ aftermath: { layout: layout_1.AFTERMATH_TOP_LAYOUT, w: layout_1.PW_W, h: layout_1.PW_H, linkedLayout: layout_1.AFTERMATH_BOTTOM_LAYOUT },
1199
+ flip: { layout: layout_1.FLIP_LAYOUT, w: layout_1.PW_W, h: layout_1.PW_H },
1200
+ mutate: { layout: layout_1.MUTATE_LAYOUT, w: layout_1.PW_W, h: layout_1.PW_H },
1201
+ prototype: { layout: layout_1.PROTO_LAYOUT, w: layout_1.PW_W, h: layout_1.PW_H },
1202
+ leveler: { layout: layout_1.LEVELER_LAYOUT, w: layout_1.PW_W, h: layout_1.PW_H },
1203
+ };
1204
+ function getParsedAbilities(card) {
1205
+ if (card.abilities && typeof card.abilities === 'object')
1206
+ return card.abilities;
1207
+ return {};
1208
+ }
1209
+ function resolveTemplate(card) {
1210
+ if (card.cardTemplate)
1211
+ return card.cardTemplate;
1212
+ const pa = getParsedAbilities(card);
1213
+ if (pa.structuredAbilities?.kind === 'planeswalker') {
1214
+ const pw = pa.structuredAbilities;
1215
+ const totalAbilities = (pa.unstructuredAbilities?.length ?? 0) + pw.loyaltyAbilities.length;
1216
+ return totalAbilities >= 4 ? 'planeswalker_tall' : 'planeswalker';
1217
+ }
1218
+ if (pa.structuredAbilities?.kind === 'saga')
1219
+ return 'saga';
1220
+ if (pa.structuredAbilities?.kind === 'class')
1221
+ return 'class';
1222
+ if (card.battleDefense)
1223
+ return 'battle';
1224
+ if (card.linkType === 'adventure')
1225
+ return 'adventure';
1226
+ if (card.linkType === 'aftermath')
1227
+ return 'aftermath';
1228
+ if (card.linkType === 'fuse')
1229
+ return 'fuse';
1230
+ if (card.linkType === 'split') {
1231
+ const text = getOracleText(card) + (card.linkedCard ? '\n' + getOracleText(card.linkedCard) : '');
1232
+ return /\bFuse\b/.test(text) ? 'fuse' : 'split';
1233
+ }
1234
+ if (card.linkType === 'flip')
1235
+ return 'flip';
1236
+ if (pa.structuredAbilities?.kind === 'leveler')
1237
+ return 'leveler';
1238
+ if (pa.structuredAbilities?.kind === 'prototype')
1239
+ return 'prototype';
1240
+ if (pa.structuredAbilities?.kind === 'mutate')
1241
+ return 'mutate';
1242
+ return 'standard';
1243
+ }
1244
+ function getArtDimensions(card, templateOverride, linked) {
1245
+ const templateKey = templateOverride ?? resolveTemplate(card);
1246
+ const config = TEMPLATE_CONFIGS[templateKey] ?? TEMPLATE_CONFIGS.standard;
1247
+ const { w: cw, h: ch } = config;
1248
+ const L = (linked && config.linkedLayout) ? config.linkedLayout : config.layout;
1249
+ // Colorless and devoid frames are full-bleed — art fills the entire card
1250
+ const fc = Array.isArray(card.frameColor) ? card.frameColor[0] : card.frameColor;
1251
+ const fe = Array.isArray(card.frameEffect) ? card.frameEffect : card.frameEffect ? [card.frameEffect] : [];
1252
+ const isFullBleed = (fc === 'colorless' || fe.includes('devoid')) && templateKey === 'standard';
1253
+ if (isFullBleed) {
1254
+ return { width: 1500, height: 2100 };
1255
+ }
1256
+ const artW = Math.round(L.art.w * cw);
1257
+ const artH = Math.round(L.art.h * ch);
1258
+ // Rotated art: user supplies landscape, renderer rotates 90° into portrait box
1259
+ const ROTATED_TEMPLATES = new Set(['split', 'fuse']);
1260
+ if ((linked && templateKey === 'aftermath') || ROTATED_TEMPLATES.has(templateKey)) {
1261
+ return { width: artH, height: artW };
1262
+ }
1263
+ return { width: artW, height: artH };
1264
+ }
1265
+ function inferLinkType(card) {
1266
+ if (!card.linkedCard)
1267
+ return undefined;
1268
+ if (card.linkType)
1269
+ return card.linkType;
1270
+ const frontText = getOracleText(card);
1271
+ const backText = getOracleText(card.linkedCard);
1272
+ const bothHaveManaCost = !!card.manaCost && !!card.linkedCard.manaCost;
1273
+ const isSpell = (types) => !!types?.length && types.some(t => t === 'instant' || t === 'sorcery');
1274
+ if (bothHaveManaCost) {
1275
+ const fullText = frontText + '\n' + backText;
1276
+ if (/\bFuse\b/.test(fullText))
1277
+ return 'fuse';
1278
+ if (/\bAftermath\b/.test(fullText))
1279
+ return 'aftermath';
1280
+ if (isSpell(card.types) && isSpell(card.linkedCard.types))
1281
+ return 'split';
1282
+ return 'modal_dfc';
1283
+ }
1284
+ // Battles always transform
1285
+ if (card.types?.includes('battle') || card.battleDefense)
1286
+ return 'transform';
1287
+ if (/\bflip\b/i.test(frontText))
1288
+ return 'flip';
1289
+ const fullText = frontText + '\n' + backText;
1290
+ if (/\btransform\b/i.test(fullText))
1291
+ return 'transform';
1292
+ return 'modal_dfc';
1293
+ }
1294
+ function computeRotations(card) {
1295
+ const identity = { x: 0, y: 0, z: 0 };
1296
+ if (!card.linkedCard || !card.linkType) {
1297
+ // Battles always get the 90° rotation even without a back face
1298
+ if (card.cardTemplate === 'battle')
1299
+ return [identity, { x: 0, y: 0, z: 90 }];
1300
+ return [identity];
1301
+ }
1302
+ switch (card.linkType) {
1303
+ case 'transform':
1304
+ case 'modal_dfc':
1305
+ // Battle transforms: rotate 90° then flip across y=x
1306
+ if (card.cardTemplate === 'battle') {
1307
+ return [identity, { x: 0, y: 0, z: 90 }, { x: 0, y: 180, z: 0 }];
1308
+ }
1309
+ return [identity, { x: 0, y: 180, z: 0 }];
1310
+ case 'flip':
1311
+ return [identity, { x: 0, y: 0, z: 180 }];
1312
+ case 'split':
1313
+ case 'fuse':
1314
+ return [identity, { x: 0, y: 0, z: 90 }];
1315
+ case 'aftermath':
1316
+ return [identity, { x: 0, y: 0, z: -90 }];
1317
+ default:
1318
+ return [identity];
1319
+ }
1320
+ }
1321
+ //# sourceMappingURL=parser.js.map