Compare commits

...

34 Commits

Author SHA1 Message Date
1e3e25ca13 feat: add token image asset
Some checks failed
Build and Deploy / build (push) Failing after 11m16s
2025-12-23 01:55:45 +01:00
36fd89cda9 feat: Introduce a modal for creating custom tokens with definable properties and board position. 2025-12-23 01:52:13 +01:00
f8188875ac docs: Add MTG comprehensive rules and pack generation algorithms, remove old rulebook and development documentation, and update GameView.
Some checks failed
Build and Deploy / build (push) Failing after 10s
2025-12-23 01:30:05 +01:00
e655e3efe2 feat: Implement smart auto-pass for non-active players and add a UI button to suspend it. 2025-12-23 01:23:43 +01:00
23b8e3203d feat: introduce game log panel and context, integrating it into the side panel and logging game events.
Some checks failed
Build and Deploy / build (push) Failing after 11m56s
2025-12-23 01:09:05 +01:00
9b25d3f0be feat: enhance card identification from image URLs and introduce cropped art support for cards.
All checks were successful
Build and Deploy / build (push) Successful in 2m7s
2025-12-23 01:03:29 +01:00
6edfb8b9e4 feat: Add hover border effect to cards when they are in the battlefield zone. 2025-12-23 00:19:55 +01:00
35407a5cd4 feat: Implement client-side equip functionality and visual attachment rendering, and server-side Aura/Equipment attachment resolution. 2025-12-23 00:03:35 +01:00
7aa34adf95 feat: implement mana cost parsing and auto-tap calculation with visual preview in game view
Some checks failed
Build and Deploy / build (push) Failing after 10s
2025-12-22 23:51:52 +01:00
f701302923 feat: Enhance card drag-and-drop experience with a 'large' view mode, dynamic animations, and layout control. 2025-12-22 23:48:29 +01:00
66836cfde5 feat: add error handling for game actions in GameManager and update service worker revision for index.html. 2025-12-22 23:28:53 +01:00
41be1d49c4 feat: Implement mana cost payment logic in the rules engine and emit client-side notifications for rule violation errors. 2025-12-22 23:27:01 +01:00
d6fb76eb3e feat: group and stack land cards by name on the battlefield display. 2025-12-22 23:21:38 +01:00
ec69c69df7 feat: Update tapped card visual to a 10-degree rotation with opacity and simplify battlefield land display to a single wrapped row.
All checks were successful
Build and Deploy / build (push) Successful in 1m49s
2025-12-22 23:09:06 +01:00
d16bfd96ee refactor: Simplify battlefield UI by removing background textures, shadows, and labels, and adjusting card positioning. 2025-12-22 23:05:45 +01:00
224cc38ba1 feat: adjust card dimensions for 'cutout' view mode 2025-12-22 22:56:48 +01:00
eb711c3253 feat: replace drag overlay image preview with CardComponent and adjust its size and styling.
All checks were successful
Build and Deploy / build (push) Successful in 1m56s
2025-12-22 22:53:56 +01:00
19c98d9629 feat: assign card imageUrl from image_uris or existing card data fields 2025-12-22 22:51:58 +01:00
784d173fec feat: Add Scryfall ID and set code to card instances for client-side image hydration and update card creation logic. 2025-12-22 22:39:00 +01:00
c88c8ced15 feat: Implement resume match button, enhance card image loading, and improve server-side logging and deck handling. 2025-12-22 22:15:32 +01:00
325f82ff6b feat: implement asynchronous bot actions with improved combat AI and real-time game state updates
All checks were successful
Build and Deploy / build (push) Successful in 2m7s
2025-12-22 21:41:31 +01:00
937620bac1 created the tournament ui and fixed the turns sequence 2025-12-22 19:54:01 +01:00
ac21657bc7 created a new reusable component for the card lsft side preview
Some checks failed
Build and Deploy / build (push) Failing after 1m12s
2025-12-22 18:45:28 +01:00
f335b33cf9 added icons to the preview 2025-12-22 18:31:48 +01:00
5dbfd006c2 used new icons for mtg symbols
Some checks failed
Build and Deploy / build (push) Failing after 10m12s
2025-12-22 18:02:00 +01:00
5b601efcb6 fixed turn bar
Some checks failed
Build and Deploy / build (push) Failing after 2m11s
2025-12-22 17:49:58 +01:00
8a65169d2a changes the smart button 2025-12-22 17:28:22 +01:00
f17ef711da feat: Add manual draw card action, interactive mana pool controls, and reorganize game view layout. 2025-12-22 17:11:49 +01:00
c1e062620e feat: Improve attacker/blocker declaration by adding client-side creature validation, enforcing correct player priority, and enhancing server-side error logging. 2025-12-22 16:47:38 +01:00
9c72bd7b8c feat: Implement basic bot AI for game actions including mulligan, playing lands, casting creatures, and declaring attackers. 2025-12-22 13:04:52 +01:00
fd7642dded fix: ensure Scryfall set cache directory exists and update service worker revision for index.html.
All checks were successful
Build and Deploy / build (push) Successful in 2m3s
2025-12-22 10:46:39 +01:00
c9d0230781 refactor: Remove unused imports and state variables from DeckBuilderView and GameRoom components.
All checks were successful
Build and Deploy / build (push) Successful in 1m56s
2025-12-22 10:26:14 +01:00
4e36157115 feat: Refine booster pack generation logic for 'The List' cards, Special Guests, and wildcard rarities in both Draft and Play Boosters.
Some checks failed
Build and Deploy / build (push) Failing after 1m11s
2025-12-20 20:03:50 +01:00
139aca6f4f feat: Implement new peasant and standard pack generation algorithms, including special guest support and subset merging, and add relevant documentation. 2025-12-20 19:53:48 +01:00
45 changed files with 3995 additions and 1247 deletions

View File

@@ -12,14 +12,10 @@ The project follows a **Modular Monolith** pattern. All backend logic is structu
## Backend and Frontend Integration (The Monolith)
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/

View File

@@ -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.

View File

@@ -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.

View File

@@ -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).

View 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.

View 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.

View File

@@ -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"), {

View File

@@ -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">

View 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>
);
};

View 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>
);
};

View 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" />;
}
};

View 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" />;
};

View 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';

View 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;
};

View File

@@ -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

View File

@@ -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'

View File

@@ -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>
);
};

View File

@@ -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' ? (

View File

@@ -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>
);

View 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>
);
};

View File

@@ -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

View File

@@ -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)]'

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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() {

View File

@@ -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 {

View 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;
};

View 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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -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 });
}
}
});

View File

@@ -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,

View File

@@ -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 {

View 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: {} };
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -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 };

View File

@@ -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