feat(lan-client): hide AI hands + persist Scryfall image cache

AI hand privacy (rendering layer only):
- renderHandZone(): when player.isAI, render face-down violet card backs
  (dark purple diagonal hatch + ornate ring) instead of face-up thumbnails.
  Count stays public (standard MTG). No image, name, preview, drag, or
  click-to-open-modal on face-down thumbs.
- renderZoneModal(): filter the "Hand" tab out of the zone modal when
  inspecting an AI opponent; reset activeZoneTab to 'graveyard' if it was
  'hand' when an AI modal opens.
- New CSS: .hand-thumb.face-down (+ .overflow variant) with violet hatch
  pattern, radial glow, and centered purple ring pseudo-element.

Scryfall image cache persistence (eliminates per-game refetching):
- _scryfallCache now saves to localStorage under commanderLab.scryfallCache.v1
- 30-day TTL, 20,000-entry cap, graceful quota-exceeded handling
- Debounced auto-save (750ms) after each batch enrichment
- Cache-hit logging: logs hit ratio when refetching, or "from cache" when
  a whole game's card pool is already in localStorage
- clearScryfallCache() helper exposed on window for manual purges

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
rgriffithandClaude Opus 4.6 committed 2026-04-05 18:32:43 -04:00
1 parent 8fbebc21f3
commit 69bdd94ef2
3 files changed
+154 -8

No files matched your search

+85 -6
View File
@@ -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() {
+45
View File
@@ -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;
+24 -2
View File
@@ -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 +=
'<div class="hand-thumb face-down" ' +
'data-player="' + playerIdx + '" data-hand-index="' + i + '" ' +
'title="Opponent hand (hidden)"></div>';
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 += '<div class="hand-thumb" onclick="openZoneModal(' + playerIdx + ')" style="background:rgba(255,255,255,0.05);display:flex;align-items:center;justify-content:center;font-size:9px;color:rgba(255,255,255,0.4);">+' + (hand.length - maxShow) + '</div>';
if (hideFaces) {
thumbsHtml += '<div class="hand-thumb face-down overflow" title="Opponent hand (hidden)">+' + (hand.length - maxShow) + '</div>';
} else {
thumbsHtml += '<div class="hand-thumb" onclick="openZoneModal(' + playerIdx + ')" style="background:rgba(255,255,255,0.05);display:flex;align-items:center;justify-content:center;font-size:9px;color:rgba(255,255,255,0.4);">+' + (hand.length - maxShow) + '</div>';
}
}
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: '&#x2620;', label: 'Graveyard' },
{ key: 'exile', icon: '&#x2B21;', label: 'Exile' },
{ key: 'command', icon: '&#x1F451;', label: 'Command' },
{ key: 'hand', icon: '&#x270B;', label: 'Hand' },
{ key: 'library', icon: '&#x1F4DA;', 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;