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:
1 parent
8fbebc21f3
commit
69bdd94ef2
3 files changed
+154
-8
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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: '☠', 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;
|
||||
|
||||
Reference in new issue
Block a user