feat: enhance card identification from image URLs and introduce cropped art support for cards.
All checks were successful
Build and Deploy / build (push) Successful in 2m7s

This commit is contained in:
2025-12-23 01:03:29 +01:00
parent 6edfb8b9e4
commit 9b25d3f0be
7 changed files with 78 additions and 20 deletions

View File

@@ -12,14 +12,10 @@ The project follows a **Modular Monolith** pattern. All backend logic is structu
## Backend and Frontend Integration (The Monolith) ## Backend and Frontend Integration (The Monolith)
The core server project (e.g., `./src/server` or `./src/app`) contains the entry point (`index.ts` or `main.ts`). Functionality is divided into **Modules**: The core server project (e.g., `./src/server` or `./src/app`) contains the entry point (`index.ts` or `main.ts`). Functionality is divided into **Modules**:
* **Controllers:** `./src/modules/[ModuleName]/controllers/` (Handle HTTP requests). ## Cards Images folder
* **Routes:** `./src/modules/[ModuleName]/routes/` (Define express/fastify routes). * **Cropped Art** `./src/server/public/cards/images/[set]/crop/
* **DTOs:** `./src/modules/[ModuleName]/dtos/` (Data Transfer Objects for validation). * **Standard Art** `./src/server/public/cards/images/[set]/full/
* **Static Assets:** `./src/public/` (for module-specific assets if necessary).
## Domain Layer ## Metadata folder
Shared business logic and database entities reside in shared directories or within the modules themselves, designed to be importable: * **Card Metadata** `./src/server/public/cards/metadata/[set]/
* **Set Metadata** `./src/server/public/cards/sets/
* **Entities:** `./src/modules/[ModuleName]/entities/` (ORM definitions, e.g., TypeORM/Prisma models).
* **Services:** `./src/modules/[ModuleName]/services/` (Business logic implementation).
* **Interfaces:** `./src/shared/interfaces/` or within the module (Type definitions).

View File

@@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "index.html", "url": "index.html",
"revision": "0.sortnjvj4s8" "revision": "0.jtdcrepbpeo"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@@ -62,8 +62,26 @@ export const CardVisual: React.FC<CardVisualProps> = ({
let src = card.imageUrl || card.image; let src = card.imageUrl || card.image;
// Use top-level properties if available (common in DraftCard / Game Card objects) // Use top-level properties if available (common in DraftCard / Game Card objects)
const setCode = card.setCode || card.set || card.definition?.set; let setCode = card.setCode || card.set || card.definition?.set;
const cardId = card.scryfallId || card.definition?.id; let cardId = card.scryfallId || card.definition?.id;
// Fallback: Attempt to extract from Image URL if IDs are missing (Fix for legacy/active games)
if ((!setCode || !cardId) && (card.imageUrl || card.image)) {
const url = card.imageUrl || card.image;
if (typeof url === 'string' && url.includes('/cards/images/')) {
const parts = url.split('/cards/images/')[1].split('/');
// Expected formats:
// 1. [set]/full/[id].jpg
// 2. [set]/crop/[id].jpg
if (parts.length >= 2) {
if (!setCode) setCode = parts[0];
if (!cardId) {
const filename = parts[parts.length - 1];
cardId = filename.replace(/\.(jpg|png)(\?.*)?$/, ''); // strip extension and query
}
}
}
}
if (viewMode === 'cutout') { if (viewMode === 'cutout') {
// Priority 1: Local Cache (standard naming convention) - PREFERRED BY USER // Priority 1: Local Cache (standard naming convention) - PREFERRED BY USER

View File

@@ -482,7 +482,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, i
landCard = { landCard = {
id: `basic-source-${type}`, id: `basic-source-${type}`,
name: type, name: type,
image_uris: { normal: LAND_URL_MAP[type] }, image_uris: { normal: LAND_URL_MAP[type], art_crop: LAND_URL_MAP[type] },
typeLine: "Basic Land", typeLine: "Basic Land",
scryfallId: `generic-${type}` scryfallId: `generic-${type}`
}; };
@@ -721,6 +721,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, i
name: type, name: type,
isLandSource: true, isLandSource: true,
image: LAND_URL_MAP[type], image: LAND_URL_MAP[type],
imageArtCrop: LAND_URL_MAP[type], // Explicitly add fallback crop
typeLine: `Basic Land — ${type}`, typeLine: `Basic Land — ${type}`,
rarity: 'common', rarity: 'common',
cmc: 0, cmc: 0,

View File

@@ -56,6 +56,7 @@ export interface CardInstance {
png?: string; png?: string;
border_crop?: string; border_crop?: string;
}; };
imageArtCrop?: string;
} }
export interface PlayerState { export interface PlayerState {

View File

@@ -65,6 +65,7 @@ export interface CardObject {
setCode?: string; setCode?: string;
controlledSinceTurn: number; // For Summoning Sickness check controlledSinceTurn: number; // For Summoning Sickness check
definition?: any; definition?: any;
imageArtCrop?: string;
} }
export interface PlayerState { export interface PlayerState {

View File

@@ -674,16 +674,38 @@ io.on('connection', (socket) => {
const game = gameManager.createGame(room.id, updatedRoom.players); const game = gameManager.createGame(room.id, updatedRoom.players);
if (decks) { if (decks) {
Object.entries(decks).forEach(([pid, deck]: [string, any]) => { Object.entries(decks).forEach(([pid, deck]: [string, any]) => {
// @ts-ignore // @ts-ignore
deck.forEach(card => { deck.forEach(card => {
// Robustly resolve setCode / scryfallId
let setCode = card.setCode || card.set || card.definition?.set;
let scryfallId = card.scryfallId || card.id || card.definition?.id;
// Fallback: Extract from Image URL if missing
if ((!setCode || !scryfallId) && card.imageUrl && card.imageUrl.includes('/cards/images/')) {
const parts = card.imageUrl.split('/cards/images/');
if (parts[1]) {
const pathParts = parts[1].split('/');
// Format: [setCode]/[full|crop]/[id].jpg OR [setCode]/[id].jpg
if (!setCode) setCode = pathParts[0];
if (!scryfallId) {
const filename = pathParts[pathParts.length - 1]; // uuid.jpg
scryfallId = filename.replace(/\.(jpg|png)$/, '');
}
}
}
gameManager.addCardToGame(room.id, { gameManager.addCardToGame(room.id, {
ownerId: pid, ownerId: pid,
controllerId: pid, controllerId: pid,
oracleId: card.oracle_id || card.id || card.definition?.oracle_id, oracleId: card.oracle_id || card.id || card.definition?.oracle_id,
scryfallId: card.scryfallId || card.id || card.definition?.id, scryfallId: scryfallId,
setCode: card.setCode || card.set || card.definition?.set, setCode: setCode,
name: card.name, name: card.name,
imageUrl: card.image_uris?.normal || card.image_uris?.large || card.imageUrl || "", // IMPORTANT: If we have setCode+scryfallId, we clear imageUrl so client uses local cache logic
imageUrl: (setCode && scryfallId) ? "" : (card.image_uris?.normal || card.image_uris?.large || card.imageUrl || ""),
imageArtCrop: card.image_uris?.art_crop || card.image_uris?.crop || card.imageArtCrop || "",
zone: 'library', zone: 'library',
typeLine: card.typeLine || card.type_line || '', typeLine: card.typeLine || card.type_line || '',
oracleText: card.oracleText || card.oracle_text || '', oracleText: card.oracleText || card.oracle_text || '',
@@ -800,14 +822,33 @@ io.on('connection', (socket) => {
[{ p: p1, d: deck1 }, { p: p2, d: deck2 }].forEach(({ p, d }) => { [{ p: p1, d: deck1 }, { p: p2, d: deck2 }].forEach(({ p, d }) => {
if (d) { if (d) {
d.forEach((card: any) => { d.forEach((card: any) => {
// Robustly resolve setCode / scryfallId
let setCode = card.setCode || card.set || card.definition?.set;
let scryfallId = card.scryfallId || card.id || card.definition?.id;
// Fallback: Extract from Image URL if missing
if ((!setCode || !scryfallId) && card.imageUrl && card.imageUrl.includes('/cards/images/')) {
const parts = card.imageUrl.split('/cards/images/');
if (parts[1]) {
const pathParts = parts[1].split('/');
if (!setCode) setCode = pathParts[0];
if (!scryfallId) {
const filename = pathParts[pathParts.length - 1]; // uuid.jpg
scryfallId = filename.replace(/\.(jpg|png)$/, '');
}
}
}
gameManager.addCardToGame(matchId, { gameManager.addCardToGame(matchId, {
ownerId: p.id, ownerId: p.id,
controllerId: p.id, controllerId: p.id,
oracleId: card.oracle_id || card.id || card.definition?.oracle_id, oracleId: card.oracle_id || card.id || card.definition?.oracle_id,
scryfallId: card.scryfallId || card.id || card.definition?.id, scryfallId: scryfallId,
setCode: card.setCode || card.set || card.definition?.set, setCode: setCode,
name: card.name, name: card.name,
imageUrl: "", // Optimisation: Client hydrates from cache // IMPORTANT: If we have setCode+scryfallId, we clear imageUrl so client uses local cache logic
imageUrl: (setCode && scryfallId) ? "" : (card.image_uris?.normal || card.image_uris?.large || card.imageUrl || ""),
imageArtCrop: card.image_uris?.art_crop || card.image_uris?.crop || card.imageArtCrop || "",
zone: 'library', zone: 'library',
typeLine: card.typeLine || card.type_line || '', typeLine: card.typeLine || card.type_line || '',
oracleText: card.oracleText || card.oracle_text || '', oracleText: card.oracleText || card.oracle_text || '',