Compare commits
34 Commits
418e9e4507
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e3e25ca13 | |||
| 36fd89cda9 | |||
| f8188875ac | |||
| e655e3efe2 | |||
| 23b8e3203d | |||
| 9b25d3f0be | |||
| 6edfb8b9e4 | |||
| 35407a5cd4 | |||
| 7aa34adf95 | |||
| f701302923 | |||
| 66836cfde5 | |||
| 41be1d49c4 | |||
| d6fb76eb3e | |||
| ec69c69df7 | |||
| d16bfd96ee | |||
| 224cc38ba1 | |||
| eb711c3253 | |||
| 19c98d9629 | |||
| 784d173fec | |||
| c88c8ced15 | |||
| 325f82ff6b | |||
| 937620bac1 | |||
| ac21657bc7 | |||
| f335b33cf9 | |||
| 5dbfd006c2 | |||
| 5b601efcb6 | |||
| 8a65169d2a | |||
| f17ef711da | |||
| c1e062620e | |||
| 9c72bd7b8c | |||
| fd7642dded | |||
| c9d0230781 | |||
| 4e36157115 | |||
| 139aca6f4f |
@@ -12,14 +12,10 @@ The project follows a **Modular Monolith** pattern. All backend logic is structu
|
||||
## 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).
|
||||
## Cards Images folder
|
||||
* **Cropped Art** `./src/server/public/cards/images/[set]/crop/
|
||||
* **Standard Art** `./src/server/public/cards/images/[set]/full/
|
||||
|
||||
## 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).
|
||||
## Metadata folder
|
||||
* **Card Metadata** `./src/server/public/cards/metadata/[set]/
|
||||
* **Set Metadata** `./src/server/public/cards/sets/
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Development Status (Central)
|
||||
|
||||
## Active Tasks
|
||||
- [x] Enable Clear Session Button (2025-12-20)
|
||||
|
||||
## Devlog Index
|
||||
- [Enable Clear Session](./devlog/2025-12-20-014500_enable_clear_session.md) - Improved UI/UX for session clearing in CubeManager.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Enable Clear Session Button in Pack Generator
|
||||
|
||||
## Object
|
||||
Enable and improve the "Clear Session" button in the Cube Manager (Pack Generator) to allow users to restart the generation process from a clean state.
|
||||
|
||||
## Changes
|
||||
- Modified `CubeManager.tsx`:
|
||||
- Updated `handleReset` logic (verified).
|
||||
- enhanced "Clear Session" button styling to be more visible (red border/text) and indicate its destructive nature.
|
||||
- Added `disabled={loading}` to prevent state conflicts during active operations.
|
||||
- **Replaced `window.confirm` with a double-click UI confirmation pattern** to ensure reliability and better UX (fixed issue where native confirmation dialog was failing).
|
||||
|
||||
## Status
|
||||
- [x] Implementation complete.
|
||||
- [x] Verified logic for `localStorage` clearing.
|
||||
- [x] Verified interaction in browser (button changes state, clears data on second click).
|
||||
15
docs/mtg-rulebook/peasant-pack-generation-algorithm.md
Normal file
15
docs/mtg-rulebook/peasant-pack-generation-algorithm.md
Normal file
@@ -0,0 +1,15 @@
|
||||
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: ~62% Common, ~37% Uncommon.
|
||||
- Can be a card from the child sets.
|
||||
Slot 14 (Foil Wildcard):
|
||||
- Same rarity weights as Slot 13, but the card must be Foil.
|
||||
Slot 15 (Marketing): Token or Art Card.
|
||||
20
docs/mtg-rulebook/standard-pack-generation-algorithm.md
Normal file
20
docs/mtg-rulebook/standard-pack-generation-algorithm.md
Normal file
@@ -0,0 +1,20 @@
|
||||
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.
|
||||
- Can be a card from the child sets.
|
||||
Slot 14 (Foil Wildcard):
|
||||
- Same rarity weights as Slot 13, but the card must be Foil.
|
||||
Slot 15 (Marketing): Token or Art Card.
|
||||
@@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.rc445urejpk"
|
||||
"revision": "0.6var2k6f1uc"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Layers, Box, Trophy, Users, Play } from 'lucide-react';
|
||||
import { CubeManager } from './modules/cube/CubeManager';
|
||||
import { TournamentManager } from './modules/tournament/TournamentManager';
|
||||
import { LobbyManager } from './modules/lobby/LobbyManager';
|
||||
import { DeckTester } from './modules/tester/DeckTester';
|
||||
import { Pack } from './services/PackGeneratorService';
|
||||
@@ -130,7 +129,13 @@ export const App: React.FC = () => {
|
||||
)}
|
||||
{activeTab === 'lobby' && <LobbyManager generatedPacks={generatedPacks} availableLands={availableLands} />}
|
||||
{activeTab === 'tester' && <DeckTester />}
|
||||
{activeTab === 'bracket' && <TournamentManager />}
|
||||
{activeTab === 'bracket' && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-slate-400">
|
||||
<Trophy className="w-16 h-16 mb-4 opacity-50" />
|
||||
<h2 className="text-xl font-bold">Tournament Manager</h2>
|
||||
<p>Tournaments are now managed within the Online Lobby.</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="bg-slate-900 border-t border-slate-800 p-2 text-center text-xs text-slate-500 shrink-0">
|
||||
|
||||
168
src/client/src/components/CardVisual.tsx
Normal file
168
src/client/src/components/CardVisual.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
|
||||
// Union type to support both Game cards and Draft cards
|
||||
// Union type to support both Game cards and Draft cards
|
||||
export type VisualCard = {
|
||||
// Common properties that might be needed
|
||||
id?: string;
|
||||
instanceId?: string;
|
||||
name?: string;
|
||||
imageUrl?: string;
|
||||
image?: string;
|
||||
image_uris?: {
|
||||
normal?: string;
|
||||
large?: string;
|
||||
png?: string;
|
||||
art_crop?: string;
|
||||
border_crop?: string;
|
||||
crop?: string;
|
||||
};
|
||||
definition?: any; // Scryfall definition
|
||||
card_faces?: any[];
|
||||
tapped?: boolean;
|
||||
faceDown?: boolean;
|
||||
counters?: any[];
|
||||
finish?: string;
|
||||
// Loose typing for properties that might vary between Game and Draft models
|
||||
power?: string | number;
|
||||
toughness?: string | number;
|
||||
manaCost?: string;
|
||||
mana_cost?: string;
|
||||
typeLine?: string;
|
||||
type_line?: string;
|
||||
oracleText?: string;
|
||||
oracle_text?: string;
|
||||
[key: string]: any; // Allow other properties loosely
|
||||
};
|
||||
|
||||
interface CardVisualProps {
|
||||
card: VisualCard;
|
||||
viewMode?: 'normal' | 'cutout' | 'large';
|
||||
isFoil?: boolean; // Explicit foil styling override
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
// Optional overlays
|
||||
showCounters?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CardVisual: React.FC<CardVisualProps> = ({
|
||||
card,
|
||||
viewMode = 'normal',
|
||||
isFoil = false,
|
||||
className,
|
||||
style,
|
||||
showCounters = true,
|
||||
children
|
||||
}) => {
|
||||
|
||||
const imageSrc = useMemo(() => {
|
||||
// Robustly resolve Image Source based on viewMode
|
||||
let src = card.imageUrl || card.image;
|
||||
|
||||
// Use top-level properties if available (common in DraftCard / Game Card objects)
|
||||
let setCode = card.setCode || card.set || card.definition?.set;
|
||||
let cardId = card.scryfallId || card.definition?.id;
|
||||
|
||||
// Fallback: Attempt to extract from Image URL if IDs are missing (Fix for legacy/active games)
|
||||
if ((!setCode || !cardId) && (card.imageUrl || card.image)) {
|
||||
const url = card.imageUrl || card.image;
|
||||
if (typeof url === 'string' && url.includes('/cards/images/')) {
|
||||
const parts = url.split('/cards/images/')[1].split('/');
|
||||
// Expected formats:
|
||||
// 1. [set]/full/[id].jpg
|
||||
// 2. [set]/crop/[id].jpg
|
||||
if (parts.length >= 2) {
|
||||
if (!setCode) setCode = parts[0];
|
||||
if (!cardId) {
|
||||
const filename = parts[parts.length - 1];
|
||||
cardId = filename.replace(/\.(jpg|png)(\?.*)?$/, ''); // strip extension and query
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (viewMode === 'cutout') {
|
||||
// Priority 1: Local Cache (standard naming convention) - PREFERRED BY USER
|
||||
if (setCode && cardId) {
|
||||
src = `/cards/images/${setCode}/crop/${cardId}.jpg`;
|
||||
}
|
||||
// Priority 2: Direct Image URIs (if available) - Fallback
|
||||
else if (card.image_uris?.art_crop || card.image_uris?.crop) {
|
||||
src = card.image_uris.art_crop || card.image_uris.crop!;
|
||||
}
|
||||
// Priority 3: Deep Definition Data
|
||||
else if (card.definition?.image_uris?.art_crop) {
|
||||
src = card.definition.image_uris.art_crop;
|
||||
}
|
||||
else if (card.definition?.card_faces?.[0]?.image_uris?.art_crop) {
|
||||
src = card.definition.card_faces[0].image_uris.art_crop;
|
||||
}
|
||||
// Priority 4: Server-provided explicit property
|
||||
else if (card.imageArtCrop) {
|
||||
src = card.imageArtCrop;
|
||||
}
|
||||
|
||||
// Fallback: If no crop found, src remains whatever it was (likely full)
|
||||
} else {
|
||||
// Normal / Full View
|
||||
|
||||
// Priority 1: Local Cache (standard naming convention) - PREFERRED
|
||||
if (setCode && cardId) {
|
||||
// Check if we want standard full image path
|
||||
src = `/cards/images/${setCode}/full/${cardId}.jpg`;
|
||||
}
|
||||
// Priority 2: Direct Image URIs
|
||||
else if (card.image_uris?.normal) {
|
||||
src = card.image_uris.normal;
|
||||
}
|
||||
else if (card.definition?.image_uris?.normal) {
|
||||
src = card.definition.image_uris.normal;
|
||||
}
|
||||
else if (card.card_faces?.[0]?.image_uris?.normal) {
|
||||
src = card.card_faces[0].image_uris.normal;
|
||||
}
|
||||
}
|
||||
return src;
|
||||
}, [card, viewMode]);
|
||||
|
||||
// Counters logic (only for Game cards usually)
|
||||
const totalCounters = useMemo(() => {
|
||||
if (!card.counters) return 0;
|
||||
return card.counters.map((c: any) => c.count).reduce((a: number, b: number) => a + b, 0);
|
||||
}, [card.counters]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden ${className || ''}`}
|
||||
style={style}
|
||||
>
|
||||
{!card.faceDown ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={card.name || 'Card'}
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-slate-900 bg-opacity-90 bg-[url('https://c1.scryfall.com/file/scryfall-card-backs/large/59/597b79b3-7d77-4261-871a-60dd17403388.jpg')] bg-cover">
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Foil Overlay */}
|
||||
{(isFoil || card.finish === 'foil') && !card.faceDown && (
|
||||
<div className="absolute inset-0 pointer-events-none mix-blend-overlay bg-gradient-to-tr from-purple-500/30 via-transparent to-emerald-500/30 opacity-50" />
|
||||
)}
|
||||
|
||||
{/* Counters */}
|
||||
{showCounters && totalCounters > 0 && (
|
||||
<div className="absolute top-1 right-1 bg-black/70 text-white text-xs px-1 rounded z-10 pointer-events-none">
|
||||
{totalCounters}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
87
src/client/src/components/GameLogPanel.tsx
Normal file
87
src/client/src/components/GameLogPanel.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useGameLog, GameLogEntry } from '../contexts/GameLogContext';
|
||||
import { ScrollText, User, Bot, Info, AlertTriangle, ShieldAlert } from 'lucide-react';
|
||||
|
||||
interface GameLogPanelProps {
|
||||
className?: string;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
export const GameLogPanel: React.FC<GameLogPanelProps> = ({ className, maxHeight = '200px' }) => {
|
||||
const { logs } = useGameLog();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom on new logs
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [logs]);
|
||||
|
||||
const getIcon = (type: GameLogEntry['type'], source: string) => {
|
||||
if (source === 'System') return <Info className="w-3 h-3 text-slate-500" />;
|
||||
if (type === 'error') return <AlertTriangle className="w-3 h-3 text-red-500" />;
|
||||
if (type === 'combat') return <ShieldAlert className="w-3 h-3 text-red-400" />;
|
||||
if (source.includes('Bot')) return <Bot className="w-3 h-3 text-indigo-400" />;
|
||||
return <User className="w-3 h-3 text-blue-400" />;
|
||||
};
|
||||
|
||||
const getTypeStyle = (type: GameLogEntry['type']) => {
|
||||
switch (type) {
|
||||
case 'error': return 'text-red-400 bg-red-900/10 border-red-900/30';
|
||||
case 'warning': return 'text-amber-400 bg-amber-900/10 border-amber-900/30';
|
||||
case 'success': return 'text-emerald-400 bg-emerald-900/10 border-emerald-900/30';
|
||||
case 'combat': return 'text-red-300 bg-red-900/20 border-red-900/40 font-bold';
|
||||
case 'action': return 'text-blue-300 bg-blue-900/10 border-blue-900/30';
|
||||
default: return 'text-slate-300 border-transparent';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col bg-slate-900 border-t border-slate-800 ${className} overflow-hidden`} style={{ maxHeight }}>
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-slate-950 border-b border-slate-800 shrink-0">
|
||||
<ScrollText className="w-3 h-3 text-slate-500" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500">Game Log</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1 custom-scrollbar text-xs font-mono">
|
||||
{logs.length === 0 && (
|
||||
<div className="text-slate-600 italic px-2 py-4 text-center">
|
||||
Game started. Actions will appear here.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`
|
||||
relative pl-2 pr-2 py-1.5 rounded border-l-2
|
||||
${getTypeStyle(log.type)}
|
||||
animate-in fade-in slide-in-from-left-2 duration-300
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="mt-0.5 shrink-0 opacity-70">
|
||||
{getIcon(log.type, log.source)}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
{/* Source Header */}
|
||||
{log.source !== 'System' && (
|
||||
<span className="text-[10px] font-bold opacity-70 mb-0.5 leading-none">
|
||||
{log.source}
|
||||
</span>
|
||||
)}
|
||||
{/* Message Body */}
|
||||
<span className="leading-tight break-words">
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
<span className="ml-auto text-[9px] text-slate-600 whitespace-nowrap mt-0.5">
|
||||
{new Date(log.timestamp).toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
93
src/client/src/components/GameToast.tsx
Normal file
93
src/client/src/components/GameToast.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { AlertCircle, CheckCircle, Info, XCircle } from 'lucide-react';
|
||||
|
||||
type GameToastType = 'success' | 'error' | 'info' | 'warning' | 'game-event';
|
||||
|
||||
interface GameToast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: GameToastType;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface GameToastContextType {
|
||||
showGameToast: (message: string, type?: GameToastType, duration?: number) => void;
|
||||
}
|
||||
|
||||
const GameToastContext = createContext<GameToastContextType | undefined>(undefined);
|
||||
|
||||
export const useGameToast = () => {
|
||||
const context = useContext(GameToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useGameToast must be used within a GameToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const GameToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<GameToast[]>([]);
|
||||
|
||||
// Use a ref to keep track of timeouts so we can clear them (optional, but good practice)
|
||||
// For simplicity here, we just use the timeout inside the callback.
|
||||
|
||||
const showGameToast = useCallback((message: string, type: GameToastType = 'info', duration = 3000) => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
setToasts((prev) => [...prev, { id, message, type, duration }]);
|
||||
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, duration);
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<GameToastContext.Provider value={{ showGameToast }}>
|
||||
{children}
|
||||
{/*
|
||||
Positioning:
|
||||
We want this to be distinct from the system toast (top-center).
|
||||
Let's put it top-center but slightly lower, OR bottom-center?
|
||||
User request: "dedicated toast".
|
||||
Let's try "Center Top" but with a very distinct style, possibly overlaying the game board directly.
|
||||
Actually, let's put it at the TOP CENTER, but occupying a dedicated space or just below the system header.
|
||||
|
||||
Using z-[1000] to ensure it's above everything in the game.
|
||||
*/}
|
||||
<div className="fixed top-20 left-1/2 -translate-x-1/2 z-[1000] flex flex-col gap-2 pointer-events-none w-full max-w-md px-4 items-center">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`
|
||||
pointer-events-auto
|
||||
flex items-center gap-3 px-6 py-3 rounded-full shadow-[0_0_15px_rgba(0,0,0,0.5)]
|
||||
animate-in slide-in-from-top-4 fade-in zoom-in-95 duration-200
|
||||
border backdrop-blur-md
|
||||
${toastStyles[toast.type]}
|
||||
`}
|
||||
>
|
||||
{getIcon(toast.type)}
|
||||
<span className="font-bold text-sm tracking-wide text-shadow-sm">{toast.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GameToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const toastStyles: Record<GameToastType, string> = {
|
||||
success: 'bg-emerald-900/90 border-emerald-500/50 text-emerald-100',
|
||||
error: 'bg-red-900/90 border-red-500/50 text-red-100',
|
||||
warning: 'bg-amber-900/90 border-amber-500/50 text-amber-100',
|
||||
info: 'bg-slate-900/90 border-slate-500/50 text-slate-100',
|
||||
'game-event': 'bg-indigo-900/90 border-indigo-500/50 text-indigo-100',
|
||||
};
|
||||
|
||||
const getIcon = (type: GameToastType) => {
|
||||
switch (type) {
|
||||
case 'success': return <CheckCircle className="w-5 h-5 text-emerald-400" />;
|
||||
case 'error': return <XCircle className="w-5 h-5 text-red-400" />;
|
||||
case 'warning': return <AlertCircle className="w-5 h-5 text-amber-400" />;
|
||||
case 'info': return <Info className="w-5 h-5 text-blue-400" />;
|
||||
case 'game-event': return <Info className="w-5 h-5 text-indigo-400" />;
|
||||
}
|
||||
};
|
||||
54
src/client/src/components/ManaIcon.tsx
Normal file
54
src/client/src/components/ManaIcon.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
|
||||
type ManaSymbol =
|
||||
| 'w' // White
|
||||
| 'u' // Blue
|
||||
| 'b' // Black
|
||||
| 'r' // Red
|
||||
| 'g' // Green
|
||||
| 'c' // Colorless
|
||||
| 'x' | 'y' | 'z' // Variables
|
||||
| 't' | 'tap' // Tap
|
||||
| 'q' | 'untap' // Untap
|
||||
| 'e' | 'energy' // Energy
|
||||
| 'p' // Phyrexian generic? (check font)
|
||||
| 'vp' // Velcro/Planechase?
|
||||
| 's' // Snow
|
||||
| '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' // Numbers
|
||||
| '10' | '11' | '12' | '13' | '14' | '15' | '16' | '17' | '18' | '19' | '20' // Higher numbers usually specialized, check support
|
||||
| 'infinity'
|
||||
| string; // Allow others
|
||||
|
||||
interface ManaIconProps {
|
||||
symbol: ManaSymbol;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2x' | '3x' | '4x' | '5x'; // 'ms-2x' etc from the font or custom sizing
|
||||
className?: string;
|
||||
shadow?: boolean; // 'ms-cost' adds a shadow usually
|
||||
fixedWidth?: boolean; // 'ms-fw'
|
||||
}
|
||||
|
||||
export const ManaIcon: React.FC<ManaIconProps> = ({
|
||||
symbol,
|
||||
size,
|
||||
className = '',
|
||||
shadow = false,
|
||||
fixedWidth = false,
|
||||
}) => {
|
||||
// Normalize symbol to lowercase
|
||||
const sym = symbol.toLowerCase();
|
||||
|
||||
// Construct class names
|
||||
// ms is the base class
|
||||
const classes = [
|
||||
'ms',
|
||||
`ms-${sym}`,
|
||||
size ? `ms-${size}` : '',
|
||||
shadow ? 'ms-cost' : '', // 'ms-cost' is often used formana costs to give them a circle/shadow look.
|
||||
fixedWidth ? 'ms-fw' : '',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return <i className={classes} title={`Mana symbol: ${symbol}`} aria-hidden="true" />;
|
||||
};
|
||||
161
src/client/src/components/SidePanelPreview.tsx
Normal file
161
src/client/src/components/SidePanelPreview.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { CardVisual, VisualCard } from './CardVisual';
|
||||
import { Eye, ChevronLeft } from 'lucide-react';
|
||||
import { ManaIcon } from './ManaIcon';
|
||||
import { formatOracleText } from '../utils/textUtils';
|
||||
import { GameLogPanel } from './GameLogPanel';
|
||||
|
||||
interface SidePanelPreviewProps {
|
||||
card: VisualCard | null;
|
||||
width: number;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: (collapsed: boolean) => void;
|
||||
onResizeStart?: (e: React.MouseEvent | React.TouchEvent) => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
showLog?: boolean;
|
||||
}
|
||||
|
||||
export const SidePanelPreview = forwardRef<HTMLDivElement, SidePanelPreviewProps>(({
|
||||
card,
|
||||
width,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onResizeStart,
|
||||
className,
|
||||
children,
|
||||
showLog = true,
|
||||
}, ref) => {
|
||||
// If collapsed, render the collapsed strip
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div ref={ref} className={`flex shrink-0 w-12 flex-col items-center py-4 bg-slate-900 border-r border-slate-800 z-30 gap-4 transition-all duration-300 ${className || ''}`}>
|
||||
<button
|
||||
onClick={() => onToggleCollapse(false)}
|
||||
className="p-3 rounded-xl transition-all duration-200 group relative text-slate-500 hover:text-purple-400 hover:bg-slate-800"
|
||||
title="Expand Preview"
|
||||
>
|
||||
<Eye className="w-6 h-6" />
|
||||
<span className="absolute left-full ml-3 top-1/2 -translate-y-1/2 bg-slate-800 text-white text-xs font-bold px-2 py-1 rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none ring-1 ring-white/10 z-50">
|
||||
Card Preview
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded View
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`flex shrink-0 flex-col items-center justify-start pt-4 border-r border-slate-800 bg-slate-900 z-30 p-4 relative group/sidebar shadow-2xl ${className || ''}`}
|
||||
style={{ width: width }}
|
||||
>
|
||||
{/* Collapse Button */}
|
||||
<button
|
||||
onClick={() => onToggleCollapse(true)}
|
||||
className="absolute top-2 right-2 p-1.5 bg-slate-800/80 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors z-20 opacity-0 group-hover/sidebar:opacity-100"
|
||||
title="Collapse Preview"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* 3D Card Container */}
|
||||
<div className="w-full relative sticky top-4 flex flex-col h-full overflow-hidden">
|
||||
<div className="relative w-full aspect-[2.5/3.5] transition-all duration-300 ease-in-out shrink-0">
|
||||
<div
|
||||
className="relative w-full h-full"
|
||||
style={{
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: card ? 'rotateY(0deg)' : 'rotateY(180deg)',
|
||||
transition: 'transform 0.6s cubic-bezier(0.4, 0.0, 0.2, 1)'
|
||||
}}
|
||||
>
|
||||
{/* Front Face */}
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
|
||||
style={{ backfaceVisibility: 'hidden' }}
|
||||
>
|
||||
{card && (
|
||||
<CardVisual
|
||||
card={card}
|
||||
viewMode="normal"
|
||||
className="w-full h-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
|
||||
// Pass specific foil prop if your card object uses different property keys or logic
|
||||
// VisualCard handles `card.finish` internally too
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Back Face */}
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full rounded-xl shadow-2xl overflow-hidden bg-slate-900"
|
||||
style={{
|
||||
backfaceVisibility: 'hidden',
|
||||
transform: 'rotateY(180deg)'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/images/back.jpg"
|
||||
alt="Card Back"
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details Section */}
|
||||
{card && (
|
||||
<div className="mt-4 flex-1 overflow-y-auto px-1 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
|
||||
<h3 className="text-lg font-bold text-slate-200 leading-tight">{card.name}</h3>
|
||||
|
||||
{/* Mana Cost */}
|
||||
{(card['manaCost'] || (card as any).mana_cost) && (
|
||||
<div className="mt-1 flex items-center text-slate-400">
|
||||
{((card['manaCost'] || (card as any).mana_cost) as string).match(/\{([^}]+)\}/g)?.map((s, i) => {
|
||||
const sym = s.replace(/[{}]/g, '').toLowerCase().replace('/', '');
|
||||
return <ManaIcon key={i} symbol={sym} shadow className="text-base mr-0.5" />;
|
||||
}) || <span className="font-mono">{card['manaCost'] || (card as any).mana_cost}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type Line */}
|
||||
{(card['typeLine'] || (card as any).type_line) && (
|
||||
<div className="text-xs text-emerald-400 uppercase tracking-wider font-bold mt-2 border-b border-white/10 pb-2 mb-3">
|
||||
{card['typeLine'] || (card as any).type_line}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Oracle Text */}
|
||||
{(card['oracleText'] || (card as any).oracle_text) && (
|
||||
<div className="text-sm text-slate-300 text-left bg-slate-900/50 p-3 rounded-lg border border-slate-800 leading-relaxed shadow-inner">
|
||||
{formatOracleText(card['oracleText'] || (card as any).oracle_text)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Resize Handle */}
|
||||
{onResizeStart && (
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 bg-transparent hover:bg-emerald-500/50 cursor-col-resize z-50 flex flex-col justify-center items-center group transition-colors touch-none"
|
||||
onMouseDown={onResizeStart}
|
||||
onTouchStart={onResizeStart}
|
||||
>
|
||||
<div className="h-8 w-1 bg-slate-700/50 rounded-full group-hover:bg-emerald-400 transition-colors" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Game Action Log - Fixed at bottom */}
|
||||
{showLog && (
|
||||
<GameLogPanel className="w-full shrink-0 border-t border-slate-800" maxHeight="30%" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SidePanelPreview.displayName = 'SidePanelPreview';
|
||||
50
src/client/src/contexts/GameLogContext.tsx
Normal file
50
src/client/src/contexts/GameLogContext.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
|
||||
export interface GameLogEntry {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
source: 'System' | 'Player' | 'Opponent' | string;
|
||||
type: 'info' | 'action' | 'combat' | 'error' | 'success' | 'warning';
|
||||
}
|
||||
|
||||
interface GameLogContextType {
|
||||
logs: GameLogEntry[];
|
||||
addLog: (message: string, type?: GameLogEntry['type'], source?: string) => void;
|
||||
clearLogs: () => void;
|
||||
}
|
||||
|
||||
const GameLogContext = createContext<GameLogContextType | undefined>(undefined);
|
||||
|
||||
export const GameLogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [logs, setLogs] = useState<GameLogEntry[]>([]);
|
||||
|
||||
const addLog = useCallback((message: string, type: GameLogEntry['type'] = 'info', source: string = 'System') => {
|
||||
const newLog: GameLogEntry = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
timestamp: Date.now(),
|
||||
message,
|
||||
source,
|
||||
type
|
||||
};
|
||||
setLogs(prev => [...prev, newLog]);
|
||||
}, []);
|
||||
|
||||
const clearLogs = useCallback(() => {
|
||||
setLogs([]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<GameLogContext.Provider value={{ logs, addLog, clearLogs }}>
|
||||
{children}
|
||||
</GameLogContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useGameLog = () => {
|
||||
const context = useContext(GameLogContext);
|
||||
if (!context) {
|
||||
throw new Error('useGameLog must be used within a GameLogProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './styles/main.css';
|
||||
import 'mana-font/css/mana.min.css';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
// Register Service Worker
|
||||
|
||||
@@ -144,7 +144,12 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
|
||||
useEffect(() => {
|
||||
if (rawScryfallData) {
|
||||
// Use local images: true
|
||||
const result = generatorService.processCards(rawScryfallData, filters, true);
|
||||
const setsMetadata = availableSets.reduce((acc, set) => {
|
||||
acc[set.code] = { parent_set_code: set.parent_set_code };
|
||||
return acc;
|
||||
}, {} as { [code: string]: { parent_set_code?: string } });
|
||||
|
||||
const result = generatorService.processCards(rawScryfallData, filters, true, setsMetadata);
|
||||
setProcessedData(result);
|
||||
}
|
||||
}, [filters, rawScryfallData]);
|
||||
@@ -217,12 +222,70 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
|
||||
|
||||
if (sourceMode === 'set') {
|
||||
// Fetch set by set
|
||||
for (const [index, setCode] of selectedSets.entries()) {
|
||||
setProgress(`Fetching set ${setCode.toUpperCase()} (${index + 1}/${selectedSets.length})...`);
|
||||
const response = await fetch(`/api/sets/${setCode}/cards`);
|
||||
if (!response.ok) throw new Error(`Failed to fetch set ${setCode}`);
|
||||
// Fetch sets (Grouping Main + Subsets)
|
||||
// We iterate through selectedSets. If a set has children also in selectedSets (or auto-detected), we fetch them together.
|
||||
// We need to avoid fetching the child set again if it was covered by the parent.
|
||||
|
||||
const processedSets = new Set<string>();
|
||||
|
||||
// We already have `effectiveSelectedSets` which includes auto-added ones.
|
||||
// Let's re-derive effective logic locally for fetching.
|
||||
const allSetsToProcess = [...selectedSets];
|
||||
const linkedSubsets = availableSets.filter(s =>
|
||||
s.parent_set_code &&
|
||||
selectedSets.includes(s.parent_set_code) &&
|
||||
s.code.length === 3 && // 3-letter code filter
|
||||
!selectedSets.includes(s.code)
|
||||
).map(s => s.code);
|
||||
allSetsToProcess.push(...linkedSubsets);
|
||||
|
||||
let totalCards = 0;
|
||||
let setIndex = 0;
|
||||
|
||||
for (const setCode of allSetsToProcess) {
|
||||
if (processedSets.has(setCode)) continue;
|
||||
|
||||
// Check if this is a Main Set that has children in our list
|
||||
// OR if it's a child that should be fetched with its parent?
|
||||
// Actually, we should look for Main Sets first.
|
||||
|
||||
let currentMain = setCode;
|
||||
let currentRelated: string[] = [];
|
||||
|
||||
// Find children of this set in our list
|
||||
const children = allSetsToProcess.filter(s => {
|
||||
const meta = availableSets.find(as => as.code === s);
|
||||
return meta && meta.parent_set_code === currentMain;
|
||||
});
|
||||
|
||||
// Also check if this set IS a child, and its parent is NOT in the list?
|
||||
// If parent IS in the list, we skip this iteration and let the parent handle it?
|
||||
const meta = availableSets.find(as => as.code === currentMain);
|
||||
if (meta && meta.parent_set_code && allSetsToProcess.includes(meta.parent_set_code)) {
|
||||
// This is a child, and we are processing the parent elsewhere. Skip.
|
||||
// But wait, the loop order is undefined.
|
||||
// Safest: always fetch by Main Set if possible.
|
||||
// If we encounter a Child whose parent is in the list, we skip.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (children.length > 0) {
|
||||
currentRelated = children;
|
||||
currentRelated.forEach(c => processedSets.add(c));
|
||||
}
|
||||
|
||||
processedSets.add(currentMain);
|
||||
setIndex++;
|
||||
|
||||
setProgress(`Fetching set ${currentMain.toUpperCase()} ${currentRelated.length > 0 ? `(+ ${currentRelated.join(', ').toUpperCase()})` : ''}...`);
|
||||
|
||||
const queryParams = currentRelated.length > 0 ? `?related=${currentRelated.join(',')}` : '';
|
||||
const response = await fetch(`/api/sets/${currentMain}/cards${queryParams}`);
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to fetch set ${currentMain}`);
|
||||
const cards: ScryfallCard[] = await response.json();
|
||||
currentCards.push(...cards);
|
||||
setRawScryfallData(prev => [...(prev || []), ...cards]);
|
||||
totalCards += cards.length;
|
||||
}
|
||||
|
||||
} else {
|
||||
@@ -249,10 +312,20 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
|
||||
// --- Step 2: Generate ---
|
||||
setProgress('Generating packs on server...');
|
||||
|
||||
// Re-calculation of effective sets for Payload is safe to match.
|
||||
const payloadSetCodes = [...selectedSets];
|
||||
const linkedPayload = availableSets.filter(s =>
|
||||
s.parent_set_code &&
|
||||
selectedSets.includes(s.parent_set_code) &&
|
||||
s.code.length === 3 && // 3-letter code filter
|
||||
!selectedSets.includes(s.code)
|
||||
).map(s => s.code);
|
||||
payloadSetCodes.push(...linkedPayload);
|
||||
|
||||
const payload = {
|
||||
cards: sourceMode === 'upload' ? currentCards : [], // For set mode, we let server refetch or handle it
|
||||
sourceMode,
|
||||
selectedSets,
|
||||
selectedSets: payloadSetCodes,
|
||||
settings: {
|
||||
...genSettings,
|
||||
withReplacement: sourceMode === 'set'
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { socketService } from '../../services/SocketService';
|
||||
import { Save, Layers, Clock, Columns, LayoutTemplate, List, LayoutGrid, ChevronDown, Check, ChevronLeft, Eye } from 'lucide-react';
|
||||
import { Save, Layers, Clock, Columns, LayoutTemplate, List, LayoutGrid, ChevronDown, Check } from 'lucide-react';
|
||||
import { StackView } from '../../components/StackView';
|
||||
import { FoilOverlay } from '../../components/CardPreview';
|
||||
import { SidePanelPreview } from '../../components/SidePanelPreview';
|
||||
import { DraftCard } from '../../services/PackGeneratorService';
|
||||
import { useCardTouch } from '../../utils/interaction';
|
||||
import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { AutoDeckBuilder } from '../../utils/AutoDeckBuilder';
|
||||
import { Wand2 } from 'lucide-react'; // Import Wand icon
|
||||
import { useToast } from '../../components/Toast';
|
||||
import { useConfirm } from '../../components/ConfirmDialog';
|
||||
import { CardComponent } from '../game/CardComponent';
|
||||
|
||||
|
||||
interface DeckBuilderViewProps {
|
||||
roomId: string;
|
||||
currentPlayerId: string;
|
||||
initialPool: any[];
|
||||
initialDeck?: any[];
|
||||
availableBasicLands?: any[];
|
||||
onSubmit?: (deck: any[]) => void;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
const ManaCurve = ({ deck }: { deck: any[] }) => {
|
||||
@@ -176,6 +179,40 @@ const ListItem: React.FC<{ card: DraftCard; onClick?: () => void; onHover?: (c:
|
||||
);
|
||||
};
|
||||
|
||||
const DeckCardItem = ({ card, useArtCrop, isFoil, onCardClick, onHover }: any) => {
|
||||
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(onHover, () => {
|
||||
if (window.matchMedia('(pointer: coarse)').matches) {
|
||||
onHover(card);
|
||||
} else {
|
||||
onCardClick(card);
|
||||
}
|
||||
}, card);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => onHover(card)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTouchMove={onTouchMove}
|
||||
className="relative group bg-slate-900 rounded-lg shrink-0 cursor-pointer hover:scale-105 transition-transform"
|
||||
>
|
||||
<div className={`relative ${useArtCrop ? 'aspect-square' : 'aspect-[2.5/3.5]'} overflow-hidden rounded-lg shadow-xl border transition-all duration-200 group-hover:ring-2 group-hover:ring-purple-400 group-hover:shadow-purple-500/30 ${isFoil ? 'border-purple-400 shadow-purple-500/20' : 'border-slate-800'}`}>
|
||||
{isFoil && <FoilOverlay />}
|
||||
{isFoil && <div className="absolute top-1 right-1 z-30 text-[10px] font-bold text-white bg-purple-600/80 px-1.5 rounded backdrop-blur-sm">FOIL</div>}
|
||||
{displayImage ? (
|
||||
<img src={displayImage} alt={card.name} className="w-full h-full object-cover" draggable={false} />
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
// Extracted Component to avoid re-mounting issues
|
||||
const CardsDisplay: React.FC<{
|
||||
cards: any[];
|
||||
@@ -273,13 +310,13 @@ const CardsDisplay: React.FC<{
|
||||
)
|
||||
};
|
||||
|
||||
export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, availableBasicLands = [] }) => {
|
||||
export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, initialDeck = [], availableBasicLands = [], onSubmit, submitLabel }) => {
|
||||
// Unlimited Timer (Static for now)
|
||||
const [timer] = useState<string>("Unlimited");
|
||||
/* --- Hooks --- */
|
||||
const { showToast } = useToast();
|
||||
// const { showToast } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
const [deckName, setDeckName] = useState('New Deck');
|
||||
// const [deckName, setDeckName] = useState('New Deck');
|
||||
const [layout, setLayout] = useState<'vertical' | 'horizontal'>(() => {
|
||||
const saved = typeof window !== 'undefined' ? localStorage.getItem('deck_layout') : null;
|
||||
return (saved as 'vertical' | 'horizontal') || 'vertical';
|
||||
@@ -359,8 +396,17 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
useEffect(() => localStorage.setItem('deck_groupBy', groupBy), [groupBy]);
|
||||
useEffect(() => localStorage.setItem('deck_cardWidth', cardWidth.toString()), [cardWidth]);
|
||||
|
||||
const [pool, setPool] = useState<any[]>(initialPool);
|
||||
const [deck, setDeck] = useState<any[]>([]);
|
||||
const [deck, setDeck] = useState<any[]>(initialDeck);
|
||||
const [pool, setPool] = useState<any[]>(() => {
|
||||
if (initialDeck && initialDeck.length > 0) {
|
||||
// Need to be careful about IDs.
|
||||
// If initialDeck cards are from the pool, they share IDs?
|
||||
// Usually yes.
|
||||
const deckIds = new Set(initialDeck.map(c => c.id));
|
||||
return initialPool.filter(c => !deckIds.has(c.id));
|
||||
}
|
||||
return initialPool;
|
||||
});
|
||||
// const [lands, setLands] = useState(...); // REMOVED: Managed directly in deck now
|
||||
const [hoveredCard, setHoveredCard] = useState<any>(null);
|
||||
const [displayCard, setDisplayCard] = useState<any>(null);
|
||||
@@ -436,7 +482,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
landCard = {
|
||||
id: `basic-source-${type}`,
|
||||
name: type,
|
||||
image_uris: { normal: LAND_URL_MAP[type] },
|
||||
image_uris: { normal: LAND_URL_MAP[type], art_crop: LAND_URL_MAP[type] },
|
||||
typeLine: "Basic Land",
|
||||
scryfallId: `generic-${type}`
|
||||
};
|
||||
@@ -498,7 +544,13 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
return cardWithDefinition;
|
||||
});
|
||||
|
||||
socketService.socket.emit('player_ready', { deck: preparedDeck });
|
||||
|
||||
|
||||
if (onSubmit) {
|
||||
onSubmit(preparedDeck);
|
||||
} else {
|
||||
socketService.socket.emit('player_ready', { deck: preparedDeck });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoBuild = async () => {
|
||||
@@ -669,6 +721,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
name: type,
|
||||
isLandSource: true,
|
||||
image: LAND_URL_MAP[type],
|
||||
imageArtCrop: LAND_URL_MAP[type], // Explicitly add fallback crop
|
||||
typeLine: `Basic Land — ${type}`,
|
||||
rarity: 'common',
|
||||
cmc: 0,
|
||||
@@ -876,113 +929,26 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
onClick={submitDeck}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white px-4 py-2 rounded-lg font-bold shadow-lg flex items-center gap-2 transition-transform hover:scale-105 text-sm"
|
||||
>
|
||||
<Save className="w-4 h-4" /> <span className="hidden sm:inline">Submit Deck</span><span className="sm:hidden">Save</span>
|
||||
<Save className="w-4 h-4" /> <span className="hidden sm:inline">{submitLabel || 'Submit Deck'}</span><span className="sm:hidden">Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden lg:flex-row flex-col">
|
||||
{/* Zoom Sidebar */}
|
||||
{/* Collapsed State: Toolbar Column */}
|
||||
{/* Collapsed State: Toolbar Column */}
|
||||
{isSidebarCollapsed ? (
|
||||
<div key="collapsed" className="hidden xl:flex shrink-0 w-12 flex-col items-center py-4 bg-slate-900 border-r border-slate-800 z-10 gap-4 transition-all duration-300">
|
||||
<button
|
||||
onClick={() => setIsSidebarCollapsed(false)}
|
||||
className="p-3 rounded-xl transition-all duration-200 group relative text-slate-500 hover:text-purple-400 hover:bg-slate-800"
|
||||
title="Expand Preview"
|
||||
>
|
||||
<Eye className="w-6 h-6" />
|
||||
<span className="absolute left-full ml-3 top-1/2 -translate-y-1/2 bg-slate-800 text-white text-xs font-bold px-2 py-1 rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none ring-1 ring-white/10 z-50">
|
||||
Card Preview
|
||||
</span>
|
||||
</button>
|
||||
<SidePanelPreview
|
||||
card={hoveredCard || displayCard}
|
||||
width={sidebarWidth}
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
onToggleCollapse={setIsSidebarCollapsed}
|
||||
onResizeStart={(e) => handleResizeStart('sidebar', e)}
|
||||
>
|
||||
{/* Mana Curve at Bottom */}
|
||||
<div className="mt-auto w-full pt-4 border-t border-slate-800">
|
||||
<div className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 text-center">Mana Curve</div>
|
||||
<ManaCurve deck={deck} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key="expanded"
|
||||
ref={sidebarRef}
|
||||
className="hidden xl:flex shrink-0 flex-col items-center justify-start pt-4 border-r border-slate-800 bg-slate-900 z-10 p-4 relative group/sidebar"
|
||||
style={{ perspective: '1000px', width: sidebarWidth }}
|
||||
>
|
||||
{/* Collapse Button */}
|
||||
<button
|
||||
onClick={() => setIsSidebarCollapsed(true)}
|
||||
className="absolute top-2 right-2 p-1.5 bg-slate-800/80 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors z-20 opacity-0 group-hover/sidebar:opacity-100"
|
||||
title="Collapse Preview"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Front content ... */}
|
||||
<div className="w-full relative sticky top-4">
|
||||
<div
|
||||
className="relative w-full aspect-[2.5/3.5] transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: hoveredCard ? 'rotateY(0deg)' : 'rotateY(180deg)'
|
||||
}}
|
||||
>
|
||||
{/* Front Face (Hovered Card) */}
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
|
||||
style={{ backfaceVisibility: 'hidden' }}
|
||||
>
|
||||
{(hoveredCard || displayCard) && (
|
||||
<div className="w-full h-full flex flex-col bg-slate-900 rounded-xl">
|
||||
<img
|
||||
src={(hoveredCard || displayCard).image || (hoveredCard || displayCard).image_uris?.normal || (hoveredCard || displayCard).card_faces?.[0]?.image_uris?.normal}
|
||||
alt={(hoveredCard || displayCard).name}
|
||||
className="w-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="mt-4 text-center">
|
||||
<h3 className="text-lg font-bold text-slate-200">{(hoveredCard || displayCard).name}</h3>
|
||||
<p className="text-xs text-slate-400 uppercase tracking-wider mt-1">{(hoveredCard || displayCard).typeLine || (hoveredCard || displayCard).type_line}</p>
|
||||
{(hoveredCard || displayCard).oracle_text && (
|
||||
<div className="mt-4 text-xs text-slate-400 text-left bg-slate-950 p-3 rounded-lg border border-slate-800 leading-relaxed max-h-60 overflow-y-auto custom-scrollbar">
|
||||
{(hoveredCard || displayCard).oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-1">{line}</p>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Back Face (Card Back) */}
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full rounded-xl shadow-2xl overflow-hidden bg-slate-900"
|
||||
style={{
|
||||
backfaceVisibility: 'hidden',
|
||||
transform: 'rotateY(180deg)'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/images/back.jpg"
|
||||
alt="Card Back"
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mana Curve at Bottom */}
|
||||
<div className="mt-auto w-full pt-4 border-t border-slate-800">
|
||||
<div className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 text-center">Mana Curve</div>
|
||||
<ManaCurve deck={deck} />
|
||||
</div>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 bg-transparent hover:bg-purple-500/50 cursor-col-resize z-50 flex flex-col justify-center items-center group transition-colors touch-none"
|
||||
onMouseDown={(e) => handleResizeStart('sidebar', e)}
|
||||
onTouchStart={(e) => handleResizeStart('sidebar', e)}
|
||||
>
|
||||
<div className="h-8 w-1 bg-slate-700/50 rounded-full group-hover:bg-purple-400 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SidePanelPreview>
|
||||
|
||||
{/* Content Area */}
|
||||
{layout === 'vertical' ? (
|
||||
@@ -1091,36 +1057,4 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
);
|
||||
};
|
||||
|
||||
const DeckCardItem = ({ card, useArtCrop, isFoil, onCardClick, onHover }: any) => {
|
||||
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(onHover, () => {
|
||||
if (window.matchMedia('(pointer: coarse)').matches) {
|
||||
onHover(card);
|
||||
} else {
|
||||
onCardClick(card);
|
||||
}
|
||||
}, card);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => onHover(card)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTouchMove={onTouchMove}
|
||||
className="relative group bg-slate-900 rounded-lg shrink-0 cursor-pointer hover:scale-105 transition-transform"
|
||||
>
|
||||
<div className={`relative ${useArtCrop ? 'aspect-square' : 'aspect-[2.5/3.5]'} overflow-hidden rounded-lg shadow-xl border transition-all duration-200 group-hover:ring-2 group-hover:ring-purple-400 group-hover:shadow-purple-500/30 ${isFoil ? 'border-purple-400 shadow-purple-500/20' : 'border-slate-800'}`}>
|
||||
{isFoil && <FoilOverlay />}
|
||||
{isFoil && <div className="absolute top-1 right-1 z-30 text-[10px] font-bold text-white bg-purple-600/80 px-1.5 rounded backdrop-blur-sm">FOIL</div>}
|
||||
{displayImage ? (
|
||||
<img src={displayImage} alt={card.name} className="w-full h-full object-cover" draggable={false} />
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { socketService } from '../../services/SocketService';
|
||||
import { LogOut, Columns, LayoutTemplate, ChevronLeft, Eye } from 'lucide-react';
|
||||
import { LogOut, Columns, LayoutTemplate } from 'lucide-react';
|
||||
import { Modal } from '../../components/Modal';
|
||||
import { FoilOverlay, FloatingPreview } from '../../components/CardPreview';
|
||||
import { SidePanelPreview } from '../../components/SidePanelPreview';
|
||||
import { useCardTouch } from '../../utils/interaction';
|
||||
import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
@@ -373,96 +374,15 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
||||
|
||||
{/* Dedicated Zoom Zone (Left Sidebar) */}
|
||||
{/* Collapsed State: Toolbar Column */}
|
||||
{isSidebarCollapsed ? (
|
||||
<div key="collapsed" className="hidden lg:flex shrink-0 w-12 flex-col items-center py-4 bg-slate-900 border-r border-slate-800/50 backdrop-blur-sm z-10 gap-4 transition-all duration-300">
|
||||
<button
|
||||
onClick={() => setIsSidebarCollapsed(false)}
|
||||
className="p-3 rounded-xl transition-all duration-200 group relative text-slate-500 hover:text-purple-400 hover:bg-slate-800"
|
||||
title="Expand Preview"
|
||||
>
|
||||
<Eye className="w-6 h-6" />
|
||||
<span className="absolute left-full ml-3 top-1/2 -translate-y-1/2 bg-slate-800 text-white text-xs font-bold px-2 py-1 rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none ring-1 ring-white/10 z-50">
|
||||
Card Preview
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key="expanded"
|
||||
ref={sidebarRef}
|
||||
className="hidden lg:flex shrink-0 flex-col items-center justify-start pt-8 border-r border-slate-800/50 bg-slate-900/20 backdrop-blur-sm z-10 relative group/sidebar"
|
||||
style={{ perspective: '1000px', width: `${sidebarWidth}px` }}
|
||||
>
|
||||
{/* Collapse Button */}
|
||||
<button
|
||||
onClick={() => setIsSidebarCollapsed(true)}
|
||||
className="absolute top-2 right-2 p-1.5 bg-slate-800/80 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors z-20 opacity-0 group-hover/sidebar:opacity-100"
|
||||
title="Collapse Preview"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="w-full relative sticky top-8 px-6">
|
||||
<div
|
||||
className="relative w-full aspect-[2.5/3.5] transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: hoveredCard ? 'rotateY(0deg)' : 'rotateY(180deg)'
|
||||
}}
|
||||
>
|
||||
{/* Front Face (Hovered Card) */}
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
|
||||
style={{ backfaceVisibility: 'hidden' }}
|
||||
>
|
||||
{(hoveredCard || displayCard) && (
|
||||
<div className="w-full h-full flex flex-col bg-slate-900 rounded-xl">
|
||||
<img
|
||||
src={(hoveredCard || displayCard).image || (hoveredCard || displayCard).image_uris?.normal || (hoveredCard || displayCard).card_faces?.[0]?.image_uris?.normal}
|
||||
alt={(hoveredCard || displayCard).name}
|
||||
className="w-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="mt-4 text-center">
|
||||
<h3 className="text-lg font-bold text-slate-200">{(hoveredCard || displayCard).name}</h3>
|
||||
<p className="text-xs text-slate-400 uppercase tracking-wider mt-1">{(hoveredCard || displayCard).typeLine || (hoveredCard || displayCard).type_line}</p>
|
||||
{(hoveredCard || displayCard).oracle_text && (
|
||||
<div className="mt-4 text-xs text-slate-400 text-left bg-slate-950 p-3 rounded-lg border border-slate-800 leading-relaxed max-h-60 overflow-y-auto custom-scrollbar">
|
||||
{(hoveredCard || displayCard).oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-1">{line}</p>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Back Face (Card Back) */}
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full rounded-xl shadow-2xl overflow-hidden bg-slate-900"
|
||||
style={{
|
||||
backfaceVisibility: 'hidden',
|
||||
transform: 'rotateY(180deg)'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/images/back.jpg"
|
||||
alt="Card Back"
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Resize Handle for Sidebar */}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 bg-transparent hover:bg-emerald-500/50 cursor-col-resize z-50 flex flex-col justify-center items-center group transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart('sidebar', e)}
|
||||
onTouchStart={(e) => handleResizeStart('sidebar', e)}
|
||||
>
|
||||
<div className="h-8 w-1 bg-slate-700/50 rounded-full group-hover:bg-emerald-400 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Dedicated Zoom Zone (Left Sidebar) */}
|
||||
<SidePanelPreview
|
||||
card={hoveredCard || displayCard}
|
||||
width={sidebarWidth}
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
onToggleCollapse={setIsSidebarCollapsed}
|
||||
onResizeStart={(e) => handleResizeStart('sidebar', e)}
|
||||
className="hidden lg:flex"
|
||||
/>
|
||||
|
||||
{/* Main Content Area: Handles both Pack and Pool based on layout */}
|
||||
{layout === 'vertical' ? (
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { CardInstance } from '../../types/game';
|
||||
import { useGesture } from './GestureManager';
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { CardVisual } from '../../components/CardVisual';
|
||||
|
||||
interface CardComponentProps {
|
||||
card: CardInstance;
|
||||
@@ -15,10 +16,11 @@ interface CardComponentProps {
|
||||
onDragEnd?: (e: React.DragEvent) => void;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
viewMode?: 'normal' | 'cutout';
|
||||
viewMode?: 'normal' | 'cutout' | 'large';
|
||||
ignoreZoneLayout?: boolean;
|
||||
}
|
||||
|
||||
export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart, onClick, onContextMenu, onMouseEnter, onMouseLeave, onDrop, onDrag, onDragEnd, style, className, viewMode = 'normal' }) => {
|
||||
export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart, onClick, onContextMenu, onMouseEnter, onMouseLeave, onDrop, onDrag, onDragEnd, style, className, viewMode = 'normal', ignoreZoneLayout = false }) => {
|
||||
const { registerCard, unregisterCard } = useGesture();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -29,28 +31,7 @@ export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart,
|
||||
return () => unregisterCard(card.instanceId);
|
||||
}, [card.instanceId]);
|
||||
|
||||
// Robustly resolve Art Crop
|
||||
let imageSrc = card.imageUrl;
|
||||
|
||||
if (card.image_uris) {
|
||||
if (viewMode === 'cutout' && card.image_uris.crop) {
|
||||
imageSrc = card.image_uris.crop;
|
||||
} else if (card.image_uris.normal) {
|
||||
imageSrc = card.image_uris.normal;
|
||||
}
|
||||
} else if (card.definition && card.definition.set && card.definition.id) {
|
||||
if (viewMode === 'cutout') {
|
||||
imageSrc = `/cards/images/${card.definition.set}/crop/${card.definition.id}.jpg`;
|
||||
} else {
|
||||
imageSrc = `/cards/images/${card.definition.set}/full/${card.definition.id}.jpg`;
|
||||
}
|
||||
} else if (viewMode === 'cutout' && card.definition) {
|
||||
if (card.definition.image_uris?.art_crop) {
|
||||
imageSrc = card.definition.image_uris.art_crop;
|
||||
} else if (card.definition.card_faces?.[0]?.image_uris?.art_crop) {
|
||||
imageSrc = card.definition.card_faces[0].image_uris.art_crop;
|
||||
}
|
||||
}
|
||||
// Robustly resolve Image Source based on viewMode is now handled in CardVisual
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -78,32 +59,27 @@ export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart,
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className={`
|
||||
relative rounded-lg shadow-md cursor-pointer transition-transform hover:scale-105 select-none
|
||||
${card.tapped ? 'rotate-45' : ''}
|
||||
${card.zone === 'hand' ? 'w-32 h-44 -ml-12 first:ml-0 hover:z-10 hover:-translate-y-4' : 'w-24 h-32'}
|
||||
relative rounded-lg shadow-md cursor-grab active:cursor-grabbing transition-all duration-300 ease-[cubic-bezier(0.25,0.8,0.25,1)] select-none
|
||||
${(!ignoreZoneLayout && card.zone === 'hand')
|
||||
? 'w-32 h-44 -ml-12 first:ml-0 hover:z-10 hover:-translate-y-4'
|
||||
: (viewMode === 'cutout' ? 'w-24 h-24' : (viewMode === 'large' ? 'w-32 h-44' : 'w-24 h-32'))}
|
||||
${className || ''}
|
||||
`}
|
||||
style={style}
|
||||
style={{
|
||||
...style,
|
||||
transform: card.tapped ? 'rotate(10deg)' : style?.transform,
|
||||
opacity: card.tapped ? 0.5 : style?.opacity ?? 1
|
||||
}}
|
||||
>
|
||||
<div className="w-full h-full relative overflow-hidden rounded-lg bg-slate-800 border-2 border-slate-700">
|
||||
{!card.faceDown ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={card.name}
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-slate-900 bg-opacity-90 bg-[url('https://c1.scryfall.com/file/scryfall-card-backs/large/59/597b79b3-7d77-4261-871a-60dd17403388.jpg')] bg-cover">
|
||||
</div>
|
||||
)}
|
||||
<div className={`w-full h-full relative rounded-lg bg-slate-800 border-2 border-slate-700 ${card.zone === 'battlefield' ? 'hover:border-slate-400' : ''}`}>
|
||||
<CardVisual
|
||||
card={card}
|
||||
viewMode={viewMode}
|
||||
className="w-full h-full rounded-lg"
|
||||
/>
|
||||
|
||||
|
||||
|
||||
{/* Counters / PowerToughness overlays can go here */}
|
||||
{(card.counters.length > 0) && (
|
||||
<div className="absolute top-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
|
||||
{card.counters.map(c => c.count).reduce((a, b) => a + b, 0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
191
src/client/src/modules/game/CreateTokenModal.tsx
Normal file
191
src/client/src/modules/game/CreateTokenModal.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from '../../components/Modal';
|
||||
|
||||
interface TokenDefinition {
|
||||
name: string;
|
||||
power: string;
|
||||
toughness: string;
|
||||
colors: string[];
|
||||
types: string;
|
||||
subtypes: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
interface CreateTokenModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (definition: TokenDefinition) => void;
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
{ id: 'W', label: 'White', bg: 'bg-yellow-100 text-yellow-900 border-yellow-300' },
|
||||
{ id: 'U', label: 'Blue', bg: 'bg-blue-100 text-blue-900 border-blue-300' },
|
||||
{ id: 'B', label: 'Black', bg: 'bg-slate-300 text-slate-900 border-slate-400' },
|
||||
{ id: 'R', label: 'Red', bg: 'bg-red-100 text-red-900 border-red-300' },
|
||||
{ id: 'G', label: 'Green', bg: 'bg-green-100 text-green-900 border-green-300' },
|
||||
{ id: 'C', label: 'Colorless', bg: 'bg-gray-100 text-gray-900 border-gray-300' },
|
||||
];
|
||||
|
||||
export const CreateTokenModal: React.FC<CreateTokenModalProps> = ({ isOpen, onClose, onCreate }) => {
|
||||
const [name, setName] = useState('Token');
|
||||
const [power, setPower] = useState('1');
|
||||
const [toughness, setToughness] = useState('1');
|
||||
const [selectedColors, setSelectedColors] = useState<string[]>([]);
|
||||
const [types, setTypes] = useState('Creature');
|
||||
const [subtypes, setSubtypes] = useState('');
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
|
||||
const toggleColor = (colorId: string) => {
|
||||
setSelectedColors(prev =>
|
||||
prev.includes(colorId)
|
||||
? prev.filter(c => c !== colorId)
|
||||
: [...prev, colorId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
onCreate({
|
||||
name,
|
||||
power,
|
||||
toughness,
|
||||
colors: selectedColors,
|
||||
types: types,
|
||||
subtypes: subtypes,
|
||||
imageUrl: imageUrl || undefined
|
||||
});
|
||||
// Reset form roughly or keep? Usually reset.
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setName('Token');
|
||||
setPower('1');
|
||||
setToughness('1');
|
||||
setSelectedColors([]);
|
||||
setTypes('Creature');
|
||||
setSubtypes('');
|
||||
setImageUrl('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Create Custom Token"
|
||||
confirmLabel="Create Token"
|
||||
onConfirm={handleCreate}
|
||||
cancelLabel="Cancel"
|
||||
maxWidth="max-w-lg"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Token Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-white focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
placeholder="e.g. Dragon, Soldier, Treasure"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* P/T */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Power</label>
|
||||
<input
|
||||
type="text"
|
||||
value={power}
|
||||
onChange={(e) => setPower(e.target.value)}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-white focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Toughness</label>
|
||||
<input
|
||||
type="text"
|
||||
value={toughness}
|
||||
onChange={(e) => setToughness(e.target.value)}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-white focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colors */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Colors</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{COLORS.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => toggleColor(c.id)}
|
||||
className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm border-2 transition-all
|
||||
${selectedColors.includes(c.id) ? c.bg + ' ring-2 ring-white ring-offset-2 ring-offset-slate-900 scale-110' : 'bg-slate-800 border-slate-600 text-slate-500 hover:bg-slate-700'}
|
||||
`}
|
||||
title={c.label}
|
||||
>
|
||||
{c.id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Types */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Type</label>
|
||||
<input
|
||||
type="text"
|
||||
value={types}
|
||||
onChange={(e) => setTypes(e.target.value)}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-white focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
placeholder="Creature, Artifact..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Subtypes</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subtypes}
|
||||
onChange={(e) => setSubtypes(e.target.value)}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-white focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
placeholder="Soldier, Drake..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image URL */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Image URL (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-white text-xs focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preview Summary */}
|
||||
<div className="mt-2 p-3 bg-slate-800/50 rounded border border-slate-700 flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-slate-900 border border-slate-600 rounded flex items-center justify-center text-xs text-slate-500 overflow-hidden relative">
|
||||
{imageUrl ? (
|
||||
<img src={imageUrl} alt="Preview" className="w-full h-full object-cover" onError={(e) => e.currentTarget.style.display = 'none'} />
|
||||
) : (
|
||||
<span>?</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-bold text-white text-sm">{name} {power}/{toughness}</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
{selectedColors.length > 0 ? selectedColors.join('/') : 'Colorless'} {types} {subtypes ? `— ${subtypes}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { CardInstance } from '../../types/game';
|
||||
|
||||
export interface ContextMenuRequest {
|
||||
@@ -72,12 +73,14 @@ export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClo
|
||||
<MenuItem label={card.faceDown ? "Flip Face Up" : "Flip Face Down"} onClick={() => handleAction('FLIP_CARD', { cardId: card.instanceId })} />
|
||||
|
||||
<div className="relative group">
|
||||
<MenuItem label="Add Counter ▸" onClick={() => { }} />
|
||||
<div className="absolute left-full top-0 ml-1 w-40 bg-slate-900 border border-slate-700 rounded shadow-lg hidden group-hover:block z-50">
|
||||
<MenuItem label="+1/+1 Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '+1/+1', amount: 1 })} />
|
||||
<MenuItem label="-1/-1 Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '-1/-1', amount: 1 })} />
|
||||
<MenuItem label="Loyalty Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: 'loyalty', amount: 1 })} />
|
||||
<MenuItem label="Remove Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '+1/+1', amount: -1 })} />
|
||||
<MenuItem label="Add Counter" hasSubmenu />
|
||||
<div className="absolute left-full top-0 pl-1 hidden group-hover:block z-50 w-40">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded shadow-lg">
|
||||
<MenuItem label="+1/+1 Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '+1/+1', amount: 1 })} />
|
||||
<MenuItem label="-1/-1 Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '-1/-1', amount: 1 })} />
|
||||
<MenuItem label="Loyalty Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: 'loyalty', amount: 1 })} />
|
||||
<MenuItem label="Remove Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '+1/+1', amount: -1 })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -179,71 +182,85 @@ export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClo
|
||||
|
||||
{request.type === 'zone' && request.zone && renderZoneMenu(request.zone)}
|
||||
|
||||
|
||||
{request.type === 'background' && (
|
||||
<>
|
||||
<div className="px-3 py-1 font-bold text-xs text-slate-500 uppercase tracking-widest border-b border-slate-800 mb-1">
|
||||
Battlefield
|
||||
</div>
|
||||
<MenuItem
|
||||
label="Create Token (1/1 Soldier)"
|
||||
onClick={() => handleAction('CREATE_TOKEN', {
|
||||
definition: {
|
||||
name: 'Soldier',
|
||||
colors: ['W'],
|
||||
types: ['Creature'],
|
||||
subtypes: ['Soldier'],
|
||||
power: 1,
|
||||
toughness: 1,
|
||||
imageUrl: 'https://cards.scryfall.io/large/front/b/d/bd4047a5-d14f-4d2d-9333-5c628dfca115.jpg' // Generic Soldier?
|
||||
},
|
||||
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||
})}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Create Token (2/2 Zombie)"
|
||||
onClick={() => handleAction('CREATE_TOKEN', {
|
||||
definition: {
|
||||
name: 'Zombie',
|
||||
colors: ['B'],
|
||||
types: ['Creature'],
|
||||
subtypes: ['Zombie'],
|
||||
power: 2,
|
||||
toughness: 2,
|
||||
imageUrl: 'https://cards.scryfall.io/large/front/b/d/bd4047a5-d14f-4d2d-9333-5c628dfca115.jpg' // Re-use or find standard
|
||||
},
|
||||
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Token Submenu */}
|
||||
<div className="relative group">
|
||||
<MenuItem label="Create Token" hasSubmenu />
|
||||
{/* Wrapper for hover bridge */}
|
||||
<div className="absolute left-full top-0 pl-1 hidden group-hover:block z-50 w-56">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded shadow-lg p-1">
|
||||
<div className="px-3 py-1 font-bold text-xs text-slate-500 uppercase tracking-widest border-b border-slate-800 mb-1">
|
||||
Standard Tokens
|
||||
</div>
|
||||
<MenuItem
|
||||
label="1/1 Soldier"
|
||||
onClick={() => handleAction('CREATE_TOKEN', {
|
||||
definition: { name: 'Soldier', colors: ['W'], types: ['Creature'], subtypes: ['Soldier'], power: 1, toughness: 1, imageUrl: 'https://cards.scryfall.io/large/front/b/d/bd4047a5-d14f-4d2d-9333-5c628dfca115.jpg' },
|
||||
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||
})}
|
||||
/>
|
||||
<MenuItem
|
||||
label="2/2 Zombie"
|
||||
onClick={() => handleAction('CREATE_TOKEN', {
|
||||
definition: { name: 'Zombie', colors: ['B'], types: ['Creature'], subtypes: ['Zombie'], power: 2, toughness: 2, imageUrl: 'https://cards.scryfall.io/large/front/b/d/bd4047a5-d14f-4d2d-9333-5c628dfca115.jpg' },
|
||||
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||
})}
|
||||
/>
|
||||
<MenuItem
|
||||
label="3/3 Beast"
|
||||
onClick={() => handleAction('CREATE_TOKEN', {
|
||||
definition: { name: 'Beast', colors: ['G'], types: ['Creature'], subtypes: ['Beast'], power: 3, toughness: 3, imageUrl: 'https://cards.scryfall.io/large/front/b/d/bd4047a5-d14f-4d2d-9333-5c628dfca115.jpg' },
|
||||
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||
})}
|
||||
/>
|
||||
<MenuItem
|
||||
label="4/4 Angel"
|
||||
onClick={() => handleAction('CREATE_TOKEN', {
|
||||
definition: { name: 'Angel', colors: ['W'], types: ['Creature'], subtypes: ['Angel'], power: 4, toughness: 4, imageUrl: 'https://cards.scryfall.io/large/front/b/d/bd4047a5-d14f-4d2d-9333-5c628dfca115.jpg' },
|
||||
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||
})}
|
||||
/>
|
||||
<div className="h-px bg-slate-800 my-1 mx-2"></div>
|
||||
<MenuItem
|
||||
label="Treasure"
|
||||
onClick={() => handleAction('CREATE_TOKEN', {
|
||||
definition: { name: 'Treasure', colors: [], types: ['Artifact'], subtypes: ['Treasure'], power: 0, toughness: 0, imageUrl: 'https://cards.scryfall.io/large/front/2/7/2776c5b9-1d22-4a00-9988-294747734185.jpg' },
|
||||
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||
})}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Food"
|
||||
onClick={() => handleAction('CREATE_TOKEN', {
|
||||
definition: { name: 'Food', colors: [], types: ['Artifact'], subtypes: ['Food'], power: 0, toughness: 0, imageUrl: 'https://cards.scryfall.io/large/front/2/7/2776c5b9-1d22-4a00-9988-294747734185.jpg' },
|
||||
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||
})}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Clue"
|
||||
onClick={() => handleAction('CREATE_TOKEN', {
|
||||
definition: { name: 'Clue', colors: [], types: ['Artifact'], subtypes: ['Clue'], power: 0, toughness: 0, imageUrl: 'https://cards.scryfall.io/large/front/2/7/2776c5b9-1d22-4a00-9988-294747734185.jpg' },
|
||||
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||
})}
|
||||
/>
|
||||
<div className="h-px bg-slate-800 my-1 mx-2"></div>
|
||||
<MenuItem
|
||||
label="Custom Token..."
|
||||
onClick={() => handleAction('OPEN_CUSTOM_TOKEN_MODAL')}
|
||||
className="text-emerald-400 font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MenuItem
|
||||
label="Add Mana..."
|
||||
onClick={() => handleAction('MANA', { x: request.x, y: request.y })} // Adjusted to use request.x/y as MenuItem's onClick doesn't pass event
|
||||
// icon={<Zap size={14} />} // Zap is not defined in this scope.
|
||||
/>
|
||||
<MenuItem
|
||||
label="Inspect Details"
|
||||
onClick={() => handleAction('INSPECT', {})}
|
||||
// icon={<Maximize size={14} />} // Maximize and RotateCw are not defined in this scope.
|
||||
/>
|
||||
<MenuItem
|
||||
label="Tap / Untap"
|
||||
onClick={() => handleAction('TAP', {})}
|
||||
// icon={<RotateCw size={14} />} // Maximize and RotateCw are not defined in this scope.
|
||||
/>
|
||||
<MenuItem
|
||||
label="Create Treasure"
|
||||
onClick={() => handleAction('CREATE_TOKEN', {
|
||||
definition: {
|
||||
name: 'Treasure',
|
||||
colors: [],
|
||||
types: ['Artifact'],
|
||||
subtypes: ['Treasure'],
|
||||
power: 0,
|
||||
toughness: 0,
|
||||
keywords: [],
|
||||
imageUrl: 'https://cards.scryfall.io/large/front/2/7/2776c5b9-1d22-4a00-9988-294747734185.jpg'
|
||||
},
|
||||
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||
})}
|
||||
onClick={() => handleAction('MANA', { x: request.x, y: request.y })}
|
||||
/>
|
||||
<div className="h-px bg-slate-800 my-1 mx-2"></div>
|
||||
<MenuItem label="Untap All My Permanents" onClick={() => handleAction('UNTAP_ALL')} />
|
||||
@@ -253,12 +270,13 @@ export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClo
|
||||
);
|
||||
};
|
||||
|
||||
const MenuItem: React.FC<{ label: string; onClick: () => void; className?: string; onMouseEnter?: () => void }> = ({ label, onClick, className = '', onMouseEnter }) => (
|
||||
const MenuItem: React.FC<{ label: string; onClick?: () => void; className?: string; onMouseEnter?: () => void; hasSubmenu?: boolean }> = ({ label, onClick, className = '', onMouseEnter, hasSubmenu }) => (
|
||||
<div
|
||||
className={`px-4 py-2 hover:bg-emerald-600/20 hover:text-emerald-300 cursor-pointer transition-colors ${className}`}
|
||||
className={`px-4 py-2 hover:bg-emerald-600/20 hover:text-emerald-300 cursor-pointer transition-colors flex justify-between items-center ${className}`}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
{label}
|
||||
<span>{label}</span>
|
||||
{hasSubmenu && <ChevronRight size={14} className="text-slate-500" />}
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -77,7 +77,10 @@ export const MulliganView: React.FC<MulliganViewProps> = ({ hand, mulliganCount,
|
||||
{/* Controls */}
|
||||
<div className="flex gap-8">
|
||||
<button
|
||||
onClick={() => onDecision(false, [])}
|
||||
onClick={() => {
|
||||
console.log("Mulligan Clicked");
|
||||
onDecision(false, []);
|
||||
}}
|
||||
className="px-8 py-4 bg-red-600/20 hover:bg-red-600/40 border border-red-500 text-red-100 rounded-xl font-bold text-lg transition-all flex flex-col items-center gap-1 group"
|
||||
>
|
||||
<span>Mulligan</span>
|
||||
@@ -85,7 +88,12 @@ export const MulliganView: React.FC<MulliganViewProps> = ({ hand, mulliganCount,
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => isSelectionValid && onDecision(true, Array.from(selectedToBottom))}
|
||||
onClick={() => {
|
||||
if (isSelectionValid) {
|
||||
console.log("Keep Hand Clicked", Array.from(selectedToBottom));
|
||||
onDecision(true, Array.from(selectedToBottom));
|
||||
}
|
||||
}}
|
||||
disabled={!isSelectionValid}
|
||||
className={`px-8 py-4 rounded-xl font-bold text-lg transition-all flex flex-col items-center gap-1 min-w-[200px] ${isSelectionValid
|
||||
? 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-[0_0_20px_rgba(16,185,129,0.4)]'
|
||||
|
||||
@@ -1,50 +1,271 @@
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { GameState, Phase, Step } from '../../types/game';
|
||||
import { Sun, Shield, Swords, Hourglass } from 'lucide-react';
|
||||
import { ManaIcon } from '../../components/ManaIcon';
|
||||
import { Shield, Swords, Hourglass, Zap, Hand, ChevronRight, XCircle, Clock, Files, Crosshair, Skull, Flag, Moon, Trash2 } from 'lucide-react';
|
||||
|
||||
interface PhaseStripProps {
|
||||
gameState: GameState;
|
||||
currentPlayerId: string;
|
||||
onAction: (type: string, payload?: any) => void;
|
||||
contextData?: any;
|
||||
isYielding?: boolean;
|
||||
onYieldToggle?: () => void;
|
||||
stopRequested?: boolean;
|
||||
onToggleSuspend?: () => void;
|
||||
}
|
||||
|
||||
export const PhaseStrip: React.FC<PhaseStripProps> = ({ gameState }) => {
|
||||
export const PhaseStrip: React.FC<PhaseStripProps> = ({
|
||||
gameState,
|
||||
currentPlayerId,
|
||||
onAction,
|
||||
contextData,
|
||||
isYielding,
|
||||
onYieldToggle,
|
||||
stopRequested,
|
||||
onToggleSuspend
|
||||
}) => {
|
||||
const currentPhase = gameState.phase as Phase;
|
||||
const currentStep = gameState.step as Step;
|
||||
const isMyTurn = gameState.activePlayerId === currentPlayerId;
|
||||
const hasPriority = gameState.priorityPlayerId === currentPlayerId;
|
||||
const isStackEmpty = !gameState.stack || gameState.stack.length === 0;
|
||||
|
||||
// Phase Definitions
|
||||
const phases: { id: Phase; icon: React.ElementType; label: string }[] = [
|
||||
{ id: 'beginning', icon: Sun, label: 'Beginning' },
|
||||
{ id: 'main1', icon: Shield, label: 'Main 1' },
|
||||
{ id: 'combat', icon: Swords, label: 'Combat' },
|
||||
{ id: 'main2', icon: Shield, label: 'Main 2' },
|
||||
{ id: 'ending', icon: Hourglass, label: 'End' },
|
||||
];
|
||||
// --- 1. Action Logic resolution ---
|
||||
let actionLabel = "Wait";
|
||||
let actionColor = "bg-slate-700";
|
||||
let actionType: string | null = null;
|
||||
let ActionIcon = Hourglass;
|
||||
let isActionEnabled = false;
|
||||
|
||||
if (isYielding) {
|
||||
actionLabel = "Cancel Yield";
|
||||
actionColor = "bg-sky-600 hover:bg-sky-500";
|
||||
actionType = 'CANCEL_YIELD';
|
||||
ActionIcon = XCircle;
|
||||
isActionEnabled = true;
|
||||
} else if (hasPriority) {
|
||||
isActionEnabled = true;
|
||||
ActionIcon = ChevronRight;
|
||||
// Default Pass styling
|
||||
actionColor = "bg-emerald-600 hover:bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.4)]";
|
||||
|
||||
if (currentStep === 'declare_attackers') {
|
||||
if (gameState.attackersDeclared) {
|
||||
actionLabel = "To Blockers";
|
||||
actionType = 'PASS_PRIORITY';
|
||||
} else {
|
||||
const count = contextData?.attackers?.length || 0;
|
||||
if (count > 0) {
|
||||
actionLabel = `Attack (${count})`;
|
||||
actionType = 'DECLARE_ATTACKERS';
|
||||
ActionIcon = Swords;
|
||||
actionColor = "bg-red-600 hover:bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.4)]";
|
||||
} else {
|
||||
actionLabel = "Skip Combat";
|
||||
actionType = 'DECLARE_ATTACKERS';
|
||||
actionColor = "bg-slate-600 hover:bg-slate-500";
|
||||
}
|
||||
}
|
||||
} else if (currentStep === 'declare_blockers') {
|
||||
const showToDamage = gameState.blockersDeclared || isMyTurn; // UI Safety for AP
|
||||
|
||||
if (showToDamage) {
|
||||
actionLabel = "To Damage";
|
||||
actionType = 'PASS_PRIORITY';
|
||||
ActionIcon = Swords;
|
||||
} else {
|
||||
actionLabel = "Confirm Blocks";
|
||||
actionType = 'DECLARE_BLOCKERS';
|
||||
ActionIcon = Shield;
|
||||
actionColor = "bg-blue-600 hover:bg-blue-500 shadow-[0_0_10px_rgba(37,99,235,0.4)]";
|
||||
}
|
||||
} else if (isStackEmpty) {
|
||||
// Standard Pass
|
||||
actionType = 'PASS_PRIORITY';
|
||||
if (gameState.phase === 'main1') actionLabel = "To Combat";
|
||||
else if (gameState.phase === 'main2') actionLabel = "End Turn";
|
||||
else actionLabel = "Pass";
|
||||
} else {
|
||||
// Resolve Logic
|
||||
actionLabel = "Resolve";
|
||||
actionType = 'PASS_PRIORITY';
|
||||
ActionIcon = Zap;
|
||||
actionColor = "bg-amber-600 hover:bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.4)]";
|
||||
}
|
||||
} else {
|
||||
// NOT PRIORITY (Waiting)
|
||||
// Suspend Button Logic for NAP
|
||||
if (!isMyTurn) {
|
||||
isActionEnabled = true;
|
||||
|
||||
if (stopRequested) {
|
||||
actionLabel = "Stop Set";
|
||||
ActionIcon = Hand;
|
||||
actionColor = "bg-red-600 hover:bg-red-500 animate-pulse font-bold border border-red-400";
|
||||
actionType = 'TOGGLE_SUSPEND';
|
||||
} else {
|
||||
actionLabel = "Suspend";
|
||||
ActionIcon = Hand;
|
||||
actionColor = "bg-yellow-600/80 hover:bg-yellow-500 text-yellow-50 border border-yellow-500/50";
|
||||
actionType = 'TOGGLE_SUSPEND';
|
||||
}
|
||||
} else {
|
||||
// I am AP but don't have priority? (Maybe waiting for server?)
|
||||
actionLabel = "Waiting...";
|
||||
ActionIcon = Hourglass;
|
||||
actionColor = "bg-white/5 text-slate-500 cursor-not-allowed";
|
||||
isActionEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
const handleAction = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Special Case: Suspend (No Priority Needed)
|
||||
if (actionType === 'TOGGLE_SUSPEND') {
|
||||
onToggleSuspend?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isYielding) {
|
||||
onYieldToggle?.();
|
||||
return;
|
||||
}
|
||||
if (!hasPriority) return;
|
||||
|
||||
if (actionType) {
|
||||
let payload: any = { type: actionType };
|
||||
if (actionType === 'DECLARE_ATTACKERS') {
|
||||
payload.attackers = contextData?.attackers || [];
|
||||
}
|
||||
onAction('game_strict_action', payload);
|
||||
}
|
||||
};
|
||||
|
||||
// --- 2. Phase/Step Definitions ---
|
||||
interface VisualStep {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
phase: Phase;
|
||||
step: Step;
|
||||
}
|
||||
|
||||
const stepsList: VisualStep[] = useMemo(() => [
|
||||
{ id: 'untap', label: 'Untap', icon: (props: any) => <ManaIcon symbol="untap" className="text-current" {...props} />, phase: 'beginning', step: 'untap' },
|
||||
{ id: 'upkeep', label: 'Upkeep', icon: Clock, phase: 'beginning', step: 'upkeep' },
|
||||
{ id: 'draw', label: 'Draw', icon: Files, phase: 'beginning', step: 'draw' },
|
||||
{ id: 'main1', label: 'Main 1', icon: Zap, phase: 'main1', step: 'main' },
|
||||
{ id: 'begin_combat', label: 'Combat Start', icon: Swords, phase: 'combat', step: 'beginning_combat' },
|
||||
{ id: 'attackers', label: 'Attack', icon: Crosshair, phase: 'combat', step: 'declare_attackers' },
|
||||
{ id: 'blockers', label: 'Block', icon: Shield, phase: 'combat', step: 'declare_blockers' },
|
||||
{ id: 'damage', label: 'Damage', icon: Skull, phase: 'combat', step: 'combat_damage' },
|
||||
{ id: 'end_combat', label: 'End Combat', icon: Flag, phase: 'combat', step: 'end_combat' },
|
||||
{ id: 'main2', label: 'Main 2', icon: Zap, phase: 'main2', step: 'main' },
|
||||
{ id: 'end', label: 'End Step', icon: Moon, phase: 'ending', step: 'end' },
|
||||
{ id: 'cleanup', label: 'Cleanup', icon: Trash2, phase: 'ending', step: 'cleanup' },
|
||||
], []);
|
||||
|
||||
// Calculate Active Step Index
|
||||
// We need to match both Phase and Step because 'main' step exists in two phases
|
||||
const activeStepIndex = stepsList.findIndex(s => {
|
||||
if (s.phase === 'main1' || s.phase === 'main2') {
|
||||
return s.phase === currentPhase && s.step === 'main'; // Special handle for split main phases
|
||||
}
|
||||
return s.step === currentStep;
|
||||
});
|
||||
|
||||
// Fallback if step mismatch
|
||||
const safeActiveIndex = activeStepIndex === -1 ? 0 : activeStepIndex;
|
||||
|
||||
|
||||
const themeBorder = isMyTurn ? 'border-emerald-500/30' : 'border-red-500/30';
|
||||
const themeShadow = isMyTurn ? 'shadow-[0_0_20px_-5px_rgba(16,185,129,0.3)]' : 'shadow-[0_0_20px_-5px_rgba(239,68,68,0.3)]';
|
||||
const themeText = isMyTurn ? 'text-emerald-400' : 'text-red-400';
|
||||
const themeBgActive = isMyTurn ? 'bg-emerald-500' : 'bg-red-500';
|
||||
const themePing = isMyTurn ? 'bg-emerald-400' : 'bg-red-400';
|
||||
const themePingSolid = isMyTurn ? 'bg-emerald-500' : 'bg-red-500';
|
||||
|
||||
return (
|
||||
<div className="flex bg-black/40 backdrop-blur-md rounded-full p-1 border border-white/10 gap-1">
|
||||
{phases.map((p) => {
|
||||
const isActive = p.id === currentPhase;
|
||||
<div className="w-full h-full flex flex-col items-center gap-2 pointer-events-auto">
|
||||
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`
|
||||
relative flex items-center justify-center w-8 h-8 rounded-full transition-all duration-300
|
||||
${isActive ? 'bg-emerald-500 text-white shadow-[0_0_10px_rgba(16,185,129,0.5)] scale-110 z-10' : 'text-slate-500 bg-transparent hover:bg-white/5'}
|
||||
`}
|
||||
title={p.label}
|
||||
>
|
||||
<p.icon size={16} />
|
||||
{/* HUD Container */}
|
||||
<div className={`
|
||||
relative w-full h-10 bg-transparent rounded-none
|
||||
flex items-center justify-between px-4 shadow-none transition-all duration-300
|
||||
border-b-2
|
||||
${themeBorder}
|
||||
${themeShadow}
|
||||
`}>
|
||||
|
||||
{/* Active Step Indicator (Text below or Tooltip) */}
|
||||
{isActive && (
|
||||
<span className="absolute -bottom-6 left-1/2 -translate-x-1/2 text-[10px] font-bold text-white uppercase tracking-wider whitespace-nowrap bg-black/80 px-2 py-0.5 rounded border border-white/10">
|
||||
{currentStep}
|
||||
{/* SECTION 1: Phase Timeline (Left) */}
|
||||
<div className={`flex items-center gap-0.5 px-2 border-r border-white/5 h-full overflow-x-auto no-scrollbar`}>
|
||||
{stepsList.map((s, idx) => {
|
||||
const isActive = idx === safeActiveIndex;
|
||||
const isPast = idx < safeActiveIndex;
|
||||
const Icon = s.icon;
|
||||
|
||||
return (
|
||||
<div key={s.id} className="relative group flex items-center justify-center min-w-[20px]">
|
||||
{/* Connector Line - simplified to just spacing/coloring */}
|
||||
{/*
|
||||
{idx > 0 && (
|
||||
<div className={`w-1 h-0.5 mx-px rounded-full ${isPast || isActive ? (isMyTurn ? 'bg-emerald-800' : 'bg-red-900') : 'bg-slate-800'}`} />
|
||||
)}
|
||||
*/}
|
||||
|
||||
{/* Icon Node */}
|
||||
<div
|
||||
className={`
|
||||
rounded flex items-center justify-center transition-all duration-300
|
||||
${isActive
|
||||
? `w-6 h-6 ${themeBgActive} text-white shadow-lg z-10 scale-110 rounded-md`
|
||||
: `w-5 h-5 ${isPast ? (isMyTurn ? 'text-emerald-800' : 'text-red-900') : 'text-slate-800'} text-opacity-80`}
|
||||
`}
|
||||
title={s.label}
|
||||
>
|
||||
<Icon size={isActive ? 14 : 12} strokeWidth={isActive ? 2.5 : 2} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* SECTION 2: Info Panel (Center/Fill) */}
|
||||
<div className="flex-1 flex items-center justify-center gap-4 px-4 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{hasPriority && (
|
||||
<span className="flex h-1.5 w-1.5 relative">
|
||||
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full ${themePing} opacity-75`}></span>
|
||||
<span className={`relative inline-flex rounded-full h-1.5 w-1.5 ${themePingSolid}`}></span>
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wider ${themeText}`}>
|
||||
{isMyTurn ? 'Your Turn' : "Opponent"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="h-4 w-px bg-white/10" />
|
||||
<div className="text-sm font-medium text-slate-200 truncate capitalize tracking-tight">
|
||||
{currentStep.replace(/_/g, ' ')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SECTION 3: Action Button (Right) */}
|
||||
<button
|
||||
onClick={handleAction}
|
||||
disabled={!isActionEnabled}
|
||||
className={`
|
||||
h-8 px-4 rounded flex items-center gap-2 transition-all duration-200
|
||||
font-bold text-xs uppercase tracking-wide text-white
|
||||
${actionColor}
|
||||
${isActionEnabled ? 'hover:brightness-110' : 'opacity-50 grayscale'}
|
||||
`}
|
||||
>
|
||||
<span>{actionLabel}</span>
|
||||
<ActionIcon size={14} />
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import { GameState } from '../../types/game';
|
||||
|
||||
interface SmartButtonProps {
|
||||
gameState: GameState;
|
||||
playerId: string;
|
||||
onAction: (type: string, payload?: any) => void;
|
||||
contextData?: any;
|
||||
isYielding?: boolean;
|
||||
onYieldToggle?: () => void;
|
||||
}
|
||||
|
||||
export const SmartButton: React.FC<SmartButtonProps> = ({ gameState, playerId, onAction, contextData, isYielding, onYieldToggle }) => {
|
||||
const isMyPriority = gameState.priorityPlayerId === playerId;
|
||||
const isStackEmpty = !gameState.stack || gameState.stack.length === 0;
|
||||
|
||||
let label = "Wait";
|
||||
let colorClass = "bg-slate-700 text-slate-400 cursor-not-allowed";
|
||||
let actionType: string | null = null;
|
||||
|
||||
if (isYielding) {
|
||||
label = "Yielding... (Tap to Cancel)";
|
||||
colorClass = "bg-sky-600 hover:bg-sky-500 text-white shadow-[0_0_15px_rgba(2,132,199,0.5)] animate-pulse";
|
||||
// Tap to cancel yield
|
||||
actionType = 'CANCEL_YIELD';
|
||||
} else if (isMyPriority) {
|
||||
if (gameState.step === 'declare_attackers') {
|
||||
if (gameState.attackersDeclared) {
|
||||
label = "Pass (to Blockers)";
|
||||
colorClass = "bg-emerald-600 hover:bg-emerald-500 text-white shadow-[0_0_15px_rgba(16,185,129,0.5)] animate-pulse";
|
||||
actionType = 'PASS_PRIORITY';
|
||||
} else {
|
||||
const count = contextData?.attackers?.length || 0;
|
||||
label = count > 0 ? `Attack with ${count}` : "Skip Combat";
|
||||
colorClass = "bg-red-600 hover:bg-red-500 text-white shadow-[0_0_15px_rgba(239,68,68,0.5)] animate-pulse";
|
||||
actionType = 'DECLARE_ATTACKERS';
|
||||
}
|
||||
} else if (gameState.step === 'declare_blockers') {
|
||||
// Todo: blockers context
|
||||
label = "Declare Blockers";
|
||||
colorClass = "bg-blue-600 hover:bg-blue-500 text-white shadow-[0_0_15px_rgba(37,99,235,0.5)] animate-pulse";
|
||||
actionType = 'DECLARE_BLOCKERS';
|
||||
} else if (isStackEmpty) {
|
||||
// Pass Priority / Advance Step
|
||||
// If Main Phase, could technically play land/cast, but button defaults to Pass
|
||||
label = "Pass Turn/Phase";
|
||||
// If we want more granular: "Move to Combat" vs "End Turn" based on phase
|
||||
if (gameState.phase === 'main1') label = "Pass to Combat";
|
||||
else if (gameState.phase === 'main2') label = "End Turn";
|
||||
else label = "Pass";
|
||||
|
||||
colorClass = "bg-emerald-600 hover:bg-emerald-500 text-white shadow-[0_0_15px_rgba(16,185,129,0.5)] animate-pulse";
|
||||
actionType = 'PASS_PRIORITY';
|
||||
} else {
|
||||
// Resolve Top Item
|
||||
const topItem = gameState.stack![gameState.stack!.length - 1];
|
||||
label = `Resolve ${topItem?.name || 'Item'}`;
|
||||
colorClass = "bg-amber-600 hover:bg-amber-500 text-white shadow-[0_0_15px_rgba(245,158,11,0.5)]";
|
||||
actionType = 'PASS_PRIORITY'; // Resolving is just passing priority when stack not empty
|
||||
}
|
||||
}
|
||||
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isLongPress = useRef(false);
|
||||
|
||||
const handlePointerDown = () => {
|
||||
isLongPress.current = false;
|
||||
timerRef.current = setTimeout(() => {
|
||||
isLongPress.current = true;
|
||||
if (onYieldToggle) {
|
||||
// Visual feedback could be added here
|
||||
onYieldToggle();
|
||||
}
|
||||
}, 600); // 600ms long press for Yield
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
if (!isLongPress.current) {
|
||||
handleClick();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (isYielding) {
|
||||
// Cancel logic
|
||||
if (onYieldToggle) onYieldToggle();
|
||||
return;
|
||||
}
|
||||
|
||||
if (actionType) {
|
||||
let payload: any = { type: actionType };
|
||||
|
||||
if (actionType === 'DECLARE_ATTACKERS') {
|
||||
payload.attackers = contextData?.attackers || [];
|
||||
}
|
||||
// TODO: Blockers payload
|
||||
|
||||
onAction('game_strict_action', payload);
|
||||
}
|
||||
};
|
||||
|
||||
// Prevent context menu on long press
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={() => { if (timerRef.current) clearTimeout(timerRef.current); }}
|
||||
onContextMenu={handleContextMenu}
|
||||
disabled={!isMyPriority && !isYielding}
|
||||
className={`
|
||||
px-6 py-3 rounded-xl font-bold text-lg uppercase tracking-wider transition-all duration-300
|
||||
${colorClass}
|
||||
border border-white/10
|
||||
flex items-center justify-center
|
||||
min-w-[200px] select-none
|
||||
`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { socketService } from '../../services/SocketService';
|
||||
import { Share2, Users, Play, LogOut, Copy, Check, Hash, Crown, XCircle, MessageSquare, Send, Bell, BellOff, X, Bot, Layers } from 'lucide-react';
|
||||
import { Users, LogOut, Copy, Check, MessageSquare, Send, Bell, BellOff, X, Bot, Layers } from 'lucide-react';
|
||||
import { useConfirm } from '../../components/ConfirmDialog';
|
||||
import { Modal } from '../../components/Modal';
|
||||
import { useToast } from '../../components/Toast';
|
||||
import { useGameToast, GameToastProvider } from '../../components/GameToast';
|
||||
import { GameLogProvider, useGameLog } from '../../contexts/GameLogContext'; // Import Log Provider and Hook
|
||||
import { GameView } from '../game/GameView';
|
||||
import { DraftView } from '../draft/DraftView';
|
||||
import { TournamentManager as TournamentView } from '../tournament/TournamentManager';
|
||||
import { DeckBuilderView } from '../draft/DeckBuilderView';
|
||||
|
||||
interface Player {
|
||||
@@ -41,7 +43,7 @@ interface GameRoomProps {
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPlayerId, initialGameState, initialDraftState, onExit }) => {
|
||||
const GameRoomContent: React.FC<GameRoomProps> = ({ room: initialRoom, currentPlayerId, initialGameState, initialDraftState, onExit }) => {
|
||||
// State
|
||||
const [room, setRoom] = useState<Room>(initialRoom);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -62,9 +64,9 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
});
|
||||
|
||||
// Services
|
||||
const { showToast } = useToast();
|
||||
const { showGameToast } = useGameToast();
|
||||
const { addLog } = useGameLog(); // Use Log Hook
|
||||
const { confirm } = useConfirm();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Restored States
|
||||
const [message, setMessage] = useState('');
|
||||
@@ -72,6 +74,8 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [gameState, setGameState] = useState<any>(initialGameState || null);
|
||||
const [draftState, setDraftState] = useState<any>(initialDraftState || null);
|
||||
const [tournamentState, setTournamentState] = useState<any>((initialRoom as any).tournament || null);
|
||||
const [preparingMatchId, setPreparingMatchId] = useState<string | null>(null);
|
||||
const [mobileTab, setMobileTab] = useState<'game' | 'chat'>('game'); // Keep for mobile
|
||||
|
||||
// Derived State
|
||||
@@ -98,14 +102,14 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
// 1. New Players
|
||||
curr.forEach(p => {
|
||||
if (!prev.find(old => old.id === p.id)) {
|
||||
showToast(`${p.name} (${p.role}) joined the room.`, 'info');
|
||||
showGameToast(`${p.name} (${p.role}) joined the room.`, 'info');
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Left Players
|
||||
prev.forEach(p => {
|
||||
if (!curr.find(newP => newP.id === p.id)) {
|
||||
showToast(`${p.name} left the room.`, 'warning');
|
||||
showGameToast(`${p.name} left the room.`, 'warning');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -114,16 +118,16 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
const old = prev.find(o => o.id === p.id);
|
||||
if (old) {
|
||||
if (!old.isOffline && p.isOffline) {
|
||||
showToast(`${p.name} lost connection.`, 'error');
|
||||
showGameToast(`${p.name} lost connection.`, 'error');
|
||||
}
|
||||
if (old.isOffline && !p.isOffline) {
|
||||
showToast(`${p.name} reconnected!`, 'success');
|
||||
showGameToast(`${p.name} reconnected!`, 'success');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
prevPlayersRef.current = curr;
|
||||
}, [room.players, notificationsEnabled, showToast]);
|
||||
}, [room.players, notificationsEnabled, showGameToast]);
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
@@ -181,16 +185,67 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
setGameState(data);
|
||||
};
|
||||
|
||||
const handleTournamentUpdate = (data: any) => {
|
||||
setTournamentState(data);
|
||||
};
|
||||
|
||||
// Also handle finish
|
||||
const handleTournamentFinished = (data: any) => {
|
||||
showGameToast(`Tournament Winner: ${data.winner.name}!`, 'success');
|
||||
};
|
||||
|
||||
socket.on('draft_update', handleDraftUpdate);
|
||||
socket.on('draft_error', handleDraftError);
|
||||
socket.on('game_update', handleGameUpdate);
|
||||
socket.on('tournament_update', handleTournamentUpdate);
|
||||
socket.on('tournament_finished', handleTournamentFinished);
|
||||
|
||||
socket.on('match_start', () => {
|
||||
setPreparingMatchId(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off('draft_update', handleDraftUpdate);
|
||||
socket.off('draft_error', handleDraftError);
|
||||
socket.off('game_update', handleGameUpdate);
|
||||
socket.off('tournament_update', handleTournamentUpdate);
|
||||
socket.off('tournament_finished', handleTournamentFinished);
|
||||
socket.off('tournament_finished', handleTournamentFinished);
|
||||
socket.off('match_start');
|
||||
socket.off('game_error');
|
||||
};
|
||||
}, []);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Add a specific effect for game_error if avoiding the big dependency array is desired,
|
||||
// or just append to the existing effect content.
|
||||
useEffect(() => {
|
||||
const socket = socketService.socket;
|
||||
const handleGameError = (data: { message: string, userId?: string }) => {
|
||||
// Only show error if it's for me, or maybe generic "Action Failed"
|
||||
if (data.userId && data.userId !== currentPlayerId) return; // Don't spam others errors?
|
||||
|
||||
showGameToast(data.message, 'error');
|
||||
addLog(data.message, 'error', 'System'); // Add to log
|
||||
};
|
||||
|
||||
const handleGameNotification = (data: { message: string, type?: 'info' | 'success' | 'warning' | 'error' }) => {
|
||||
showGameToast(data.message, data.type || 'info');
|
||||
|
||||
// Infer source from message content or default to System
|
||||
// Ideally backend sends source, but for now we parse or default
|
||||
let source = 'System';
|
||||
if (data.message.includes('turn')) source = 'Game'; // Example heuristic
|
||||
|
||||
addLog(data.message, (data.type as any) || 'info', source);
|
||||
};
|
||||
|
||||
socket.on('game_error', handleGameError);
|
||||
socket.on('game_notification', handleGameNotification);
|
||||
return () => {
|
||||
socket.off('game_error', handleGameError);
|
||||
socket.off('game_notification', handleGameNotification);
|
||||
};
|
||||
}, [currentPlayerId, showGameToast]);
|
||||
|
||||
const sendMessage = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -272,6 +327,29 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
return <DeckBuilderView roomId={room.id} currentPlayerId={currentPlayerId} initialPool={myPool} availableBasicLands={room.basicLands} />;
|
||||
}
|
||||
|
||||
if (room.status === 'tournament' && tournamentState) {
|
||||
if (preparingMatchId) {
|
||||
const myTournamentPlayer = tournamentState.players.find((p: any) => p.id === currentPlayerId);
|
||||
const myPool = draftState?.players[currentPlayerId]?.pool || [];
|
||||
const myDeck = myTournamentPlayer?.deck || [];
|
||||
|
||||
return <DeckBuilderView
|
||||
roomId={room.id}
|
||||
currentPlayerId={currentPlayerId}
|
||||
initialPool={myPool}
|
||||
initialDeck={myDeck}
|
||||
availableBasicLands={room.basicLands}
|
||||
onSubmit={(deck) => {
|
||||
socketService.socket.emit('match_ready', { matchId: preparingMatchId, deck });
|
||||
setPreparingMatchId(null);
|
||||
showGameToast("Deck ready! Waiting for game to start...", 'success');
|
||||
}}
|
||||
submitLabel="Ready for Match"
|
||||
/>;
|
||||
}
|
||||
return <TournamentView tournament={tournamentState} currentPlayerId={currentPlayerId} onJoinMatch={setPreparingMatchId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl flex flex-col items-center justify-center">
|
||||
<h2 className="text-3xl font-bold text-white mb-4">Waiting for Players...</h2>
|
||||
@@ -601,3 +679,13 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GameRoom: React.FC<GameRoomProps> = (props) => {
|
||||
return (
|
||||
<GameToastProvider>
|
||||
<GameLogProvider>
|
||||
<GameRoomContent {...props} />
|
||||
</GameLogProvider>
|
||||
</GameToastProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,97 +1,129 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Users } from 'lucide-react';
|
||||
import { useToast } from '../../components/Toast';
|
||||
import React from 'react';
|
||||
import { Trophy, Play } from 'lucide-react';
|
||||
import { socketService } from '../../services/SocketService';
|
||||
|
||||
interface TournamentPlayer {
|
||||
id: string;
|
||||
name: string;
|
||||
isBot: boolean;
|
||||
}
|
||||
|
||||
interface Match {
|
||||
id: number;
|
||||
p1: string;
|
||||
p2: string;
|
||||
id: string;
|
||||
round: number;
|
||||
matchIndex: number;
|
||||
player1: TournamentPlayer | null;
|
||||
player2: TournamentPlayer | null;
|
||||
winnerId?: string;
|
||||
status: 'pending' | 'ready' | 'in_progress' | 'finished';
|
||||
}
|
||||
|
||||
interface Bracket {
|
||||
round1: Match[];
|
||||
totalPlayers: number;
|
||||
interface Tournament {
|
||||
id: string;
|
||||
players: TournamentPlayer[];
|
||||
rounds: Match[][];
|
||||
currentRound: number;
|
||||
status: 'setup' | 'active' | 'finished';
|
||||
winner?: TournamentPlayer;
|
||||
}
|
||||
|
||||
export const TournamentManager: React.FC = () => {
|
||||
const [playerInput, setPlayerInput] = useState('');
|
||||
const [bracket, setBracket] = useState<Bracket | null>(null);
|
||||
const { showToast } = useToast();
|
||||
interface TournamentManagerProps {
|
||||
tournament: Tournament;
|
||||
currentPlayerId: string;
|
||||
onJoinMatch: (matchId: string) => void;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
export const TournamentManager: React.FC<TournamentManagerProps> = ({ tournament, currentPlayerId, onJoinMatch }) => {
|
||||
const { rounds, winner } = tournament;
|
||||
|
||||
const generateBracket = () => {
|
||||
if (!playerInput.trim()) return;
|
||||
const names = playerInput.split('\n').filter(n => n.trim() !== '').map(n => n.trim());
|
||||
if (names.length < 2) {
|
||||
showToast("Enter at least 2 players.", 'error');
|
||||
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 });
|
||||
const handleJoinMatch = (matchId: string) => {
|
||||
socketService.socket.emit('join_match', { matchId }, (response: any) => {
|
||||
if (!response.success) {
|
||||
console.error(response.message);
|
||||
// Ideally show toast
|
||||
alert(response.message); // Fallback
|
||||
} else {
|
||||
onJoinMatch(matchId);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto 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 className="h-full overflow-y-auto max-w-6xl mx-auto p-4 md:p-6 text-slate-100">
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl mb-8 flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<Trophy className="w-6 h-6 text-yellow-500" /> Tournament Bracket
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm mt-1">Round {tournament.currentRound}</p>
|
||||
</div>
|
||||
{winner && (
|
||||
<div className="bg-yellow-500/20 border border-yellow-500 text-yellow-200 px-6 py-3 rounded-xl flex items-center gap-3 animate-pulse">
|
||||
<Trophy className="w-8 h-8" />
|
||||
<div>
|
||||
<div className="text-xs uppercase font-bold tracking-wider">Winner</div>
|
||||
<div className="text-xl font-bold">{winner.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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 className="flex gap-8 overflow-x-auto pb-8 snap-x">
|
||||
{rounds.map((roundMatches, roundIndex) => (
|
||||
<div key={roundIndex} className="flex flex-col justify-center gap-16 min-w-[280px] snap-center">
|
||||
<h3 className="text-center font-bold text-slate-500 uppercase tracking-widest text-sm mb-4">
|
||||
{roundIndex === rounds.length - 1 ? "Finals" : `Round ${roundIndex + 1}`}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-8 justify-center flex-1">
|
||||
{roundMatches.map((match) => {
|
||||
const isMyMatch = (match.player1?.id === currentPlayerId || match.player2?.id === currentPlayerId);
|
||||
const isPlayable = isMyMatch && match.status === 'ready' && !match.winnerId;
|
||||
|
||||
return (
|
||||
<div key={match.id} className={`bg-slate-900 border ${isMyMatch ? 'border-blue-500 ring-1 ring-blue-500/50' : 'border-slate-700'} rounded-lg p-0 overflow-hidden relative shadow-lg`}>
|
||||
{/* Status Indicator */}
|
||||
{match.status === 'in_progress' && <div className="absolute top-0 right-0 bg-green-500 text-xs text-black font-bold px-2 py-0.5">LIVE</div>}
|
||||
|
||||
<div className={`p-3 border-b border-slate-800 flex justify-between items-center ${match.winnerId === match.player1?.id ? 'bg-emerald-900/30' : ''}`}>
|
||||
<span className={match.player1 ? 'font-bold' : 'text-slate-600 italic'}>
|
||||
{match.player1 ? match.player1.name : 'Waiting...'}
|
||||
</span>
|
||||
{match.winnerId === match.player1?.id && <Trophy className="w-4 h-4 text-emerald-500" />}
|
||||
</div>
|
||||
<div className={`p-3 flex justify-between items-center ${match.winnerId === match.player2?.id ? 'bg-emerald-900/30' : ''}`}>
|
||||
<span className={match.player2 ? 'font-bold' : 'text-slate-600 italic'}>
|
||||
{match.player2 ? match.player2.name : 'Waiting...'}
|
||||
</span>
|
||||
{match.winnerId === match.player2?.id && <Trophy className="w-4 h-4 text-emerald-500" />}
|
||||
</div>
|
||||
|
||||
{isPlayable && (
|
||||
<button
|
||||
onClick={() => handleJoinMatch(match.id)}
|
||||
className="w-full bg-blue-600 hover:bg-blue-500 text-white font-bold py-2 flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<Play className="w-4 h-4" /> Play Match
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Resume Button */}
|
||||
{match.status === 'in_progress' && isMyMatch && (
|
||||
<button
|
||||
onClick={() => handleJoinMatch(match.id)}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-500 text-white font-bold py-2 flex items-center justify-center gap-2 transition-colors animate-pulse"
|
||||
>
|
||||
<Play className="w-4 h-4" /> Resume Match
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface ProcessedPools {
|
||||
mythics: DraftCard[];
|
||||
lands: DraftCard[];
|
||||
tokens: DraftCard[];
|
||||
specialGuests: DraftCard[];
|
||||
}
|
||||
|
||||
export interface SetsMap {
|
||||
@@ -71,6 +72,7 @@ export interface SetsMap {
|
||||
mythics: DraftCard[];
|
||||
lands: DraftCard[];
|
||||
tokens: DraftCard[];
|
||||
specialGuests: DraftCard[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,10 +84,11 @@ export interface PackGenerationSettings {
|
||||
|
||||
export class PackGeneratorService {
|
||||
|
||||
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: [] };
|
||||
processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }, useLocalImages: boolean = false, setsMetadata: { [code: string]: { parent_set_code?: string } } = {}): { pools: ProcessedPools, sets: SetsMap } {
|
||||
const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [], specialGuests: [] };
|
||||
const setsMap: SetsMap = {};
|
||||
|
||||
// 1. First Pass: Organize into SetsMap
|
||||
cards.forEach(cardData => {
|
||||
const rarity = cardData.rarity;
|
||||
const typeLine = cardData.type_line || '';
|
||||
@@ -159,10 +162,11 @@ export class PackGeneratorService {
|
||||
else if (rarity === 'uncommon') pools.uncommons.push(cardObj);
|
||||
else if (rarity === 'rare') pools.rares.push(cardObj);
|
||||
else if (rarity === 'mythic') pools.mythics.push(cardObj);
|
||||
else pools.specialGuests.push(cardObj); // Catch-all for special/bonus
|
||||
|
||||
// Add to Sets Map
|
||||
if (!setsMap[cardData.set]) {
|
||||
setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [] };
|
||||
setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [], specialGuests: [] };
|
||||
}
|
||||
const setEntry = setsMap[cardData.set];
|
||||
|
||||
@@ -182,6 +186,43 @@ export class PackGeneratorService {
|
||||
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); }
|
||||
else { pools.specialGuests.push(cardObj); setEntry.specialGuests.push(cardObj); } // Catch-all for special/bonus
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Second Pass: Merge Subsets (Masterpieces) into Parents
|
||||
Object.keys(setsMap).forEach(setCode => {
|
||||
const meta = setsMetadata[setCode];
|
||||
if (meta && meta.parent_set_code) {
|
||||
const parentCode = meta.parent_set_code;
|
||||
if (setsMap[parentCode]) {
|
||||
const parentSet = setsMap[parentCode];
|
||||
const childSet = setsMap[setCode];
|
||||
|
||||
// Move ALL cards from child set to parent's 'specialGuests' pool
|
||||
// We iterate all pools of the child set
|
||||
const allChildCards = [
|
||||
...childSet.commons,
|
||||
...childSet.uncommons,
|
||||
...childSet.rares,
|
||||
...childSet.mythics,
|
||||
...childSet.specialGuests, // Include explicit specials
|
||||
// ...childSet.lands, // usually keeps land separate? or special lands?
|
||||
// Let's treat everything non-token as special guest candidate
|
||||
];
|
||||
|
||||
parentSet.specialGuests.push(...allChildCards);
|
||||
pools.specialGuests.push(...allChildCards);
|
||||
|
||||
// IMPORTANT: If we are in 'by_set' mode, we might NOT want to generate packs for the child set anymore?
|
||||
// Or we leave them there but they are ALSO in the parent's special pool?
|
||||
// The request implies "merged".
|
||||
// If we leave them in setsMap under their own code, they will generate their own packs in 'by_set' mode.
|
||||
// If the user selected BOTH, they probably want the "Special Guest" experience AND maybe separate packs?
|
||||
// Usually "Drafting WOT" separately is possible.
|
||||
// But "Drafting WOE" should include "WOT".
|
||||
// So copying is correct.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -198,7 +239,8 @@ export class PackGeneratorService {
|
||||
rares: this.shuffle(pools.rares),
|
||||
mythics: this.shuffle(pools.mythics),
|
||||
lands: this.shuffle(pools.lands),
|
||||
tokens: this.shuffle(pools.tokens)
|
||||
tokens: this.shuffle(pools.tokens),
|
||||
specialGuests: this.shuffle(pools.specialGuests)
|
||||
};
|
||||
|
||||
let packId = 1;
|
||||
@@ -224,7 +266,8 @@ export class PackGeneratorService {
|
||||
rares: this.shuffle(setData.rares),
|
||||
mythics: this.shuffle(setData.mythics),
|
||||
lands: this.shuffle(setData.lands),
|
||||
tokens: this.shuffle(setData.tokens)
|
||||
tokens: this.shuffle(setData.tokens),
|
||||
specialGuests: this.shuffle(setData.specialGuests)
|
||||
};
|
||||
|
||||
while (true) {
|
||||
@@ -251,10 +294,6 @@ export class PackGeneratorService {
|
||||
const drawC = this.drawColorBalanced(currentPools.commons, commonsNeeded, namesInThisPack);
|
||||
|
||||
if (!drawC.success && currentPools.commons.length >= commonsNeeded) {
|
||||
// If we have enough cards but failed strict color balancing, we might accept it or fail.
|
||||
// Standard algo returns null on failure. Let's do same to be safe, or just accept partial.
|
||||
// Given "Naive approach" in drawColorBalanced, if it returns success=false but has cards, it meant it couldn't find unique ones?
|
||||
// drawUniqueCards (called by drawColorBalanced) checks if we have enough cards.
|
||||
return null;
|
||||
} else if (currentPools.commons.length < commonsNeeded) {
|
||||
return null;
|
||||
@@ -265,9 +304,9 @@ export class PackGeneratorService {
|
||||
drawC.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
|
||||
// 2. Slot 7: Common / The List
|
||||
// 1-87: Common from Main Set
|
||||
// 88-97: Card from "The List" (Common/Uncommon)
|
||||
// 98-100: Uncommon from "The List"
|
||||
// 1-87: 1 Common from Main Set.
|
||||
// 88-97: 1 Card from "The List" (Common/Uncommon reprint).
|
||||
// 98-100: 1 Uncommon from "The List".
|
||||
const roll7 = Math.floor(Math.random() * 100) + 1;
|
||||
let slot7Card: DraftCard | undefined;
|
||||
|
||||
@@ -276,25 +315,30 @@ export class PackGeneratorService {
|
||||
const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.commons = res.remainingPool; }
|
||||
} else if (roll7 <= 97) {
|
||||
// List (Common/Uncommon). Simulating by picking 50/50 C/U if actual List not available
|
||||
const useUncommon = Math.random() < 0.5;
|
||||
const pool = useUncommon ? currentPools.uncommons : currentPools.commons;
|
||||
// Fallback if one pool is empty
|
||||
const effectivePool = pool.length > 0 ? pool : (useUncommon ? currentPools.commons : currentPools.uncommons);
|
||||
|
||||
if (effectivePool.length > 0) {
|
||||
const res = this.drawUniqueCards(effectivePool, 1, namesInThisPack);
|
||||
// List (Common/Uncommon). Use SpecialGuests or 50/50 fallback
|
||||
if (currentPools.specialGuests.length > 0) {
|
||||
const res = this.drawUniqueCards(currentPools.specialGuests, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.specialGuests = res.remainingPool; }
|
||||
} else {
|
||||
// Fallback
|
||||
const pool = Math.random() < 0.5 ? currentPools.commons : currentPools.uncommons;
|
||||
const res = this.drawUniqueCards(pool, 1, namesInThisPack);
|
||||
if (res.success) {
|
||||
slot7Card = res.selected[0];
|
||||
// Identify which pool to update
|
||||
if (effectivePool === currentPools.uncommons) currentPools.uncommons = res.remainingPool;
|
||||
else currentPools.commons = res.remainingPool;
|
||||
if (pool === currentPools.commons) currentPools.commons = res.remainingPool;
|
||||
else currentPools.uncommons = res.remainingPool;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 98-100: Uncommon (from List or pool)
|
||||
const res = this.drawUniqueCards(currentPools.uncommons, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.uncommons = res.remainingPool; }
|
||||
// 98-100: Uncommon from "The List"
|
||||
if (currentPools.specialGuests.length > 0) {
|
||||
const res = this.drawUniqueCards(currentPools.specialGuests, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.specialGuests = res.remainingPool; }
|
||||
} else {
|
||||
// Fallback
|
||||
const res = this.drawUniqueCards(currentPools.uncommons, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.uncommons = res.remainingPool; }
|
||||
}
|
||||
}
|
||||
|
||||
if (slot7Card) {
|
||||
@@ -305,7 +349,6 @@ export class PackGeneratorService {
|
||||
// 3. Slots 8-11: Uncommons (4 cards)
|
||||
const uncommonsNeeded = 4;
|
||||
const drawU = this.drawUniqueCards(currentPools.uncommons, uncommonsNeeded, namesInThisPack);
|
||||
// We accept partial if pool depleted to avoid crashing, but standard behavior is usually strict.
|
||||
packCards.push(...drawU.selected);
|
||||
currentPools.uncommons = drawU.remainingPool;
|
||||
drawU.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
@@ -329,25 +372,19 @@ export class PackGeneratorService {
|
||||
namesInThisPack.add(landCard.name);
|
||||
}
|
||||
|
||||
// Helper for Wildcards
|
||||
// Helper for Wildcards (Peasant)
|
||||
const drawWildcard = (foil: boolean) => {
|
||||
// ~62% Common, ~37% Uncommon
|
||||
const wRoll = Math.random() * 100;
|
||||
let wRarity = 'common';
|
||||
// ~49% Common, ~24% Uncommon, ~13% Rare, ~13% Mythic
|
||||
if (wRoll > 87) wRarity = 'mythic';
|
||||
else if (wRoll > 74) wRarity = 'rare';
|
||||
else if (wRoll > 50) wRarity = 'uncommon';
|
||||
else wRarity = 'common';
|
||||
if (wRoll > 62) wRarity = 'uncommon';
|
||||
|
||||
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; }
|
||||
if (wRarity === 'uncommon') { poolToUse = currentPools.uncommons; updatePool = (p) => currentPools.uncommons = p; }
|
||||
else { poolToUse = currentPools.commons; updatePool = (p) => currentPools.commons = p; }
|
||||
|
||||
// Fallback
|
||||
if (poolToUse.length === 0) {
|
||||
if (currentPools.commons.length > 0) { poolToUse = currentPools.commons; updatePool = (p) => currentPools.commons = p; }
|
||||
}
|
||||
@@ -380,14 +417,14 @@ export class PackGeneratorService {
|
||||
}
|
||||
|
||||
} else {
|
||||
// --- NEW ALGORITHM (Play Booster) ---
|
||||
// --- NEW ALGORITHM (Standard / 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
|
||||
currentPools.commons = drawC.remainingPool;
|
||||
drawC.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
|
||||
// 2. Slots 8-10: Uncommons (3 cards)
|
||||
@@ -399,7 +436,7 @@ export class PackGeneratorService {
|
||||
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);
|
||||
const isMythic = Math.random() < 0.125;
|
||||
let rarePicked = false;
|
||||
|
||||
if (isMythic && currentPools.mythics.length > 0) {
|
||||
@@ -422,10 +459,11 @@ export class PackGeneratorService {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 4. Slot 7: Common / The List / Special Guest
|
||||
// 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).
|
||||
const roll7 = Math.floor(Math.random() * 100) + 1;
|
||||
let slot7Card: DraftCard | undefined;
|
||||
|
||||
@@ -434,36 +472,42 @@ export class PackGeneratorService {
|
||||
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; }
|
||||
// List (Common/Uncommon)
|
||||
if (currentPools.specialGuests.length > 0) {
|
||||
const res = this.drawUniqueCards(currentPools.specialGuests, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.specialGuests = res.remainingPool; }
|
||||
} else {
|
||||
const pool = Math.random() < 0.5 ? currentPools.commons : currentPools.uncommons;
|
||||
const res = this.drawUniqueCards(pool, 1, namesInThisPack);
|
||||
if (res.success) {
|
||||
slot7Card = res.selected[0];
|
||||
if (pool === currentPools.commons) currentPools.commons = res.remainingPool;
|
||||
else currentPools.uncommons = res.remainingPool;
|
||||
}
|
||||
}
|
||||
} else if (roll7 <= 99) {
|
||||
// List (Rare/Mythic)
|
||||
if (currentPools.specialGuests.length > 0) {
|
||||
const res = this.drawUniqueCards(currentPools.specialGuests, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.specialGuests = res.remainingPool; }
|
||||
} else {
|
||||
const pool = Math.random() < 0.125 ? currentPools.mythics : currentPools.rares;
|
||||
const res = this.drawUniqueCards(pool, 1, namesInThisPack);
|
||||
if (res.success) {
|
||||
slot7Card = res.selected[0];
|
||||
if (pool === currentPools.mythics) currentPools.mythics = res.remainingPool;
|
||||
else currentPools.rares = 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) {
|
||||
// 100: Special Guest
|
||||
if (currentPools.specialGuests.length > 0) {
|
||||
const res = this.drawUniqueCards(currentPools.specialGuests, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.specialGuests = res.remainingPool; }
|
||||
} else {
|
||||
// Fallback Mythic
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,7 +532,6 @@ export class PackGeneratorService {
|
||||
// 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) {
|
||||
@@ -498,8 +541,7 @@ export class PackGeneratorService {
|
||||
}
|
||||
|
||||
// 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
|
||||
// Weights: ~49% C, ~24% U, ~13% R, ~13% M
|
||||
const drawWildcard = (foil: boolean) => {
|
||||
const wRoll = Math.random() * 100;
|
||||
let wRarity = 'common';
|
||||
@@ -508,7 +550,6 @@ export class PackGeneratorService {
|
||||
else if (wRoll > 50) wRarity = 'uncommon';
|
||||
else wRarity = 'common';
|
||||
|
||||
// Adjust buckets
|
||||
let poolToUse: DraftCard[] = [];
|
||||
let updatePool = (_newPool: DraftCard[]) => { };
|
||||
|
||||
@@ -518,7 +559,6 @@ export class PackGeneratorService {
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -541,26 +581,15 @@ export class PackGeneratorService {
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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'.
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -162,14 +162,16 @@ export class ScryfallService {
|
||||
const data = await response.json();
|
||||
if (data.data) {
|
||||
return data.data.filter((s: any) =>
|
||||
['core', 'expansion', 'masters', 'draft_innovation'].includes(s.set_type)
|
||||
['core', 'expansion', 'masters', 'draft_innovation', 'commander', 'funny', 'masterpiece', 'eternal'].includes(s.set_type)
|
||||
).map((s: any) => ({
|
||||
code: s.code,
|
||||
name: s.name,
|
||||
set_type: s.set_type,
|
||||
released_at: s.released_at,
|
||||
icon_svg_uri: s.icon_svg_uri,
|
||||
digital: s.digital
|
||||
digital: s.digital,
|
||||
parent_set_code: s.parent_set_code,
|
||||
card_count: s.card_count
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -178,7 +180,7 @@ export class ScryfallService {
|
||||
return [];
|
||||
}
|
||||
|
||||
async fetchSetCards(setCode: string, onProgress?: (current: number) => void): Promise<ScryfallCard[]> {
|
||||
async fetchSetCards(setCode: string, relatedSets: 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?
|
||||
@@ -186,7 +188,9 @@ export class ScryfallService {
|
||||
// But for now, we just fetch and merge.
|
||||
|
||||
let cards: ScryfallCard[] = [];
|
||||
let url = `https://api.scryfall.com/cards/search?q=set:${setCode}&unique=cards`;
|
||||
const setClause = `e:${setCode}` + relatedSets.map(s => ` OR e:${s}`).join('');
|
||||
// User requested pattern: (e:main or e:sub) and is:booster unique=prints
|
||||
let url = `https://api.scryfall.com/cards/search?q=(${setClause}) unique=prints is:booster`;
|
||||
|
||||
while (url) {
|
||||
try {
|
||||
@@ -228,4 +232,6 @@ export interface ScryfallSet {
|
||||
released_at: string;
|
||||
icon_svg_uri: string;
|
||||
digital: boolean;
|
||||
parent_set_code?: string;
|
||||
card_count: number;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,13 @@ class SocketService {
|
||||
this.socket = io(URL, {
|
||||
autoConnect: false
|
||||
});
|
||||
|
||||
// Debug Wrapper
|
||||
const originalEmit = this.socket.emit;
|
||||
this.socket.emit = (event: string, ...args: any[]) => {
|
||||
console.log(`[Socket] 📤 Emitting: ${event}`, args);
|
||||
return originalEmit.apply(this.socket, [event, ...args]);
|
||||
};
|
||||
}
|
||||
|
||||
connect() {
|
||||
|
||||
@@ -20,7 +20,9 @@ export interface StackObject {
|
||||
|
||||
export interface CardInstance {
|
||||
instanceId: string;
|
||||
oracleId: string; // Scryfall ID
|
||||
scryfallId: string; // Used for cache hydration
|
||||
setCode?: string; // Used for cache hydration
|
||||
oracleId: string; // Scryfall Oracle ID
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
controllerId: string;
|
||||
@@ -54,6 +56,7 @@ export interface CardInstance {
|
||||
png?: string;
|
||||
border_crop?: string;
|
||||
};
|
||||
imageArtCrop?: string;
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
@@ -67,6 +70,7 @@ export interface PlayerState {
|
||||
manaPool?: Record<string, number>;
|
||||
handKept?: boolean;
|
||||
mulliganCount?: number;
|
||||
isBot?: boolean;
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
|
||||
147
src/client/src/utils/manaUtils.ts
Normal file
147
src/client/src/utils/manaUtils.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
|
||||
import { CardInstance, PlayerState } from '../types/game';
|
||||
|
||||
// Helper to determine land color identity from type line or name
|
||||
export const getLandColor = (card: CardInstance): string | null => {
|
||||
const typeLine = card.typeLine || '';
|
||||
const types = card.types || [];
|
||||
|
||||
if (!typeLine.includes('Land') && !types.includes('Land')) return null;
|
||||
|
||||
if (typeLine.includes('Plains')) return 'W';
|
||||
if (typeLine.includes('Island')) return 'U';
|
||||
if (typeLine.includes('Swamp')) return 'B';
|
||||
if (typeLine.includes('Mountain')) return 'R';
|
||||
if (typeLine.includes('Forest')) return 'G';
|
||||
|
||||
// TODO: Wastes
|
||||
return null;
|
||||
};
|
||||
|
||||
export const parseManaCost = (manaCost: string): { generic: number, colors: Record<string, number>, hybrids: string[][] } => {
|
||||
const cost = { generic: 0, colors: { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 } as Record<string, number>, hybrids: [] as string[][] };
|
||||
|
||||
if (!manaCost) return cost;
|
||||
|
||||
const matches = manaCost.match(/{[^{}]+}/g);
|
||||
if (!matches) return cost;
|
||||
|
||||
matches.forEach(symbol => {
|
||||
const content = symbol.replace(/[{}]/g, '');
|
||||
|
||||
if (!isNaN(Number(content))) {
|
||||
cost.generic += Number(content);
|
||||
}
|
||||
else if (content.includes('/')) {
|
||||
const parts = content.split('/');
|
||||
const options = parts.filter(p => ['W', 'U', 'B', 'R', 'G', 'C'].includes(p));
|
||||
if (options.length >= 1) {
|
||||
cost.hybrids.push(options);
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (['W', 'U', 'B', 'R', 'G', 'C'].includes(content)) {
|
||||
cost.colors[content]++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return cost;
|
||||
};
|
||||
|
||||
// Returns a set of card IDs to tap
|
||||
export const calculateAutoTap = (
|
||||
costStr: string,
|
||||
player: PlayerState,
|
||||
myLands: CardInstance[]
|
||||
): Set<string> => {
|
||||
const landsToTap = new Set<string>();
|
||||
const cost = parseManaCost(costStr);
|
||||
|
||||
// Clone pool so we don't mutate state locally
|
||||
const pool = { ...player.manaPool };
|
||||
if (!pool.W) pool.W = 0; if (!pool.U) pool.U = 0; if (!pool.B) pool.B = 0;
|
||||
if (!pool.R) pool.R = 0; if (!pool.G) pool.G = 0; if (!pool.C) pool.C = 0;
|
||||
|
||||
// Filter usable lands (untapped)
|
||||
// We only consider lands that haven't been marked for tap yet (initially none)
|
||||
const availableLands = myLands.filter(l => !l.tapped);
|
||||
|
||||
// 1. Pay Colored Costs
|
||||
for (const color of ['W', 'U', 'B', 'R', 'G', 'C']) {
|
||||
let required = cost.colors[color];
|
||||
if (required <= 0) continue;
|
||||
|
||||
// Pool First
|
||||
if (pool[color] >= required) {
|
||||
pool[color] -= required;
|
||||
required = 0;
|
||||
} else {
|
||||
required -= pool[color];
|
||||
pool[color] = 0;
|
||||
}
|
||||
|
||||
// Lands
|
||||
if (required > 0) {
|
||||
const producers = availableLands.filter(l => !landsToTap.has(l.instanceId) && getLandColor(l) === color);
|
||||
if (producers.length >= required) {
|
||||
for (let i = 0; i < required; i++) {
|
||||
landsToTap.add(producers[i].instanceId);
|
||||
}
|
||||
required = 0;
|
||||
} else {
|
||||
// Cannot pay strictly
|
||||
return new Set(); // Fail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Pay Hybrid (Greedy)
|
||||
for (const options of cost.hybrids) {
|
||||
let paid = false;
|
||||
for (const color of options) {
|
||||
if (pool[color] > 0) {
|
||||
pool[color]--;
|
||||
paid = true;
|
||||
break;
|
||||
}
|
||||
const land = availableLands.find(l => !landsToTap.has(l.instanceId) && getLandColor(l) === color);
|
||||
if (land) {
|
||||
landsToTap.add(land.instanceId);
|
||||
paid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If greedy fail, we might fail overall.
|
||||
// Real auto-tapper might backtrack, but for preview/MVP we match server greedy logic.
|
||||
if (!paid) return new Set();
|
||||
}
|
||||
|
||||
// 3. Pay Generic
|
||||
let genericRequired = cost.generic;
|
||||
if (genericRequired > 0) {
|
||||
// Pool
|
||||
for (const color of Object.keys(pool)) {
|
||||
if (genericRequired <= 0) break;
|
||||
const available = pool[color];
|
||||
if (available > 0) {
|
||||
const take = Math.min(available, genericRequired);
|
||||
pool[color] -= take;
|
||||
genericRequired -= take;
|
||||
}
|
||||
}
|
||||
// Lands
|
||||
if (genericRequired > 0) {
|
||||
const unusedLands = availableLands.filter(l => !landsToTap.has(l.instanceId) && getLandColor(l) !== null);
|
||||
if (unusedLands.length >= genericRequired) {
|
||||
for (let i = 0; i < genericRequired; i++) {
|
||||
landsToTap.add(unusedLands[i].instanceId);
|
||||
}
|
||||
} else {
|
||||
return new Set(); // Fail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return landsToTap;
|
||||
};
|
||||
94
src/client/src/utils/textUtils.tsx
Normal file
94
src/client/src/utils/textUtils.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { ManaIcon } from '../components/ManaIcon';
|
||||
|
||||
/**
|
||||
* Helper to parse a text segment and replace {X} symbols with icons.
|
||||
*/
|
||||
const parseSymbols = (text: string): React.ReactNode => {
|
||||
if (!text) return null;
|
||||
const parts = text.split(/(\{.*?\})/g);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => {
|
||||
if (part.startsWith('{') && part.endsWith('}')) {
|
||||
let content = part.slice(1, -1).toLowerCase();
|
||||
content = content.replace('/', '');
|
||||
|
||||
// Manual mapping for special symbols
|
||||
const symbolMap: Record<string, string> = {
|
||||
't': 'tap',
|
||||
'q': 'untap',
|
||||
};
|
||||
|
||||
if (symbolMap[content]) {
|
||||
content = symbolMap[content];
|
||||
}
|
||||
|
||||
return (
|
||||
<ManaIcon
|
||||
key={index}
|
||||
symbol={content}
|
||||
className="text-[0.9em] text-slate-900 mx-[1px] align-baseline inline-block"
|
||||
shadow
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span key={index}>{part}</span>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a string containing Magic: The Gathering symbols and lists.
|
||||
* Replaces symbols with ManaIcon components and bulleted lists with HTML structure.
|
||||
*/
|
||||
export const formatOracleText = (text: string | null | undefined): React.ReactNode => {
|
||||
if (!text) return null;
|
||||
|
||||
// Split by specific bullet character or newlines first
|
||||
// Some cards use actual newlines for abilities, some use bullets for modes.
|
||||
// We want to handle "•" as a list item start.
|
||||
|
||||
// Strategy:
|
||||
// 1. Split by newline to respect existing paragraph breaks.
|
||||
// 2. Inside each paragraph, check for bullets.
|
||||
|
||||
const lines = text.split('\n');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{lines.map((line, lineIdx) => {
|
||||
if (!line.trim()) return null;
|
||||
|
||||
// Check for bullets
|
||||
if (line.includes('•')) {
|
||||
const segments = line.split('•');
|
||||
|
||||
return (
|
||||
<div key={lineIdx} className="flex flex-col gap-0.5">
|
||||
{segments.map((seg, segIdx) => {
|
||||
const content = seg.trim();
|
||||
if (!content) return null;
|
||||
|
||||
// If it's the very first segment and the line didn't start with bullet, it's intro text.
|
||||
// If the line started with "•", segments[0] is empty (handled above).
|
||||
const isListItem = segIdx > 0 || line.trim().startsWith('•');
|
||||
|
||||
return (
|
||||
<div key={segIdx} className={`flex gap-1 ${isListItem ? 'ml-2 pl-2 border-l-2 border-white/10' : ''}`}>
|
||||
{isListItem && <span className="text-emerald-400 font-bold">•</span>}
|
||||
<span className={isListItem ? "text-slate-200" : ""}>{parseSymbols(content)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div key={lineIdx}>{parseSymbols(line)}</div>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
7
src/package-lock.json
generated
7
src/package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"express": "^4.21.2",
|
||||
"ioredis": "^5.8.2",
|
||||
"lucide-react": "^0.475.0",
|
||||
"mana-font": "^1.18.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"socket.io": "^4.8.1",
|
||||
@@ -5417,6 +5418,12 @@
|
||||
"sourcemap-codec": "^1.4.8"
|
||||
}
|
||||
},
|
||||
"node_modules/mana-font": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/mana-font/-/mana-font-1.18.0.tgz",
|
||||
"integrity": "sha512-PKCR5z/eeOBbox0Lu5b6A0QUWVUUa3A3LWXb9sw4N5ThIWXxRbCagXPSL4k6Cnh2Fre6Y4+2Xl819OrY7m1cUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"express": "^4.21.2",
|
||||
"ioredis": "^5.8.2",
|
||||
"lucide-react": "^0.475.0",
|
||||
"mana-font": "^1.18.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"socket.io": "^4.8.1",
|
||||
|
||||
@@ -13,17 +13,24 @@ export class RulesEngine {
|
||||
public passPriority(playerId: string): boolean {
|
||||
if (this.state.priorityPlayerId !== playerId) return false; // Not your turn
|
||||
|
||||
this.state.players[playerId].hasPassed = true;
|
||||
const player = this.state.players[playerId];
|
||||
player.hasPassed = true;
|
||||
this.state.passedPriorityCount++;
|
||||
|
||||
// Check if all players passed
|
||||
if (this.state.passedPriorityCount >= this.state.turnOrder.length) {
|
||||
const totalPlayers = this.state.turnOrder.length;
|
||||
|
||||
// Check if all players passed in a row
|
||||
if (this.state.passedPriorityCount >= totalPlayers) {
|
||||
// 1. If Stack is NOT empty, Resolve Top
|
||||
if (this.state.stack.length > 0) {
|
||||
this.resolveTopStack();
|
||||
} else {
|
||||
}
|
||||
// 2. If Stack IS empty, Advance Step
|
||||
else {
|
||||
this.advanceStep();
|
||||
}
|
||||
} else {
|
||||
// Pass Priority to Next Player
|
||||
this.passPriorityToNext();
|
||||
}
|
||||
return true;
|
||||
@@ -74,7 +81,18 @@ export class RulesEngine {
|
||||
const card = this.state.cards[cardId];
|
||||
if (!card || card.zone !== 'hand') throw new Error("Invalid card.");
|
||||
|
||||
// TODO: Check Timing (Instant vs Sorcery)
|
||||
// Validate Status (Aura needs target)
|
||||
if (this.isAura(card)) {
|
||||
if (targets.length === 0) throw new Error("Aura requires a target.");
|
||||
// Note: We don't strictly validate target legality here for "Casting",
|
||||
// but usually you can't cast if no legal target exists.
|
||||
// We'll trust the input for now, but `resolveTopStack` will verify correctness.
|
||||
}
|
||||
|
||||
// Validate Mana Cost
|
||||
if (card.manaCost) {
|
||||
this.payManaCost(playerId, card.manaCost);
|
||||
}
|
||||
|
||||
// Move to Stack
|
||||
card.zone = 'stack';
|
||||
@@ -95,6 +113,244 @@ export class RulesEngine {
|
||||
return true;
|
||||
}
|
||||
|
||||
public activateAbility(playerId: string, sourceId: string, _abilityIndex: number, targets: string[] = []) {
|
||||
if (this.state.priorityPlayerId !== playerId) throw new Error("Not your priority.");
|
||||
|
||||
const source = this.state.cards[sourceId];
|
||||
if (!source) throw new Error("Invalid source.");
|
||||
|
||||
// TODO: Generic Ability System
|
||||
// For now, we hardcode "Equip" as a special case if the card is Equipment.
|
||||
// In a real engine, we'd look up source.abilities[abilityIndex].
|
||||
|
||||
if (this.isEquipment(source) && source.zone === 'battlefield') {
|
||||
// Equip Ability
|
||||
// Cost: Usually generic. For MVP, assume {1} or free? Or look at card text?
|
||||
// Let's assume generic cost {1} for demo purposes unless defined.
|
||||
// 702.6a Equip [cost]: [Cost]: Attach to target creature you control. Sorcery speed.
|
||||
|
||||
// Speed Check (Sorcery)
|
||||
if (this.state.stack.length > 0 || (this.state.phase !== 'main1' && this.state.phase !== 'main2')) {
|
||||
throw new Error("Equip can only be used as a sorcery.");
|
||||
}
|
||||
|
||||
// Target Check
|
||||
if (targets.length !== 1) throw new Error("Equip requires exactly one target.");
|
||||
const targetId = targets[0];
|
||||
const target = this.state.cards[targetId];
|
||||
|
||||
if (!target || target.zone !== 'battlefield' || !this.isCreature(target) || target.controllerId !== playerId) {
|
||||
throw new Error("Invalid Equip target. Must be a creature you control.");
|
||||
}
|
||||
|
||||
// Pay Cost (Mock: {1})
|
||||
// this.payManaCost(playerId, "{1}");
|
||||
|
||||
// Resolve Immediately (Simple) or Stack (Correct)?
|
||||
// Activated abilities go on stack.
|
||||
this.state.stack.push({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
sourceId: sourceId,
|
||||
controllerId: playerId,
|
||||
type: 'ability',
|
||||
name: `Equip ${source.name} to ${target.name}`,
|
||||
text: `Attach ${source.name} to ${target.name}`,
|
||||
targets: [targetId]
|
||||
});
|
||||
|
||||
this.resetPriority(playerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error("Ability not implemented.");
|
||||
}
|
||||
|
||||
// --- Mana System ---
|
||||
|
||||
private parseManaCost(manaCost: string): { generic: number, colors: Record<string, number>, hybrids: string[][] } {
|
||||
const cost = { generic: 0, colors: { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 } as Record<string, number>, hybrids: [] as string[][] };
|
||||
|
||||
if (!manaCost) return cost;
|
||||
|
||||
// Use regex to match {X} blocks
|
||||
const matches = manaCost.match(/{[^{}]+}/g);
|
||||
if (!matches) return cost;
|
||||
|
||||
matches.forEach(symbol => {
|
||||
const content = symbol.replace(/[{}]/g, '');
|
||||
|
||||
// Check for generic number
|
||||
if (!isNaN(Number(content))) {
|
||||
cost.generic += Number(content);
|
||||
}
|
||||
// Check for Hybrid (contains /)
|
||||
else if (content.includes('/')) {
|
||||
// e.g. W/U, 2/W
|
||||
const parts = content.split('/');
|
||||
// For now, assume Color/Color hybrid.
|
||||
// TODO: Handle 2/W or P (Phyrexian) if needed.
|
||||
// We push the options as an array of possible colors/costs.
|
||||
// Filter valid colors to be safe.
|
||||
const options = parts.filter(p => ['W', 'U', 'B', 'R', 'G', 'C'].includes(p));
|
||||
|
||||
// Handle "2/W" -> If part is '2', it's generic? Auto-tap makes this hard.
|
||||
// For MVP, focus on Color/Color which solves the User Request (W/U).
|
||||
if (options.length >= 2) {
|
||||
cost.hybrids.push(options);
|
||||
} else if (options.length === 1 && !isNaN(Number(parts[0]))) {
|
||||
// Case like 2/W ?
|
||||
// cost.hybrids.push([...options, 'GENERIC_' + parts[0]]);
|
||||
// Let's stick to simple Color/Color for now or single color fallback.
|
||||
cost.hybrids.push(options); // treat as just the color requirement if regex fails strictly
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Standard colors
|
||||
if (['W', 'U', 'B', 'R', 'G', 'C'].includes(content)) {
|
||||
cost.colors[content]++;
|
||||
} else {
|
||||
// Handle 'X' or other symbols if necessary, currently ignored as 0 cost
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return cost;
|
||||
}
|
||||
|
||||
private getLandColor(card: any): string | null {
|
||||
if (!card.typeLine?.includes('Land') && !card.types.includes('Land')) return null;
|
||||
|
||||
// Basic heuristic based on type line names
|
||||
if (card.typeLine.includes('Plains')) return 'W';
|
||||
if (card.typeLine.includes('Island')) return 'U';
|
||||
if (card.typeLine.includes('Swamp')) return 'B';
|
||||
if (card.typeLine.includes('Mountain')) return 'R';
|
||||
if (card.typeLine.includes('Forest')) return 'G';
|
||||
|
||||
// Fallback: Wastes or special lands?
|
||||
// For MVP, we assume typed basic lands or nothing.
|
||||
return null;
|
||||
}
|
||||
|
||||
private payManaCost(playerId: string, manaCostStr: string) {
|
||||
const player = this.state.players[playerId];
|
||||
const cost = this.parseManaCost(manaCostStr);
|
||||
|
||||
// 1. Gather Resources
|
||||
const pool = { ...player.manaPool }; // Copy pool
|
||||
const lands = Object.values(this.state.cards).filter(c =>
|
||||
c.controllerId === playerId &&
|
||||
c.zone === 'battlefield' &&
|
||||
!c.tapped &&
|
||||
(c.types.includes('Land') || c.typeLine?.includes('Land'))
|
||||
);
|
||||
|
||||
const landsToTap: string[] = []; // List of IDs
|
||||
|
||||
// 2. Pay Colored Costs
|
||||
for (const color of ['W', 'U', 'B', 'R', 'G', 'C']) {
|
||||
let required = cost.colors[color];
|
||||
if (required <= 0) continue;
|
||||
|
||||
// a. Pay from Pool first
|
||||
if (pool[color] >= required) {
|
||||
pool[color] -= required;
|
||||
required = 0;
|
||||
} else {
|
||||
required -= pool[color];
|
||||
pool[color] = 0;
|
||||
}
|
||||
|
||||
// b. Pay from Lands
|
||||
if (required > 0) {
|
||||
// Find lands producing this color
|
||||
const producers = lands.filter(l => !landsToTap.includes(l.instanceId) && this.getLandColor(l) === color);
|
||||
|
||||
if (producers.length >= required) {
|
||||
// Mark first N as used
|
||||
for (let i = 0; i < required; i++) {
|
||||
landsToTap.push(producers[i].instanceId);
|
||||
}
|
||||
required = 0;
|
||||
} else {
|
||||
// Use all we have, but it's not enough
|
||||
throw new Error(`Insufficient ${color} mana.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2.5 Pay Hybrid Costs (Greedy Strategy)
|
||||
// For each hybrid requirement (e.g. [W, U]), try to pay with first option, then second.
|
||||
// Note: This is greedy. Optimal payment is NP-hard in general magic application,
|
||||
// but for simple auto-tapping we assume simple priority.
|
||||
for (const options of cost.hybrids) {
|
||||
let paid = false;
|
||||
// Try each color option
|
||||
for (const color of options) {
|
||||
// Check Pool
|
||||
if (pool[color] > 0) {
|
||||
pool[color]--;
|
||||
paid = true;
|
||||
break;
|
||||
}
|
||||
// Check Lands
|
||||
// Find a land that produces this color and is UNUSED
|
||||
const land = lands.find(l => !landsToTap.includes(l.instanceId) && this.getLandColor(l) === color);
|
||||
if (land) {
|
||||
landsToTap.push(land.instanceId);
|
||||
paid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!paid) {
|
||||
throw new Error(`Insufficient mana for hybrid cost {${options.join('/')}}.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Pay Generic Cost
|
||||
let genericRequired = cost.generic;
|
||||
|
||||
if (genericRequired > 0) {
|
||||
// a. Consume any remaining pools (greedy, order doesn't matter for generic usually, but maybe keep 'better' colors? No, random for now)
|
||||
for (const color of Object.keys(pool)) {
|
||||
if (genericRequired <= 0) break;
|
||||
const available = pool[color];
|
||||
if (available > 0) {
|
||||
const params = Math.min(available, genericRequired);
|
||||
pool[color] -= params;
|
||||
genericRequired -= params;
|
||||
}
|
||||
}
|
||||
|
||||
// b. Tap remaining unused lands
|
||||
if (genericRequired > 0) {
|
||||
// Filter lands not yet marked for tap
|
||||
const unusedLands = lands.filter(l => !landsToTap.includes(l.instanceId) && this.getLandColor(l) !== null);
|
||||
|
||||
if (unusedLands.length >= genericRequired) {
|
||||
for (let i = 0; i < genericRequired; i++) {
|
||||
landsToTap.push(unusedLands[i].instanceId);
|
||||
}
|
||||
genericRequired = 0;
|
||||
} else {
|
||||
throw new Error("Insufficient mana for generic cost.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Commit Payments
|
||||
// Update Pool
|
||||
player.manaPool = pool;
|
||||
// Tap Lands
|
||||
landsToTap.forEach(lid => {
|
||||
const land = this.state.cards[lid];
|
||||
land.tapped = true;
|
||||
console.log(`Auto-tapped ${land.name} for mana.`);
|
||||
});
|
||||
console.log(`Paid mana cost ${manaCostStr}. Remaining Pool:`, pool);
|
||||
}
|
||||
|
||||
public addMana(playerId: string, mana: { color: string, amount: number }) {
|
||||
// Check if player has priority or if checking for mana abilities?
|
||||
// 605.3a: Player may activate mana ability whenever they have priority... or when rule/effect asks for mana payment.
|
||||
@@ -156,7 +412,9 @@ export class RulesEngine {
|
||||
if (this.state.phase !== 'combat' || this.state.step !== 'declare_blockers') throw new Error("Not Declare Blockers step.");
|
||||
if (this.state.activePlayerId === playerId) throw new Error("Active Player cannot declare blockers.");
|
||||
|
||||
blockers.forEach(({ blockerId, attackerId }) => {
|
||||
// Safe handling if blockers is undefined
|
||||
const declaredBlockers = blockers || [];
|
||||
declaredBlockers.forEach(({ blockerId, attackerId }) => {
|
||||
const blocker = this.state.cards[blockerId];
|
||||
const attacker = this.state.cards[attackerId];
|
||||
|
||||
@@ -171,7 +429,9 @@ export class RulesEngine {
|
||||
// Note: 509.2. Damage Assignment Order (if multiple blockers)
|
||||
});
|
||||
|
||||
console.log(`Player ${playerId} declared ${blockers.length} blockers.`);
|
||||
console.log(`Player ${playerId} declared ${declaredBlockers.length} blockers.`);
|
||||
|
||||
this.state.blockersDeclared = true; // Fix: Ensure state reflects blockers were declared
|
||||
|
||||
// Priority goes to Active Player first after blockers declared
|
||||
this.resetPriority(this.state.activePlayerId);
|
||||
@@ -239,7 +499,7 @@ export class RulesEngine {
|
||||
toughness: number,
|
||||
keywords?: string[],
|
||||
imageUrl?: string
|
||||
}) {
|
||||
}, position?: { x: number, y: number }) {
|
||||
const token: any = { // Using any allowing partial CardObject construction
|
||||
instanceId: Math.random().toString(36).substring(7),
|
||||
oracleId: 'token-' + Math.random(),
|
||||
@@ -263,7 +523,7 @@ export class RulesEngine {
|
||||
imageUrl: definition.imageUrl || '',
|
||||
damageMarked: 0,
|
||||
controlledSinceTurn: this.state.turnCount,
|
||||
position: { x: Math.random() * 80, y: Math.random() * 80, z: ++this.state.maxZ }
|
||||
position: position ? { ...position, z: ++this.state.maxZ } : { x: Math.random() * 80, y: Math.random() * 80, z: ++this.state.maxZ }
|
||||
};
|
||||
|
||||
// Type-safe assignment
|
||||
@@ -320,18 +580,87 @@ export class RulesEngine {
|
||||
);
|
||||
|
||||
if (isPermanent) {
|
||||
this.moveCardToZone(card.instanceId, 'battlefield', false, item.resolutionPosition);
|
||||
if (this.isAura(card)) {
|
||||
// Aura Resolution
|
||||
const targetId = item.targets[0];
|
||||
const target = this.state.cards[targetId];
|
||||
if (target && target.zone === 'battlefield' && this.canAttach(card, target)) {
|
||||
this.moveCardToZone(card.instanceId, 'battlefield', false, item.resolutionPosition);
|
||||
card.attachedTo = target.instanceId;
|
||||
console.log(`${card.name} enters attached to ${target.name}`);
|
||||
} else {
|
||||
// 303.4g If an Aura is entering the battlefield and there is no legal object or player for it to enchant... it is put into its owner's graveyard.
|
||||
console.log(`${card.name} failed to attach. Putting into GY.`);
|
||||
this.moveCardToZone(card.instanceId, 'graveyard');
|
||||
}
|
||||
} else {
|
||||
// Normal Permanent
|
||||
this.moveCardToZone(card.instanceId, 'battlefield', false, item.resolutionPosition);
|
||||
}
|
||||
} else {
|
||||
// Instant / Sorcery
|
||||
this.moveCardToZone(card.instanceId, 'graveyard');
|
||||
}
|
||||
}
|
||||
} else if (item.type === 'ability') {
|
||||
// Handle Ability Resolution (Equip or other Generic)
|
||||
const source = this.state.cards[item.sourceId];
|
||||
if (source && source.zone === 'battlefield') {
|
||||
// Equipment Logic
|
||||
if (this.isEquipment(source)) {
|
||||
const targetId = item.targets[0];
|
||||
const target = this.state.cards[targetId];
|
||||
|
||||
// Generic Validation Check for "Attach"
|
||||
if (target && target.zone === 'battlefield' && this.validateTarget(source, target)) {
|
||||
source.attachedTo = target.instanceId;
|
||||
console.log(`Equipped ${source.name} to ${target.name}`);
|
||||
} else {
|
||||
console.log(`Equip failed: Target invalid.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After resolution, Active Player gets priority again (Rule 117.3b)
|
||||
this.resetPriority(this.state.activePlayerId);
|
||||
}
|
||||
|
||||
// --- Targeting System (Generic) ---
|
||||
|
||||
public validateTarget(source: any, target: any, actionType: 'cast' | 'equip' | 'ability' = 'ability'): boolean {
|
||||
// 1. Basic Existence
|
||||
if (!target || target.zone !== 'battlefield') return false;
|
||||
|
||||
// 2. Protection (Simple Check)
|
||||
if (target.pro_protection) {
|
||||
// Check if source characteristics match protection
|
||||
// E.g. Protection from White -> if source is White, return false.
|
||||
// Not fully implemented yet, but placeholder is here.
|
||||
}
|
||||
|
||||
// 3. Specific Restrictions based on Source Type
|
||||
if (this.isAura(source) || actionType === 'equip') {
|
||||
// "Enchant/Equip Creature" is default for MVP unless specified otherwise
|
||||
// Parse Text or Definition if available
|
||||
// For now, strict Creature check for Equip/Aura (unless Aura says otherwise in text)
|
||||
if (this.isEquipment(source) && !this.isCreature(target)) return false;
|
||||
if (this.isAura(source)) {
|
||||
// Example parsing: "Enchant land"
|
||||
if (source.oracleText?.toLowerCase().includes('enchant land')) {
|
||||
if (!target.types.includes('Land')) return false;
|
||||
} else {
|
||||
// Default Aura -> Creature
|
||||
if (!target.types.includes('Creature')) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Shroud / Hexproof check would go here
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private advanceStep() {
|
||||
// Transition Table
|
||||
const structure: Record<Phase, Step[]> = {
|
||||
@@ -422,10 +751,15 @@ export class RulesEngine {
|
||||
|
||||
// 0. Mulligan Step
|
||||
if (step === 'mulligan') {
|
||||
const total = Object.keys(this.state.players).length;
|
||||
const kept = Object.values(this.state.players).filter(p => p.handKept).length;
|
||||
console.log(`[RulesEngine] Performing Mulligan TBA. Kept: ${kept}/${total}`);
|
||||
|
||||
// Draw 7 for everyone if they have 0 cards in hand and haven't kept
|
||||
Object.values(this.state.players).forEach(p => {
|
||||
const hand = Object.values(this.state.cards).filter(c => c.ownerId === p.id && c.zone === 'hand');
|
||||
if (hand.length === 0 && !p.handKept) {
|
||||
console.log(`[RulesEngine] Initial Draw 7 for ${p.name}`);
|
||||
// Initial Draw
|
||||
for (let i = 0; i < 7; i++) {
|
||||
this.drawCard(p.id);
|
||||
@@ -435,10 +769,12 @@ export class RulesEngine {
|
||||
// Check if all kept
|
||||
const allKept = Object.values(this.state.players).every(p => p.handKept);
|
||||
if (allKept) {
|
||||
console.log("All players kept hand. Starting game.");
|
||||
console.log("[RulesEngine] All players kept hand. Advancing Step.");
|
||||
// Normally untap is automatic?
|
||||
// advanceStep will go to beginning/untap
|
||||
this.advanceStep();
|
||||
} else {
|
||||
console.log("[RulesEngine] Waiting for more mulligan decisions.");
|
||||
}
|
||||
return; // Wait for actions
|
||||
}
|
||||
@@ -454,9 +790,38 @@ export class RulesEngine {
|
||||
|
||||
// 2. Draw Step
|
||||
if (step === 'draw') {
|
||||
const player = this.state.players[activePlayerId];
|
||||
if (this.state.turnCount > 1 || this.state.turnOrder.length > 2) {
|
||||
this.drawCard(activePlayerId);
|
||||
// If Bot: Auto Draw
|
||||
if (player && player.isBot) {
|
||||
console.log(`[Auto] Bot ${player.name} drawing card.`);
|
||||
this.drawCard(activePlayerId);
|
||||
// After draw, AP priority
|
||||
this.resetPriority(activePlayerId);
|
||||
} else {
|
||||
// If Human: Wait for Manual Action
|
||||
console.log(`[Manual] Waiting for Human ${player?.name} to draw.`);
|
||||
// We do NOT call drawCard here.
|
||||
// We DO reset priority to them so they can take the action?
|
||||
// Actually, if we are in 'draw' step, strict rules say AP gets priority.
|
||||
// Yet, the "Turn Based Action" of drawing usually happens *immediately* at start of step, BEFORE priority.
|
||||
// 504.1. First, the active player draws a card. This turn-based action doesn't use the stack.
|
||||
// 504.2. Second, the active player gets priority.
|
||||
// So for "Manual" feeling, we pause BEFORE 504.1 is considered "done"?
|
||||
// Effectively, we treat the "DRAW_CARD" action as the completion of 504.1.
|
||||
|
||||
// Ensure they are the priority player so the UI lets them act (if we key off priority)
|
||||
// But strict action validation for DRAW_CARD will check if they are AP and in Draw step.
|
||||
if (this.state.priorityPlayerId !== activePlayerId) {
|
||||
this.state.priorityPlayerId = activePlayerId;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Skip draw (Turn 1 in 2p game)
|
||||
console.log("Skipping Draw (Turn 1 2P).");
|
||||
this.resetPriority(activePlayerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Cleanup Step
|
||||
@@ -471,14 +836,25 @@ export class RulesEngine {
|
||||
// 4. Combat Steps requiring declaration (Pause for External Action)
|
||||
if (step === 'declare_attackers') {
|
||||
// WAITING for declareAttackers() from Client
|
||||
// Do NOT reset priority yet.
|
||||
// TODO: Maybe set a timeout or auto-skip if no creatures?
|
||||
// 508.1. Active Player gets priority to declare attackers.
|
||||
// Unlike other steps where AP gets priority to cast spells, here the "Action" determines the flow.
|
||||
// But technically, the AP *must* act. So we ensure they have priority.
|
||||
if (this.state.priorityPlayerId !== activePlayerId) {
|
||||
this.resetPriority(activePlayerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (step === 'declare_blockers') {
|
||||
// WAITING for declareBlockers() from Client (Defending Player)
|
||||
// Do NOT reset priority yet.
|
||||
// 509.1. Defending Player gets priority to declare blockers.
|
||||
// In 1v1, this is the non-active player.
|
||||
const defendingPlayerId = this.state.turnOrder.find(id => id !== activePlayerId);
|
||||
if (defendingPlayerId) {
|
||||
if (this.state.priorityPlayerId !== defendingPlayerId) {
|
||||
this.resetPriority(defendingPlayerId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -557,7 +933,8 @@ export class RulesEngine {
|
||||
console.log(`Player ${playerId} draws ${card.name}`);
|
||||
} else {
|
||||
// Empty library loss?
|
||||
console.log(`Player ${playerId} attempts to draw from empty library.`);
|
||||
// Empty library loss?
|
||||
console.warn(`[RulesEngine] Player ${playerId} attempts to draw from empty library. (Deck size: 0)`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,6 +1019,18 @@ export class RulesEngine {
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Equipment Validity
|
||||
Object.values(cards).forEach(c => {
|
||||
if (c.zone === 'battlefield' && this.isEquipment(c) && c.attachedTo) {
|
||||
const target = cards[c.attachedTo];
|
||||
if (!target || target.zone !== 'battlefield') {
|
||||
console.log(`SBA: ${c.name} (Equipment) detached (Host invalid).`);
|
||||
c.attachedTo = undefined;
|
||||
sbaPerformed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return sbaPerformed;
|
||||
}
|
||||
|
||||
@@ -724,15 +1113,30 @@ export class RulesEngine {
|
||||
});
|
||||
}
|
||||
|
||||
// Layer 7e: Switch Power/Toughness - skipped for now
|
||||
|
||||
// Final Floor rule: T cannot be less than 0 for logic? No, T can be negative for calculation, but usually treated as 0 for damage?
|
||||
// Actually CR says negative numbers are real in calculation, but treated as 0 for dealing damage.
|
||||
// We store true values.
|
||||
|
||||
card.power = p;
|
||||
card.toughness = t;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private isAura(card: any): boolean {
|
||||
return card.types && card.types.includes('Enchantment') && card.subtypes && card.subtypes.includes('Aura');
|
||||
}
|
||||
|
||||
private isEquipment(card: any): boolean {
|
||||
return card.types && card.types.includes('Artifact') && card.subtypes && card.subtypes.includes('Equipment');
|
||||
}
|
||||
|
||||
private isCreature(card: any): boolean {
|
||||
return card.types && card.types.includes('Creature');
|
||||
}
|
||||
|
||||
private canAttach(source: any, target: any): boolean {
|
||||
if (this.isAura(source)) {
|
||||
if (source.oracleText?.toLowerCase().includes('enchant land')) return target.types.includes('Land');
|
||||
return target.types.includes('Creature');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +61,11 @@ export interface CardObject {
|
||||
position?: { x: number; y: number; z: number };
|
||||
|
||||
// Metadata
|
||||
scryfallId?: string;
|
||||
setCode?: string;
|
||||
controlledSinceTurn: number; // For Summoning Sickness check
|
||||
definition?: any;
|
||||
imageArtCrop?: string;
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
@@ -76,6 +79,7 @@ export interface PlayerState {
|
||||
handKept?: boolean; // For Mulligan phase
|
||||
mulliganCount?: number;
|
||||
manaPool: Record<string, number>; // { W: 0, U: 1, ... }
|
||||
isBot?: boolean;
|
||||
}
|
||||
|
||||
export interface StackObject {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'url';
|
||||
import { RoomManager } from './managers/RoomManager';
|
||||
import { GameManager } from './managers/GameManager';
|
||||
import { DraftManager } from './managers/DraftManager';
|
||||
import { TournamentManager } from './managers/TournamentManager';
|
||||
import { CardService } from './services/CardService';
|
||||
import { ScryfallService } from './services/ScryfallService';
|
||||
import { PackGeneratorService } from './services/PackGeneratorService';
|
||||
@@ -31,8 +32,22 @@ const io = new Server(httpServer, {
|
||||
const roomManager = new RoomManager();
|
||||
const gameManager = new GameManager();
|
||||
const draftManager = new DraftManager();
|
||||
const tournamentManager = new TournamentManager();
|
||||
const persistenceManager = new PersistenceManager(roomManager, draftManager, gameManager);
|
||||
|
||||
// Game Over Listener
|
||||
gameManager.on('game_over', ({ gameId, winnerId }) => {
|
||||
console.log(`[Index] Game Over received: ${gameId}, Winner: ${winnerId}`);
|
||||
// ... existing logic ...
|
||||
});
|
||||
|
||||
// Game Update Listener (For async bot actions)
|
||||
gameManager.on('game_update', (roomId, game) => {
|
||||
if (game && roomId) {
|
||||
io.to(roomId).emit('game_update', game);
|
||||
}
|
||||
});
|
||||
|
||||
// Load previous state
|
||||
persistenceManager.load();
|
||||
|
||||
@@ -130,7 +145,8 @@ app.get('/api/sets', async (_req: Request, res: Response) => {
|
||||
|
||||
app.get('/api/sets/:code/cards', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const cards = await scryfallService.fetchSetCards(req.params.code);
|
||||
const related = req.query.related ? (req.query.related as string).split(',') : [];
|
||||
const cards = await scryfallService.fetchSetCards(req.params.code, related);
|
||||
|
||||
// Implicitly cache images for these cards so local URLs work
|
||||
if (cards.length > 0) {
|
||||
@@ -218,7 +234,18 @@ app.post('/api/packs/generate', async (req: Request, res: Response) => {
|
||||
ignoreTokens: false
|
||||
};
|
||||
|
||||
const { pools, sets } = packGeneratorService.processCards(poolCards, activeFilters);
|
||||
// Fetch metadata for merging subsets
|
||||
const allSets = await scryfallService.fetchSets();
|
||||
const setsMetadata: { [code: string]: { parent_set_code?: string } } = {};
|
||||
if (allSets && Array.isArray(allSets)) {
|
||||
allSets.forEach((s: any) => {
|
||||
if (selectedSets && selectedSets.includes(s.code)) {
|
||||
setsMetadata[s.code] = { parent_set_code: s.parent_set_code };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const { pools, sets } = packGeneratorService.processCards(poolCards, activeFilters, setsMetadata);
|
||||
|
||||
// Extract available basic lands for deck building
|
||||
const basicLands = pools.lands.filter(c => c.typeLine?.includes('Basic'));
|
||||
@@ -269,39 +296,20 @@ const draftInterval = setInterval(() => {
|
||||
// Check if EVERYONE is ready to start game automatically
|
||||
const activePlayers = room.players.filter(p => p.role === 'player');
|
||||
if (activePlayers.length > 0 && activePlayers.every(p => p.ready)) {
|
||||
console.log(`All players ready (including bots) in room ${roomId}. Starting game.`);
|
||||
room.status = 'playing';
|
||||
console.log(`All players ready (including bots) in room ${roomId}. Starting TOURNAMENT.`);
|
||||
room.status = 'tournament';
|
||||
io.to(roomId).emit('room_update', room);
|
||||
|
||||
const game = gameManager.createGame(roomId, room.players);
|
||||
// Create Tournament
|
||||
const tournament = tournamentManager.createTournament(roomId, room.players.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
isBot: !!p.isBot,
|
||||
deck: p.deck
|
||||
})));
|
||||
|
||||
// Populate Decks
|
||||
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',
|
||||
typeLine: card.typeLine || card.type_line || '',
|
||||
oracleText: card.oracleText || card.oracle_text || '',
|
||||
manaCost: card.manaCost || card.mana_cost || '',
|
||||
keywords: card.keywords || [],
|
||||
power: card.power,
|
||||
toughness: card.toughness,
|
||||
damageMarked: 0,
|
||||
controlledSinceTurn: 0
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const engine = new RulesEngine(game);
|
||||
engine.startGame();
|
||||
io.to(roomId).emit('game_update', game);
|
||||
room.tournament = tournament;
|
||||
io.to(roomId).emit('tournament_update', tournament);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,9 +342,11 @@ const draftInterval = setInterval(() => {
|
||||
gameManager.addCardToGame(roomId, {
|
||||
ownerId: p.id,
|
||||
controllerId: p.id,
|
||||
oracleId: card.oracle_id || card.id,
|
||||
oracleId: card.oracle_id || card.id || card.definition?.oracle_id,
|
||||
scryfallId: card.scryfallId || card.id || card.definition?.id,
|
||||
setCode: card.setCode || card.set || card.definition?.set,
|
||||
name: card.name,
|
||||
imageUrl: card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "",
|
||||
imageUrl: card.image_uris?.normal || card.image_uris?.large || card.imageUrl || "",
|
||||
zone: 'library',
|
||||
typeLine: card.typeLine || card.type_line || '',
|
||||
oracleText: card.oracleText || card.oracle_text || '',
|
||||
@@ -352,6 +362,7 @@ const draftInterval = setInterval(() => {
|
||||
// Initialize Game State (Draw Hands)
|
||||
const engine = new RulesEngine(game);
|
||||
engine.startGame();
|
||||
gameManager.triggerBotCheck(roomId);
|
||||
|
||||
io.to(roomId).emit('game_update', game);
|
||||
}
|
||||
@@ -397,7 +408,12 @@ io.on('connection', (socket) => {
|
||||
if (currentDraft) socket.emit('draft_update', currentDraft);
|
||||
}
|
||||
|
||||
callback({ success: true, room, draftState: currentDraft });
|
||||
if (room.status === 'tournament' && room.tournament) {
|
||||
socket.emit('tournament_update', room.tournament);
|
||||
// Assuming join_room is initial join, probably not in a match yet unless re-joining
|
||||
}
|
||||
|
||||
callback({ success: true, room, draftState: currentDraft, tournament: room.tournament });
|
||||
} else {
|
||||
callback({ success: false, message: 'Room not found or full' });
|
||||
}
|
||||
@@ -437,11 +453,27 @@ io.on('connection', (socket) => {
|
||||
if (room.status === 'playing') {
|
||||
currentGame = gameManager.getGame(roomId);
|
||||
if (currentGame) socket.emit('game_update', currentGame);
|
||||
} else if (room.status === 'tournament') {
|
||||
if (room.tournament) {
|
||||
socket.emit('tournament_update', room.tournament);
|
||||
|
||||
// If player was in a match
|
||||
// We need to check if they have a matchId in their player object
|
||||
// room.players is the source of truth
|
||||
const p = room.players.find(rp => rp.id === playerId);
|
||||
if (p && p.matchId) {
|
||||
currentGame = gameManager.getGame(p.matchId);
|
||||
if (currentGame) {
|
||||
socket.join(p.matchId); // Re-join socket room
|
||||
socket.emit('game_update', currentGame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ACK Callback
|
||||
if (typeof callback === 'function') {
|
||||
callback({ success: true, room, draftState: currentDraft, gameState: currentGame });
|
||||
callback({ success: true, room, draftState: currentDraft, gameState: currentGame, tournament: room.tournament });
|
||||
}
|
||||
} else {
|
||||
// Room found but player not in it? Or room not found?
|
||||
@@ -595,38 +627,17 @@ io.on('connection', (socket) => {
|
||||
io.to(room.id).emit('room_update', updatedRoom);
|
||||
const activePlayers = updatedRoom.players.filter(p => p.role === 'player');
|
||||
if (activePlayers.length > 0 && activePlayers.every(p => p.ready)) {
|
||||
updatedRoom.status = 'playing';
|
||||
updatedRoom.status = 'tournament';
|
||||
io.to(room.id).emit('room_update', updatedRoom);
|
||||
|
||||
const game = gameManager.createGame(room.id, updatedRoom.players);
|
||||
activePlayers.forEach(p => {
|
||||
if (p.deck) {
|
||||
p.deck.forEach((card: any) => {
|
||||
gameManager.addCardToGame(room.id, {
|
||||
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',
|
||||
typeLine: card.typeLine || card.type_line || '',
|
||||
oracleText: card.oracleText || card.oracle_text || '',
|
||||
manaCost: card.manaCost || card.mana_cost || '',
|
||||
keywords: card.keywords || [],
|
||||
power: card.power, // Add Power
|
||||
toughness: card.toughness, // Add Toughness
|
||||
damageMarked: 0,
|
||||
controlledSinceTurn: 0
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize Game State (Draw Hands)
|
||||
const engine = new RulesEngine(game);
|
||||
engine.startGame();
|
||||
|
||||
io.to(room.id).emit('game_update', game);
|
||||
const tournament = tournamentManager.createTournament(room.id, updatedRoom.players.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
isBot: !!p.isBot,
|
||||
deck: p.deck
|
||||
})));
|
||||
updatedRoom.tournament = tournament;
|
||||
io.to(room.id).emit('tournament_update', tournament);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -663,14 +674,38 @@ io.on('connection', (socket) => {
|
||||
const game = gameManager.createGame(room.id, updatedRoom.players);
|
||||
if (decks) {
|
||||
Object.entries(decks).forEach(([pid, deck]: [string, any]) => {
|
||||
|
||||
// @ts-ignore
|
||||
deck.forEach(card => {
|
||||
// Robustly resolve setCode / scryfallId
|
||||
let setCode = card.setCode || card.set || card.definition?.set;
|
||||
let scryfallId = card.scryfallId || card.id || card.definition?.id;
|
||||
|
||||
// Fallback: Extract from Image URL if missing
|
||||
if ((!setCode || !scryfallId) && card.imageUrl && card.imageUrl.includes('/cards/images/')) {
|
||||
const parts = card.imageUrl.split('/cards/images/');
|
||||
if (parts[1]) {
|
||||
const pathParts = parts[1].split('/');
|
||||
// Format: [setCode]/[full|crop]/[id].jpg OR [setCode]/[id].jpg
|
||||
if (!setCode) setCode = pathParts[0];
|
||||
|
||||
if (!scryfallId) {
|
||||
const filename = pathParts[pathParts.length - 1]; // uuid.jpg
|
||||
scryfallId = filename.replace(/\.(jpg|png)$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gameManager.addCardToGame(room.id, {
|
||||
ownerId: pid,
|
||||
controllerId: pid,
|
||||
oracleId: card.oracle_id || card.id,
|
||||
oracleId: card.oracle_id || card.id || card.definition?.oracle_id,
|
||||
scryfallId: scryfallId,
|
||||
setCode: setCode,
|
||||
name: card.name,
|
||||
imageUrl: card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "",
|
||||
// IMPORTANT: If we have setCode+scryfallId, we clear imageUrl so client uses local cache logic
|
||||
imageUrl: (setCode && scryfallId) ? "" : (card.image_uris?.normal || card.image_uris?.large || card.imageUrl || ""),
|
||||
imageArtCrop: card.image_uris?.art_crop || card.image_uris?.crop || card.imageArtCrop || "",
|
||||
zone: 'library',
|
||||
typeLine: card.typeLine || card.type_line || '',
|
||||
oracleText: card.oracleText || card.oracle_text || '',
|
||||
@@ -688,6 +723,7 @@ io.on('connection', (socket) => {
|
||||
// Initialize Game State (Draw Hands)
|
||||
const engine = new RulesEngine(game);
|
||||
engine.startGame();
|
||||
gameManager.triggerBotCheck(room.id);
|
||||
|
||||
io.to(room.id).emit('game_update', game);
|
||||
}
|
||||
@@ -698,9 +734,12 @@ io.on('connection', (socket) => {
|
||||
if (!context) return;
|
||||
const { room, player } = context;
|
||||
|
||||
const game = gameManager.handleAction(room.id, action, player.id);
|
||||
// Fix: If in a match (Tournament), actions go to matchId, not roomId
|
||||
const targetGameId = player.matchId || room.id;
|
||||
|
||||
const game = gameManager.handleAction(targetGameId, action, player.id);
|
||||
if (game) {
|
||||
io.to(room.id).emit('game_update', game);
|
||||
io.to(game.roomId).emit('game_update', game);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -709,9 +748,128 @@ io.on('connection', (socket) => {
|
||||
if (!context) return;
|
||||
const { room, player } = context;
|
||||
|
||||
const game = gameManager.handleStrictAction(room.id, action, player.id);
|
||||
// Fix: If in a match (Tournament), actions go to matchId, not roomId
|
||||
const targetGameId = player.matchId || room.id;
|
||||
|
||||
const game = gameManager.handleStrictAction(targetGameId, action, player.id);
|
||||
if (game) {
|
||||
io.to(room.id).emit('game_update', game);
|
||||
io.to(game.roomId).emit('game_update', game);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('join_match', ({ matchId }, callback) => {
|
||||
const context = getContext();
|
||||
if (!context) return;
|
||||
const { room, player } = context;
|
||||
|
||||
if (!room.tournament) {
|
||||
callback({ success: false, message: "No active tournament." });
|
||||
return;
|
||||
}
|
||||
|
||||
const match = tournamentManager.getMatch(room.tournament, matchId);
|
||||
if (!match) {
|
||||
callback({ success: false, message: "Match not found." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.status === 'pending') {
|
||||
callback({ success: false, message: "Match is pending." });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Game Exists (Maybe it was already created by the other player becoming ready?)
|
||||
let game = gameManager.getGame(matchId);
|
||||
|
||||
// Join Socket to Match Room
|
||||
socket.join(matchId);
|
||||
player.matchId = matchId; // Track match
|
||||
|
||||
// If game exists (both players already ready), send it
|
||||
if (game) {
|
||||
socket.emit('game_update', game);
|
||||
}
|
||||
|
||||
callback({ success: true, match, gameCreated: !!game });
|
||||
});
|
||||
|
||||
socket.on('match_ready', ({ matchId, deck }) => {
|
||||
const context = getContext();
|
||||
if (!context) return;
|
||||
const { room, player } = context;
|
||||
|
||||
if (!room.tournament) return;
|
||||
|
||||
const readyState = tournamentManager.setMatchReady(room.id, matchId, player.id, deck);
|
||||
if (readyState?.bothReady) {
|
||||
console.log(`[Index] Both players ready for match ${matchId}. Starting Game.`);
|
||||
|
||||
const match = tournamentManager.getMatch(room.tournament, matchId);
|
||||
if (match && match.player1 && match.player2) {
|
||||
const p1 = room.players.find(p => p.id === match.player1!.id)!;
|
||||
const p2 = room.players.find(p => p.id === match.player2!.id)!;
|
||||
|
||||
// Get Decks from Ready State (stored in tournament manager)
|
||||
const deck1 = readyState.decks[p1.id];
|
||||
const deck2 = readyState.decks[p2.id];
|
||||
|
||||
const game = gameManager.createGame(matchId, [
|
||||
{ id: p1.id, name: p1.name, isBot: p1.isBot },
|
||||
{ id: p2.id, name: p2.name, isBot: p2.isBot }
|
||||
]);
|
||||
|
||||
// Populate Decks
|
||||
[{ p: p1, d: deck1 }, { p: p2, d: deck2 }].forEach(({ p, d }) => {
|
||||
if (d) {
|
||||
d.forEach((card: any) => {
|
||||
// Robustly resolve setCode / scryfallId
|
||||
let setCode = card.setCode || card.set || card.definition?.set;
|
||||
let scryfallId = card.scryfallId || card.id || card.definition?.id;
|
||||
|
||||
// Fallback: Extract from Image URL if missing
|
||||
if ((!setCode || !scryfallId) && card.imageUrl && card.imageUrl.includes('/cards/images/')) {
|
||||
const parts = card.imageUrl.split('/cards/images/');
|
||||
if (parts[1]) {
|
||||
const pathParts = parts[1].split('/');
|
||||
if (!setCode) setCode = pathParts[0];
|
||||
if (!scryfallId) {
|
||||
const filename = pathParts[pathParts.length - 1]; // uuid.jpg
|
||||
scryfallId = filename.replace(/\.(jpg|png)$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gameManager.addCardToGame(matchId, {
|
||||
ownerId: p.id,
|
||||
controllerId: p.id,
|
||||
oracleId: card.oracle_id || card.id || card.definition?.oracle_id,
|
||||
scryfallId: scryfallId,
|
||||
setCode: setCode,
|
||||
name: card.name,
|
||||
// IMPORTANT: If we have setCode+scryfallId, we clear imageUrl so client uses local cache logic
|
||||
imageUrl: (setCode && scryfallId) ? "" : (card.image_uris?.normal || card.image_uris?.large || card.imageUrl || ""),
|
||||
imageArtCrop: card.image_uris?.art_crop || card.image_uris?.crop || card.imageArtCrop || "",
|
||||
zone: 'library',
|
||||
typeLine: card.typeLine || card.type_line || '',
|
||||
oracleText: card.oracleText || card.oracle_text || '',
|
||||
manaCost: card.manaCost || card.mana_cost || '',
|
||||
keywords: card.keywords || [],
|
||||
power: card.power,
|
||||
toughness: card.toughness,
|
||||
damageMarked: 0,
|
||||
controlledSinceTurn: 0
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const engine = new RulesEngine(game);
|
||||
engine.startGame();
|
||||
gameManager.triggerBotCheck(matchId);
|
||||
|
||||
io.to(matchId).emit('game_update', game);
|
||||
io.to(matchId).emit('match_start', { gameId: matchId });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,10 +2,20 @@
|
||||
import { StrictGameState, PlayerState, CardObject } from '../game/types';
|
||||
import { RulesEngine } from '../game/RulesEngine';
|
||||
|
||||
export class GameManager {
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// Augment EventEmitter to type the emit event if we could, but for now standard.
|
||||
// We expect SocketService to listen to 'game_update' from GameManager.
|
||||
|
||||
export class GameManager extends EventEmitter {
|
||||
public games: Map<string, StrictGameState> = new Map();
|
||||
|
||||
createGame(roomId: string, players: { id: string; name: string }[]): StrictGameState {
|
||||
// Helper to emit generic game notifications
|
||||
public notify(roomId: string, message: string, type: 'info' | 'success' | 'warning' | 'error' = 'info', targetId?: string) {
|
||||
this.emit('game_notification', roomId, { message, type, targetId });
|
||||
}
|
||||
|
||||
createGame(gameId: string, players: { id: string; name: string; isBot?: boolean }[]): StrictGameState {
|
||||
|
||||
// Convert array to map
|
||||
const playerRecord: Record<string, PlayerState> = {};
|
||||
@@ -13,6 +23,7 @@ export class GameManager {
|
||||
playerRecord[p.id] = {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
isBot: p.isBot,
|
||||
life: 20,
|
||||
poison: 0,
|
||||
energy: 0,
|
||||
@@ -25,7 +36,7 @@ export class GameManager {
|
||||
const firstPlayerId = players.length > 0 ? players[0].id : '';
|
||||
|
||||
const gameState: StrictGameState = {
|
||||
roomId,
|
||||
roomId: gameId,
|
||||
players: playerRecord,
|
||||
cards: {}, // Populated later
|
||||
stack: [],
|
||||
@@ -49,10 +60,66 @@ export class GameManager {
|
||||
gameState.players[firstPlayerId].isActive = true;
|
||||
}
|
||||
|
||||
this.games.set(roomId, gameState);
|
||||
this.games.set(gameId, gameState);
|
||||
return gameState;
|
||||
}
|
||||
|
||||
// Track rooms where a bot is currently "thinking" to avoid double-queuing
|
||||
private thinkingRooms: Set<string> = new Set();
|
||||
// Throttle logs
|
||||
private lastBotLog: Record<string, number> = {};
|
||||
|
||||
// Helper to trigger bot actions if game is stuck or just started
|
||||
public triggerBotCheck(roomId: string): StrictGameState | null {
|
||||
const game = this.games.get(roomId);
|
||||
if (!game) return null;
|
||||
|
||||
// specific hack for Mulligan phase synchronization
|
||||
if (game.step === 'mulligan') {
|
||||
Object.values(game.players).forEach(p => {
|
||||
if (p.isBot && !p.handKept) {
|
||||
const engine = new RulesEngine(game);
|
||||
try { engine.resolveMulligan(p.id, true, []); } catch (e) { }
|
||||
}
|
||||
});
|
||||
// If bots acted in mulligan, we might need to verify if game advances.
|
||||
// But for Mulligan, we don't need delays as much because it's a hidden phase usually.
|
||||
// Let's keep mulligan instant for simplicity, or we can delay it too?
|
||||
// Let's keep instant for mulligan to "Start Game" faster.
|
||||
}
|
||||
|
||||
const priorityId = game.priorityPlayerId;
|
||||
const priorityPlayer = game.players[priorityId];
|
||||
|
||||
// If it is a Bot's turn to have priority, and we aren't already processing
|
||||
if (priorityPlayer?.isBot && !this.thinkingRooms.has(roomId)) {
|
||||
const now = Date.now();
|
||||
if (!this.lastBotLog[roomId] || now - this.lastBotLog[roomId] > 5000) {
|
||||
console.log(`[Bot Loop] Bot ${priorityPlayer.name} is thinking...`);
|
||||
this.lastBotLog[roomId] = now;
|
||||
}
|
||||
this.thinkingRooms.add(roomId);
|
||||
|
||||
setTimeout(() => {
|
||||
this.thinkingRooms.delete(roomId);
|
||||
this.processBotActions(game);
|
||||
// After processing one action, we trigger check again to see if we need to do more (e.g. Pass -> Pass -> My Turn)
|
||||
// But we need to emit the update first!
|
||||
// processBotActions actually mutates state.
|
||||
// We should ideally emit 'game_update' here if we were outside the main socket loop.
|
||||
// Since GameManager doesn't have the SocketService instance directly usually,
|
||||
// strictly speaking we need to rely on the caller to emit, OR GameManager should emit.
|
||||
// GameManager extends EventEmitter. We can emit 'state_change'.
|
||||
this.emit('game_update', roomId, game); // Force emit update
|
||||
|
||||
// Recursive check (will trigger next timeout if still bot's turn)
|
||||
this.triggerBotCheck(roomId);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
getGame(roomId: string): StrictGameState | undefined {
|
||||
return this.games.get(roomId);
|
||||
}
|
||||
@@ -79,17 +146,31 @@ export class GameManager {
|
||||
engine.castSpell(actorId, action.cardId, action.targets, action.position);
|
||||
break;
|
||||
case 'DECLARE_ATTACKERS':
|
||||
engine.declareAttackers(actorId, action.attackers);
|
||||
try {
|
||||
engine.declareAttackers(actorId, action.attackers);
|
||||
} catch (err: any) {
|
||||
console.error(`[DeclareAttackers Error] Actor: ${actorId}, Active: ${game.activePlayerId}, Priority: ${game.priorityPlayerId}, Step: ${game.step}`);
|
||||
throw err; // Re-throw to catch block below
|
||||
}
|
||||
break;
|
||||
case 'DECLARE_BLOCKERS':
|
||||
engine.declareBlockers(actorId, action.blockers);
|
||||
break;
|
||||
case 'CREATE_TOKEN':
|
||||
engine.createToken(actorId, action.definition);
|
||||
engine.createToken(actorId, action.definition, action.position);
|
||||
break;
|
||||
case 'MULLIGAN_DECISION':
|
||||
engine.resolveMulligan(actorId, action.keep, action.cardsToBottom);
|
||||
break;
|
||||
case 'DRAW_CARD':
|
||||
// Strict validation: Must be Draw step, Must be Active Player
|
||||
if (game.step !== 'draw') throw new Error("Can only draw in Draw Step.");
|
||||
if (game.activePlayerId !== actorId) throw new Error("Only Active Player can draw.");
|
||||
|
||||
engine.drawCard(actorId);
|
||||
// After drawing, 504.2 says AP gets priority.
|
||||
engine.resetPriority(actorId);
|
||||
break;
|
||||
// TODO: Activate Ability
|
||||
default:
|
||||
console.warn(`Unknown strict action: ${action.type}`);
|
||||
@@ -97,14 +178,187 @@ export class GameManager {
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(`Rule Violation [${action?.type || 'UNKNOWN'}]: ${e.message}`);
|
||||
// TODO: Return error to user?
|
||||
// For now, just logging and not updating state (transactional-ish)
|
||||
// Notify the user (and others?) about the error
|
||||
this.emit('game_error', roomId, { message: e.message, userId: actorId });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bot Cycle: Trigger Async Check (Instead of synchronous loop)
|
||||
this.triggerBotCheck(roomId);
|
||||
|
||||
// Check Win Condition
|
||||
this.checkWinCondition(game, roomId);
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
// Check if game is over
|
||||
public checkWinCondition(game: StrictGameState, gameId: string) {
|
||||
const alivePlayers = Object.values(game.players).filter(p => p.life > 0 && p.poison < 10);
|
||||
|
||||
// 1v1 Logic
|
||||
if (alivePlayers.length === 1 && Object.keys(game.players).length > 1) {
|
||||
// Winner found
|
||||
const winner = alivePlayers[0];
|
||||
// Only emit once
|
||||
if (game.phase !== 'ending') {
|
||||
console.log(`[GameManager] Game Over. Winner: ${winner.name}`);
|
||||
this.emit('game_over', { gameId, winnerId: winner.id });
|
||||
this.notify(gameId, `Game Over! ${winner.name} wins!`, 'success');
|
||||
game.phase = 'ending'; // Mark as ending so we don't double emit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bot AI Logic ---
|
||||
private processBotActions(game: StrictGameState) {
|
||||
const engine = new RulesEngine(game);
|
||||
const botId = game.priorityPlayerId;
|
||||
const bot = game.players[botId];
|
||||
|
||||
if (!bot || !bot.isBot) return;
|
||||
|
||||
// 1. Mulligan: Always Keep (but check if we have cards?)
|
||||
if (game.step === 'mulligan') {
|
||||
const hand = Object.values(game.cards).filter(c => c.ownerId === botId && c.zone === 'hand');
|
||||
if (hand.length === 0 && !bot.handKept) {
|
||||
// We have NO cards to keep? Something is wrong (deck didn't load?).
|
||||
// Don't loop infinitely trying to keep an empty hand if that's invalid,
|
||||
// but technically "keeping" 0 cards is just accepting 0 cards.
|
||||
// However, usually this means initialization failed.
|
||||
// We'll log once and stop? Or just keep to unstuck the game?
|
||||
// Let's try to keep.
|
||||
// console.warn(`[Bot AI] ${bot.name} has 0 cards in hand during Mulligan. Initializing?`);
|
||||
}
|
||||
if (!bot.handKept) {
|
||||
try { engine.resolveMulligan(botId, true, []); } catch (e) { }
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Play Land (Main Phase, empty stack)
|
||||
if ((game.phase === 'main1' || game.phase === 'main2') && game.stack.length === 0) {
|
||||
if (game.landsPlayedThisTurn < 1) {
|
||||
const hand = Object.values(game.cards).filter(c => c.ownerId === botId && c.zone === 'hand');
|
||||
const land = hand.find(c => c.typeLine?.includes('Land') || c.types.includes('Land'));
|
||||
if (land) {
|
||||
console.log(`[Bot AI] ${bot.name} plays land ${land.name}`);
|
||||
try {
|
||||
engine.playLand(botId, land.instanceId);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn("Bot failed to play land:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Play Spell (Main Phase, empty stack)
|
||||
if ((game.phase === 'main1' || game.phase === 'main2') && game.stack.length === 0) {
|
||||
const hand = Object.values(game.cards).filter(c => c.ownerId === botId && c.zone === 'hand');
|
||||
const spell = hand.find(c => !c.typeLine?.includes('Land') && !c.types.includes('Land'));
|
||||
|
||||
if (spell) {
|
||||
// Only cast creatures for now to be safe with targets
|
||||
if (spell.types.includes('Creature')) {
|
||||
console.log(`[Bot AI] ${bot.name} casts creature ${spell.name}`);
|
||||
try {
|
||||
engine.castSpell(botId, spell.instanceId, []);
|
||||
return;
|
||||
} catch (e) { console.warn("Bot failed to cast spell:", e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Combat: Declare Attackers (Active Player only)
|
||||
if (game.step === 'declare_attackers' && game.activePlayerId === botId && !game.attackersDeclared) {
|
||||
const attackers = Object.values(game.cards).filter(c =>
|
||||
c.controllerId === botId &&
|
||||
c.zone === 'battlefield' &&
|
||||
c.types.includes('Creature') &&
|
||||
!c.tapped &&
|
||||
!c.keywords.includes('Defender') // Simple check
|
||||
);
|
||||
|
||||
const opponents = game.turnOrder.filter(pid => pid !== botId);
|
||||
const targetId = opponents[0];
|
||||
|
||||
// Simple Heuristic: Attack with everything if we have profitable attacks?
|
||||
// For now: Attack with everything that isn't summon sick or defender.
|
||||
if (attackers.length > 0 && targetId) {
|
||||
// Randomly decide to attack to simulate "thinking" or non-suicidal behavior?
|
||||
// For MVP: Aggro Bot - always attacks.
|
||||
const declaration = attackers.map(c => ({ attackerId: c.instanceId, targetId }));
|
||||
console.log(`[Bot AI] ${bot.name} attacks with ${attackers.length} creatures.`);
|
||||
try { engine.declareAttackers(botId, declaration); } catch (e) { }
|
||||
return;
|
||||
} else {
|
||||
console.log(`[Bot AI] ${bot.name} skips combat.`);
|
||||
try { engine.declareAttackers(botId, []); } catch (e) { }
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Combat: Declare Blockers (Defending Player)
|
||||
if (game.step === 'declare_blockers' && game.activePlayerId !== botId && !game.blockersDeclared) {
|
||||
// Identify attackers attacking ME
|
||||
const attackers = Object.values(game.cards).filter(c => c.attacking === botId);
|
||||
|
||||
if (attackers.length > 0) {
|
||||
// Identify my blockers
|
||||
const blockers = Object.values(game.cards).filter(c =>
|
||||
c.controllerId === botId &&
|
||||
c.zone === 'battlefield' &&
|
||||
c.types.includes('Creature') &&
|
||||
!c.tapped
|
||||
);
|
||||
|
||||
// Simple Heuristic: Block 1-to-1 if possible, just to stop damage.
|
||||
// Don't double block.
|
||||
const declaration: { blockerId: string, attackerId: string }[] = [];
|
||||
|
||||
blockers.forEach((blocker, idx) => {
|
||||
if (idx < attackers.length) {
|
||||
declaration.push({ blockerId: blocker.instanceId, attackerId: attackers[idx].instanceId });
|
||||
}
|
||||
});
|
||||
|
||||
if (declaration.length > 0) {
|
||||
console.log(`[Bot AI] ${bot.name} declares ${declaration.length} blockers.`);
|
||||
try { engine.declareBlockers(botId, declaration); } catch (e) { }
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: No blocks
|
||||
console.log(`[Bot AI] ${bot.name} declares no blockers.`);
|
||||
try { engine.declareBlockers(botId, []); } catch (e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. End Step / Cleanup -> Pass
|
||||
if (game.phase === 'ending') {
|
||||
try { engine.passPriority(botId); } catch (e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. Default: Pass Priority (Catch-all for response windows, or empty stack)
|
||||
// Add artificial delay logic here? Use setTimeout?
|
||||
// We can't easily wait in this synchronous loop. The loop relies on state updating.
|
||||
// If we want delay, we should likely return from the loop and use `setTimeout` to call `triggerBotCheck` again?
|
||||
// But `handleStrictAction` expects immediate return.
|
||||
// Ideally, the BOT actions should happen asynchronously if we want delay.
|
||||
// For now, we accept instant-speed bots.
|
||||
|
||||
// console.log(`[Bot AI] ${bot.name} passes priority.`);
|
||||
try { engine.passPriority(botId); } catch (e) {
|
||||
console.warn("Bot failed to pass priority", e);
|
||||
// Force break loop if we are stuck?
|
||||
// RulesEngine.passPriority usually always succeeds if it's your turn.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Legacy Sandbox Action Handler (for Admin/Testing) ---
|
||||
handleAction(roomId: string, action: any, actorId: string): StrictGameState | null {
|
||||
const game = this.games.get(roomId);
|
||||
@@ -118,25 +372,31 @@ export class GameManager {
|
||||
|
||||
console.log(`[GameManager] Handling Action: ${action.type} for ${roomId} by ${actorId}`);
|
||||
|
||||
switch (action.type) {
|
||||
case 'UPDATE_LIFE':
|
||||
if (game.players[actorId]) {
|
||||
game.players[actorId].life += (action.amount || 0);
|
||||
}
|
||||
break;
|
||||
case 'MOVE_CARD':
|
||||
this.moveCard(game, action, actorId);
|
||||
break;
|
||||
case 'TAP_CARD':
|
||||
this.tapCard(game, action, actorId);
|
||||
break;
|
||||
case 'DRAW_CARD':
|
||||
const engine = new RulesEngine(game);
|
||||
engine.drawCard(actorId);
|
||||
break;
|
||||
case 'RESTART_GAME':
|
||||
this.restartGame(roomId);
|
||||
break;
|
||||
try {
|
||||
switch (action.type) {
|
||||
case 'UPDATE_LIFE':
|
||||
if (game.players[actorId]) {
|
||||
game.players[actorId].life += (action.amount || 0);
|
||||
}
|
||||
break;
|
||||
case 'MOVE_CARD':
|
||||
this.moveCard(game, action, actorId);
|
||||
break;
|
||||
case 'TAP_CARD':
|
||||
this.tapCard(game, action, actorId);
|
||||
break;
|
||||
case 'DRAW_CARD':
|
||||
const engine = new RulesEngine(game);
|
||||
engine.drawCard(actorId);
|
||||
break;
|
||||
case 'RESTART_GAME':
|
||||
this.restartGame(roomId);
|
||||
break;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(`Legacy Action Error [${action?.type}]: ${e.message}`);
|
||||
this.emit('game_error', roomId, { message: e.message, userId: actorId });
|
||||
return null;
|
||||
}
|
||||
|
||||
return game;
|
||||
@@ -197,10 +457,12 @@ export class GameManager {
|
||||
toughness: 0,
|
||||
basePower: 0,
|
||||
baseToughness: 0,
|
||||
imageUrl: '',
|
||||
imageUrl: cardData.imageUrl || '',
|
||||
controllerId: '',
|
||||
ownerId: '',
|
||||
oracleId: '',
|
||||
scryfallId: cardData.scryfallId || '',
|
||||
setCode: cardData.setCode || '',
|
||||
name: '',
|
||||
...cardData,
|
||||
damageMarked: 0,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Tournament } from './TournamentManager';
|
||||
|
||||
interface Player {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -8,6 +10,7 @@ interface Player {
|
||||
socketId?: string; // Current or last known socket
|
||||
isOffline?: boolean;
|
||||
isBot?: boolean;
|
||||
matchId?: string; // Current match in tournament
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
@@ -23,10 +26,11 @@ interface Room {
|
||||
players: Player[];
|
||||
packs: any[]; // Store generated packs (JSON)
|
||||
basicLands?: any[];
|
||||
status: 'waiting' | 'drafting' | 'deck_building' | 'playing' | 'finished';
|
||||
status: 'waiting' | 'drafting' | 'deck_building' | 'playing' | 'finished' | 'tournament';
|
||||
messages: ChatMessage[];
|
||||
maxPlayers: number;
|
||||
lastActive: number; // For persistence cleanup
|
||||
tournament?: Tournament | null;
|
||||
}
|
||||
|
||||
export class RoomManager {
|
||||
|
||||
280
src/server/managers/TournamentManager.ts
Normal file
280
src/server/managers/TournamentManager.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export interface TournamentPlayer {
|
||||
id: string;
|
||||
name: string;
|
||||
isBot: boolean;
|
||||
deck?: any[]; // Snapshot of deck
|
||||
}
|
||||
|
||||
export interface Match {
|
||||
id: string; // "round-X-match-Y"
|
||||
round: number;
|
||||
matchIndex: number; // 0-based index in the round
|
||||
player1: TournamentPlayer | null; // Null if bye or waiting for previous match
|
||||
player2: TournamentPlayer | null;
|
||||
winnerId?: string;
|
||||
status: 'pending' | 'ready' | 'in_progress' | 'finished';
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
readyPlayers: string[]; // IDs of players who have submitted deck
|
||||
}
|
||||
|
||||
export interface Tournament {
|
||||
id: string; // usually roomId
|
||||
players: TournamentPlayer[];
|
||||
rounds: Match[][]; // Array of rounds, each containing matches
|
||||
currentRound: number;
|
||||
status: 'setup' | 'active' | 'finished';
|
||||
winner?: TournamentPlayer;
|
||||
}
|
||||
|
||||
export class TournamentManager extends EventEmitter {
|
||||
private tournaments: Map<string, Tournament> = new Map();
|
||||
|
||||
createTournament(roomId: string, players: TournamentPlayer[]): Tournament {
|
||||
// 1. Shuffle Players
|
||||
const shuffled = [...players].sort(() => Math.random() - 0.5);
|
||||
|
||||
// 2. Generate Bracket (Single Elimination)
|
||||
// Calc next power of 2
|
||||
const total = shuffled.length;
|
||||
const size = Math.pow(2, Math.ceil(Math.log2(total)));
|
||||
// const byes = size - total;
|
||||
|
||||
// Distribute byes? Simple method: Add "Bye" players, then resolved them immediately.
|
||||
// Actually, let's keep it robust.
|
||||
// Round 1:
|
||||
|
||||
|
||||
// Proper Roster with Byes
|
||||
const roster: (TournamentPlayer | null)[] = [...shuffled];
|
||||
while (roster.length < size) {
|
||||
roster.push(null); // Null = BYE
|
||||
}
|
||||
|
||||
// Create Rounds recursively? Or just Round 1 and empty slots for others?
|
||||
// Let's pre-allocate the structure
|
||||
const rounds: Match[][] = [];
|
||||
let currentSize = size;
|
||||
let roundNum = 1;
|
||||
|
||||
while (currentSize > 1) {
|
||||
const matchCount = currentSize / 2;
|
||||
const roundMatches: Match[] = [];
|
||||
for (let i = 0; i < matchCount; i++) {
|
||||
roundMatches.push({
|
||||
id: `r${roundNum}-m${i}`,
|
||||
round: roundNum,
|
||||
matchIndex: i,
|
||||
player1: null,
|
||||
player2: null,
|
||||
status: 'pending',
|
||||
readyPlayers: []
|
||||
});
|
||||
}
|
||||
rounds.push(roundMatches);
|
||||
currentSize = matchCount;
|
||||
roundNum++;
|
||||
}
|
||||
|
||||
// Fill Round 1
|
||||
const r1 = rounds[0];
|
||||
for (let i = 0; i < r1.length; i++) {
|
||||
r1[i].player1 = roster[i * 2];
|
||||
r1[i].player2 = roster[i * 2 + 1];
|
||||
r1[i].status = 'ready'; // Potential auto-resolve if Bye
|
||||
}
|
||||
|
||||
const t: Tournament = {
|
||||
id: roomId,
|
||||
players,
|
||||
rounds,
|
||||
currentRound: 1,
|
||||
status: 'active'
|
||||
};
|
||||
|
||||
this.tournaments.set(roomId, t);
|
||||
|
||||
// Auto-resolve Byes and potentially Bot vs Bot in Round 1
|
||||
this.checkAutoResolutions(t);
|
||||
|
||||
return t;
|
||||
}
|
||||
|
||||
getTournament(roomId: string): Tournament | undefined {
|
||||
return this.tournaments.get(roomId);
|
||||
}
|
||||
|
||||
// Called when a game ends or a Bye is processed
|
||||
recordMatchResult(roomId: string, matchId: string, winnerId: string): Tournament | null {
|
||||
const t = this.tournaments.get(roomId);
|
||||
if (!t) return null;
|
||||
|
||||
// Find match
|
||||
let match: Match | undefined;
|
||||
for (const r of t.rounds) {
|
||||
match = r.find(m => m.id === matchId);
|
||||
if (match) break;
|
||||
}
|
||||
|
||||
if (!match) return null;
|
||||
if (match.status === 'finished') return t; // Already done
|
||||
|
||||
// Verify winner is part of match
|
||||
const winner = (match.player1?.id === winnerId) ? match.player1 : (match.player2?.id === winnerId) ? match.player2 : null;
|
||||
if (!winner) {
|
||||
// Maybe it was a Bye resolution where winnerId is valid?
|
||||
// If bye, player2 is null, winner is player1.
|
||||
if (match.player2 === null && match.player1?.id === winnerId) {
|
||||
// ok
|
||||
} else if (match.player1 === null && match.player2?.id === winnerId) {
|
||||
// ok
|
||||
} else {
|
||||
console.warn(`Invalid winner ${winnerId} for match ${matchId}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
match.status = 'finished';
|
||||
match.winnerId = winnerId;
|
||||
match.endTime = Date.now();
|
||||
|
||||
// Advance Winner to Next Round
|
||||
this.advanceToNextRound(t, match, winnerId);
|
||||
|
||||
// Trigger further auto-resolutions (e.g. if next match is now Bot vs Bot)
|
||||
this.checkAutoResolutions(t);
|
||||
|
||||
return t;
|
||||
}
|
||||
|
||||
private advanceToNextRound(t: Tournament, match: Match, winnerId: string) {
|
||||
// Logic: Match M in Round R feeds into Match floor(M/2) in Round R+1
|
||||
// If M is even (0, 2), it is Player 1 of next match.
|
||||
// If M is odd (1, 3), it is Player 2 of next match.
|
||||
|
||||
const nextRoundIdx = match.round; // rounds is 0-indexed array, so round 1 is at index 0. Next round is at index 1.
|
||||
// Wait, I stored round as 1-based in Match interface.
|
||||
// rounds[0] = Make Round 1
|
||||
// rounds[1] = Make Round 2
|
||||
|
||||
if (nextRoundIdx >= t.rounds.length) {
|
||||
// Tournament Over
|
||||
t.status = 'finished';
|
||||
t.winner = t.players.find(p => p.id === winnerId);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRound = t.rounds[nextRoundIdx];
|
||||
const nextMatchIndex = Math.floor(match.matchIndex / 2);
|
||||
const nextMatch = nextRound[nextMatchIndex];
|
||||
|
||||
if (!nextMatch) {
|
||||
console.error("Critical: Next match not found in bracket logic.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine slot
|
||||
const winner = t.players.find(p => p.id === winnerId);
|
||||
if (match.matchIndex % 2 === 0) {
|
||||
nextMatch.player1 = winner || null;
|
||||
} else {
|
||||
nextMatch.player2 = winner || null;
|
||||
}
|
||||
|
||||
// Check if next match is now ready
|
||||
if (nextMatch.player1 && nextMatch.player2) {
|
||||
nextMatch.status = 'ready';
|
||||
}
|
||||
// If one is BYE (null)?
|
||||
// My roster logic filled byes as nulls.
|
||||
// If we have a Bye in Step 1, it resolves.
|
||||
// In later rounds, null means "Waiting for opponent".
|
||||
// So status remains 'pending'.
|
||||
}
|
||||
|
||||
private checkAutoResolutions(t: Tournament) {
|
||||
|
||||
|
||||
// Currently we check ALL rounds because a fast resolution might cascade
|
||||
for (const r of t.rounds) {
|
||||
for (const m of r) {
|
||||
if (m.status !== 'ready') continue;
|
||||
|
||||
// 1. Check Byes (Player vs Null)
|
||||
if (m.player1 && !m.player2) {
|
||||
console.log(`[Tournament] Auto-resolving Bye for ${m.player1.name} in ${m.id}`);
|
||||
this.recordMatchResult(t.id, m.id, m.player1.id);
|
||||
continue;
|
||||
}
|
||||
// (Should not happen with my filler logic, but symetrically)
|
||||
if (!m.player1 && m.player2) {
|
||||
this.recordMatchResult(t.id, m.id, m.player2.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Check Bot vs Bot
|
||||
if (m.player1?.isBot && m.player2?.isBot) {
|
||||
// Coin flip
|
||||
const winner = Math.random() > 0.5 ? m.player1 : m.player2;
|
||||
console.log(`[Tournament] Auto-resolving Bot Match ${m.id}: ${m.player1.name} vs ${m.player2.name} -> Winner: ${winner.name}`);
|
||||
this.recordMatchResult(t.id, m.id, winner.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For frontend to know connection status
|
||||
getMatch(t: Tournament, matchId: string): Match | undefined {
|
||||
for (const r of t.rounds) {
|
||||
const m = r.find(x => x.id === matchId);
|
||||
if (m) return m;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setMatchReady(roomId: string, matchId: string, playerId: string, deck: any[]): { bothReady: boolean, decks: Record<string, any[]> } | null {
|
||||
const t = this.getTournament(roomId);
|
||||
if (!t) return null;
|
||||
|
||||
const match = this.getMatch(t, matchId);
|
||||
if (!match) return null;
|
||||
|
||||
// Update Player Deck in Tournament Roster
|
||||
const player = t.players.find(p => p.id === playerId);
|
||||
if (player) {
|
||||
if (deck && deck.length > 0) {
|
||||
player.deck = deck;
|
||||
} else if (!player.deck || player.deck.length === 0) {
|
||||
// Only warn if we are missing a deck entirely
|
||||
console.warn(`[Tournament] received empty deck for ${playerId}, and no previous deck found.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add to Ready
|
||||
if (!match.readyPlayers.includes(playerId)) {
|
||||
match.readyPlayers.push(playerId);
|
||||
}
|
||||
|
||||
// Check if both ready
|
||||
const p1 = match.player1;
|
||||
const p2 = match.player2;
|
||||
|
||||
if (p1 && p2) {
|
||||
const p1Ready = p1.isBot || match.readyPlayers.includes(p1.id);
|
||||
const p2Ready = p2.isBot || match.readyPlayers.includes(p2.id);
|
||||
|
||||
if (p1Ready && p2Ready) {
|
||||
match.status = 'in_progress'; // lock it
|
||||
// Return decks
|
||||
const p1Deck = t.players.find(p => p.id === p1.id)?.deck || [];
|
||||
const p2Deck = t.players.find(p => p.id === p2.id)?.deck || [];
|
||||
return { bothReady: true, decks: { [p1.id]: p1Deck, [p2.id]: p2Deck } };
|
||||
}
|
||||
}
|
||||
|
||||
return { bothReady: false, decks: {} };
|
||||
}
|
||||
}
|
||||
BIN
src/server/public/images/token.jpg
Normal file
BIN
src/server/public/images/token.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
@@ -34,6 +34,7 @@ export interface ProcessedPools {
|
||||
mythics: DraftCard[];
|
||||
lands: DraftCard[];
|
||||
tokens: DraftCard[];
|
||||
specialGuests: DraftCard[];
|
||||
}
|
||||
|
||||
export interface SetsMap {
|
||||
@@ -46,6 +47,7 @@ export interface SetsMap {
|
||||
mythics: DraftCard[];
|
||||
lands: DraftCard[];
|
||||
tokens: DraftCard[];
|
||||
specialGuests: DraftCard[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +59,9 @@ export interface PackGenerationSettings {
|
||||
|
||||
export class PackGeneratorService {
|
||||
|
||||
processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }): { pools: ProcessedPools, sets: SetsMap } {
|
||||
processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }, setsMetadata: { [code: string]: { parent_set_code?: string } } = {}): { pools: ProcessedPools, sets: SetsMap } {
|
||||
console.time('processCards');
|
||||
const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [] };
|
||||
const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [], specialGuests: [] };
|
||||
const setsMap: SetsMap = {};
|
||||
|
||||
let processedCount = 0;
|
||||
@@ -118,10 +120,11 @@ export class PackGeneratorService {
|
||||
else if (rarity === 'uncommon') pools.uncommons.push(cardObj);
|
||||
else if (rarity === 'rare') pools.rares.push(cardObj);
|
||||
else if (rarity === 'mythic') pools.mythics.push(cardObj);
|
||||
else pools.specialGuests.push(cardObj);
|
||||
|
||||
// Add to Sets Map
|
||||
if (!setsMap[cardData.set]) {
|
||||
setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [] };
|
||||
setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [], specialGuests: [] };
|
||||
}
|
||||
const setEntry = setsMap[cardData.set];
|
||||
|
||||
@@ -147,11 +150,38 @@ export class PackGeneratorService {
|
||||
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); }
|
||||
else { pools.specialGuests.push(cardObj); setEntry.specialGuests.push(cardObj); }
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
});
|
||||
|
||||
// 2. Second Pass: Merge Subsets (Masterpieces) into Parents
|
||||
Object.keys(setsMap).forEach(setCode => {
|
||||
const meta = setsMetadata[setCode];
|
||||
if (meta && meta.parent_set_code) {
|
||||
const parentCode = meta.parent_set_code;
|
||||
if (setsMap[parentCode]) {
|
||||
const parentSet = setsMap[parentCode];
|
||||
const childSet = setsMap[setCode];
|
||||
|
||||
const allChildCards = [
|
||||
...childSet.commons,
|
||||
...childSet.uncommons,
|
||||
...childSet.rares,
|
||||
...childSet.mythics,
|
||||
...childSet.specialGuests
|
||||
];
|
||||
|
||||
parentSet.specialGuests.push(...allChildCards);
|
||||
pools.specialGuests.push(...allChildCards);
|
||||
|
||||
// Remove child set from map so we don't generate separate packs for it
|
||||
delete setsMap[setCode];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[PackGenerator] Processed ${processedCount} cards.`);
|
||||
console.timeEnd('processCards');
|
||||
return { pools, sets: setsMap };
|
||||
@@ -176,7 +206,8 @@ export class PackGeneratorService {
|
||||
rares: this.shuffle([...pools.rares]),
|
||||
mythics: this.shuffle([...pools.mythics]),
|
||||
lands: this.shuffle([...pools.lands]),
|
||||
tokens: this.shuffle([...pools.tokens])
|
||||
tokens: this.shuffle([...pools.tokens]),
|
||||
specialGuests: this.shuffle([...pools.specialGuests])
|
||||
};
|
||||
|
||||
// Log pool sizes
|
||||
@@ -197,7 +228,8 @@ export class PackGeneratorService {
|
||||
rares: this.shuffle([...pools.rares]),
|
||||
mythics: this.shuffle([...pools.mythics]),
|
||||
lands: this.shuffle([...pools.lands]),
|
||||
tokens: this.shuffle([...pools.tokens])
|
||||
tokens: this.shuffle([...pools.tokens]),
|
||||
specialGuests: this.shuffle([...pools.specialGuests])
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,7 +288,8 @@ export class PackGeneratorService {
|
||||
rares: this.shuffle([...data.rares]),
|
||||
mythics: this.shuffle([...data.mythics]),
|
||||
lands: this.shuffle([...data.lands]),
|
||||
tokens: this.shuffle([...data.tokens])
|
||||
tokens: this.shuffle([...data.tokens]),
|
||||
specialGuests: this.shuffle([...data.specialGuests])
|
||||
};
|
||||
|
||||
let packsGeneratedForSet = 0;
|
||||
@@ -276,7 +309,8 @@ export class PackGeneratorService {
|
||||
rares: this.shuffle([...data.rares]),
|
||||
mythics: this.shuffle([...data.mythics]),
|
||||
lands: this.shuffle([...data.lands]),
|
||||
tokens: this.shuffle([...data.tokens])
|
||||
tokens: this.shuffle([...data.tokens]),
|
||||
specialGuests: this.shuffle([...data.specialGuests])
|
||||
};
|
||||
}
|
||||
|
||||
@@ -305,8 +339,7 @@ export class PackGeneratorService {
|
||||
const packCards: DraftCard[] = [];
|
||||
const namesInPack = new Set<string>();
|
||||
|
||||
// Standard: 14 cards exactly. Peasant: 13 cards exactly.
|
||||
const targetSize = rarityMode === 'peasant' ? 13 : 14;
|
||||
const targetSize = 14;
|
||||
|
||||
// Helper to abstract draw logic
|
||||
const draw = (pool: DraftCard[], count: number, poolKey: keyof ProcessedPools) => {
|
||||
@@ -322,90 +355,183 @@ export class PackGeneratorService {
|
||||
return result.selected;
|
||||
};
|
||||
|
||||
// 1. Commons (6)
|
||||
draw(pools.commons, 6, 'commons');
|
||||
if (rarityMode === 'peasant') {
|
||||
// 1. Commons (6) - Color Balanced
|
||||
// Using drawColorBalanced helper
|
||||
const drawC = this.drawColorBalanced(pools.commons, 6, namesInPack, withReplacement);
|
||||
if (drawC.selected.length > 0) {
|
||||
packCards.push(...drawC.selected);
|
||||
if (!withReplacement) {
|
||||
pools.commons = drawC.remainingPool;
|
||||
drawC.selected.forEach(c => namesInPack.add(c.name));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Slot 7: Common / The List
|
||||
// 1-87: Common
|
||||
// 88-97: List (C/U)
|
||||
// 98-100: List (U)
|
||||
const roll7 = Math.floor(Math.random() * 100) + 1;
|
||||
const hasGuests = pools.specialGuests.length > 0;
|
||||
|
||||
if (roll7 <= 87) {
|
||||
draw(pools.commons, 1, 'commons');
|
||||
} else if (roll7 <= 97) {
|
||||
// List (C/U) - Fallback logic
|
||||
if (hasGuests) draw(pools.specialGuests, 1, 'specialGuests');
|
||||
else {
|
||||
// 50/50 fallback
|
||||
const useU = Math.random() < 0.5;
|
||||
if (useU) draw(pools.uncommons, 1, 'uncommons');
|
||||
else draw(pools.commons, 1, 'commons');
|
||||
}
|
||||
} else {
|
||||
// 98-100: List (U)
|
||||
if (hasGuests) draw(pools.specialGuests, 1, 'specialGuests');
|
||||
else draw(pools.uncommons, 1, 'uncommons');
|
||||
}
|
||||
|
||||
// 3. Uncommons (4)
|
||||
draw(pools.uncommons, 4, 'uncommons');
|
||||
|
||||
// 4. Land (Slot 12)
|
||||
const isFoilLand = Math.random() < 0.2;
|
||||
const landPicks = draw(pools.lands, 1, 'lands');
|
||||
if (landPicks.length > 0 && isFoilLand) {
|
||||
const idx = packCards.indexOf(landPicks[0]);
|
||||
if (idx !== -1) {
|
||||
packCards[idx] = { ...packCards[idx], finish: 'foil' };
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Wildcards (Slot 13 & 14)
|
||||
// Peasant weights: ~62% Common, ~37% Uncommon
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const isFoil = i === 1;
|
||||
const wRoll = Math.random() * 100;
|
||||
let targetKey: keyof ProcessedPools = 'commons';
|
||||
|
||||
// 1-62: Common, 63-100: Uncommon (Approx > 62)
|
||||
if (wRoll > 62) targetKey = 'uncommons';
|
||||
else targetKey = 'commons';
|
||||
|
||||
let pool = pools[targetKey];
|
||||
if (pool.length === 0) {
|
||||
// Fallback
|
||||
targetKey = 'commons';
|
||||
pool = pools.commons;
|
||||
}
|
||||
|
||||
const res = this.drawCards(pool, 1, namesInPack, withReplacement);
|
||||
if (res.selected.length > 0) {
|
||||
const card = { ...res.selected[0] };
|
||||
if (isFoil) card.finish = 'foil';
|
||||
packCards.push(card);
|
||||
if (!withReplacement) {
|
||||
// @ts-ignore
|
||||
pools[targetKey] = res.remainingPool;
|
||||
namesInPack.add(card.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Slot 7 (Common or List)
|
||||
const roll7 = Math.random() * 100;
|
||||
if (roll7 < 87) {
|
||||
// Common
|
||||
draw(pools.commons, 1, 'commons');
|
||||
} else {
|
||||
// Uncommon/List
|
||||
// If pool empty, try fallback if standard? No, strict as per previous instruction.
|
||||
draw(pools.uncommons, 1, 'uncommons');
|
||||
}
|
||||
// STANDARD MODE
|
||||
|
||||
// 3. Uncommons (3 or 4 dependent on PEASANT vs STANDARD)
|
||||
const uNeeded = rarityMode === 'peasant' ? 4 : 3;
|
||||
draw(pools.uncommons, uNeeded, 'uncommons');
|
||||
// 1. Commons (6)
|
||||
const drawC = this.drawColorBalanced(pools.commons, 6, namesInPack, withReplacement);
|
||||
if (drawC.selected.length > 0) {
|
||||
packCards.push(...drawC.selected);
|
||||
if (!withReplacement) {
|
||||
pools.commons = drawC.remainingPool;
|
||||
drawC.selected.forEach(c => namesInPack.add(c.name));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Rare/Mythic (Standard Only)
|
||||
if (rarityMode === 'standard') {
|
||||
// 2. Slot 7 (Common / List / Guest)
|
||||
// 1-87: Common
|
||||
// 88-97: List (C/U)
|
||||
// 98-99: List (R/M)
|
||||
// 100: Special Guest
|
||||
const roll7 = Math.floor(Math.random() * 100) + 1; // 1-100
|
||||
const hasGuests = pools.specialGuests.length > 0;
|
||||
|
||||
if (roll7 <= 87) {
|
||||
draw(pools.commons, 1, 'commons');
|
||||
} else if (roll7 <= 97) {
|
||||
// List C/U
|
||||
if (hasGuests) draw(pools.specialGuests, 1, 'specialGuests');
|
||||
else {
|
||||
if (Math.random() < 0.5) draw(pools.uncommons, 1, 'uncommons');
|
||||
else draw(pools.commons, 1, 'commons');
|
||||
}
|
||||
} else if (roll7 <= 99) {
|
||||
// List R/M
|
||||
if (hasGuests) draw(pools.specialGuests, 1, 'specialGuests');
|
||||
else {
|
||||
if (Math.random() < 0.5) draw(pools.mythics, 1, 'mythics');
|
||||
else draw(pools.rares, 1, 'rares');
|
||||
}
|
||||
} else {
|
||||
// 100: Special Guest
|
||||
if (hasGuests) draw(pools.specialGuests, 1, 'specialGuests');
|
||||
else draw(pools.mythics, 1, 'mythics'); // Fallback to Mythic
|
||||
}
|
||||
|
||||
// 3. Uncommons (3)
|
||||
draw(pools.uncommons, 3, 'uncommons');
|
||||
|
||||
// 4. Main Rare/Mythic (Slot 11)
|
||||
const isMythic = Math.random() < 0.125;
|
||||
let pickedR = false;
|
||||
|
||||
if (isMythic && pools.mythics.length > 0) {
|
||||
const sel = draw(pools.mythics, 1, 'mythics');
|
||||
if (sel.length) pickedR = true;
|
||||
}
|
||||
|
||||
if (!pickedR && pools.rares.length > 0) {
|
||||
if (!pickedR) {
|
||||
draw(pools.rares, 1, 'rares');
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Land
|
||||
const isFoilLand = Math.random() < 0.2;
|
||||
if (pools.lands.length > 0) {
|
||||
// For lands, we generally want random basic lands anyway even in finite cubes if possible?
|
||||
// But adhering to 'withReplacement' logic strictly.
|
||||
const res = this.drawCards(pools.lands, 1, namesInPack, withReplacement);
|
||||
if (res.selected.length) {
|
||||
const l = { ...res.selected[0] };
|
||||
if (isFoilLand) l.finish = 'foil';
|
||||
packCards.push(l);
|
||||
if (!withReplacement) {
|
||||
pools.lands = res.remainingPool;
|
||||
namesInPack.add(l.name);
|
||||
// 5. Land (Slot 12)
|
||||
const isFoilLand = Math.random() < 0.2;
|
||||
const landPicks = draw(pools.lands, 1, 'lands');
|
||||
if (landPicks.length > 0 && isFoilLand) {
|
||||
const idx = packCards.indexOf(landPicks[0]);
|
||||
if (idx !== -1) {
|
||||
packCards[idx] = { ...packCards[idx], finish: 'foil' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Wildcards (2 slots) + Foil Wildcard
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const isFoil = i === 1; // 2nd is foil
|
||||
const wRoll = Math.random() * 100;
|
||||
let targetPool = pools.commons;
|
||||
let targetKey: keyof ProcessedPools = 'commons';
|
||||
// 6. Wildcards (Slot 13 & 14)
|
||||
// Standard weights: ~49% C, ~24% U, ~13% R, ~13% M
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const isFoil = i === 1;
|
||||
const wRoll = Math.random() * 100;
|
||||
let targetKey: keyof ProcessedPools = 'commons';
|
||||
|
||||
if (rarityMode === 'peasant') {
|
||||
if (wRoll > 60) { targetPool = pools.uncommons; targetKey = 'uncommons'; }
|
||||
else { targetPool = pools.commons; targetKey = 'commons'; }
|
||||
} else {
|
||||
if (wRoll > 87) { targetPool = pools.mythics; targetKey = 'mythics'; }
|
||||
else if (wRoll > 74) { targetPool = pools.rares; targetKey = 'rares'; }
|
||||
else if (wRoll > 50) { targetPool = pools.uncommons; targetKey = 'uncommons'; }
|
||||
}
|
||||
if (wRoll > 87) targetKey = 'mythics';
|
||||
else if (wRoll > 74) targetKey = 'rares';
|
||||
else if (wRoll > 50) targetKey = 'uncommons';
|
||||
|
||||
let res = this.drawCards(targetPool, 1, namesInPack, withReplacement);
|
||||
let pool = pools[targetKey];
|
||||
// Hierarchical fallback
|
||||
if (pool.length === 0) {
|
||||
if (targetKey === 'mythics' && pools.rares.length) targetKey = 'rares';
|
||||
if ((targetKey === 'rares' || targetKey === 'mythics') && pools.uncommons.length) targetKey = 'uncommons';
|
||||
if (targetKey !== 'commons' && pools.commons.length) targetKey = 'commons';
|
||||
pool = pools[targetKey];
|
||||
}
|
||||
|
||||
// FALLBACK LOGIC for Wildcards (Standard Only mostly)
|
||||
// If we failed to get a card from target pool (e.g. rolled Mythic but set has none), try lower rarity
|
||||
if (!res.success && rarityMode === 'standard') {
|
||||
if (targetKey === 'mythics' && pools.rares.length) { res = this.drawCards(pools.rares, 1, namesInPack, withReplacement); targetKey = 'rares'; }
|
||||
else if (targetKey === 'rares' && pools.uncommons.length) { res = this.drawCards(pools.uncommons, 1, namesInPack, withReplacement); targetKey = 'uncommons'; }
|
||||
else if (targetKey === 'uncommons' && pools.commons.length) { res = this.drawCards(pools.commons, 1, namesInPack, withReplacement); targetKey = 'commons'; }
|
||||
}
|
||||
|
||||
if (res.selected.length) {
|
||||
const c = { ...res.selected[0] };
|
||||
if (isFoil) c.finish = 'foil';
|
||||
packCards.push(c);
|
||||
if (!withReplacement) {
|
||||
// @ts-ignore
|
||||
pools[targetKey] = res.remainingPool;
|
||||
namesInPack.add(c.name);
|
||||
const res = this.drawCards(pool, 1, namesInPack, withReplacement);
|
||||
if (res.selected.length > 0) {
|
||||
const card = { ...res.selected[0] };
|
||||
if (isFoil) card.finish = 'foil';
|
||||
packCards.push(card);
|
||||
if (!withReplacement) {
|
||||
// @ts-ignore
|
||||
pools[targetKey] = res.remainingPool;
|
||||
namesInPack.add(card.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -428,21 +554,21 @@ export class PackGeneratorService {
|
||||
|
||||
packCards.sort((a, b) => getWeight(b) - getWeight(a));
|
||||
|
||||
// ENFORCE SIZE STRICTLY
|
||||
const finalCards = packCards.slice(0, targetSize);
|
||||
|
||||
// Strict Validation
|
||||
if (finalCards.length < targetSize) {
|
||||
if (packCards.length < targetSize) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: packId,
|
||||
setName: setName,
|
||||
cards: finalCards
|
||||
cards: packCards
|
||||
};
|
||||
}
|
||||
|
||||
private drawColorBalanced(pool: DraftCard[], count: number, existingNames: Set<string>, withReplacement: boolean) {
|
||||
return this.drawCards(pool, count, existingNames, withReplacement);
|
||||
}
|
||||
|
||||
// Unified Draw Method
|
||||
private drawCards(pool: DraftCard[], count: number, existingNames: Set<string>, withReplacement: boolean) {
|
||||
if (pool.length === 0) return { selected: [], remainingPool: pool, success: false };
|
||||
|
||||
@@ -193,13 +193,15 @@ export class ScryfallService {
|
||||
const data = await resp.json();
|
||||
|
||||
const sets = data.data
|
||||
.filter((s: any) => ['core', 'expansion', 'masters', 'draft_innovation', 'commander', 'funny'].includes(s.set_type))
|
||||
.filter((s: any) => ['core', 'expansion', 'masters', 'draft_innovation', 'commander', 'funny', 'masterpiece', 'eternal'].includes(s.set_type))
|
||||
.map((s: any) => ({
|
||||
code: s.code,
|
||||
name: s.name,
|
||||
set_type: s.set_type,
|
||||
released_at: s.released_at,
|
||||
digital: s.digital
|
||||
digital: s.digital,
|
||||
parent_set_code: s.parent_set_code,
|
||||
card_count: s.card_count
|
||||
}));
|
||||
|
||||
return sets;
|
||||
@@ -209,7 +211,7 @@ export class ScryfallService {
|
||||
}
|
||||
}
|
||||
|
||||
async fetchSetCards(setCode: string): Promise<ScryfallCard[]> {
|
||||
async fetchSetCards(setCode: string, relatedSets: string[] = []): Promise<ScryfallCard[]> {
|
||||
const setHash = setCode.toLowerCase();
|
||||
const setCachePath = path.join(SETS_DIR, `${setHash}.json`);
|
||||
|
||||
@@ -226,26 +228,30 @@ export class ScryfallService {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[ScryfallService] Fetching cards for set ${setCode} from API...`);
|
||||
console.log(`[ScryfallService] Fetching cards for set ${setCode} (related: ${relatedSets.join(',')}) from API...`);
|
||||
let allCards: ScryfallCard[] = [];
|
||||
let url = `https://api.scryfall.com/cards/search?q=set:${setCode}&unique=cards`;
|
||||
|
||||
// Construct Composite Query: (e:main OR e:sub1 OR e:sub2) is:booster unique=prints
|
||||
const setClause = `e:${setCode}` + relatedSets.map(s => ` OR e:${s}`).join('');
|
||||
let url = `https://api.scryfall.com/cards/search?q=(${setClause}) unique=prints is:booster`;
|
||||
|
||||
try {
|
||||
while (url) {
|
||||
console.log(`[ScryfallService] Requesting: ${url}`);
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) {
|
||||
if (r.status === 404) {
|
||||
console.log(`[ScryfallService] [API CALL] Requesting: ${url}`);
|
||||
const resp = await fetch(url);
|
||||
console.log(`[ScryfallService] [API RESPONSE] Status: ${resp.status}`);
|
||||
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 404) {
|
||||
console.warn(`[ScryfallService] 404 Not Found for URL: ${url}. Assuming set has no cards.`);
|
||||
break;
|
||||
}
|
||||
|
||||
const errBody = await r.text();
|
||||
console.error(`[ScryfallService] Error fetching ${url}: ${r.status} ${r.statusText}`, errBody);
|
||||
throw new Error(`Failed to fetch set: ${r.statusText} (${r.status}) - ${errBody}`);
|
||||
const errBody = await resp.text();
|
||||
console.error(`[ScryfallService] Error fetching ${url}: ${resp.status} ${resp.statusText}`, errBody);
|
||||
throw new Error(`Failed to fetch set: ${resp.statusText} (${resp.status}) - ${errBody}`);
|
||||
}
|
||||
|
||||
const d = await r.json();
|
||||
const d = await resp.json();
|
||||
|
||||
if (d.data) {
|
||||
allCards.push(...d.data);
|
||||
@@ -261,6 +267,9 @@ export class ScryfallService {
|
||||
|
||||
// Save Set Cache
|
||||
if (allCards.length > 0) {
|
||||
if (!fs.existsSync(path.dirname(setCachePath))) {
|
||||
fs.mkdirSync(path.dirname(setCachePath), { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(setCachePath, JSON.stringify(allCards, null, 2));
|
||||
|
||||
// Smartly save individuals: only if missing from cache
|
||||
|
||||
Reference in New Issue
Block a user