Initial Commit
This commit is contained in:
25
.agent/rules/development-folders.md
Normal file
25
.agent/rules/development-folders.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
## Documentation and Work Logging
|
||||
You are required to use the `./docs/development/devlog` directory to track all work plans and their current status individually; within this directory, create logs using the strict filename format `yyyy-mm-dd-hh24miss_very_brief_description`. Additionally, use the `./docs/development` directory to maintain a summary `CENTRAL.md` file containing links to specific files within `./docs/development/devlog` alongside a brief synthesis of the development status.
|
||||
|
||||
## Source Code Organization
|
||||
Use `./src` as the root for the monolithic **Node.js (TypeScript)** solution.
|
||||
The project follows a **Modular Monolith** pattern. All backend logic is structured by modules, while frontend code (React) resides within a client directory or is served as static assets from the node application.
|
||||
|
||||
## 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**:
|
||||
|
||||
* **Controllers:** `./src/modules/[ModuleName]/controllers/` (Handle HTTP requests).
|
||||
* **Routes:** `./src/modules/[ModuleName]/routes/` (Define express/fastify routes).
|
||||
* **DTOs:** `./src/modules/[ModuleName]/dtos/` (Data Transfer Objects for validation).
|
||||
* **Static Assets:** `./src/public/` (for module-specific assets if necessary).
|
||||
|
||||
## Domain Layer
|
||||
Shared business logic and database entities reside in shared directories or within the modules themselves, designed to be importable:
|
||||
|
||||
* **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).
|
||||
21
.agent/rules/development-guide.md
Normal file
21
.agent/rules/development-guide.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
## Planning and Workflow
|
||||
You must always start by producing an implementation plan in the dedicated folders and proposing to the user that they view it by default. If the user chooses to proceed, you are to continue with the implementation of the plan without stopping. It is essential that you update the plan continuously as the development progresses to reflect the current state of the project.
|
||||
|
||||
## Code Philosophy and Integration
|
||||
You are required to work with the existing code, specifically utilizing the logic found in the provided gemini-generated.js file—including its regex parsing, Scryfall data fetching, caching strategies, and pack generation algorithms—as the core of the "Draft Preparation Phase". You must refactor this monolithic component into modular services, such as a `CardParserService` and `PackGeneratorService`, to effectively separate the user interface from the business logic, making the system suitable for a multiplayer backend state. The software must be extremely optimized and easy to use; while the preparation phase happens on the client side, the live draft state must be synchronized via the backend. The average user, acting as the Draft Host, must be guided through every operation, from uploading a list and fetching data to configuring packs and opening a lobby for multiplayer.
|
||||
|
||||
## UI/UX Design and Tech Stack
|
||||
The graphics must be professional, immersive, and reassuring, utilizing a dark mode or gaming theme that replicates the visual style of the provided file (using Tailwind and Lucide icons) while elevating it to a production standard. The interface must include shortcuts for quick usage, and data saving must be immediate without requiring the user to click "Save" buttons. You must implement the specific views found in the provided code, including List View, Grid View, and the 3D perspective Stack View, ensuring they work seamlessly in a multiplayer context. The development will utilize **React** for the frontend, reusing component logic like `StackView` and `PackCard`, and **Node.js with TypeScript** for the backend. You must use **Socket.IO** to handle the real-time multiplayer draft state for synchronizing pack passing between clients, and the frontend generator must produce a JSON object of packs to be sent to the backend to initialize the session.
|
||||
|
||||
## Documentation and Architecture
|
||||
For every implementation, update, or modification request, you must create a detailed work plan to be followed strictly. If a request implies complex development, such as synchronizing drag-and-drop actions across multiple clients, you must warn the user beforehand. Before starting, always write a file dedicated to the request and the created plan within the `./docs/development` folder, and keep this file updated throughout the execution of the work plan. The platform must be organized into applications managed via a sidebar, such as the Cube Manager (using the adapted logic), Lobby Manager, and Live Draft interface. Each application must be toggleable and purchasable via an internal administration section.
|
||||
|
||||
## Design System, Navigation, and Viewport
|
||||
Interfaces must always be created using Material Design principles adapted for a dark gaming UI, with icons that clearly identify the purpose of the application or function. The interface must be fully responsive, with specific handling for mobile devices, such as converting the "Stack View" to a "Swipe View" where necessary. The menu must be a lateral sidebar with two levels: the first for active applications and the second for functions within those applications. A top search bar must be implemented as an accelerator to search for cards, players, or lobbies hierarchically. The main viewport must be configurable with tabs; every application opens as a new tab, and upon restarting, the tabs open last time must remain active. Specifically, starting a draft should open a new tab for the "Live Lobby," and the system must handle persistence to reconnect users to their active session upon reloading.
|
||||
|
||||
## Localization, Database, and Error Handling
|
||||
The management system is currently supporting English only.
|
||||
15
.agent/rules/functional-goal.md
Normal file
15
.agent/rules/functional-goal.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
## Core Objective and User Experience
|
||||
The primary objective is to engineer a high-performance, browser-based Magic: The Gathering Multiplayer Draft Simulator that bridges the gap between utilitarian management tools and immersive, game-like user experiences. The platform must be specifically designed to cater to the "Average User" acting as a Host, who requires a friction-free setup process without technical hurdles, and the "Player," who demands a snappy, visually engaging drafting environment accessible on any device. The goal is to provide a seamless transition from deck preparation to active gameplay, ensuring that the software feels responsive and robust, much like a native application rather than a static webpage.
|
||||
|
||||
## The Cube Manager and Preparation Phase
|
||||
A central functional domain is the "Cube Manager," which handles the preparation phase. The system must provide automated ingestion capabilities that accept raw text lists—such as Arena exports or bulk text—and intelligently parse them using specific regex logic to identify card quantities and names. Following parsing, the system is required to seamlessly fetch and enrich card metadata, including images, rarity, and set information, from the Scryfall API while implementing robust caching to ensure speed. This data drives a sophisticated Pack Generation engine that creates "Booster Packs" based on customizable rules, such as "Peasant" distribution or "Chaos" modes, while automatically filtering out unplayable tokens or basic lands. Users must be empowered to verify their generated pools through distinct visualizations, including List, Grid, and a 3D perspective "Stack" view, before committing to a game.
|
||||
|
||||
## The Live Draft and Multiplayer Synchronization
|
||||
The core gameplay revolves around the "Live Draft" phase, which necessitates a real-time multiplayer environment where eight or more players pass packs simultaneously. You must implement a synchronization engine (via **Socket.IO**) to ensure that state changes, such as picking a card or passing a pack, are instantly propagated across all connected clients with millisecond precision. The interface must support intuitive interactions, allowing players to draft via drag-and-drop or click-to-pick mechanisms, with built-in logic to auto-sort picked cards by color or mana cost. Crucially, the system must handle network latency and interruptions gracefully; if a player disconnects or refreshes the page, they must be immediately reconnected to the exact state of their active pick without any data loss.
|
||||
|
||||
## Ecosystem, Responsiveness, and Session Management
|
||||
The user experience must mimic a comprehensive operating system through a tabbed multitasking interface, allowing users to keep the Live Draft open in one tab while managing the Cube in another within the same viewport. The platform operates on a modular "App Store" model, where functional areas like Tournaments, Brackets, or Collection management can be toggled or activated via an admin panel. The interface must be rigorously responsive, employing a mobile-first adaptation strategy that transforms wide desktop column views into touch-friendly carousels or swipe interfaces on smaller screens. Furthermore, all user actions, from deck building to cube editing, must trigger zero-friction auto-saving to backend databases via AJAX/Fetch calls, eliminating the need for manual save buttons and ensuring total data integrity throughout the session.
|
||||
5
.agent/rules/running-guide.md
Normal file
5
.agent/rules/running-guide.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
Everytime there is a new feature or something was changed, run the application and test every new feature in (also in browser) to avoid future bugs.
|
||||
141
.gitignore
vendored
Normal file
141
.gitignore
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
.output
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Sveltekit cache directory
|
||||
.svelte-kit/
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# Firebase cache directory
|
||||
.firebase/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v3
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# Vite files
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
.vite/
|
||||
50
Makefile
Normal file
50
Makefile
Normal file
@@ -0,0 +1,50 @@
|
||||
# MTG Draft Maker - Node.js Monolith
|
||||
|
||||
# Variables
|
||||
PROJECT_DIR := src
|
||||
|
||||
.PHONY: help install dev dev-server dev-client build start clean
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "======================================================================"
|
||||
@echo " MTG Draft Maker - Node.js Monolith"
|
||||
@echo "======================================================================"
|
||||
@echo " make install : Install dependencies"
|
||||
@echo " make dev : Run both Server and Client (Development)"
|
||||
@echo " make dev-server : Run only Server (Development)"
|
||||
@echo " make dev-client : Run only Client (Development)"
|
||||
@echo " make build : Build Backend and Frontend for Production"
|
||||
@echo " make start : Run built application (Production)"
|
||||
@echo " make clean : Clean node_modules and build artifacts"
|
||||
@echo "======================================================================"
|
||||
|
||||
install:
|
||||
@echo ">>> Installing dependencies..."
|
||||
cd $(PROJECT_DIR) && npm install
|
||||
|
||||
dev:
|
||||
@echo ">>> Starting Development Environment..."
|
||||
cd $(PROJECT_DIR) && npm run dev
|
||||
|
||||
dev-server:
|
||||
@echo ">>> Starting Backend (Dev)..."
|
||||
cd $(PROJECT_DIR) && npm run server
|
||||
|
||||
dev-client:
|
||||
@echo ">>> Starting Frontend (Dev)..."
|
||||
cd $(PROJECT_DIR) && npm run client
|
||||
|
||||
build:
|
||||
@echo ">>> Building for Production..."
|
||||
cd $(PROJECT_DIR) && npm run build
|
||||
|
||||
start:
|
||||
@echo ">>> Starting Production Server..."
|
||||
cd $(PROJECT_DIR) && npm run start
|
||||
|
||||
clean:
|
||||
@echo ">>> Cleaning artifacts..."
|
||||
rm -rf $(PROJECT_DIR)/node_modules
|
||||
rm -rf $(PROJECT_DIR)/dist
|
||||
rm -rf $(PROJECT_DIR)/client/dist
|
||||
17
docs/development/CENTRAL.md
Normal file
17
docs/development/CENTRAL.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Development Central Log
|
||||
|
||||
## Status Overview
|
||||
The project has successfully migrated from a .NET backend to a Node.js Modular Monolith. The core "Draft Preparation" and "Tournament Bracket" functionalities have been implemented in the frontend using React, adhering to the reference design.
|
||||
|
||||
## Recent Updates
|
||||
- **[2025-12-14] Core Implementation**: Refactored `gemini-generated.js` into modular services and components. Implemented Cube Manager and Tournament Manager. [Link](./devlog/2025-12-14-194558_core_implementation.md)
|
||||
- **[2025-12-14] Parser Robustness**: Improving `CardParserService` to handle formats without Scryfall IDs (e.g., Arena exports). [Link](./devlog/2025-12-14-210000_fix_parser_robustness.md)
|
||||
|
||||
## Active Modules
|
||||
1. **Cube Manager**: Fully functional (Parsing, Fetching, Pack Generation).
|
||||
2. **Tournament Manager**: Basic Bracket generation implemented.
|
||||
|
||||
## Roadmap
|
||||
1. **Backend Integration**: Connect frontend generation to backend via Socket.IO.
|
||||
2. **Live Draft**: Implement the multiplayer drafting interface.
|
||||
3. **User Session**: Handle host/player sessions.
|
||||
@@ -0,0 +1,33 @@
|
||||
# PROJ001: Initial Project Setup and Logic Refactoring (Node.js Migration)
|
||||
|
||||
## Status: COMPLETED
|
||||
|
||||
### Achievements
|
||||
- **Architecture**: Pivoted from .NET to a **Node.js Monolith** structure to natively support real-time state synchronization via Socket.IO.
|
||||
- **Frontend Infrastructure**: Configured **React** 19 + **Vite** + **Tailwind CSS** (v3) in `src/client`.
|
||||
- **Backend Infrastructure**: Initialized **Express** server with **Socket.IO** in `src/server` for handling API requests and multiplayer draft state.
|
||||
- **Refactoring**: Successfully ported legacy `gemini-generated.js` logic into specialized TypeScript services:
|
||||
- `CardParserService.ts`: Regex-based list parsing.
|
||||
- `ScryfallService.ts`: Data fetching with caching.
|
||||
- `PackGeneratorService.ts`: Pack creation logic.
|
||||
- **UI Implementation**: Developed `CubeManager`, `PackCard`, and `StackView` components.
|
||||
- **Cleanup**: Removed all .NET artifacts and dependencies.
|
||||
- **Tooling**: Updated `Makefile` for unified Node.js development commands.
|
||||
|
||||
### How to Run
|
||||
- **Install**: `make install` (or `cd src && npm install`)
|
||||
- **Run Development**: `make dev` (Runs Server and Client concurrently)
|
||||
- **Build**: `make build`
|
||||
|
||||
### Manual Verification Steps
|
||||
1. **Run**: `make dev`
|
||||
2. **Access**: Open `http://localhost:5173` (Client).
|
||||
3. **Test**:
|
||||
- Click "Load Demo List" in the Cube Manager.
|
||||
- Verify cards are fetched from Scryfall.
|
||||
- Click "Generate Pools".
|
||||
- Verify packs are generated and visible in Stack/Grid views.
|
||||
|
||||
### Next Steps
|
||||
- Implement `DraftSession` state management in `src/server`.
|
||||
- Define Socket.IO events for lobby creation and player connection.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Migration to Node.js Backend
|
||||
|
||||
## Objective
|
||||
Convert the project from a .NET backend to a Node.js (TypeScript) backend and remove the .NET infrastructure.
|
||||
|
||||
## Plan
|
||||
|
||||
### Phase 1: Structure Initialization
|
||||
- [ ] Initialize `src` as a Node.js project (`package.json`, `tsconfig.json`).
|
||||
- [ ] Create directory structure:
|
||||
- [ ] `src/server`: Backend logic.
|
||||
- [ ] `src/client`: Move existing React frontend here.
|
||||
- [ ] `src/shared`: Shared interfaces/types.
|
||||
|
||||
### Phase 2: React Frontend Migration
|
||||
- [ ] Move `src/MtgDraft.Web/Client` contents to `src/client/src`.
|
||||
- [ ] Move configuration files (`vite.config.ts`, `tailwind.config.js`, etc.) to `src/client` root or adjust as needed.
|
||||
- [ ] Ensure frontend builds and runs via Vite (dev server).
|
||||
|
||||
### Phase 3: Node.js Backend Implementation
|
||||
- [ ] Set up Express/Fastify server in `src/server/index.ts`.
|
||||
- [ ] Configure Socket.IO foundations.
|
||||
- [ ] Configure build scripts to build client and server.
|
||||
|
||||
### Phase 4: Verification
|
||||
- [ ] Verify application runs with `npm run dev`.
|
||||
|
||||
### Phase 5: Cleanup
|
||||
- [ ] Delete `MtgDraft.*` folders.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Implementation of Core Functionalities
|
||||
|
||||
## Status
|
||||
Completed
|
||||
|
||||
## Description
|
||||
Implemented the core functionalities based on the reference `gemini-generated.js` file, refactoring the monolithic logic into a modular architecture.
|
||||
|
||||
## Changes
|
||||
1. **Services**:
|
||||
- Created `CardParserService` for parsing bulk text lists.
|
||||
- Created `ScryfallService` for fetching card data with caching and batching.
|
||||
- Created `PackGeneratorService` for generating booster packs with various rules (Peasant, Standard, Chaos).
|
||||
|
||||
2. **Modules**:
|
||||
- **CubeManager**: Implemented the Draft Preparation Phase UI (Input, Filters, Generation).
|
||||
- **TournamentManager**: Implemented the Tournament Bracket generation logic and UI.
|
||||
|
||||
3. **Components**:
|
||||
- `PackCard`: card component with List, Grid, and Stack views.
|
||||
- `StackView`: 3D card stack visualization.
|
||||
- `TournamentPackView`: "Blind Mode" / Box view for generated packs.
|
||||
|
||||
4. **Architecture**:
|
||||
- Created `App.tsx` as the main shell with Tab navigation (Draft vs Bracket).
|
||||
- Integrated all components into the main entry point.
|
||||
|
||||
## Next Steps
|
||||
- Integrate Socket.IO for real-time draft synchronization (Multiplayer).
|
||||
- Implement the "Live Draft" interface.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bug Fix: React Render Error and Pack Generation Stability
|
||||
|
||||
## Issue
|
||||
User reported "root.render(" error visible on page and "Generate Packs" button ineffective.
|
||||
|
||||
## Diagnosis
|
||||
1. **main.tsx**: Found nested `root.render( <StrictMode> root.render(...) )` call. This caused runtime errors and visible artifact text.
|
||||
2. **CubeManager.tsx**: Service classes (`ScryfallService`, `PackGeneratorService`) were instantiated inside the functional component body without `useMemo`. This caused recreation on every render, leading to cache loss (`ScryfallService` internal cache) and potential state inconsistencies.
|
||||
3. **Pack Generation**: Double-clicking or rapid state updates caused "phantom" generation runs with empty pools, resetting the packs list to 0 immediately after success.
|
||||
|
||||
## Resolution
|
||||
1. **Fixed main.tsx**: Removed the nested `root.render` call.
|
||||
2. **Refactored CubeManager.tsx**:
|
||||
* Memoized all services using `useMemo`.
|
||||
* Added `loading` state to `generatePacks` to prevent double-submissions.
|
||||
* Wrapped generation logic in `setTimeout` to allow UI updates and `try/catch` for robustness.
|
||||
|
||||
## Status
|
||||
Verified via browser subagent (logs confirmed 241 packs generated). UI now prevents race conditions.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Bug Fix: Card Parser Robustness
|
||||
|
||||
## User Request
|
||||
"The problem is that if the scryfall id is missing, no card is retrieved so no card is generated, instead the system should be able to retrieve cards and generate packs even without scryfall id"
|
||||
|
||||
## Diagnosis
|
||||
The `CardParserService` currently performs basic name extraction. It fails to strip set codes and collector numbers common in export formats (e.g., MTG Arena exports like `1 Shock (M20) 160`).
|
||||
This causes `ScryfallService` to search for "Shock (M20) 160" as an exact name, which fails. The system relies on successful Scryfall matches to populate the card pool; without matches, the pool is empty, and generation produces 0 packs.
|
||||
|
||||
## Implementation Plan
|
||||
1. **Refactor `CardParserService.ts`**:
|
||||
* Enhance regex to explicitly handle and strip:
|
||||
* Parentheses containing text (e.g., `(M20)`).
|
||||
* Collector numbers at the end of lines.
|
||||
* Set codes in square brackets if present.
|
||||
* Maintain support for `Quantity Name` format.
|
||||
* Ensure exact name cleanup to maximize Scryfall "exact match" hits.
|
||||
|
||||
2. **Verification**:
|
||||
* Create a test input imitating Arena export.
|
||||
* Verify via browser subagent that cards are fetched and packs are generated.
|
||||
|
||||
## Update Central
|
||||
Update `CENTRAL.md` with this task.
|
||||
12
src/client/index.html
Normal file
12
src/client/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MTG Draft Maker</title>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-50">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
44
src/client/src/App.tsx
Normal file
44
src/client/src/App.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Layers, Box, Trophy } from 'lucide-react';
|
||||
import { CubeManager } from './modules/cube/CubeManager';
|
||||
import { TournamentManager } from './modules/tournament/TournamentManager';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'draft' | 'bracket'>('draft');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 text-slate-100 font-sans pb-20">
|
||||
<header className="bg-slate-800 border-b border-slate-700 p-4 sticky top-0 z-50 shadow-lg">
|
||||
<div className="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-purple-600 p-2 rounded-lg"><Layers className="w-6 h-6 text-white" /></div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">MTG Peasant Drafter</h1>
|
||||
<p className="text-slate-400 text-xs uppercase tracking-wider">Pack Generator & Tournament Manager</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700">
|
||||
<button
|
||||
onClick={() => setActiveTab('draft')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'draft' ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
<Box className="w-4 h-4" /> Draft Management
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('bracket')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'bracket' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
<Trophy className="w-4 h-4" /> Tournament / Bracket
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{activeTab === 'draft' && <CubeManager />}
|
||||
{activeTab === 'bracket' && <TournamentManager />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
120
src/client/src/components/PackCard.tsx
Normal file
120
src/client/src/components/PackCard.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { DraftCard, Pack } from '../services/PackGeneratorService';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { StackView } from './StackView';
|
||||
|
||||
interface PackCardProps {
|
||||
pack: Pack;
|
||||
viewMode: 'list' | 'grid' | 'stack';
|
||||
}
|
||||
|
||||
const ListItem: React.FC<{ card: DraftCard }> = ({ card }) => {
|
||||
const getRarityColorClass = (rarity: string) => {
|
||||
switch (rarity) {
|
||||
case 'common': return 'bg-black text-white border-slate-600';
|
||||
case 'uncommon': return 'bg-slate-300 text-slate-900 border-white';
|
||||
case 'rare': return 'bg-yellow-500 text-yellow-950 border-yellow-200';
|
||||
case 'mythic': return 'bg-orange-600 text-white border-orange-300';
|
||||
default: return 'bg-slate-500';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="relative group">
|
||||
<div className="flex items-center justify-between py-1 px-2 rounded hover:bg-slate-700/50 cursor-pointer">
|
||||
<span className={`font-medium ${card.rarity === 'mythic' ? 'text-orange-400' : card.rarity === 'rare' ? 'text-yellow-400' : card.rarity === 'uncommon' ? 'text-slate-200' : 'text-slate-400'}`}>
|
||||
{card.name}
|
||||
</span>
|
||||
<span className={`w-2 h-2 rounded-full border ${getRarityColorClass(card.rarity)} !p-0 !text-[0px]`}></span>
|
||||
</div>
|
||||
{card.image && (
|
||||
<div className="hidden group-hover:block absolute left-0 top-full z-50 mt-1 pointer-events-none">
|
||||
<div className="bg-black p-1 rounded-lg border border-slate-500 shadow-2xl w-48">
|
||||
<img src={card.image} alt={card.name} className="w-full rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode }) => {
|
||||
const mythics = pack.cards.filter(c => c.rarity === 'mythic');
|
||||
const rares = pack.cards.filter(c => c.rarity === 'rare');
|
||||
const uncommons = pack.cards.filter(c => c.rarity === 'uncommon');
|
||||
const commons = pack.cards.filter(c => c.rarity === 'common');
|
||||
|
||||
const copyPackToClipboard = () => {
|
||||
const text = pack.cards.map(c => c.name).join('\n');
|
||||
navigator.clipboard.writeText(text);
|
||||
// Toast notification could go here
|
||||
alert(`Pack list ${pack.id} copied!`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-slate-800 rounded-xl border border-slate-700 shadow-lg flex flex-col ${viewMode === 'stack' ? 'bg-transparent border-none shadow-none' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className={`p-3 bg-slate-900 border-b border-slate-700 flex justify-between items-center rounded-t-xl ${viewMode === 'stack' ? 'bg-slate-800 border border-slate-700 mb-4 rounded-xl' : ''}`}>
|
||||
<div className="flex flex-col">
|
||||
<h3 className="font-bold text-purple-400 text-sm md:text-base">Pack #{pack.id}</h3>
|
||||
<span className="text-xs text-slate-500 font-mono">{pack.setName}</span>
|
||||
</div>
|
||||
<button onClick={copyPackToClipboard} className="text-slate-400 hover:text-white p-1 rounded hover:bg-slate-700 transition-colors flex items-center gap-2 text-xs">
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={`${viewMode !== 'stack' ? 'p-4' : ''}`}>
|
||||
{viewMode === 'list' && (
|
||||
<div className="text-sm space-y-4">
|
||||
{(mythics.length > 0 || rares.length > 0) && (
|
||||
<div>
|
||||
<div className="text-xs font-bold text-yellow-500 uppercase mb-2 border-b border-slate-700 pb-1">Rare / Mythic ({mythics.length + rares.length})</div>
|
||||
<ul className="space-y-1">
|
||||
{mythics.map(card => <ListItem key={card.id} card={card} />)}
|
||||
{rares.map(card => <ListItem key={card.id} card={card} />)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-300 uppercase mb-2 border-b border-slate-700 pb-1">Uncommons ({uncommons.length})</div>
|
||||
<ul className="space-y-1">
|
||||
{uncommons.map(card => <ListItem key={card.id} card={card} />)}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-500 uppercase mb-2 border-b border-slate-700 pb-1">Commons ({commons.length})</div>
|
||||
<ul className="space-y-1">
|
||||
{commons.map(card => <ListItem key={card.id} card={card} />)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'grid' && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{pack.cards.map((card) => (
|
||||
<div key={card.id} className="relative aspect-[2.5/3.5] bg-slate-900 rounded-lg overflow-hidden group hover:scale-105 transition-transform duration-200 shadow-xl border border-slate-800">
|
||||
{card.image ? (
|
||||
<img src={card.image} alt={card.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-xs text-center p-1 text-slate-500 font-bold border-2 border-slate-700 m-1 rounded">
|
||||
{card.name}
|
||||
</div>
|
||||
)}
|
||||
<div className={`absolute bottom-0 left-0 right-0 h-1.5 ${card.rarity === 'mythic' ? 'bg-gradient-to-r from-orange-500 to-red-600' :
|
||||
card.rarity === 'rare' ? 'bg-gradient-to-r from-yellow-400 to-yellow-600' :
|
||||
card.rarity === 'uncommon' ? 'bg-gradient-to-r from-gray-300 to-gray-500' :
|
||||
'bg-black'
|
||||
}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'stack' && <StackView cards={pack.cards} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
53
src/client/src/components/StackView.tsx
Normal file
53
src/client/src/components/StackView.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { DraftCard } from '../services/PackGeneratorService';
|
||||
|
||||
interface StackViewProps {
|
||||
cards: DraftCard[];
|
||||
}
|
||||
|
||||
export const StackView: React.FC<StackViewProps> = ({ cards }) => {
|
||||
const getRarityColorClass = (rarity: string) => {
|
||||
switch (rarity) {
|
||||
case 'common': return 'bg-black text-white border-slate-600';
|
||||
case 'uncommon': return 'bg-slate-300 text-slate-900 border-white';
|
||||
case 'rare': return 'bg-yellow-500 text-yellow-950 border-yellow-200';
|
||||
case 'mythic': return 'bg-orange-600 text-white border-orange-300';
|
||||
default: return 'bg-slate-500';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-sm mx-auto group perspective-1000 py-20">
|
||||
<div className="relative flex flex-col items-center transition-all duration-500 ease-in-out group-hover:space-y-4 space-y-[-16rem] py-10">
|
||||
{cards.map((card, index) => {
|
||||
const colorClass = getRarityColorClass(card.rarity);
|
||||
// Random slight rotation for "organic" look
|
||||
const rotation = (index % 2 === 0 ? 1 : -1) * (Math.random() * 2);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
className="relative w-64 aspect-[2.5/3.5] rounded-xl shadow-2xl transition-transform duration-300 hover:scale-110 hover:z-50 hover:rotate-0 origin-center bg-slate-800 border-2 border-slate-900"
|
||||
style={{
|
||||
zIndex: index,
|
||||
transform: `rotate(${rotation}deg)`
|
||||
}}
|
||||
>
|
||||
{card.image ? (
|
||||
<img src={card.image} alt={card.name} className="w-full h-full object-cover rounded-lg" />
|
||||
) : (
|
||||
<div className="w-full h-full p-4 text-center flex items-center justify-center font-bold text-slate-500">
|
||||
{card.name}
|
||||
</div>
|
||||
)}
|
||||
<div className={`absolute top-2 right-2 w-3 h-3 rounded-full shadow-md z-10 border ${colorClass}`} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-center text-slate-500 text-xs mt-4 opacity-50 group-hover:opacity-0 transition-opacity">
|
||||
Hover to expand stack
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
75
src/client/src/components/TournamentPackView.tsx
Normal file
75
src/client/src/components/TournamentPackView.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { Target, Package, Layers } from 'lucide-react';
|
||||
import { Pack } from '../services/PackGeneratorService';
|
||||
|
||||
interface TournamentPackViewProps {
|
||||
packs: Pack[];
|
||||
}
|
||||
|
||||
export const TournamentPackView: React.FC<TournamentPackViewProps> = ({ packs }) => {
|
||||
const packsBySet = packs.reduce((acc, pack) => {
|
||||
const key = pack.setName || 'Unknown Set';
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(pack);
|
||||
return acc;
|
||||
}, {} as { [key: string]: Pack[] });
|
||||
|
||||
const BOX_SIZE = 30;
|
||||
|
||||
return (
|
||||
<div className="space-y-12 animate-in fade-in duration-700">
|
||||
{Object.entries(packsBySet).map(([setName, setPacks]) => {
|
||||
const boxes = [];
|
||||
for (let i = 0; i < setPacks.length; i += BOX_SIZE) boxes.push(setPacks.slice(i, i + BOX_SIZE));
|
||||
|
||||
return (
|
||||
<div key={setName} className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-px bg-slate-700 flex-1"></div>
|
||||
<h3 className="text-2xl font-black text-slate-200 uppercase tracking-widest flex items-center gap-2">
|
||||
<Target className="w-6 h-6 text-purple-500" /> {setName}
|
||||
</h3>
|
||||
<div className="h-px bg-slate-700 flex-1"></div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{boxes.map((boxPacks, boxIndex) => (
|
||||
<div key={boxIndex} className="bg-slate-800/50 border border-slate-700 rounded-2xl p-6 relative">
|
||||
<div className="absolute -top-4 left-6 bg-amber-600 text-white px-4 py-1 rounded-full font-bold shadow-lg flex items-center gap-2 border-2 border-slate-900 z-10">
|
||||
<Package className="w-4 h-4" /> BOX {boxIndex + 1}
|
||||
<span className="text-amber-200 text-xs font-normal ml-1">({boxPacks.length} packs)</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 mt-4">
|
||||
{boxPacks.map((pack) => (
|
||||
<div key={pack.id} className="aspect-[2.5/3.5] bg-gradient-to-br from-slate-700 to-slate-800 rounded-xl border-2 border-slate-600 shadow-xl relative group overflow-hidden cursor-pointer hover:border-amber-500/50 transition-colors">
|
||||
<div className="absolute inset-2 border-2 border-dashed border-slate-600/30 rounded-lg flex flex-col items-center justify-center">
|
||||
<Layers className="w-8 h-8 text-slate-600 mb-2 opacity-50" />
|
||||
<span className="text-2xl font-black text-slate-500 opacity-20">MTG</span>
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-0 right-0 text-center">
|
||||
<div className="bg-slate-900/90 text-white text-xs font-bold py-1 px-2 mx-2 rounded border border-slate-700 truncate">#{pack.id}</div>
|
||||
<div className="text-[10px] text-slate-400 mt-1 uppercase tracking-widest font-semibold truncate px-2">{pack.setName}</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 bg-black/80 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<div className="text-center p-2">
|
||||
<p className="text-amber-400 font-bold text-xs">Contains {pack.cards.length} cards:</p>
|
||||
{pack.cards.some(c => c.rarity === 'mythic' || c.rarity === 'rare') && (
|
||||
<p className="text-yellow-400 text-xs font-bold">★ Rare / Mythic</p>
|
||||
)}
|
||||
<p className="text-slate-300 text-[10px] italic mt-1">Click for full list</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
16
src/client/src/main.tsx
Normal file
16
src/client/src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './styles/main.css';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
if (rootElement) {
|
||||
const root = createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
{/* <CubeManager /> is now part of App */}
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
336
src/client/src/modules/cube/CubeManager.tsx
Normal file
336
src/client/src/modules/cube/CubeManager.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Layers, RotateCcw, Box, Check, Loader2, Upload, Eye, EyeOff, LayoutGrid, List, Sliders, Settings } from 'lucide-react';
|
||||
import { CardParserService } from '../../services/CardParserService';
|
||||
import { ScryfallService, ScryfallCard } from '../../services/ScryfallService';
|
||||
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
|
||||
import { PackCard } from '../../components/PackCard';
|
||||
import { TournamentPackView } from '../../components/TournamentPackView';
|
||||
|
||||
export const CubeManager: React.FC = () => {
|
||||
// --- Services ---
|
||||
// --- Services ---
|
||||
// Memoize services to persist cache across renders
|
||||
const parserService = React.useMemo(() => new CardParserService(), []);
|
||||
const scryfallService = React.useMemo(() => new ScryfallService(), []);
|
||||
const generatorService = React.useMemo(() => new PackGeneratorService(), []);
|
||||
|
||||
// --- State ---
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [progress, setProgress] = useState('');
|
||||
|
||||
const [rawScryfallData, setRawScryfallData] = useState<ScryfallCard[] | null>(null);
|
||||
const [processedData, setProcessedData] = useState<{ pools: ProcessedPools, sets: SetsMap } | null>(null);
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
ignoreBasicLands: true,
|
||||
ignoreCommander: true,
|
||||
ignoreTokens: true
|
||||
});
|
||||
|
||||
const [packs, setPacks] = useState<Pack[]>([]);
|
||||
|
||||
// UI State
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>('list');
|
||||
const [tournamentMode, setTournamentMode] = useState(false);
|
||||
|
||||
// Generation Settings
|
||||
const [genSettings, setGenSettings] = useState<PackGenerationSettings>({
|
||||
mode: 'mixed',
|
||||
rarityMode: 'peasant'
|
||||
});
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// --- Effects ---
|
||||
useEffect(() => {
|
||||
if (rawScryfallData) {
|
||||
const result = generatorService.processCards(rawScryfallData, filters);
|
||||
setProcessedData(result);
|
||||
}
|
||||
}, [filters, rawScryfallData]);
|
||||
|
||||
// --- Handlers ---
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => setInputText(e.target?.result as string || '');
|
||||
reader.readAsText(file);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const loadDemoData = () => {
|
||||
const demo = `20 Shock
|
||||
20 Llanowar Elves
|
||||
20 Giant Growth
|
||||
20 Counterspell
|
||||
20 Dark Ritual
|
||||
20 Lightning Bolt
|
||||
20 Opt
|
||||
20 Consider
|
||||
20 Ponder
|
||||
20 Preordain
|
||||
20 Brainstorm
|
||||
20 Duress
|
||||
20 Faithless Looting
|
||||
20 Thrill of Possibility
|
||||
20 Terror
|
||||
10 Serra Angel
|
||||
10 Vampire Nighthawk
|
||||
10 Eternal Witness
|
||||
10 Mulldrifter
|
||||
10 Flametongue Kavu
|
||||
5 Wrath of God
|
||||
5 Birds of Paradise
|
||||
2 Jace, the Mind Sculptor
|
||||
1 Sheoldred, the Apocalypse
|
||||
20 Island
|
||||
1 Sol Ring
|
||||
1 Command Tower`;
|
||||
setInputText(demo);
|
||||
};
|
||||
|
||||
const fetchAndParse = async () => {
|
||||
setLoading(true);
|
||||
setPacks([]);
|
||||
setProgress('Parsing text...');
|
||||
setTournamentMode(false);
|
||||
|
||||
try {
|
||||
const identifiers = parserService.parse(inputText);
|
||||
|
||||
// Expand quantity for fetching logic (though service handles it, let's just pass uniques to service)
|
||||
// The service `fetchCollection` deduplicates automatically.
|
||||
|
||||
// Map identifier interface
|
||||
const fetchList = identifiers.map(id => id.type === 'id' ? { id: id.value } : { name: id.value });
|
||||
|
||||
// Fetch
|
||||
await scryfallService.fetchCollection(fetchList, (current, total) => {
|
||||
setProgress(`Fetching Scryfall data... (${current}/${total})`);
|
||||
});
|
||||
|
||||
// Expand based on original quantities
|
||||
const expandedCards: ScryfallCard[] = [];
|
||||
identifiers.forEach(id => {
|
||||
const card = scryfallService.getCachedCard(id.type === 'id' ? { id: id.value } : { name: id.value });
|
||||
if (card) {
|
||||
for (let i = 0; i < id.quantity; i++) expandedCards.push(card);
|
||||
}
|
||||
});
|
||||
|
||||
setRawScryfallData(expandedCards);
|
||||
// Processing happens via useEffect
|
||||
|
||||
setLoading(false);
|
||||
setProgress('');
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
alert(err.message || "Error during process.");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generatePacks = () => {
|
||||
if (!processedData) return;
|
||||
|
||||
if (processedData.pools.commons.length === 0 && processedData.pools.uncommons.length === 0 && processedData.pools.rares.length === 0) {
|
||||
alert(`Not enough valid cards.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// Use setTimeout to allow UI to show loading spinner before sync calculation blocks
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const newPacks = generatorService.generatePacks(processedData.pools, processedData.sets, genSettings);
|
||||
|
||||
if (newPacks.length === 0) {
|
||||
alert(`Not enough cards to generate valid packs (minimum required for selected mode).`);
|
||||
} else {
|
||||
setPacks(newPacks);
|
||||
setTournamentMode(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Generation failed", e);
|
||||
alert("Error generating packs: " + e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const toggleFilter = (key: keyof typeof filters) => {
|
||||
setFilters(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-8 p-4 md:p-6">
|
||||
|
||||
{/* --- LEFT COLUMN: CONTROLS --- */}
|
||||
<div className="lg:col-span-4 flex flex-col gap-4">
|
||||
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="text-sm font-semibold text-slate-300 flex items-center gap-2">
|
||||
<Box className="w-4 h-4" /> Input Bulk
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => fileInputRef.current?.click()} className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 hover:underline">
|
||||
<Upload className="w-3 h-3" /> Upload
|
||||
</button>
|
||||
<input type="file" ref={fileInputRef} className="hidden" accept=".csv,.txt" onChange={handleFileUpload} />
|
||||
<button onClick={loadDemoData} className="text-xs text-purple-400 hover:text-purple-300 hover:underline">Demo</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-4 bg-slate-900 p-3 rounded-lg border border-slate-700">
|
||||
<h4 className="text-xs font-bold text-slate-400 uppercase mb-2 flex items-center gap-2">
|
||||
<Sliders className="w-3 h-3" /> Import Options
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-white text-slate-300">
|
||||
<input type="checkbox" checked={filters.ignoreBasicLands} onChange={() => toggleFilter('ignoreBasicLands')} className="rounded border-slate-600 bg-slate-800 text-purple-500 focus:ring-purple-500" />
|
||||
<span>Ignore Basic Lands</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-white text-slate-300">
|
||||
<input type="checkbox" checked={filters.ignoreCommander} onChange={() => toggleFilter('ignoreCommander')} className="rounded border-slate-600 bg-slate-800 text-purple-500 focus:ring-purple-500" />
|
||||
<span>Ignore Commander Sets</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-white text-slate-300">
|
||||
<input type="checkbox" checked={filters.ignoreTokens} onChange={() => toggleFilter('ignoreTokens')} className="rounded border-slate-600 bg-slate-800 text-purple-500 focus:ring-purple-500" />
|
||||
<span>Ignore Tokens</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="w-full h-40 bg-slate-900 border border-slate-700 rounded-lg p-3 text-xs font-mono text-slate-300 focus:ring-2 focus:ring-purple-500 outline-none resize-none mb-4 whitespace-pre"
|
||||
placeholder="Paste list or upload file..."
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={fetchAndParse}
|
||||
disabled={loading || !inputText}
|
||||
className={`w-full py-2 mb-4 rounded-lg font-bold flex justify-center items-center gap-2 transition-all ${loading ? 'bg-slate-700 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-500 text-white'}`}
|
||||
>
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> {progress}</> : <><Check className="w-4 h-4" /> 1. Parse Bulk</>}
|
||||
</button>
|
||||
|
||||
{/* Generation Settings */}
|
||||
{processedData && Object.keys(processedData.sets).length > 0 && (
|
||||
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-700 mb-4 animate-in fade-in slide-in-from-top-4 duration-500">
|
||||
<h3 className="text-sm font-bold text-white mb-2 flex items-center gap-2">
|
||||
<Settings className="w-4 h-4 text-emerald-400" /> Configuration
|
||||
</h3>
|
||||
|
||||
{/* Mode */}
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase mb-1 block">Card Source</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
||||
<input type="radio" name="genMode" value="mixed" checked={genSettings.mode === 'mixed'} onChange={() => setGenSettings({ ...genSettings, mode: 'mixed' })} className="accent-purple-500" />
|
||||
<span>Chaos Draft (Mix All)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
||||
<input type="radio" name="genMode" value="by_set" checked={genSettings.mode === 'by_set'} onChange={() => setGenSettings({ ...genSettings, mode: 'by_set' })} className="accent-purple-500" />
|
||||
<span>Split by Expansion</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rarity */}
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase mb-1 block">Format</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className={`flex items-center gap-2 text-sm cursor-pointer p-2 rounded border ${genSettings.rarityMode === 'peasant' ? 'bg-slate-700 border-purple-500' : 'border-transparent hover:bg-slate-800'}`}>
|
||||
<input type="radio" name="rarMode" value="peasant" checked={genSettings.rarityMode === 'peasant'} onChange={() => setGenSettings({ ...genSettings, rarityMode: 'peasant' })} className="accent-purple-500" />
|
||||
<div>
|
||||
<span className="block font-bold text-white">Peasant (13 Cards)</span>
|
||||
<span className="text-xs text-slate-400">10 Commons, 3 Uncommons</span>
|
||||
</div>
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 text-sm cursor-pointer p-2 rounded border ${genSettings.rarityMode === 'standard' ? 'bg-slate-700 border-amber-500' : 'border-transparent hover:bg-slate-800'}`}>
|
||||
<input type="radio" name="rarMode" value="standard" checked={genSettings.rarityMode === 'standard'} onChange={() => setGenSettings({ ...genSettings, rarityMode: 'standard' })} className="accent-amber-500" />
|
||||
<div>
|
||||
<span className="block font-bold text-white">Standard (14 Cards)</span>
|
||||
<span className="text-xs text-slate-400">10C, 3U, 1 Rare/Mythic</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sets Info */}
|
||||
<div className="max-h-40 overflow-y-auto text-xs space-y-1 pr-2 custom-scrollbar border-t border-slate-800 pt-2">
|
||||
{Object.values(processedData.sets).sort((a, b) => b.commons.length - a.commons.length).map(set => (
|
||||
<div key={set.code} className="flex justify-between items-center text-slate-400 border-b border-slate-800 pb-1">
|
||||
<span className="truncate w-32" title={set.name}>{set.name}</span>
|
||||
<span className="font-mono text-[10px]">{set.commons.length}C / {set.uncommons.length}U / {set.rares.length}R</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={generatePacks}
|
||||
disabled={!processedData || Object.keys(processedData.sets).length === 0 || loading}
|
||||
className={`w-full py-3 px-4 rounded-lg font-bold flex justify-center items-center gap-2 transition-all ${!processedData || Object.keys(processedData.sets).length === 0 || loading ? 'bg-slate-700 cursor-not-allowed text-slate-500' : 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-900/20'}`}
|
||||
>
|
||||
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <RotateCcw className="w-5 h-5" />}
|
||||
{loading ? 'Generating...' : '2. Generate Packs'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- RIGHT COLUMN: PACKS --- */}
|
||||
<div className="lg:col-span-8">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-center mb-6 gap-4 sticky top-4 z-40 bg-slate-900/90 backdrop-blur-sm p-3 rounded-xl border border-white/5 shadow-2xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<span className="bg-slate-700 text-purple-400 px-3 py-1 rounded-lg text-sm border border-slate-600">{packs.length}</span>
|
||||
Packs
|
||||
</h2>
|
||||
{packs.length > 0 && (
|
||||
<button onClick={() => setTournamentMode(!tournamentMode)} className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold border transition-all ${tournamentMode ? 'bg-amber-500/20 border-amber-500 text-amber-500 animate-pulse' : 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-400'}`}>
|
||||
{tournamentMode ? <><EyeOff className="w-4 h-4" /> Tournament Mode</> : <><Eye className="w-4 h-4" /> Editor Mode</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!tournamentMode && (
|
||||
<div className="flex bg-slate-800 rounded-lg p-1 border border-slate-700">
|
||||
<button onClick={() => setViewMode('list')} className={`p-2 rounded ${viewMode === 'list' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><List className="w-4 h-4" /></button>
|
||||
<button onClick={() => setViewMode('grid')} className={`p-2 rounded ${viewMode === 'grid' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><LayoutGrid className="w-4 h-4" /></button>
|
||||
<button onClick={() => setViewMode('stack')} className={`p-2 rounded ${viewMode === 'stack' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><Layers className="w-4 h-4" /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{packs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed border-slate-700 rounded-2xl bg-slate-800/30 text-slate-500">
|
||||
<Box className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p>No packs generated.</p>
|
||||
</div>
|
||||
) : (
|
||||
tournamentMode ? (
|
||||
<TournamentPackView packs={packs} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 pb-20">
|
||||
{packs.map((pack) => (
|
||||
<PackCard key={pack.id} pack={pack} viewMode={viewMode} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div >
|
||||
);
|
||||
};
|
||||
92
src/client/src/modules/tournament/TournamentManager.tsx
Normal file
92
src/client/src/modules/tournament/TournamentManager.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
interface Match {
|
||||
id: number;
|
||||
p1: string;
|
||||
p2: string;
|
||||
}
|
||||
|
||||
interface Bracket {
|
||||
round1: Match[];
|
||||
totalPlayers: number;
|
||||
}
|
||||
|
||||
export const TournamentManager: React.FC = () => {
|
||||
const [playerInput, setPlayerInput] = useState('');
|
||||
const [bracket, setBracket] = useState<Bracket | null>(null);
|
||||
|
||||
const shuffleArray = (array: any[]) => {
|
||||
let currentIndex = array.length, randomIndex;
|
||||
const newArray = [...array];
|
||||
while (currentIndex !== 0) {
|
||||
randomIndex = Math.floor(Math.random() * currentIndex);
|
||||
currentIndex--;
|
||||
[newArray[currentIndex], newArray[randomIndex]] = [newArray[randomIndex], newArray[currentIndex]];
|
||||
}
|
||||
return newArray;
|
||||
};
|
||||
|
||||
const generateBracket = () => {
|
||||
if (!playerInput.trim()) return;
|
||||
const names = playerInput.split('\n').filter(n => n.trim() !== '').map(n => n.trim());
|
||||
if (names.length < 2) { alert("Enter at least 2 players."); return; }
|
||||
|
||||
const shuffled = shuffleArray(names);
|
||||
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(shuffled.length)));
|
||||
const byesNeeded = nextPowerOf2 - shuffled.length;
|
||||
|
||||
const fullRoster = [...shuffled];
|
||||
for (let i = 0; i < byesNeeded; i++) fullRoster.push("BYE");
|
||||
|
||||
const pairings: Match[] = [];
|
||||
for (let i = 0; i < fullRoster.length; i += 2) {
|
||||
pairings.push({ id: i, p1: fullRoster[i], p2: fullRoster[i + 1] });
|
||||
}
|
||||
|
||||
setBracket({ round1: pairings, totalPlayers: names.length });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-4 md:p-6">
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl mb-8">
|
||||
<h2 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-blue-400" /> Players
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 mb-2">Enter one name per line</p>
|
||||
<textarea
|
||||
className="w-full h-32 bg-slate-900 border border-slate-700 rounded-lg p-3 text-sm text-slate-300 focus:ring-2 focus:ring-blue-500 outline-none resize-none mb-4"
|
||||
placeholder={`Player 1\nPlayer 2...`}
|
||||
value={playerInput}
|
||||
onChange={(e) => setPlayerInput(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={generateBracket}
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded-lg font-bold w-full md:w-auto transition-colors"
|
||||
>
|
||||
Generate Bracket
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bracket && (
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl overflow-x-auto">
|
||||
<h3 className="text-lg font-bold text-white mb-6 border-b border-slate-700 pb-2">Round 1 (Single Elimination)</h3>
|
||||
<div className="flex flex-col gap-4 min-w-[300px]">
|
||||
{bracket.round1.map((match, i) => (
|
||||
<div key={i} className="bg-slate-900 border border-slate-700 rounded-lg p-4 flex flex-col gap-2 relative">
|
||||
<div className="absolute -left-3 top-1/2 w-3 h-px bg-slate-600"></div>
|
||||
<div className="flex justify-between items-center bg-slate-800/50 p-2 rounded border border-slate-700/50">
|
||||
<span className={match.p1 === 'BYE' ? 'text-slate-500 italic' : 'font-bold text-white'}>{match.p1}</span>
|
||||
</div>
|
||||
<div className="text-xs text-center text-slate-500">VS</div>
|
||||
<div className="flex justify-between items-center bg-slate-800/50 p-2 rounded border border-slate-700/50">
|
||||
<span className={match.p2 === 'BYE' ? 'text-slate-500 italic' : 'font-bold text-white'}>{match.p2}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
64
src/client/src/services/CardParserService.ts
Normal file
64
src/client/src/services/CardParserService.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export interface CardIdentifier {
|
||||
type: 'id' | 'name';
|
||||
value: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export class CardParserService {
|
||||
parse(text: string): CardIdentifier[] {
|
||||
const lines = text.split('\n').filter(line => line.trim() !== '');
|
||||
const rawCardList: CardIdentifier[] = [];
|
||||
const uuidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.toLowerCase().startsWith('quantity') || line.toLowerCase().startsWith('count,name')) return;
|
||||
|
||||
const idMatch = line.match(uuidRegex);
|
||||
const cleanLineForQty = line.replace(/['"]/g, '');
|
||||
const quantityMatch = cleanLineForQty.match(/^(\d+)[xX\s,;]/);
|
||||
const quantity = quantityMatch ? parseInt(quantityMatch[1], 10) : 1;
|
||||
|
||||
let identifier: { type: 'id' | 'name', value: string } | null = null;
|
||||
|
||||
if (idMatch) {
|
||||
identifier = { type: 'id', value: idMatch[0] };
|
||||
} else {
|
||||
const cleanLine = line.replace(/['"]/g, '');
|
||||
// Remove leading quantity
|
||||
let name = cleanLine.replace(/^(\d+)[xX\s,;]+/, '').trim();
|
||||
|
||||
// Remove set codes in parentheses/brackets e.g. (M20), [STA]
|
||||
// This regex looks for ( starts, anything inside, ) ends, or same for []
|
||||
name = name.replace(/\s*[\(\[].*?[\)\]]/g, '');
|
||||
|
||||
// Remove trailing collector numbers (digits at the very end)
|
||||
name = name.replace(/\s+\d+$/, '');
|
||||
|
||||
// Remove trailing punctuation
|
||||
name = name.replace(/^[,;]+|[,;]+$/g, '').trim();
|
||||
|
||||
// If CSV like "Name, SetCode", take first part
|
||||
if (name.includes(',')) name = name.split(',')[0].trim();
|
||||
|
||||
if (name && name.length > 1) identifier = { type: 'name', value: name };
|
||||
}
|
||||
|
||||
if (identifier) {
|
||||
// Return one entry per quantity? Or aggregated?
|
||||
// The original code pushed multiple entries to an array.
|
||||
// For a parser service, returning the count is better, but to match logic:
|
||||
// "for (let i = 0; i < quantity; i++) rawCardList.push(identifier);"
|
||||
// I will return one object with Quantity property to be efficient.
|
||||
|
||||
rawCardList.push({
|
||||
type: identifier.type,
|
||||
value: identifier.value,
|
||||
quantity: quantity
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (rawCardList.length === 0) throw new Error("No valid cards found.");
|
||||
return rawCardList;
|
||||
}
|
||||
}
|
||||
230
src/client/src/services/PackGeneratorService.ts
Normal file
230
src/client/src/services/PackGeneratorService.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { ScryfallCard } from './ScryfallService';
|
||||
|
||||
export interface DraftCard {
|
||||
id: string; // Internal UUID
|
||||
scryfallId: string;
|
||||
name: string;
|
||||
rarity: string;
|
||||
colors: string[];
|
||||
image: string;
|
||||
set: string;
|
||||
setCode: string;
|
||||
setType: string;
|
||||
}
|
||||
|
||||
export interface Pack {
|
||||
id: number;
|
||||
setName: string;
|
||||
cards: DraftCard[];
|
||||
}
|
||||
|
||||
export interface ProcessedPools {
|
||||
commons: DraftCard[];
|
||||
uncommons: DraftCard[];
|
||||
rares: DraftCard[];
|
||||
mythics: DraftCard[];
|
||||
}
|
||||
|
||||
export interface SetsMap {
|
||||
[code: string]: {
|
||||
name: string;
|
||||
code: string;
|
||||
commons: DraftCard[];
|
||||
uncommons: DraftCard[];
|
||||
rares: DraftCard[];
|
||||
mythics: DraftCard[];
|
||||
}
|
||||
}
|
||||
|
||||
export interface PackGenerationSettings {
|
||||
mode: 'mixed' | 'by_set';
|
||||
rarityMode: 'peasant' | 'standard'; // Peasant: 10C/3U, Standard: 10C/3U/1R
|
||||
}
|
||||
|
||||
export class PackGeneratorService {
|
||||
|
||||
processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }): { pools: ProcessedPools, sets: SetsMap } {
|
||||
const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [] };
|
||||
const setsMap: SetsMap = {};
|
||||
|
||||
cards.forEach(cardData => {
|
||||
const rarity = cardData.rarity;
|
||||
const typeLine = cardData.type_line || '';
|
||||
const setType = cardData.set_type;
|
||||
const layout = cardData.layout;
|
||||
|
||||
// Filters
|
||||
if (filters.ignoreBasicLands && typeLine.includes('Basic')) return;
|
||||
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;
|
||||
}
|
||||
|
||||
const cardObj: DraftCard = {
|
||||
id: crypto.randomUUID(),
|
||||
scryfallId: cardData.id,
|
||||
name: cardData.name,
|
||||
rarity: rarity,
|
||||
colors: cardData.colors || [],
|
||||
image: cardData.image_uris?.normal || cardData.card_faces?.[0]?.image_uris?.normal || '',
|
||||
set: cardData.set_name,
|
||||
setCode: cardData.set,
|
||||
setType: setType
|
||||
};
|
||||
|
||||
// Add to pools
|
||||
if (rarity === 'common') pools.commons.push(cardObj);
|
||||
else if (rarity === 'uncommon') pools.uncommons.push(cardObj);
|
||||
else if (rarity === 'rare') pools.rares.push(cardObj);
|
||||
else if (rarity === 'mythic') pools.mythics.push(cardObj);
|
||||
|
||||
// Add to Sets Map
|
||||
if (!setsMap[cardData.set]) {
|
||||
setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [] };
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
return { pools, sets: setsMap };
|
||||
}
|
||||
|
||||
generatePacks(pools: ProcessedPools, sets: SetsMap, settings: PackGenerationSettings): Pack[] {
|
||||
let newPacks: Pack[] = [];
|
||||
|
||||
if (settings.mode === 'mixed') {
|
||||
let currentPools = {
|
||||
commons: this.shuffle(pools.commons),
|
||||
uncommons: this.shuffle(pools.uncommons),
|
||||
rares: this.shuffle(pools.rares),
|
||||
mythics: this.shuffle(pools.mythics)
|
||||
};
|
||||
|
||||
let packId = 1;
|
||||
while (true) {
|
||||
const result = this.buildSinglePack(currentPools, packId, 'Chaos / Mixed', settings.rarityMode);
|
||||
if (!result) {
|
||||
break;
|
||||
}
|
||||
newPacks.push(result.pack);
|
||||
currentPools = result.remainingPools;
|
||||
packId++;
|
||||
}
|
||||
} else {
|
||||
// By Set
|
||||
let packId = 1;
|
||||
const sortedSetKeys = Object.keys(sets).sort();
|
||||
|
||||
sortedSetKeys.forEach(setCode => {
|
||||
const setData = sets[setCode];
|
||||
let currentPools = {
|
||||
commons: this.shuffle(setData.commons),
|
||||
uncommons: this.shuffle(setData.uncommons),
|
||||
rares: this.shuffle(setData.rares),
|
||||
mythics: this.shuffle(setData.mythics)
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const result = this.buildSinglePack(currentPools, packId, setData.name, settings.rarityMode);
|
||||
if (!result) break;
|
||||
newPacks.push(result.pack);
|
||||
currentPools = result.remainingPools;
|
||||
packId++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return newPacks;
|
||||
}
|
||||
|
||||
private buildSinglePack(pools: ProcessedPools, packId: number, setName: string, rarityMode: 'peasant' | 'standard') {
|
||||
const packCards: DraftCard[] = [];
|
||||
let currentPools = { ...pools };
|
||||
const namesInThisPack = new Set<string>();
|
||||
|
||||
const COMMONS_COUNT = 10;
|
||||
const UNCOMMONS_COUNT = 3;
|
||||
|
||||
if (rarityMode === 'standard') {
|
||||
const isMythicDrop = Math.random() < 0.125;
|
||||
let rareSuccess = false;
|
||||
|
||||
if (isMythicDrop && 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;
|
||||
}
|
||||
} else if (!rareSuccess && 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;
|
||||
}
|
||||
} 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
const drawC = this.drawUniqueCards(currentPools.commons, COMMONS_COUNT, namesInThisPack);
|
||||
if (!drawC.success) return null;
|
||||
packCards.push(...drawC.selected);
|
||||
currentPools.commons = drawC.remainingPool;
|
||||
|
||||
const rarityWeight: { [key: string]: number } = { 'mythic': 4, 'rare': 3, 'uncommon': 2, 'common': 1 };
|
||||
packCards.sort((a, b) => rarityWeight[b.rarity] - rarityWeight[a.rarity]);
|
||||
|
||||
return { pack: { id: packId, setName, cards: packCards }, remainingPools: currentPools };
|
||||
}
|
||||
|
||||
private drawUniqueCards(pool: DraftCard[], count: number, existingNames: Set<string>) {
|
||||
const selected: DraftCard[] = [];
|
||||
const skipped: DraftCard[] = [];
|
||||
const namesInPack = new Set(existingNames);
|
||||
const workingPool = [...pool];
|
||||
|
||||
while (selected.length < count && workingPool.length > 0) {
|
||||
const card = workingPool.shift()!;
|
||||
if (!namesInPack.has(card.name)) {
|
||||
selected.push(card);
|
||||
namesInPack.add(card.name);
|
||||
} else {
|
||||
skipped.push(card);
|
||||
}
|
||||
}
|
||||
const remainingPool = [...workingPool, ...skipped];
|
||||
return { selected, remainingPool, success: selected.length === count };
|
||||
}
|
||||
|
||||
private shuffle(array: any[]) {
|
||||
let currentIndex = array.length, randomIndex;
|
||||
const newArray = [...array];
|
||||
while (currentIndex !== 0) {
|
||||
randomIndex = Math.floor(Math.random() * currentIndex);
|
||||
currentIndex--;
|
||||
[newArray[currentIndex], newArray[randomIndex]] = [newArray[randomIndex], newArray[currentIndex]];
|
||||
}
|
||||
return newArray;
|
||||
}
|
||||
}
|
||||
88
src/client/src/services/ScryfallService.ts
Normal file
88
src/client/src/services/ScryfallService.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export interface ScryfallCard {
|
||||
id: string;
|
||||
name: string;
|
||||
rarity: string;
|
||||
set: string;
|
||||
set_name: string;
|
||||
set_type: string;
|
||||
layout: string;
|
||||
type_line: string;
|
||||
colors?: string[];
|
||||
image_uris?: { normal: string };
|
||||
card_faces?: { image_uris: { normal: string } }[];
|
||||
}
|
||||
|
||||
export class ScryfallService {
|
||||
private cacheById = new Map<string, ScryfallCard>();
|
||||
private cacheByName = new Map<string, ScryfallCard>();
|
||||
|
||||
async fetchCollection(identifiers: { id?: string; name?: string }[], onProgress?: (current: number, total: number) => void): Promise<ScryfallCard[]> {
|
||||
// Deduplicate
|
||||
const uniqueRequests: { id?: string; name?: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
identifiers.forEach(item => {
|
||||
const key = item.id ? `id:${item.id}` : `name:${item.name?.toLowerCase()}`;
|
||||
// Check internal cache or seen
|
||||
if (item.id && this.cacheById.has(item.id)) return;
|
||||
if (item.name && this.cacheByName.has(item.name.toLowerCase())) return;
|
||||
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
uniqueRequests.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
const fetchedCards: ScryfallCard[] = [];
|
||||
const chunks = [];
|
||||
for (let i = 0; i < uniqueRequests.length; i += 75) chunks.push(uniqueRequests.slice(i, i + 75));
|
||||
|
||||
let totalFetched = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (onProgress) onProgress(totalFetched, uniqueRequests.length);
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.scryfall.com/cards/collection', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identifiers: chunk })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.data) {
|
||||
data.data.forEach((card: ScryfallCard) => {
|
||||
this.cacheById.set(card.id, card);
|
||||
if (card.name) this.cacheByName.set(card.name.toLowerCase(), card);
|
||||
fetchedCards.push(card);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Scryfall fetch error:", error);
|
||||
}
|
||||
|
||||
totalFetched += chunk.length;
|
||||
await new Promise(r => setTimeout(r, 75)); // Rate limit respect
|
||||
}
|
||||
|
||||
// Return everything requested (from cache included)
|
||||
const result: ScryfallCard[] = [];
|
||||
identifiers.forEach(item => {
|
||||
if (item.id) {
|
||||
const c = this.cacheById.get(item.id);
|
||||
if (c) result.push(c);
|
||||
} else if (item.name) {
|
||||
const c = this.cacheByName.get(item.name.toLowerCase());
|
||||
if (c) result.push(c);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
getCachedCard(identifier: { id?: string; name?: string }): ScryfallCard | undefined {
|
||||
if (identifier.id) return this.cacheById.get(identifier.id);
|
||||
if (identifier.name) return this.cacheByName.get(identifier.name.toLowerCase());
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
3
src/client/src/styles/main.css
Normal file
3
src/client/src/styles/main.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
4782
src/package-lock.json
generated
Normal file
4782
src/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
src/package.json
Normal file
34
src/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "mtg-draft-maker",
|
||||
"version": "1.0.0",
|
||||
"description": "MTG Draft Maker - Monolith Node.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
||||
"server": "tsx watch server/index.ts",
|
||||
"client": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"start": "node server/dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"socket.io": "^4.8.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"lucide-react": "^0.475.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.1",
|
||||
"@types/express": "^4.17.21",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3",
|
||||
"tsx": "^4.19.2",
|
||||
"concurrently": "^9.1.0"
|
||||
}
|
||||
}
|
||||
6
src/postcss.config.cjs
Normal file
6
src/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
34
src/server/index.ts
Normal file
34
src/server/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { Server } from 'socket.io';
|
||||
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: "*", // Adjust for production
|
||||
methods: ["GET", "POST"]
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// API Routes
|
||||
app.get('/api/health', (_req: Request, res: Response) => {
|
||||
res.json({ status: 'ok', message: 'Server is running' });
|
||||
});
|
||||
|
||||
// Socket.IO connection
|
||||
io.on('connection', (socket) => {
|
||||
console.log('A user connected', socket.id);
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('User disconnected', socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
15
src/tailwind.config.cjs
Normal file
15
src/tailwind.config.cjs
Normal file
@@ -0,0 +1,15 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./client/index.html',
|
||||
'./client/src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Custom colors if needed, mirroring the dark gaming theme
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
39
src/tsconfig.json
Normal file
39
src/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./client/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"client/**/*.ts",
|
||||
"client/**/*.tsx",
|
||||
"server/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
26
src/vite.config.ts
Normal file
26
src/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: 'client', // Set root to client folder where index.html resides
|
||||
build: {
|
||||
outDir: '../dist', // Build to src/dist (outside client)
|
||||
emptyOutDir: true
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './client/src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000', // Proxy API requests to backend
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:3000',
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user