diff --git a/lan-client/precon-loader.js b/lan-client/precon-loader.js index 0ea58be..2ee373b 100644 --- a/lan-client/precon-loader.js +++ b/lan-client/precon-loader.js @@ -8,12 +8,75 @@ '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'; +// ──────────────────────────────────────────────────────────── +// Scryfall image cache: cardName -> { imageUrl, typeLine, ... } +// Persisted to localStorage so repeat games don't re-hit Scryfall. +// ──────────────────────────────────────────────────────────── +const _SCRYFALL_CACHE_KEY = 'commanderLab.scryfallCache.v1'; +const _SCRYFALL_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days +const _SCRYFALL_CACHE_MAX_ENTRIES = 20000; + +let _scryfallCache = {}; +(function _loadScryfallCacheFromLocalStorage() { + try { + const raw = localStorage.getItem(_SCRYFALL_CACHE_KEY); + if (!raw) return; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return; + const now = Date.now(); + const stamp = parsed.__stamp || 0; + if (now - stamp > _SCRYFALL_CACHE_TTL_MS) { + console.log('[scryfall-cache] expired (> 30 days), clearing'); + localStorage.removeItem(_SCRYFALL_CACHE_KEY); + return; + } + delete parsed.__stamp; + _scryfallCache = parsed; + const n = Object.keys(_scryfallCache).length; + console.log('[scryfall-cache] loaded ' + n + ' entries from localStorage'); + } catch (e) { + console.warn('[scryfall-cache] load failed:', e); + try { localStorage.removeItem(_SCRYFALL_CACHE_KEY); } catch (_) {} + _scryfallCache = {}; + } +})(); + +let _scryfallCacheSaveTimer = null; +function _persistScryfallCache() { + // Debounced — coalesce rapid writes during batch enrichment + if (_scryfallCacheSaveTimer) return; + _scryfallCacheSaveTimer = setTimeout(function () { + _scryfallCacheSaveTimer = null; + try { + // Trim to MAX_ENTRIES if over (drop entries with no imageUrl first) + const keys = Object.keys(_scryfallCache); + if (keys.length > _SCRYFALL_CACHE_MAX_ENTRIES) { + const trimmed = {}; + const sorted = keys.sort((a, b) => + (_scryfallCache[b].imageUrl ? 1 : 0) - (_scryfallCache[a].imageUrl ? 1 : 0) + ).slice(0, _SCRYFALL_CACHE_MAX_ENTRIES); + for (const k of sorted) trimmed[k] = _scryfallCache[k]; + _scryfallCache = trimmed; + } + const payload = Object.assign({ __stamp: Date.now() }, _scryfallCache); + localStorage.setItem(_SCRYFALL_CACHE_KEY, JSON.stringify(payload)); + } catch (e) { + // Quota exceeded — clear and skip. Cache will rebuild next session. + console.warn('[scryfall-cache] persist failed (quota?):', e); + try { localStorage.removeItem(_SCRYFALL_CACHE_KEY); } catch (_) {} + } + }, 750); +} + +function clearScryfallCache() { + _scryfallCache = {}; + try { localStorage.removeItem(_SCRYFALL_CACHE_KEY); } catch (_) {} + console.log('[scryfall-cache] cleared'); +} + // ============================================================ // startGameWithDecks // Called by ui.js startGame() when players have selected precon decks. @@ -168,18 +231,32 @@ async function enrichAllZonesWithScryfall() { // Gather all unique card names across all players and all zones const nameSet = new Set(); + let totalCards = 0; + let cacheHits = 0; 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 (!card.name) continue; + totalCards++; + if (_scryfallCache[card.name]) { + cacheHits++; + } else { + nameSet.add(card.name); + } } } } - if (nameSet.size === 0) return; + if (nameSet.size === 0) { + // Everything was in cache — just apply and bail, no network + addLogEntry('\u2705 Card images loaded from cache (' + cacheHits + ' cards).'); + _applyScryfallDataToAllCards(); + return; + } const names = Array.from(nameSet); - addLogEntry('\uD83C\uDF0D Fetching card images from Scryfall (' + names.length + ' unique cards)...'); + const hitRatio = totalCards > 0 ? Math.round((cacheHits / totalCards) * 100) : 0; + addLogEntry('\uD83C\uDF0D Fetching ' + names.length + ' new cards from Scryfall (' + hitRatio + '% cache hit rate)...'); // Split into batches of 75 (Scryfall max) const BATCH = 75; @@ -234,6 +311,8 @@ async function _fetchScryfallBatch(names) { for (const name of names) { if (!_scryfallCache[name]) _scryfallCache[name] = { imageUrl: '', typeLine: '', oracleText: '', manaCost: '', cmc: 0, power: '', toughness: '' }; } + + _persistScryfallCache(); } function _applyScryfallDataToAllCards() { diff --git a/lan-client/style.css b/lan-client/style.css index 3f34961..f68f589 100644 --- a/lan-client/style.css +++ b/lan-client/style.css @@ -1027,6 +1027,51 @@ input { font-family: inherit; } pointer-events: none; } +/* Face-down hand card (opponent AI hands) */ +.hand-thumb.face-down { + cursor: default; + border-color: rgba(139,92,246,0.25); + background: + radial-gradient(ellipse at 50% 40%, rgba(139,92,246,0.28) 0%, transparent 55%), + repeating-linear-gradient( + 45deg, + #0f0a1a 0px, + #0f0a1a 4px, + #15102a 4px, + #15102a 8px + ); + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.hand-thumb.face-down::after { + content: ''; + position: absolute; + inset: 18% 22%; + border-radius: 50%; + border: 1px solid rgba(192,132,252,0.35); + box-shadow: + inset 0 0 8px rgba(139,92,246,0.35), + 0 0 6px rgba(168,85,247,0.25); + pointer-events: none; +} + +.hand-thumb.face-down.overflow { + font-family: var(--font-display); + font-size: 11px; + font-weight: 700; + color: rgba(192,132,252,0.7); + letter-spacing: 0.04em; +} +.hand-thumb.face-down.overflow::after { display: none; } + +.hand-thumb.face-down:hover { + transform: none; + border-color: rgba(139,92,246,0.45); +} + /* Hand drag ghost */ .hand-drag-ghost { position: fixed; diff --git a/lan-client/ui.js b/lan-client/ui.js index c0b73e1..c7b35c1 100644 --- a/lan-client/ui.js +++ b/lan-client/ui.js @@ -263,6 +263,7 @@ function renderHandZone(playerIdx) { if (!handEl) return; const hand = player.zones.hand || []; + const hideFaces = !!player.isAI; // AI hands render face-down // Update count badge const countEl = document.getElementById('hand-count-' + pNum); @@ -280,6 +281,14 @@ function renderHandZone(playerIdx) { const maxShow = Math.min(hand.length, 7); // limit thumbnails for (let i = 0; i < maxShow; i++) { const card = hand[i]; + if (hideFaces) { + // AI hand — show a face-down card back (no name, no image, no preview) + thumbsHtml += + '
'; + continue; + } const imgSrc = card.imageUrl || ''; const escapedName = (card.name || '').replace(/'/g, "\\'"); const escapedType = (card.typeLine || '').replace(/'/g, "\\'"); @@ -301,7 +310,11 @@ function renderHandZone(playerIdx) { } if (hand.length > maxShow) { - thumbsHtml += '
+' + (hand.length - maxShow) + '
'; + if (hideFaces) { + thumbsHtml += '
+' + (hand.length - maxShow) + '
'; + } else { + thumbsHtml += '
+' + (hand.length - maxShow) + '
'; + } } handEl.innerHTML = thumbsHtml; @@ -873,19 +886,28 @@ function renderZoneModal() { const player = gameState.players[zoneModalPlayerId]; if (!player) return; + // AI players' hands are private — don't allow the Hand tab to be active + if (player.isAI && activeZoneTab === 'hand') { + activeZoneTab = 'graveyard'; + } + const titleEl = document.getElementById('zone-modal-title'); if (titleEl) titleEl.textContent = player.name + "'s Zones"; // Render tabs const tabsEl = document.getElementById('zone-modal-tabs'); if (tabsEl) { - const zones = [ + let zones = [ { key: 'graveyard', icon: '☠', label: 'Graveyard' }, { key: 'exile', icon: '⬡', label: 'Exile' }, { key: 'command', icon: '👑', label: 'Command' }, { key: 'hand', icon: '✋', label: 'Hand' }, { key: 'library', icon: '📚', label: 'Library' }, ]; + // Strip the Hand tab when inspecting an AI opponent (hand is private) + if (player.isAI) { + zones = zones.filter(z => z.key !== 'hand'); + } tabsEl.innerHTML = zones.map(z => { const count = (player.zones[z.key] || []).length;