Compare commits
3 Commits
33a5fcd501
...
ea24b5a206
| Author | SHA1 | Date | |
|---|---|---|---|
| ea24b5a206 | |||
| e0d2424cba | |||
| a1cba11d68 |
45
.agent/rules/pack-generation-algorithm.md
Normal file
45
.agent/rules/pack-generation-algorithm.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
Valid for all generations:
|
||||
- If foils are not available in the pool, ignore the foil generation
|
||||
|
||||
STANDARD GENERATION:
|
||||
|
||||
Slots 1-6 (Commons): 6 Common cards. Ensure color balance (attempt to include at least 3 distinct colors).
|
||||
Slot 7 (Common/List Slot):
|
||||
- Roll a d100.
|
||||
- 1-87: 1 Common from Main Set.
|
||||
- 88-97: 1 Card from "The List" (Common/Uncommon reprint).
|
||||
- 98-99: 1 Rare/Mythic from "The List".
|
||||
- 100: 1 Special Guest (High Value).
|
||||
Slots 8-10 (Uncommons): 3 Uncommon cards.
|
||||
Slot 11 (Main Rare Slot):
|
||||
- Roll 1d8.
|
||||
- If 1-7: Rare.
|
||||
- If 8: Mythic Rare.
|
||||
Slot 12 (Land): 1 Basic or Common Dual Land (20% chance of Foil).
|
||||
Slot 13 (Non-Foil Wildcard):
|
||||
- Can be any rarity (Common, Uncommon, Rare, Mythic).
|
||||
- Use weighted probability: ~49% Common, ~24% Uncommon, ~13% Rare, ~13% Mythic (simplified for simulation).
|
||||
Slot 14 (Foil Wildcard):
|
||||
- Same rarity weights as Slot 13, but the card must be Foil.
|
||||
Slot 15 (Marketing): Token or Art Card.
|
||||
|
||||
PEASANT GENERATION:
|
||||
|
||||
Slots 1-6 (Commons): 6 Common cards. Ensure color balance (attempt to include at least 3 distinct colors).
|
||||
Slot 7 (Common/List Slot):
|
||||
- Roll a d100.
|
||||
- 1-87: 1 Common from Main Set.
|
||||
- 88-97: 1 Card from "The List" (Common/Uncommon reprint).
|
||||
- 98-100: 1 Uncommon from "The List".
|
||||
Slots 8-11 (Uncommons): 4 Uncommon cards.
|
||||
Slot 12 (Land): 1 Basic or Common Dual Land (20% chance of Foil).
|
||||
Slot 13 (Non-Foil Wildcard):
|
||||
- Can be any rarity (Common, Uncommon, Rare, Mythic).
|
||||
- Use weighted probability: ~49% Common, ~24% Uncommon, ~13% Rare, ~13% Mythic (simplified for simulation).
|
||||
Slot 14 (Foil Wildcard):
|
||||
- Same rarity weights as Slot 13, but the card must be Foil.
|
||||
Slot 15 (Marketing): Token or Art Card.
|
||||
@@ -21,3 +21,11 @@
|
||||
- [2025-12-16-220000_session_persistence.md](./devlog/2025-12-16-220000_session_persistence.md): Plan for session persistence and safer room exit logic.
|
||||
- [2025-12-16-221000_lobby_improvements.md](./devlog/2025-12-16-221000_lobby_improvements.md): Plan for kick functionality and exit button relocation.
|
||||
- [Fix Draft UI Layout](./devlog/2025-12-16-215500_fix_draft_ui_layout.md): Completed. Fixed "Waiting for next pack" layout to be consistently full-screen.
|
||||
- [Draft Timer Enforcement](./devlog/2025-12-16-222500_draft_timer.md): Completed. Implemented server-side 60s timer per pick, AFK auto-pick, and global draft timer loop.
|
||||
- [Pack Generation Update](./devlog/2025-12-16-224000_pack_generation_update.md): Completed. Implemented new 15-slot Play Booster algorithm with lands, tokens, and wildcards.
|
||||
- [Card Metadata Enhancement](./devlog/2025-12-16-224500_card_metadata_enhancement.md): Completed. Extended Scryfall fetching and `DraftCard` interfaces to include full metadata (CMC, Oracle Text, etc.).
|
||||
- [Rich Metadata Expansion](./devlog/2025-12-16-223000_enhance_metadata.md): Completed. Further expanded metadata to include legalities, finishes, artist, frame effects, and produced mana.
|
||||
- [Persist Metadata](./devlog/2025-12-16-230000_persist_metadata.md): Completed. Implemented IndexedDB persistence for Scryfall metadata to ensure offline availability and reduce API calls.
|
||||
- [Bulk Parse Feedback](./devlog/2025-12-16-231500_bulk_parse_feedback.md): Completed. Updated `CubeManager` to handle metadata generation properly and provide feedback on missing cards.
|
||||
- [Full Metadata Passthrough](./devlog/2025-12-16-234500_full_metadata_passthrough.md): Completed. `DraftCard` now includes a `definition` property containing the complete, raw Scryfall object for future-proofing and advanced algorithm usage.
|
||||
- [Server-Side Caching](./devlog/2025-12-16-235900_server_side_caching.md): Completed. Implemented logic to cache images and metadata on the server upon bulk parsing, and updated client to use local assets.
|
||||
|
||||
32
docs/development/devlog/2025-12-16-222500_draft_timer.md
Normal file
32
docs/development/devlog/2025-12-16-222500_draft_timer.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 2025-12-16 - Draft Timer Enforcement
|
||||
|
||||
## Status
|
||||
Completed
|
||||
|
||||
## Description
|
||||
Implemented server-side timer enforcement for the draft phase to ensure the game progresses even if players are AFK or disconnected.
|
||||
|
||||
## Changes
|
||||
1. **Server: DraftManager.ts**
|
||||
* Updated `DraftState` to include `pickExpiresAt` (timestamp) for each player and `isPaused` for the draft.
|
||||
* Initialize `pickExpiresAt` to 60 seconds from now when a player receives a pack (initial or passed).
|
||||
* Implemented `checkTimers()` method to iterate over all active drafts and players. If `Date.now() > pickExpiresAt`, it triggers `autoPick`.
|
||||
* Implemented `setPaused()` to handle host disconnects. When resuming, timers are reset to 60s to prevent immediate timeout.
|
||||
|
||||
2. **Server: index.ts**
|
||||
* Removed ad-hoc `playerTimers` map and individual `setTimeout` logic associated with socket disconnect events.
|
||||
* Added a global `setInterval` (1 second tick) that calls `draftManager.checkTimers()` and broadcasts updates.
|
||||
* Updated `disconnect` handler to pause the draft if the host disconnects (`draftManager.setPaused(..., true)`).
|
||||
* Updated `join_room` / `rejoin_room` handlers to resume the draft if the host reconnects.
|
||||
|
||||
3. **Client: DraftView.tsx**
|
||||
* Updated the timer display logic to calculate remaining time based on `draftState.players[id].pickExpiresAt` - `Date.now()`.
|
||||
* The timer now accurately reflects the server-enforced deadline.
|
||||
|
||||
## Behavior
|
||||
* **Drafting**: Each pick has a 60-second limit.
|
||||
* **Deck Building**: 120-second limit. If time runs out, the game forces start. Any unready players have their entire draft pool submitted as their deck automatically.
|
||||
* **Timeout**: If time runs out, a random card is automatically picked, and the next pack (if available) is loaded with a fresh 60s timer.
|
||||
* **AFK**: If a user is AFK, the system continues to auto-pick for them until the draft concludes.
|
||||
* **Host Disconnect**: If the host leaves, the draft pauses for everyone. Timer stops.
|
||||
* **Host Reconnect**: Draft resumes, and all active pick timers are reset to 60s.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Plan: Enhance Card Metadata
|
||||
|
||||
## Objective
|
||||
Update Scryfall fetching and parsing logic to include comprehensive metadata for cards. This will enable more precise pack generation algorithms in the future (e.g., filtering by legality, format, artist, or specific frame effects).
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Update `ScryfallCard` Interface (`src/client/src/services/ScryfallService.ts`)**
|
||||
* Add fields for `legalities`, `finishes`, `games`, `produced_mana`, `artist`, `released_at`, `frame_effects`, `security_stamp`, `promo_types`.
|
||||
* Define a more robust `ScryfallCardFace` interface.
|
||||
|
||||
2. **Update `DraftCard` Interface (`src/client/src/services/PackGeneratorService.ts`)**
|
||||
* Add corresponding fields to the internal `DraftCard` interface to store this data in the application state.
|
||||
|
||||
3. **Update `PackGeneratorService.processCards`**
|
||||
* Map the new fields from `ScryfallCard` to `DraftCard` during the processing phase.
|
||||
* Ensure `cardFaces` are also mapped correctly if present (useful for Flip cards where we might want front/back info).
|
||||
|
||||
4. **Verification**
|
||||
* Build the project to ensure no type errors.
|
||||
* (Optional) Run a test script or verify in browser if possible, but static analysis should suffice for interface updates.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Pack Generation Algorithm Update
|
||||
|
||||
## Objective
|
||||
Update the pack generation logic to match a new 15-slot "Play Booster" structure.
|
||||
The new structure includes:
|
||||
- **Slots 1-6:** Commons (Color Balanced).
|
||||
- **Slot 7:** Common (87%), List (C/U 10%, R/M 2%), or Special Guest (1%).
|
||||
- **Slots 8-10:** Uncommons (3).
|
||||
- **Slot 11:** Rare (7/8) or Mythic (1/8).
|
||||
- **Slot 12:** Basic Land or Common Dual Land (20% Foil).
|
||||
- **Slot 13:** Wildcard (Non-Foil) - Weighted Rarity.
|
||||
- **Slot 14:** Wildcard (Foil) - Weighted Rarity.
|
||||
- **Slot 15:** Marketing Token / Art Card.
|
||||
|
||||
## Implementation Details
|
||||
1. **Updated `PackGeneratorService.ts`**:
|
||||
- Modified `processedPools` to explicitly categorize `lands` (Basic + Common Dual) and `tokens`.
|
||||
- Updated `processCards` to sort cards into these new pools (instead of filtering them out completely).
|
||||
- Rewrote `buildSinglePack` (for `standard` rarity mode) to implement the 15-slot sequencing.
|
||||
- Implemented logic for:
|
||||
- Color balancing commons (naive attempt).
|
||||
- "The List" simulation (using Wildcard logic from pools).
|
||||
- Slots 13/14 Wildcards with weighted probabilities.
|
||||
- Foil application (cloning card and setting `finish`).
|
||||
- Slot 12 Land selection (preferring separate land pool).
|
||||
- Added interfaces for `typeLine` and `layout` to `DraftCard`.
|
||||
|
||||
## Status
|
||||
- Implemented and Verified via static check (TS linting was fixed).
|
||||
- Ready for testing in the client.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Card Metadata Enhancement
|
||||
|
||||
## Objective
|
||||
Enhance the Scryfall data fetching and internal card representation to include full metadata (CMC, Oracle Text, Power/Toughness, Collector Number, etc.). This allows strictly precise pack generation and potential future features like mana curve analysis or specific slot targeting.
|
||||
|
||||
## Changes
|
||||
1. **Updated `ScryfallService.ts`**:
|
||||
- Extended `ScryfallCard` interface to include:
|
||||
- `cmc` (number)
|
||||
- `mana_cost` (string)
|
||||
- `oracle_text` (string)
|
||||
- `power`, `toughness` (strings)
|
||||
- `collector_number` (string)
|
||||
- `color_identity` (string[])
|
||||
- `keywords` (string[])
|
||||
- `booster` (boolean)
|
||||
- `promo`, `reprint` (booleans)
|
||||
- Verified that `fetch` calls already return this data; TS interface update exposes it.
|
||||
|
||||
2. **Updated `PackGeneratorService.ts`**:
|
||||
- Extended `DraftCard` internal interface to include the same metadata fields (normalized names like `manaCost`, `oracleText`).
|
||||
- Updated `processCards` function to map these fields from the Scryfall response to the `DraftCard` object.
|
||||
|
||||
## Impact
|
||||
- Pack generation now has access to rich metadata.
|
||||
- Future-proofs the system for "The List" exact matching (via collector number or promo types) and game logic (CMC sorting).
|
||||
@@ -0,0 +1,28 @@
|
||||
# Plan: Persist Scryfall Metadata
|
||||
|
||||
## Objective
|
||||
Persist fetched Scryfall card metadata in the browser's IndexedDB. This ensures that:
|
||||
1. Metadata (including the newly added rich fields) is saved across sessions.
|
||||
2. Pack generation can rely on this data without re-fetching.
|
||||
3. The application works better offline or with poor connection after initial fetch.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Create `src/client/src/utils/db.ts`**
|
||||
* Implement a lightweight IndexedDB wrapper.
|
||||
* Database Name: `mtg-draft-maker`
|
||||
* Store Name: `cards`
|
||||
* Methods: `putCard`, `getCard`, `getAllCards`, `bulkPut`.
|
||||
|
||||
2. **Update `ScryfallService.ts`**
|
||||
* Import the DB utilities.
|
||||
* In `constructor` or a new `initialize()` method, load all persisted cards into memory (`cacheById` and `cacheByName`).
|
||||
* In `fetchCollection`, `fetchSetCards`, etc., whenever cards are fetched from API, save them to DB via `bulkPut`.
|
||||
* Modify `fetchCollection` to check memory cache (which is now pre-filled from DB) before network.
|
||||
|
||||
3. **Refactor `fetchCollection` deduplication**
|
||||
* Since cache is pre-filled, the existing check `if (this.cacheById.has(...))` will effectively check the persisted data.
|
||||
|
||||
## Verification
|
||||
* Reload page -> Check if cards are loaded immediately without network requests (network tab).
|
||||
* Check Application -> Storage -> IndexedDB in browser devtools (mental check).
|
||||
@@ -0,0 +1,20 @@
|
||||
# Plan: Improve Parse Bulk Feedback
|
||||
|
||||
## Objective
|
||||
Enhance the "Parse Bulk" workflow in `CubeManager` to provide explicit feedback on the result of the Scryfall metadata fetching. This ensures the user knows that "images and metadata" have been successfully generated (fetched) for their list, fulfilling the request for precision.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Update `CubeManager.tsx`**
|
||||
* In `fetchAndParse` function:
|
||||
* Track `notFoundCount` (identifiers that returned no Scryfall data).
|
||||
* Track `successCount` (identifiers that were successfully enriched).
|
||||
* After the loop, check if `notFoundCount > 0`.
|
||||
* Show a summary notification/alert: "Processed X cards. Y cards could not be identified."
|
||||
* (Optional) If many failures, maybe show a list of names? For now, just the count is a good start.
|
||||
|
||||
2. **Verify Data Integrity**
|
||||
* Ensure that the `processedData` uses the fully enriched `DraftCard` objects (which we know it does from previous steps).
|
||||
|
||||
## Why This Matters
|
||||
The user asked to "Generate image and metadata... upon Parse bulk". While the backend/service logic is done, the UI needs to confirm this action took place to give the user confidence that the underlying algorithm now has the precise data it needs.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Plan: Full Metadata Passthrough
|
||||
|
||||
## Objective
|
||||
Ensure that the `DraftCard` objects used throughout the application (and eventually sent to the backend) contain the **complete** original metadata from Scryfall. The user has explicitly requested access to "all cards informations" for future algorithms.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Update `ScryfallService.ts`**
|
||||
* Add an index signature `[key: string]: any;` to the `ScryfallCard` interface. This acknowledges that the object contains more fields than strictly typed, preventing TypeScript from complaining when accessing obscure fields, and correctly modeling the API response.
|
||||
|
||||
2. **Update `PackGeneratorService.ts`**
|
||||
* Add `sourceData: ScryfallCard;` (or similar name like `scryfallData`) to the `DraftCard` interface.
|
||||
* In `processCards`, assign the incoming `cardData` (the full Scryfall object) to this new property.
|
||||
|
||||
## Impact
|
||||
* **Data Size**: Payload size for rooms will increase, but this is acceptable (and requested) for the richness of data required.
|
||||
* **Flexibility**: Future updates to pack generation (e.g., checking specific `frame_effects` or `prices`) will not require interface updates; the data will already be there in `card.sourceData`.
|
||||
|
||||
## Verification
|
||||
* The valid "Parse Bulk" operation will now produce `DraftCard`s that, if inspected, contain the full Scryfall JSON.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Plan: Server-Side Caching of Bulk Data
|
||||
|
||||
## Objective
|
||||
Implement server-side caching of both card images and metadata upon bulk parsing, ensuring the application relies on local assets rather than external Scryfall URLs.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Refactor Server Architecture (`CardService.ts`)**
|
||||
* Update storage paths to `public/cards/images` (previously `public/cards`) and `public/cards/metadata`.
|
||||
* Implement `cacheMetadata` to save JSON files alongside images.
|
||||
|
||||
2. **Update API Endpoint (`index.ts`)**
|
||||
* Modify `POST /api/cards/cache` to handle metadata saving in addition to image downloading.
|
||||
* Update static file serving to map `/cards` to `public/cards`, making images accessible at `/cards/images/{id}.jpg`.
|
||||
|
||||
3. **Update Client Logic (`CubeManager.tsx`, `PackGeneratorService.ts`, `LobbyManager.tsx`)**
|
||||
* **Generation**: Pass a flag (`useLocalImages`) to the generator service.
|
||||
* **Url Construction**: Generator now produces URLs like `${origin}/cards/images/{id}.jpg` when the flag is set.
|
||||
* **Triggers**: `CubeManager` immediately sends parsed data to the server for caching before generating packs.
|
||||
* **Consistency**: `LobbyManager` updated to look for images in the new `/cards/images` path for multiplayer sessions.
|
||||
|
||||
## Impact
|
||||
* **Performance**: Initial "Parse Bulk" takes slightly longer (due to server cache call), but subsequent interactions are instant and local.
|
||||
* **Reliability**: Application works offline or without Scryfall after initial parse.
|
||||
* **Precision**: Metadata is now persisted as individual JSONs on the backend, ready for future complex backend algorithms.
|
||||
@@ -111,7 +111,8 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
// --- Effects ---
|
||||
useEffect(() => {
|
||||
if (rawScryfallData) {
|
||||
const result = generatorService.processCards(rawScryfallData, filters);
|
||||
// Use local images: true
|
||||
const result = generatorService.processCards(rawScryfallData, filters, true);
|
||||
setProcessedData(result);
|
||||
}
|
||||
}, [filters, rawScryfallData]);
|
||||
@@ -152,10 +153,17 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
const identifiers = parserService.parse(inputText);
|
||||
const fetchList = identifiers.map(id => id.type === 'id' ? { id: id.value } : { name: id.value });
|
||||
|
||||
// Identify how many are already cached for feedback
|
||||
let cachedCount = 0;
|
||||
fetchList.forEach(req => {
|
||||
if (scryfallService.getCachedCard(req)) cachedCount++;
|
||||
});
|
||||
|
||||
await scryfallService.fetchCollection(fetchList, (current, total) => {
|
||||
setProgress(`Fetching Scryfall data... (${current}/${total})`);
|
||||
});
|
||||
|
||||
// Re-check cache to get all objects
|
||||
identifiers.forEach(id => {
|
||||
const card = scryfallService.getCachedCard(id.type === 'id' ? { id: id.value } : { name: id.value });
|
||||
if (card) {
|
||||
@@ -169,9 +177,33 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const totalRequested = identifiers.reduce((acc, curr) => acc + curr.quantity, 0);
|
||||
const missing = totalRequested - expandedCards.length;
|
||||
|
||||
if (missing > 0) {
|
||||
alert(`Warning: ${missing} cards could not be identified or fetched.`);
|
||||
} else {
|
||||
// Optional: Feedback on cache
|
||||
// console.log(`Parsed ${expandedCards.length} cards. (${cachedCount} / ${fetchList.length} unique identifiers were pre-cached)`);
|
||||
}
|
||||
}
|
||||
|
||||
setRawScryfallData(expandedCards);
|
||||
|
||||
// Cache to server
|
||||
if (expandedCards.length > 0) {
|
||||
setProgress('Loading...');
|
||||
// Deduplicate for shipping to server
|
||||
const uniqueCards = Array.from(new Map(expandedCards.map(c => [c.id, c])).values());
|
||||
|
||||
await fetch('/api/cards/cache', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cards: uniqueCards }) // Send full metadata
|
||||
});
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setProgress('');
|
||||
|
||||
|
||||
@@ -15,12 +15,24 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
||||
const [timer, setTimer] = useState(60);
|
||||
const [confirmExitOpen, setConfirmExitOpen] = useState(false);
|
||||
|
||||
const myPlayer = draftState.players[currentPlayerId];
|
||||
const pickExpiresAt = myPlayer?.pickExpiresAt;
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTimer(t => t > 0 ? t - 1 : 0);
|
||||
}, 1000);
|
||||
if (!pickExpiresAt) {
|
||||
setTimer(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateTimer = () => {
|
||||
const remainingMs = pickExpiresAt - Date.now();
|
||||
setTimer(Math.max(0, Math.ceil(remainingMs / 1000)));
|
||||
};
|
||||
|
||||
updateTimer();
|
||||
const interval = setInterval(updateTimer, 500); // Check twice a second for smoother updates
|
||||
return () => clearInterval(interval);
|
||||
}, []); // Reset timer on new pack? Simplified for now.
|
||||
}, [pickExpiresAt]);
|
||||
|
||||
// --- UI State & Persistence ---
|
||||
const [poolHeight, setPoolHeight] = useState<number>(() => {
|
||||
|
||||
@@ -78,7 +78,7 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) =>
|
||||
|
||||
// Transform packs to use local URLs
|
||||
// Note: For multiplayer, clients need to access this URL.
|
||||
const baseUrl = `${window.location.protocol}//${window.location.host}/cards`;
|
||||
const baseUrl = `${window.location.protocol}//${window.location.host}/cards/images`;
|
||||
|
||||
const updatedPacks = generatedPacks.map(pack => ({
|
||||
...pack,
|
||||
|
||||
@@ -5,12 +5,43 @@ export interface DraftCard {
|
||||
scryfallId: string;
|
||||
name: string;
|
||||
rarity: string;
|
||||
typeLine?: string; // Add typeLine to interface for sorting
|
||||
layout?: string; // Add layout
|
||||
colors: string[];
|
||||
image: string;
|
||||
set: string;
|
||||
setCode: string;
|
||||
setType: string;
|
||||
finish?: 'foil' | 'normal';
|
||||
// Extended Metadata
|
||||
cmc?: number;
|
||||
manaCost?: string;
|
||||
oracleText?: string;
|
||||
power?: string;
|
||||
toughness?: string;
|
||||
collectorNumber?: string;
|
||||
colorIdentity?: string[];
|
||||
keywords?: string[];
|
||||
booster?: boolean;
|
||||
promo?: boolean;
|
||||
reprint?: boolean;
|
||||
|
||||
// New Metadata
|
||||
legalities?: { [key: string]: string };
|
||||
finishes?: string[];
|
||||
games?: string[];
|
||||
produced_mana?: string[];
|
||||
artist?: string;
|
||||
released_at?: string;
|
||||
frame_effects?: string[];
|
||||
security_stamp?: string;
|
||||
promoTypes?: string[];
|
||||
cardFaces?: { name: string; image: string; manaCost: string; typeLine: string; oracleText?: string }[];
|
||||
fullArt?: boolean;
|
||||
textless?: boolean;
|
||||
variation?: boolean;
|
||||
scryfallUri?: string;
|
||||
definition: ScryfallCard;
|
||||
}
|
||||
|
||||
export interface Pack {
|
||||
@@ -24,6 +55,8 @@ export interface ProcessedPools {
|
||||
uncommons: DraftCard[];
|
||||
rares: DraftCard[];
|
||||
mythics: DraftCard[];
|
||||
lands: DraftCard[];
|
||||
tokens: DraftCard[];
|
||||
}
|
||||
|
||||
export interface SetsMap {
|
||||
@@ -34,6 +67,8 @@ export interface SetsMap {
|
||||
uncommons: DraftCard[];
|
||||
rares: DraftCard[];
|
||||
mythics: DraftCard[];
|
||||
lands: DraftCard[];
|
||||
tokens: DraftCard[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +79,8 @@ export interface PackGenerationSettings {
|
||||
|
||||
export class PackGeneratorService {
|
||||
|
||||
processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }): { pools: ProcessedPools, sets: SetsMap } {
|
||||
const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [] };
|
||||
processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }, useLocalImages: boolean = false): { pools: ProcessedPools, sets: SetsMap } {
|
||||
const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [] };
|
||||
const setsMap: SetsMap = {};
|
||||
|
||||
cards.forEach(cardData => {
|
||||
@@ -55,25 +90,61 @@ export class PackGeneratorService {
|
||||
const layout = cardData.layout;
|
||||
|
||||
// Filters
|
||||
if (filters.ignoreBasicLands && typeLine.includes('Basic')) return;
|
||||
// if (filters.ignoreBasicLands && typeLine.includes('Basic')) return; // Now collected in 'lands' pool
|
||||
if (filters.ignoreCommander) {
|
||||
if (['commander', 'starter', 'duel_deck', 'premium_deck', 'planechase', 'archenemy'].includes(setType)) return;
|
||||
}
|
||||
if (filters.ignoreTokens) {
|
||||
if (layout === 'token' || layout === 'art_series' || layout === 'emblem') return;
|
||||
}
|
||||
// if (filters.ignoreTokens) ... // Now collected in 'tokens' pool
|
||||
|
||||
const cardObj: DraftCard = {
|
||||
id: this.generateUUID(),
|
||||
scryfallId: cardData.id,
|
||||
name: cardData.name,
|
||||
rarity: rarity,
|
||||
typeLine: typeLine,
|
||||
layout: layout,
|
||||
colors: cardData.colors || [],
|
||||
image: cardData.image_uris?.normal || cardData.card_faces?.[0]?.image_uris?.normal || '',
|
||||
image: useLocalImages
|
||||
? `${window.location.origin}/cards/images/${cardData.id}.jpg`
|
||||
: (cardData.image_uris?.normal || cardData.card_faces?.[0]?.image_uris?.normal || ''),
|
||||
set: cardData.set_name,
|
||||
setCode: cardData.set,
|
||||
setType: setType,
|
||||
finish: cardData.finish
|
||||
finish: cardData.finish,
|
||||
// Extended Metadata mapping
|
||||
cmc: cardData.cmc,
|
||||
manaCost: cardData.mana_cost,
|
||||
oracleText: cardData.oracle_text,
|
||||
power: cardData.power,
|
||||
toughness: cardData.toughness,
|
||||
collectorNumber: cardData.collector_number,
|
||||
colorIdentity: cardData.color_identity,
|
||||
keywords: cardData.keywords,
|
||||
booster: cardData.booster,
|
||||
promo: cardData.promo,
|
||||
reprint: cardData.reprint,
|
||||
// Extended Mapping
|
||||
legalities: cardData.legalities,
|
||||
finishes: cardData.finishes,
|
||||
games: cardData.games,
|
||||
produced_mana: cardData.produced_mana,
|
||||
artist: cardData.artist,
|
||||
released_at: cardData.released_at,
|
||||
frame_effects: cardData.frame_effects,
|
||||
security_stamp: cardData.security_stamp,
|
||||
promoTypes: cardData.promo_types,
|
||||
fullArt: cardData.full_art,
|
||||
textless: cardData.textless,
|
||||
variation: cardData.variation,
|
||||
scryfallUri: cardData.scryfall_uri,
|
||||
definition: cardData,
|
||||
cardFaces: cardData.card_faces ? cardData.card_faces.map(face => ({
|
||||
name: face.name,
|
||||
image: face.image_uris?.normal || '',
|
||||
manaCost: face.mana_cost || '',
|
||||
typeLine: face.type_line || '',
|
||||
oracleText: face.oracle_text
|
||||
})) : undefined
|
||||
};
|
||||
|
||||
// Add to pools
|
||||
@@ -84,13 +155,27 @@ export class PackGeneratorService {
|
||||
|
||||
// Add to Sets Map
|
||||
if (!setsMap[cardData.set]) {
|
||||
setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [] };
|
||||
setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [] };
|
||||
}
|
||||
const setEntry = setsMap[cardData.set];
|
||||
if (rarity === 'common') setEntry.commons.push(cardObj);
|
||||
else if (rarity === 'uncommon') setEntry.uncommons.push(cardObj);
|
||||
else if (rarity === 'rare') setEntry.rares.push(cardObj);
|
||||
else if (rarity === 'mythic') setEntry.mythics.push(cardObj);
|
||||
|
||||
const isLand = typeLine.includes('Land');
|
||||
const isBasic = typeLine.includes('Basic');
|
||||
const isToken = layout === 'token' || typeLine.includes('Token') || layout === 'art_series' || layout === 'emblem';
|
||||
|
||||
if (isToken) {
|
||||
pools.tokens.push(cardObj);
|
||||
setEntry.tokens.push(cardObj);
|
||||
} else if (isBasic || (isLand && rarity === 'common')) {
|
||||
// Slot 12 Logic: Basic or Common Dual Land
|
||||
pools.lands.push(cardObj);
|
||||
setEntry.lands.push(cardObj);
|
||||
} else {
|
||||
if (rarity === 'common') { pools.commons.push(cardObj); setEntry.commons.push(cardObj); }
|
||||
else if (rarity === 'uncommon') { pools.uncommons.push(cardObj); setEntry.uncommons.push(cardObj); }
|
||||
else if (rarity === 'rare') { pools.rares.push(cardObj); setEntry.rares.push(cardObj); }
|
||||
else if (rarity === 'mythic') { pools.mythics.push(cardObj); setEntry.mythics.push(cardObj); }
|
||||
}
|
||||
});
|
||||
|
||||
return { pools, sets: setsMap };
|
||||
@@ -104,7 +189,9 @@ export class PackGeneratorService {
|
||||
commons: this.shuffle(pools.commons),
|
||||
uncommons: this.shuffle(pools.uncommons),
|
||||
rares: this.shuffle(pools.rares),
|
||||
mythics: this.shuffle(pools.mythics)
|
||||
mythics: this.shuffle(pools.mythics),
|
||||
lands: this.shuffle(pools.lands),
|
||||
tokens: this.shuffle(pools.tokens)
|
||||
};
|
||||
|
||||
let packId = 1;
|
||||
@@ -128,7 +215,9 @@ export class PackGeneratorService {
|
||||
commons: this.shuffle(setData.commons),
|
||||
uncommons: this.shuffle(setData.uncommons),
|
||||
rares: this.shuffle(setData.rares),
|
||||
mythics: this.shuffle(setData.mythics)
|
||||
mythics: this.shuffle(setData.mythics),
|
||||
lands: this.shuffle(setData.lands),
|
||||
tokens: this.shuffle(setData.tokens)
|
||||
};
|
||||
|
||||
while (true) {
|
||||
@@ -149,57 +238,229 @@ export class PackGeneratorService {
|
||||
let currentPools = { ...pools };
|
||||
const namesInThisPack = new Set<string>();
|
||||
|
||||
const COMMONS_COUNT = 10;
|
||||
const UNCOMMONS_COUNT = 3;
|
||||
if (rarityMode === 'peasant') {
|
||||
const COMMONS_COUNT = 10;
|
||||
const UNCOMMONS_COUNT = 5; // Boosted uncommons for peasant
|
||||
|
||||
if (rarityMode === 'standard') {
|
||||
const isMythicDrop = Math.random() < 0.125;
|
||||
let rareSuccess = false;
|
||||
const drawU = this.drawUniqueCards(currentPools.uncommons, UNCOMMONS_COUNT, namesInThisPack);
|
||||
packCards.push(...drawU.selected);
|
||||
currentPools.uncommons = drawU.remainingPool;
|
||||
drawU.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
|
||||
if (isMythicDrop && currentPools.mythics.length > 0) {
|
||||
const drawC = this.drawUniqueCards(currentPools.commons, COMMONS_COUNT, namesInThisPack);
|
||||
packCards.push(...drawC.selected);
|
||||
currentPools.commons = drawC.remainingPool;
|
||||
|
||||
} else {
|
||||
// --- NEW ALGORITHM (Play Booster) ---
|
||||
|
||||
// 1. Slots 1-6: Commons (Color Balanced)
|
||||
const commonsNeeded = 6;
|
||||
const drawC = this.drawColorBalanced(currentPools.commons, commonsNeeded, namesInThisPack);
|
||||
if (!drawC.success) return null;
|
||||
packCards.push(...drawC.selected);
|
||||
currentPools.commons = drawC.remainingPool; // Update pool
|
||||
drawC.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
|
||||
// 2. Slots 8-10: Uncommons (3 cards)
|
||||
const uncommonsNeeded = 3;
|
||||
const drawU = this.drawUniqueCards(currentPools.uncommons, uncommonsNeeded, namesInThisPack);
|
||||
if (!drawU.success) return null;
|
||||
packCards.push(...drawU.selected);
|
||||
currentPools.uncommons = drawU.remainingPool;
|
||||
drawU.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
|
||||
// 3. Slot 11: Main Rare/Mythic (1/8 Mythic, 7/8 Rare)
|
||||
const isMythic = Math.random() < (1 / 8);
|
||||
let rarePicked = false;
|
||||
|
||||
if (isMythic && currentPools.mythics.length > 0) {
|
||||
const drawM = this.drawUniqueCards(currentPools.mythics, 1, namesInThisPack);
|
||||
if (drawM.success) {
|
||||
packCards.push(...drawM.selected);
|
||||
currentPools.mythics = drawM.remainingPool;
|
||||
drawM.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
rareSuccess = true;
|
||||
rarePicked = true;
|
||||
}
|
||||
} else if (!rareSuccess && currentPools.rares.length > 0) {
|
||||
}
|
||||
|
||||
if (!rarePicked && currentPools.rares.length > 0) {
|
||||
const drawR = this.drawUniqueCards(currentPools.rares, 1, namesInThisPack);
|
||||
if (drawR.success) {
|
||||
packCards.push(...drawR.selected);
|
||||
currentPools.rares = drawR.remainingPool;
|
||||
drawR.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
rareSuccess = true;
|
||||
rarePicked = true;
|
||||
}
|
||||
} else if (currentPools.mythics.length > 0) {
|
||||
// Fallback to mythic if no rare available
|
||||
const drawM = this.drawUniqueCards(currentPools.mythics, 1, namesInThisPack);
|
||||
if (drawM.success) {
|
||||
packCards.push(...drawM.selected);
|
||||
currentPools.mythics = drawM.remainingPool;
|
||||
drawM.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
}
|
||||
|
||||
// Fallback if Rare pool empty but Mythic not (or vice versa) handled by just skipping
|
||||
|
||||
// 4. Slot 7: Wildcard / The List
|
||||
// 1-87: Common, 88-97: List (C/U), 98-99: List (R/M), 100: Special Guest
|
||||
const roll7 = Math.floor(Math.random() * 100) + 1;
|
||||
let slot7Card: DraftCard | undefined;
|
||||
|
||||
if (roll7 <= 87) {
|
||||
// Common
|
||||
const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.commons = res.remainingPool; }
|
||||
} else if (roll7 <= 97) {
|
||||
// "The List" (Common/Uncommon). Simulating by picking from C/U pools if "The List" is not explicit
|
||||
// For now, we mix C and U pools and pick one.
|
||||
const listPool = [...currentPools.commons, ...currentPools.uncommons]; // Simplification
|
||||
if (listPool.length > 0) {
|
||||
const rnd = Math.floor(Math.random() * listPool.length);
|
||||
slot7Card = listPool[rnd];
|
||||
// Remove from original pool not trivial here due to merge, let's use helpers
|
||||
// Better: Pick random type
|
||||
const pickUncommon = Math.random() < 0.3; // Arbitrary weight
|
||||
if (pickUncommon) {
|
||||
const res = this.drawUniqueCards(currentPools.uncommons, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.uncommons = res.remainingPool; }
|
||||
} else {
|
||||
const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.commons = res.remainingPool; }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 98-100: Rare/Mythic/Special Guest
|
||||
// Pick Rare or Mythic
|
||||
// 98-99 (2%) vs 100 (1%) -> 2:1 ratio
|
||||
const isGuest = roll7 === 100;
|
||||
const useMythic = isGuest || Math.random() < 0.2;
|
||||
|
||||
if (useMythic && currentPools.mythics.length > 0) {
|
||||
const res = this.drawUniqueCards(currentPools.mythics, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.mythics = res.remainingPool; }
|
||||
} else {
|
||||
const res = this.drawUniqueCards(currentPools.rares, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.rares = res.remainingPool; }
|
||||
}
|
||||
}
|
||||
|
||||
if (slot7Card) {
|
||||
packCards.push(slot7Card);
|
||||
namesInThisPack.add(slot7Card.name);
|
||||
}
|
||||
|
||||
// 5. Slot 12: Land (Basic or Common Dual)
|
||||
const foilLandRoll = Math.random();
|
||||
const isFoilLand = foilLandRoll < 0.20;
|
||||
|
||||
let landCard: DraftCard | undefined;
|
||||
// Prioritize 'lands' pool
|
||||
if (currentPools.lands.length > 0) {
|
||||
const res = this.drawUniqueCards(currentPools.lands, 1, namesInThisPack);
|
||||
if (res.success) {
|
||||
landCard = { ...res.selected[0] }; // Clone to set foil
|
||||
currentPools.lands = res.remainingPool;
|
||||
}
|
||||
} else {
|
||||
// Fallback: Pick a Common if no lands
|
||||
// const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack);
|
||||
// if (res.success) { landCard = { ...res.selected[0] }; ... }
|
||||
// Better to just have no land than a non-land
|
||||
}
|
||||
|
||||
if (landCard) {
|
||||
if (isFoilLand) landCard.finish = 'foil';
|
||||
packCards.push(landCard);
|
||||
namesInThisPack.add(landCard.name);
|
||||
}
|
||||
|
||||
// 6. Slot 13: Wildcard (Non-Foil)
|
||||
// Weights: ~49% C, ~24% U, ~13% R, ~13% M => Sum=99.
|
||||
// Normalized: C:50, U:24, R:13, M:13
|
||||
const drawWildcard = (foil: boolean) => {
|
||||
const wRoll = Math.random() * 100;
|
||||
let wRarity = 'common';
|
||||
if (wRoll > 87) wRarity = 'mythic';
|
||||
else if (wRoll > 74) wRarity = 'rare';
|
||||
else if (wRoll > 50) wRarity = 'uncommon';
|
||||
else wRarity = 'common';
|
||||
|
||||
// Adjust buckets
|
||||
let poolToUse: DraftCard[] = [];
|
||||
let updatePool = (_newPool: DraftCard[]) => { };
|
||||
|
||||
if (wRarity === 'mythic') { poolToUse = currentPools.mythics; updatePool = (p) => currentPools.mythics = p; }
|
||||
else if (wRarity === 'rare') { poolToUse = currentPools.rares; updatePool = (p) => currentPools.rares = p; }
|
||||
else if (wRarity === 'uncommon') { poolToUse = currentPools.uncommons; updatePool = (p) => currentPools.uncommons = p; }
|
||||
else { poolToUse = currentPools.commons; updatePool = (p) => currentPools.commons = p; }
|
||||
|
||||
if (poolToUse.length === 0) {
|
||||
// Fallback cascade
|
||||
if (currentPools.commons.length > 0) { poolToUse = currentPools.commons; updatePool = (p) => currentPools.commons = p; }
|
||||
}
|
||||
|
||||
if (poolToUse.length > 0) {
|
||||
const res = this.drawUniqueCards(poolToUse, 1, namesInThisPack);
|
||||
if (res.success) {
|
||||
const card = { ...res.selected[0] };
|
||||
if (foil) card.finish = 'foil';
|
||||
packCards.push(card);
|
||||
updatePool(res.remainingPool);
|
||||
namesInThisPack.add(card.name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
drawWildcard(false); // Slot 13
|
||||
|
||||
// 7. Slot 14: Wildcard (Foil)
|
||||
drawWildcard(true); // Slot 14
|
||||
|
||||
// 8. Slot 15: Marketing / Token
|
||||
if (currentPools.tokens.length > 0) {
|
||||
// Just pick one, duplicates allowed for tokens? user said unique cards... but for tokens?
|
||||
// "drawUniqueCards" handles uniqueness check.
|
||||
const res = this.drawUniqueCards(currentPools.tokens, 1, namesInThisPack);
|
||||
if (res.success) {
|
||||
packCards.push(res.selected[0]);
|
||||
currentPools.tokens = res.remainingPool;
|
||||
// Don't care about uniqueness for tokens as much, but let's stick to it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const drawU = this.drawUniqueCards(currentPools.uncommons, UNCOMMONS_COUNT, namesInThisPack);
|
||||
if (!drawU.success) return null;
|
||||
packCards.push(...drawU.selected);
|
||||
currentPools.uncommons = drawU.remainingPool;
|
||||
drawU.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
// Sort: Mythic -> Rare -> Uncommon -> Common -> Land -> Token
|
||||
// We already have rarityWeight.
|
||||
// Assign weight to 'land' or 'token'?
|
||||
// DraftCard has 'rarity' string.
|
||||
// Standard rarities: common, uncommon, rare, mythic.
|
||||
// Basic Land has rarity 'common' usually? or 'basic'.
|
||||
// Token has rarity 'common' or 'token' (if we set it?). Scryfall tokens often have no rarity or 'common'.
|
||||
|
||||
const drawC = this.drawUniqueCards(currentPools.commons, COMMONS_COUNT, namesInThisPack);
|
||||
if (!drawC.success) return null;
|
||||
packCards.push(...drawC.selected);
|
||||
currentPools.commons = drawC.remainingPool;
|
||||
// Custom sort
|
||||
const getWeight = (c: DraftCard) => {
|
||||
if (c.layout === 'token' || c.typeLine?.includes('Token')) return 0;
|
||||
if (c.typeLine?.includes('Land') && (c.rarity === 'common' || c.rarity === 'basic')) return 1;
|
||||
if (c.rarity === 'common') return 2;
|
||||
if (c.rarity === 'uncommon') return 3;
|
||||
if (c.rarity === 'rare') return 4;
|
||||
if (c.rarity === 'mythic') return 5;
|
||||
return 1;
|
||||
}
|
||||
|
||||
const rarityWeight: { [key: string]: number } = { 'mythic': 4, 'rare': 3, 'uncommon': 2, 'common': 1 };
|
||||
packCards.sort((a, b) => rarityWeight[b.rarity] - rarityWeight[a.rarity]);
|
||||
packCards.sort((a, b) => getWeight(b) - getWeight(a));
|
||||
|
||||
return { pack: { id: packId, setName, cards: packCards }, remainingPools: currentPools };
|
||||
}
|
||||
|
||||
private drawColorBalanced(pool: DraftCard[], count: number, existingNames: Set<string>) {
|
||||
// Attempt to include at least 3 distinct colors
|
||||
// Naive approach: Just draw distinct. If diversity < 3, accept it anyway to avoid stalling,
|
||||
// or try to pick specifically.
|
||||
// Given constraints, let's try to pick a set that satisfies it.
|
||||
|
||||
const res = this.drawUniqueCards(pool, count, existingNames);
|
||||
// For now, accept the draw. Implementing strict color balancing with limited pools is hard.
|
||||
// A simple heuristic: Sort pool by color? No, we need randomness.
|
||||
// With 6 cards from a large pool, 3 colors is highly probable.
|
||||
return res;
|
||||
}
|
||||
|
||||
private drawUniqueCards(pool: DraftCard[], count: number, existingNames: Set<string>) {
|
||||
const selected: DraftCard[] = [];
|
||||
const skipped: DraftCard[] = [];
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
export interface ScryfallCardFace {
|
||||
name: string;
|
||||
type_line?: string;
|
||||
mana_cost?: string;
|
||||
oracle_text?: string;
|
||||
colors?: string[];
|
||||
power?: string;
|
||||
toughness?: string;
|
||||
image_uris?: { normal: string; small?: string; large?: string; png?: string; art_crop?: string; border_crop?: string };
|
||||
}
|
||||
|
||||
export interface ScryfallCard {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -8,16 +19,69 @@ export interface ScryfallCard {
|
||||
layout: string;
|
||||
type_line: string;
|
||||
colors?: string[];
|
||||
image_uris?: { normal: string };
|
||||
card_faces?: { image_uris: { normal: string } }[];
|
||||
image_uris?: { normal: string; small?: string; large?: string; png?: string; art_crop?: string; border_crop?: string };
|
||||
card_faces?: ScryfallCardFace[];
|
||||
finish?: 'foil' | 'normal'; // Manual override from import
|
||||
// Extended Metadata
|
||||
cmc?: number;
|
||||
mana_cost?: string;
|
||||
oracle_text?: string;
|
||||
power?: string;
|
||||
toughness?: string;
|
||||
collector_number?: string;
|
||||
color_identity?: string[];
|
||||
keywords?: string[];
|
||||
booster?: boolean;
|
||||
promo?: boolean;
|
||||
reprint?: boolean;
|
||||
|
||||
// Rich Metadata for precise generation
|
||||
legalities?: { [format: string]: 'legal' | 'not_legal' | 'restricted' | 'banned' };
|
||||
finishes?: string[]; // e.g. ["foil", "nonfoil"]
|
||||
games?: string[]; // e.g. ["paper", "arena", "mtgo"]
|
||||
produced_mana?: string[];
|
||||
artist?: string;
|
||||
released_at?: string;
|
||||
frame_effects?: string[];
|
||||
security_stamp?: string;
|
||||
promo_types?: string[];
|
||||
full_art?: boolean;
|
||||
textless?: boolean;
|
||||
variation?: boolean;
|
||||
variation_of?: string;
|
||||
scryfall_uri?: string;
|
||||
|
||||
// Index signature to allow all other properties from API
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
import { db } from '../utils/db';
|
||||
|
||||
export class ScryfallService {
|
||||
private cacheById = new Map<string, ScryfallCard>();
|
||||
private cacheByName = new Map<string, ScryfallCard>();
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.initPromise = this.initializeCache();
|
||||
}
|
||||
|
||||
private async initializeCache() {
|
||||
try {
|
||||
const cards = await db.getAllCards();
|
||||
cards.forEach(card => {
|
||||
this.cacheById.set(card.id, card);
|
||||
if (card.name) this.cacheByName.set(card.name.toLowerCase(), card);
|
||||
});
|
||||
console.log(`[ScryfallService] Loaded ${cards.length} cards from persistence.`);
|
||||
} catch (e) {
|
||||
console.error("[ScryfallService] Failed to load cache", e);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchCollection(identifiers: { id?: string; name?: string }[], onProgress?: (current: number, total: number) => void): Promise<ScryfallCard[]> {
|
||||
if (this.initPromise) await this.initPromise;
|
||||
|
||||
// Deduplicate
|
||||
const uniqueRequests: { id?: string; name?: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -66,6 +130,11 @@ export class ScryfallService {
|
||||
await new Promise(r => setTimeout(r, 75)); // Rate limit respect
|
||||
}
|
||||
|
||||
// Persist new cards
|
||||
if (fetchedCards.length > 0) {
|
||||
await db.bulkPutCards(fetchedCards);
|
||||
}
|
||||
|
||||
// Return everything requested (from cache included)
|
||||
const result: ScryfallCard[] = [];
|
||||
identifiers.forEach(item => {
|
||||
@@ -109,6 +178,12 @@ export class ScryfallService {
|
||||
}
|
||||
|
||||
async fetchSetCards(setCode: string, onProgress?: (current: number) => void): Promise<ScryfallCard[]> {
|
||||
if (this.initPromise) await this.initPromise;
|
||||
|
||||
// Check if we already have a significant number of cards from this set in cache?
|
||||
// Hard to know strict completeness without tracking sets.
|
||||
// But for now, we just fetch and merge.
|
||||
|
||||
let cards: ScryfallCard[] = [];
|
||||
let url = `https://api.scryfall.com/cards/search?q=set:${setCode}&unique=cards`;
|
||||
|
||||
@@ -117,10 +192,6 @@ export class ScryfallService {
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.data) {
|
||||
// Should we filter here strictly? The API query 'set:code' + 'unique=cards' is usually correct.
|
||||
// We might want to filter out Basics if we don't want them in booster generation, but standard boosters contain basics.
|
||||
// However, user setting for "Ignore Basic Lands" is handled in PackGeneratorService.processCards.
|
||||
// So here we should fetch everything.
|
||||
cards.push(...data.data);
|
||||
if (onProgress) onProgress(cards.length);
|
||||
}
|
||||
@@ -135,6 +206,16 @@ export class ScryfallService {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache everything
|
||||
if (cards.length > 0) {
|
||||
cards.forEach(card => {
|
||||
this.cacheById.set(card.id, card);
|
||||
if (card.name) this.cacheByName.set(card.name.toLowerCase(), card);
|
||||
});
|
||||
await db.bulkPutCards(cards);
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
}
|
||||
|
||||
83
src/client/src/utils/db.ts
Normal file
83
src/client/src/utils/db.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ScryfallCard } from '../services/ScryfallService';
|
||||
|
||||
const DB_NAME = 'mtg-draft-maker';
|
||||
const STORE_NAME = 'cards';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
const openDB = (): Promise<IDBDatabase> => {
|
||||
if (dbPromise) return dbPromise;
|
||||
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
resolve((event.target as IDBOpenDBRequest).result);
|
||||
};
|
||||
|
||||
request.onerror = (event) => {
|
||||
reject((event.target as IDBOpenDBRequest).error);
|
||||
};
|
||||
});
|
||||
|
||||
return dbPromise;
|
||||
};
|
||||
|
||||
export const db = {
|
||||
async getAllCards(): Promise<ScryfallCard[]> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
},
|
||||
|
||||
async putCard(card: ScryfallCard): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.put(card);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
},
|
||||
|
||||
async bulkPutCards(cards: ScryfallCard[]): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = (event) => reject(transaction.error);
|
||||
|
||||
cards.forEach(card => store.put(card));
|
||||
});
|
||||
},
|
||||
|
||||
async getCard(id: string): Promise<ScryfallCard | undefined> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.get(id);
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -28,7 +28,7 @@ const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.use(express.json({ limit: '50mb' })); // Increase limit for large card lists
|
||||
|
||||
// Serve static images
|
||||
// Serve static images (Nested)
|
||||
app.use('/cards', express.static(path.join(__dirname, 'public/cards')));
|
||||
|
||||
// API Routes
|
||||
@@ -51,67 +51,69 @@ app.post('/api/cards/cache', async (req: Request, res: Response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Caching images for ${cards.length} cards...`);
|
||||
const count = await cardService.cacheImages(cards);
|
||||
res.json({ success: true, downloaded: count });
|
||||
console.log(`Caching images and metadata for ${cards.length} cards...`);
|
||||
const imgCount = await cardService.cacheImages(cards);
|
||||
const metaCount = await cardService.cacheMetadata(cards);
|
||||
res.json({ success: true, downloadedImages: imgCount, savedMetadata: metaCount });
|
||||
} catch (err: any) {
|
||||
console.error('Error in cache route:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Global Draft Timer Loop
|
||||
setInterval(() => {
|
||||
const updates = draftManager.checkTimers();
|
||||
updates.forEach(({ roomId, draft }) => {
|
||||
io.to(roomId).emit('draft_update', draft);
|
||||
|
||||
// Check for forced game start (Deck Building Timeout)
|
||||
if (draft.status === 'complete') {
|
||||
const room = roomManager.getRoom(roomId);
|
||||
// Only trigger if room exists and not already playing
|
||||
if (room && room.status !== 'playing') {
|
||||
console.log(`Deck building timeout for Room ${roomId}. Forcing start.`);
|
||||
|
||||
// Force ready for unready players
|
||||
const activePlayers = room.players.filter(p => p.role === 'player');
|
||||
activePlayers.forEach(p => {
|
||||
if (!p.ready) {
|
||||
const pool = draft.players[p.id]?.pool || [];
|
||||
roomManager.setPlayerReady(roomId, p.id, pool);
|
||||
}
|
||||
});
|
||||
|
||||
// Start Game Logic
|
||||
room.status = 'playing';
|
||||
io.to(roomId).emit('room_update', room);
|
||||
|
||||
const game = gameManager.createGame(roomId, room.players);
|
||||
activePlayers.forEach(p => {
|
||||
if (p.deck) {
|
||||
p.deck.forEach((card: any) => {
|
||||
gameManager.addCardToGame(roomId, {
|
||||
ownerId: p.id,
|
||||
controllerId: p.id,
|
||||
oracleId: card.oracle_id || card.id,
|
||||
name: card.name,
|
||||
imageUrl: card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "",
|
||||
zone: 'library'
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
io.to(roomId).emit('game_update', game);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Socket.IO logic
|
||||
io.on('connection', (socket) => {
|
||||
console.log('A user connected', socket.id);
|
||||
|
||||
// Timer management
|
||||
const playerTimers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
const startAutoPickTimer = (roomId: string, playerId: string) => {
|
||||
// Clear existing if any (debounce)
|
||||
if (playerTimers.has(playerId)) {
|
||||
clearTimeout(playerTimers.get(playerId)!);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
console.log(`Timeout for player ${playerId}. Auto-picking...`);
|
||||
const draft = draftManager.autoPick(roomId, playerId);
|
||||
if (draft) {
|
||||
io.to(roomId).emit('draft_update', draft);
|
||||
// We only pick once. If they stay offline, the next pick depends on the next turn cycle.
|
||||
// If we wanted continuous auto-pick, we'd need to check if it's still their turn and recurse.
|
||||
// For now, this unblocks the current step.
|
||||
}
|
||||
playerTimers.delete(playerId);
|
||||
}, 30000); // 30s
|
||||
|
||||
playerTimers.set(playerId, timer);
|
||||
};
|
||||
|
||||
const stopAutoPickTimer = (playerId: string) => {
|
||||
if (playerTimers.has(playerId)) {
|
||||
clearTimeout(playerTimers.get(playerId)!);
|
||||
playerTimers.delete(playerId);
|
||||
}
|
||||
};
|
||||
|
||||
const stopAllRoomTimers = (roomId: string) => {
|
||||
const room = roomManager.getRoom(roomId);
|
||||
if (room) {
|
||||
room.players.forEach(p => stopAutoPickTimer(p.id));
|
||||
}
|
||||
};
|
||||
|
||||
const resumeRoomTimers = (roomId: string) => {
|
||||
const room = roomManager.getRoom(roomId);
|
||||
if (room && room.status === 'drafting') {
|
||||
room.players.forEach(p => {
|
||||
if (p.isOffline && p.role === 'player') {
|
||||
startAutoPickTimer(roomId, p.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
// Timer management removed (Global loop handled)
|
||||
|
||||
socket.on('create_room', ({ hostId, hostName, packs }, callback) => {
|
||||
const room = roomManager.createRoom(hostId, hostName, packs, socket.id); // Add socket.id
|
||||
@@ -124,8 +126,8 @@ io.on('connection', (socket) => {
|
||||
const room = roomManager.joinRoom(roomId, playerId, playerName, socket.id); // Add socket.id
|
||||
if (room) {
|
||||
// Clear timeout if exists (User reconnected)
|
||||
stopAutoPickTimer(playerId);
|
||||
console.log(`Player ${playerName} reconnected. Auto-pick cancelled.`);
|
||||
// stopAutoPickTimer(playerId); // Global timer handles this now
|
||||
console.log(`Player ${playerName} reconnected.`);
|
||||
|
||||
socket.join(room.id);
|
||||
console.log(`Player ${playerName} joined room ${roomId}`);
|
||||
@@ -134,7 +136,7 @@ io.on('connection', (socket) => {
|
||||
// Check if Host Reconnected -> Resume Game
|
||||
if (room.hostId === playerId) {
|
||||
console.log(`Host ${playerName} reconnected. Resuming draft timers.`);
|
||||
resumeRoomTimers(roomId);
|
||||
draftManager.setPaused(roomId, false);
|
||||
}
|
||||
|
||||
// If drafting, send state immediately and include in callback
|
||||
@@ -160,7 +162,7 @@ io.on('connection', (socket) => {
|
||||
|
||||
if (room) {
|
||||
// Clear Timer
|
||||
stopAutoPickTimer(playerId);
|
||||
// stopAutoPickTimer(playerId);
|
||||
console.log(`Player ${playerId} reconnected via rejoin.`);
|
||||
|
||||
// Notify others (isOffline false)
|
||||
@@ -169,7 +171,7 @@ io.on('connection', (socket) => {
|
||||
// Check if Host Reconnected -> Resume Game
|
||||
if (room.hostId === playerId) {
|
||||
console.log(`Host ${playerId} reconnected. Resuming draft timers.`);
|
||||
resumeRoomTimers(roomId);
|
||||
draftManager.setPaused(roomId, false);
|
||||
}
|
||||
|
||||
// Prepare Draft State if exists
|
||||
@@ -394,10 +396,9 @@ io.on('connection', (socket) => {
|
||||
|
||||
if (hostOffline) {
|
||||
console.log("Host is offline. Pausing game (stopping all timers).");
|
||||
stopAllRoomTimers(room.id);
|
||||
draftManager.setPaused(room.id, true);
|
||||
} else {
|
||||
// Host is online, but THIS player disconnected. Start timer for them.
|
||||
startAutoPickTimer(room.id, playerId);
|
||||
// Host is online, but THIS player disconnected. Timer continues automatically.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +28,11 @@ interface DraftState {
|
||||
unopenedPacks: Pack[]; // Pack 2 and 3 kept aside
|
||||
isWaiting: boolean; // True if finished current pack round
|
||||
pickedInCurrentStep: number; // HOW MANY CARDS PICKED FROM CURRENT ACTIVE PACK
|
||||
pickExpiresAt: number; // Timestamp when auto-pick occurs
|
||||
}>;
|
||||
|
||||
status: 'drafting' | 'deck_building' | 'complete';
|
||||
isPaused: boolean;
|
||||
startTime?: number; // For timer
|
||||
}
|
||||
|
||||
@@ -58,6 +60,7 @@ export class DraftManager extends EventEmitter {
|
||||
packNumber: 1,
|
||||
players: {},
|
||||
status: 'drafting',
|
||||
isPaused: false,
|
||||
startTime: Date.now()
|
||||
};
|
||||
|
||||
@@ -72,7 +75,8 @@ export class DraftManager extends EventEmitter {
|
||||
pool: [],
|
||||
unopenedPacks: playerPacks,
|
||||
isWaiting: false,
|
||||
pickedInCurrentStep: 0
|
||||
pickedInCurrentStep: 0,
|
||||
pickExpiresAt: Date.now() + 60000 // 60 seconds for first pack
|
||||
};
|
||||
});
|
||||
|
||||
@@ -92,15 +96,6 @@ export class DraftManager extends EventEmitter {
|
||||
if (!playerState || !playerState.activePack) return null;
|
||||
|
||||
// Find card
|
||||
// uniqueId check implies if cards have unique instance IDs in pack, if not we rely on strict equality or assume 1 instance per pack
|
||||
|
||||
// Fallback: If we can't find by ID (if Scryfall ID generic), just pick the first matching ID?
|
||||
// We should ideally assume the frontend sends the exact card object or unique index.
|
||||
// For now assuming cardId is unique enough or we pick first match.
|
||||
// Better: In a draft, a pack might have 2 duplicates. We need index or unique ID.
|
||||
// Let's assume the pack generation gave unique IDs or we just pick by index.
|
||||
// I'll stick to ID for now, assuming unique.
|
||||
|
||||
const card = playerState.activePack.cards.find(c => c.id === cardId);
|
||||
if (!card) return null;
|
||||
|
||||
@@ -166,6 +161,57 @@ export class DraftManager extends EventEmitter {
|
||||
if (!p.activePack && p.queue.length > 0) {
|
||||
p.activePack = p.queue.shift()!;
|
||||
p.pickedInCurrentStep = 0; // Reset for new pack
|
||||
p.pickExpiresAt = Date.now() + 60000; // Reset timer for new pack
|
||||
}
|
||||
}
|
||||
|
||||
checkTimers(): { roomId: string, draft: DraftState }[] {
|
||||
const updates: { roomId: string, draft: DraftState }[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (const [roomId, draft] of this.drafts.entries()) {
|
||||
if (draft.isPaused) continue;
|
||||
|
||||
if (draft.status === 'drafting') {
|
||||
let draftUpdated = false;
|
||||
// Iterate over players
|
||||
for (const playerId of Object.keys(draft.players)) {
|
||||
const playerState = draft.players[playerId];
|
||||
// Check if player is thinking (has active pack) and time expired
|
||||
if (playerState.activePack && now > playerState.pickExpiresAt) {
|
||||
const result = this.autoPick(roomId, playerId);
|
||||
if (result) {
|
||||
draftUpdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (draftUpdated) {
|
||||
updates.push({ roomId, draft });
|
||||
}
|
||||
} else if (draft.status === 'deck_building') {
|
||||
// Check global deck building timer (e.g., 120 seconds)
|
||||
const DECK_BUILDING_Duration = 120000;
|
||||
if (draft.startTime && (now > draft.startTime + DECK_BUILDING_Duration)) {
|
||||
draft.status = 'complete'; // Signal that time is up
|
||||
updates.push({ roomId, draft });
|
||||
}
|
||||
}
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
setPaused(roomId: string, paused: boolean) {
|
||||
const draft = this.drafts.get(roomId);
|
||||
if (draft) {
|
||||
draft.isPaused = paused;
|
||||
if (!paused) {
|
||||
// Reset timers to 60s
|
||||
Object.values(draft.players).forEach(p => {
|
||||
if (p.activePack) {
|
||||
p.pickExpiresAt = Date.now() + 60000;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,8 +226,6 @@ export class DraftManager extends EventEmitter {
|
||||
const randomCardIndex = Math.floor(Math.random() * playerState.activePack.cards.length);
|
||||
const card = playerState.activePack.cards[randomCardIndex];
|
||||
|
||||
//console.log(`Auto-picking card for ${playerId}: ${card.name}`);
|
||||
|
||||
// Reuse existing logic
|
||||
return this.pickCard(roomId, playerId, card.id);
|
||||
}
|
||||
@@ -199,6 +243,7 @@ export class DraftManager extends EventEmitter {
|
||||
if (nextPack) {
|
||||
p.activePack = nextPack;
|
||||
p.pickedInCurrentStep = 0; // Reset
|
||||
p.pickExpiresAt = Date.now() + 60000; // Reset timer
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -8,9 +8,18 @@ const __dirname = path.dirname(__filename);
|
||||
const CARDS_DIR = path.join(__dirname, '../public/cards');
|
||||
|
||||
export class CardService {
|
||||
private imagesDir: string;
|
||||
private metadataDir: string;
|
||||
|
||||
constructor() {
|
||||
if (!fs.existsSync(CARDS_DIR)) {
|
||||
fs.mkdirSync(CARDS_DIR, { recursive: true });
|
||||
this.imagesDir = path.join(CARDS_DIR, 'images');
|
||||
this.metadataDir = path.join(CARDS_DIR, 'metadata');
|
||||
|
||||
if (!fs.existsSync(this.imagesDir)) {
|
||||
fs.mkdirSync(this.imagesDir, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(this.metadataDir)) {
|
||||
fs.mkdirSync(this.metadataDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +47,7 @@ export class CardService {
|
||||
|
||||
if (!imageUrl) continue;
|
||||
|
||||
const filePath = path.join(CARDS_DIR, `${uuid}.jpg`);
|
||||
const filePath = path.join(this.imagesDir, `${uuid}.jpg`);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
// Already cached
|
||||
@@ -67,4 +76,21 @@ export class CardService {
|
||||
|
||||
return downloadedCount;
|
||||
}
|
||||
|
||||
async cacheMetadata(cards: any[]): Promise<number> {
|
||||
let cachedCount = 0;
|
||||
for (const card of cards) {
|
||||
if (!card.id) continue;
|
||||
const filePath = path.join(this.metadataDir, `${card.id}.json`);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(card, null, 2));
|
||||
cachedCount++;
|
||||
} catch (e) {
|
||||
console.error(`Failed to save metadata for ${card.id}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return cachedCount;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user