diff --git a/lan-client/precon-loader.js b/lan-client/precon-loader.js
index cbffdb6..8a8427a 100644
--- a/lan-client/precon-loader.js
+++ b/lan-client/precon-loader.js
@@ -1,47 +1,43 @@
/**
* precon-loader.js
* Handles loading precon .dck files from the backend into the lan-client simulator.
- * Depends on: engine.js (gameState, initGame, drawCard, addLogEntry, renderPlayerPanels)
+ * Depends on: engine.js (gameState, initGame, addLogEntry, renderPlayerPanels)
* ai-bridge.js (AIBridge.deckSource.fetchPreconDeck)
* ui.js (setupDeckChoices, setupPlayerCount, setupStartingLife)
*/
'use strict';
+// In-memory Scryfall image cache: cardName -> { imageUrl, typeLine, oracleText, manaCost, power, toughness }
+const _scryfallCache = {};
+
+// Scryfall collection endpoint — accepts up to 75 card identifiers per call
+const SCRYFALL_COLLECTION_URL = 'https://api.scryfall.com/cards/collection';
+
// ============================================================
// startGameWithDecks
// Called by ui.js startGame() when players have selected precon decks.
-// Initialises the game state first, then async-loads each player's deck.
// ============================================================
async function startGameWithDecks() {
- // Collect player names from the setup form
const playerNames = [];
for (let i = 0; i < (setupPlayerCount || 4); i++) {
const inp = document.getElementById('player-name-' + i);
playerNames.push(inp && inp.value.trim() ? inp.value.trim() : 'Player ' + (i + 1));
}
- // Boot the game with empty zones first so the battlefield renders immediately
initGame(setupPlayerCount || 4, playerNames, setupStartingLife || 40);
- // Now async-fill each player's zones from their chosen precon
const choices = typeof setupDeckChoices !== 'undefined' ? setupDeckChoices : {};
const loadPromises = Object.entries(choices).map(async ([idxStr, choice]) => {
const playerIdx = parseInt(idxStr, 10);
if (choice.type !== 'precon' || !choice.preconId) return;
-
try {
- // Ensure fileName always ends with .dck
- const fileName = choice.preconId.endsWith('.dck')
- ? choice.preconId
- : choice.preconId + '.dck';
-
+ const fileName = choice.preconId.endsWith('.dck') ? choice.preconId : choice.preconId + '.dck';
const data = await AIBridge.deckSource.fetchPreconDeck(fileName);
if (!data || !data.decklist) {
addLogEntry('\u26A0 Could not load deck for Player ' + (playerIdx + 1) + ' (no decklist returned)');
return;
}
-
loadPreconDeckFromDck(playerIdx, data.decklist, choice.preconId);
} catch (e) {
console.warn('[precon-loader] Failed to load deck for player', playerIdx, e);
@@ -51,24 +47,17 @@ async function startGameWithDecks() {
await Promise.all(loadPromises);
- // Refresh the full UI after all decks are loaded
if (typeof renderPlayerPanels === 'function') renderPlayerPanels();
if (typeof updateActivePlayerHighlight === 'function') updateActivePlayerHighlight();
+
+ // Kick off image enrichment for all players in the background (non-blocking)
+ enrichAllZonesWithScryfall();
}
// ============================================================
// loadPreconDeckFromDck
// Parses a Forge-format .dck file string and populates a player's zones.
-//
-// Forge .dck format:
-// [metadata]
-// Name=Deck Name
-// [Commander]
-// 1 Lathril, Blade of the Elves
-// [Main]
-// 1 Sol Ring
-// ...
// ============================================================
function loadPreconDeckFromDck(playerIdx, dckContent, deckLabel) {
if (!gameState) return;
@@ -83,16 +72,12 @@ function loadPreconDeckFromDck(playerIdx, dckContent, deckLabel) {
const line = rawLine.trim();
if (!line || line.startsWith('//')) continue;
- // Section headers: [Commander], [Main], [Sideboard], [metadata]
if (line.startsWith('[') && line.endsWith(']')) {
section = line.slice(1, -1);
continue;
}
-
- // Skip metadata key=value lines
if (line.includes('=') && !line.match(/^\d/)) continue;
- // Card lines: "1 Sol Ring" or "1 Sol Ring|ELD"
const m = line.match(/^(\d+)\s+(.+?)(?:\|.*)?$/);
if (!m) continue;
@@ -102,26 +87,21 @@ function loadPreconDeckFromDck(playerIdx, dckContent, deckLabel) {
if (section === 'Commander') {
commander = name;
} else if (section === 'Main') {
- for (let q = 0; q < qty; q++) {
- mainCards.push(name);
- }
+ for (let q = 0; q < qty; q++) mainCards.push(name);
}
- // Sideboard is ignored for Commander
}
- // Clear existing zones before populating
- player.zones.command = [];
- player.zones.library = [];
- player.zones.hand = [];
+ player.zones.command = [];
+ player.zones.library = [];
+ player.zones.hand = [];
player.zones.graveyard = [];
- player.zones.exile = [];
+ player.zones.exile = [];
- // Populate command zone
if (commander) {
player.zones.command.push({
id: 'cmd-' + playerIdx + '-' + Date.now(),
name: commander,
- imageUrl: '', // Scryfall fetch can enrich this later
+ imageUrl: '',
typeLine: 'Legendary Creature',
oracleText: '',
manaCost: '',
@@ -129,7 +109,6 @@ function loadPreconDeckFromDck(playerIdx, dckContent, deckLabel) {
player.commanderCardName = commander;
}
- // Populate library
for (const cardName of mainCards) {
player.zones.library.push({
id: 'lib-' + playerIdx + '-' + cardName + '-' + Math.random().toString(36).slice(2, 6),
@@ -141,19 +120,18 @@ function loadPreconDeckFromDck(playerIdx, dckContent, deckLabel) {
});
}
- // Shuffle library (Fisher-Yates)
+ // Fisher-Yates shuffle
const lib = player.zones.library;
for (let i = lib.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[lib[i], lib[j]] = [lib[j], lib[i]];
}
- // Draw opening hand (7 cards)
+ // Draw opening hand
for (let d = 0; d < 7 && lib.length > 0; d++) {
player.zones.hand.push(lib.shift());
}
- // Update UI for this player
if (typeof updateZoneCountsForPlayer === 'function') updateZoneCountsForPlayer(playerIdx);
if (typeof renderCommanderZone === 'function') renderCommanderZone(playerIdx);
if (typeof renderHandZone === 'function') renderHandZone(playerIdx);
@@ -161,7 +139,161 @@ function loadPreconDeckFromDck(playerIdx, dckContent, deckLabel) {
const label = deckLabel ? deckLabel.replace('.dck', '') : 'Unknown Deck';
addLogEntry(
'\uD83C\uDCCF ' + player.name + ' loaded: ' + label + '' +
- (commander ? ' — Commander: ' + commander + '' : '') +
+ (commander ? ' \u2014 Commander: ' + commander + '' : '') +
' (' + lib.length + ' in library, 7 in hand)'
);
}
+
+
+// ============================================================
+// SCRYFALL IMAGE ENRICHMENT
+// After zones are populated with card names, batch-fetch card
+// data from Scryfall and backfill imageUrl on every card object.
+// Uses the /cards/collection endpoint (75 cards per request).
+// Results are cached in _scryfallCache so re-renders are instant.
+// ============================================================
+
+async function enrichAllZonesWithScryfall() {
+ if (!gameState) return;
+
+ // Gather all unique card names across all players and all zones
+ const nameSet = new Set();
+ for (const player of gameState.players) {
+ for (const zone of Object.values(player.zones)) {
+ for (const card of zone) {
+ if (card.name && !_scryfallCache[card.name]) nameSet.add(card.name);
+ }
+ }
+ }
+
+ if (nameSet.size === 0) return;
+
+ const names = Array.from(nameSet);
+ addLogEntry('\uD83C\uDF0D Fetching card images from Scryfall (' + names.length + ' unique cards)...');
+
+ // Split into batches of 75 (Scryfall max)
+ const BATCH = 75;
+ for (let i = 0; i < names.length; i += BATCH) {
+ const batch = names.slice(i, i + BATCH);
+ try {
+ await _fetchScryfallBatch(batch);
+ } catch (e) {
+ console.warn('[precon-loader] Scryfall batch failed', e);
+ }
+ // Polite delay between batches to respect Scryfall rate limit (50-100ms)
+ if (i + BATCH < names.length) await _sleep(100);
+ }
+
+ // Apply cached data to every card object in every zone
+ _applyScryfallDataToAllCards();
+
+ addLogEntry('\u2705 Card images loaded.');
+}
+
+async function _fetchScryfallBatch(names) {
+ const identifiers = names.map(name => ({ name }));
+ const resp = await fetch(SCRYFALL_COLLECTION_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ identifiers }),
+ });
+ if (!resp.ok) throw new Error('Scryfall HTTP ' + resp.status);
+ const json = await resp.json();
+
+ for (const card of (json.data || [])) {
+ const name = card.name;
+ // Pick the best image: normal > large > small, handle double-faced
+ let imageUrl = '';
+ if (card.image_uris) {
+ imageUrl = card.image_uris.normal || card.image_uris.large || card.image_uris.small || '';
+ } else if (card.card_faces && card.card_faces[0] && card.card_faces[0].image_uris) {
+ imageUrl = card.card_faces[0].image_uris.normal || card.card_faces[0].image_uris.large || '';
+ }
+ _scryfallCache[name] = {
+ imageUrl,
+ typeLine: card.type_line || '',
+ oracleText: card.oracle_text || (card.card_faces && card.card_faces[0] ? card.card_faces[0].oracle_text : '') || '',
+ manaCost: card.mana_cost || (card.card_faces && card.card_faces[0] ? card.card_faces[0].mana_cost : '') || '',
+ power: card.power || '',
+ toughness: card.toughness || '',
+ };
+ }
+
+ // Mark not-found cards so we don't re-fetch them on next enrichment
+ for (const name of names) {
+ if (!_scryfallCache[name]) _scryfallCache[name] = { imageUrl: '', typeLine: '', oracleText: '', manaCost: '', power: '', toughness: '' };
+ }
+}
+
+function _applyScryfallDataToAllCards() {
+ if (!gameState) return;
+ for (const player of gameState.players) {
+ for (const zone of Object.values(player.zones)) {
+ for (const card of zone) {
+ const data = _scryfallCache[card.name];
+ if (data) {
+ card.imageUrl = data.imageUrl || card.imageUrl;
+ card.typeLine = data.typeLine || card.typeLine;
+ card.oracleText = data.oracleText || card.oracleText;
+ card.manaCost = data.manaCost || card.manaCost;
+ if (data.power) card.power = data.power;
+ if (data.toughness) card.toughness = data.toughness;
+ }
+ }
+ }
+ // Also enrich battlefield cards owned by this player
+ if (gameState.battlefieldCards) {
+ for (const card of gameState.battlefieldCards) {
+ if (card.ownerIndex !== player.id) continue;
+ const data = _scryfallCache[card.name];
+ if (data && !card.imageUrl) {
+ card.imageUrl = data.imageUrl || '';
+ card.typeLine = data.typeLine || card.typeLine;
+ card.oracleText = data.oracleText || card.oracleText;
+ card.manaCost = data.manaCost || card.manaCost;
+ }
+ }
+ }
+ }
+
+ // Trigger UI refresh so images appear without a full reload
+ if (typeof renderPlayerPanels === 'function') renderPlayerPanels();
+ if (typeof renderCommanderZone === 'function') {
+ for (let i = 0; i < gameState.players.length; i++) renderCommanderZone(i);
+ }
+ if (typeof renderHandZone === 'function') {
+ for (let i = 0; i < gameState.players.length; i++) renderHandZone(i);
+ }
+ if (typeof updateZoneCounts === 'function') updateZoneCounts();
+}
+
+/**
+ * Enrich a single card object on-demand (useful for cards drawn or played after initial load).
+ * Returns a Promise that resolves when the card's imageUrl is set.
+ */
+async function enrichCardWithScryfall(card) {
+ if (!card || !card.name) return;
+ if (card.imageUrl) return; // already has image
+
+ const cached = _scryfallCache[card.name];
+ if (cached) {
+ card.imageUrl = cached.imageUrl || '';
+ card.typeLine = cached.typeLine || card.typeLine;
+ card.oracleText = cached.oracleText || card.oracleText;
+ card.manaCost = cached.manaCost || card.manaCost;
+ return;
+ }
+
+ await _fetchScryfallBatch([card.name]);
+ const data = _scryfallCache[card.name];
+ if (data) {
+ card.imageUrl = data.imageUrl || '';
+ card.typeLine = data.typeLine || card.typeLine;
+ card.oracleText = data.oracleText || card.oracleText;
+ card.manaCost = data.manaCost || card.manaCost;
+ }
+}
+
+function _sleep(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}