feat: Implement game and server persistence using Redis and file storage, and add a collapsible, resizable card preview sidebar to the game view.

This commit is contained in:
2025-12-18 17:40:36 +01:00
parent a2a45a995c
commit 842beae419
16 changed files with 10436 additions and 300 deletions

View File

@@ -0,0 +1,23 @@
# 2024-12-18 17:35:00 - Strict Rules Engine Implementation
## Description
Implemented a comprehensive Magic: The Gathering rules engine (Core Logic) to replace the sandbox mode with strict rules enforcement. This includes a State Machine for Turn Structure, Priority System, Stack, and State-Based Actions.
## Key Changes
1. **Core Types**: Created `src/server/game/types.ts` defining `Phase`, `Step`, `StrictGameState`, `StackObject`, etc.
2. **Rules Engine**: Created `src/server/game/RulesEngine.ts` implementing:
- **Turn Structure**: Untap, Upkeep, Draw, Main, Combat (Steps), End.
- **Priority System**: Passing priority, stack resolution, phase transition.
- **Actions**: `playLand`, `castSpell` with validation.
- **State-Based Actions**: Lethal damage, Zero toughness, Player loss checks.
3. **Game Manager Refactor**:
- Updated `GameManager.ts` to use `StrictGameState`.
- Implemented `handleStrictAction` to delegate to `RulesEngine`.
- Retained `handleAction` for legacy/sandbox/admin support.
4. **Socket Handling**:
- Added `game_strict_action` event listener in `server/index.ts`.
## Next Steps
- Client-side integration: The frontend needs to be updated to visualize the Phases, Stack, and Priority (Pass Button).
- Move from "Sandbox" UI to "Rules Enforcement" UI.

View File

@@ -0,0 +1,20 @@
# 2024-12-18 18:00:00 - High-Velocity UX Implementation (Part 1)
## Description
Started implementing the Frontend components for the High-Velocity UX Specification. Focused on the "Smart Priority Interface" components first to enable strict rules interaction.
## Key Changes
1. **Frontend Plan**: Created `docs/development/plans/high-velocity-ux.md` detailing the gesture engine and UI components.
2. **Strict Types**: Updated `src/client/types/game.ts` to include `Phase`, `Step`, `StackObject`, and `StrictGameState` extensions.
3. **Smart Button**: Created `SmartButton.tsx` which derives state (Pass/Resolve/Wait) from the `StrictGameState`.
- Green: Pass/Advance Phase.
- Orange: Resolve Stack.
- Grey: Wait (Not Priority).
4. **Phase Strip**: Created `PhaseStrip.tsx` to visualize the linear turn structure and highlight the current step.
5. **GameView Integration**: Updated `GameView.tsx` to house these new controls in the bottom area. Wire up `SmartButton` to emit `game_strict_action`.
## Next Steps
- Implement `GestureManager` context for Swipe-to-Tap and Swipe-to-Combat.
- Implement `StackVisualizer` to show objects on the stack.
- Connect `ContextMenu` to strict actions (Activate Ability).

View File

@@ -0,0 +1,21 @@
# 2024-12-18 18:25:00 - High-Velocity UX Implementation (Part 2: Gestures & Backend Polish)
## Description
Advanced the High-Velocity UX implementation by introducing the Gesture Engine and refining the backend Rules Engine to support card movement during resolution.
## Key Changes
1. **Gesture Manager**: Implemented `GestureManager.tsx` and integrated it into the Battlefield.
- Provides Swipe-to-Tap functionality via pointer tracking and intersection checking.
- Draws a visual SVG path trail for user feedback.
- Integrated with `CardComponent` via `useGesture` hook to register card DOM elements.
2. **Stack Visualizer**: Implemented `StackVisualizer.tsx` to render the stack on the right side of the screen, showing strict object ordering.
3. **Backend Rules Engine**:
- Updated `RulesEngine.ts` to fully implement `resolveTopStack` and `drawCard`.
- Added `moveCardToZone` helper to manage state transitions (untapping, resetting position).
- Fixed typings and logic flow for resolving spells to graveyard vs battlefield.
## Next Steps
- Implement Radial Menu context for activating abilities.
- Add sound effects for gestures.
- Polish visual transitions for stack resolution.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,146 @@
# Implementation Plan: MTG Rules Engine & High-Velocity UX
## Objective
Implement a strict Magic: The Gathering rules engine (Backend) and a "Rich Input" high-velocity user experience (Frontend). The goal is to enforce 100% of the Comprehensive Rules on the server while allowing fluid, gesture-based actions on the client.
**Reference:** MagicCompRules 20251114.txt
---
## PART 1: THE RULES ENGINE (Server-Side Logic)
### A. Game Setup & Initialization (CR 103)
The engine must execute this sequence automatically before the first turn:
1. **Deck Validation**: Verify decks meet format requirements (e.g., 60 cards min).
2. **Life Initialization**: Set Life Totals to 20.
3. **The Mulligan Process (CR 103.5)**:
- **Initial Draw**: Both players draw 7 cards.
- **Decision Loop**: Prompt players in turn order: "Keep Hand" or "Mulligan".
- **Execution**: If Mulligan, shuffle hand into library and draw 7 new cards.
- **London Rule**: For each Mulligan (N), user must select N cards to bottom.
- **Concurrency**: Decisions sequential, Shuffles/Draws simultaneous.
### B. The Turn Structure State Machine (CR 500)
Strict phase cycle. Priority (CR 117) must be passed by both players to advance.
1. **Beginning Phase (CR 501)**
- **Untap Step**: AP untaps all permanents. No Priority.
- **Upkeep Step**: Triggers go on stack. AP Priority.
- **Draw Step**: AP draws. Triggers. AP Priority.
2. **Pre-Combat Main Phase (CR 505)**
- AP may Play Land (Special Action, 1/turn, Stack empty).
- AP may Cast Sorcery/Creature/Artifact/Enchantment/Planeswalker.
3. **Combat Phase (CR 506)**
- **Start of Combat**: Triggers. Priority.
- **Declare Attackers (CR 508)**:
- AP selects attackers & targets (Player/Planeswalker).
- Engine validates restrictions & taps attackers.
- Triggers. AP Priority.
- **Declare Blockers (CR 509)**:
- DP assign blockers.
- Engine validates (Flying, Menace, etc.).
- Damage Ordering (if multi-blocked).
- Triggers. AP Priority.
- **Combat Damage (CR 510)**:
- AP assigns damage (Lethal to 1st, then rest).
- Damage dealt simultaneously.
- Priority Check.
- **End of Combat**: Priority Check.
4. **Post-Combat Main Phase**
- Identical to Pre-Combat Main.
5. **Ending Phase (CR 512)**
- **End Step**: Triggers. Priority.
- **Cleanup Step (CR 514)**:
- Discard to hand size.
- Remove marked damage.
- No Priority (unless trigger occurs).
### C. The Interaction Core: Priority & The Stack (CR 405 & 117)
LIFO (Last-In, First-Out) Array.
- **Priority Passing (CR 117.3d)**: Game advances only when all players pass on empty stack.
- **Response Window**: After cast, AP gets priority. If AP passes -> DP gets priority. If DP passes -> Resolve.
- **State-Based Actions (The Referee Check - CR 704)**: Checked BEFORE every priority gain.
- **Lethal Damage**: Damage >= Toughness -> Graveyard.
- **0 Toughness**: Toughness <= 0 -> Graveyard.
- **Legend Rule**: Duplicate legendary -> Controller chooses capture.
- **Aura Check**: Invalid attachment -> Graveyard.
### D. Manual Mana Engine (CR 106)
- **Production**: Tap Land -> Add color to **Floating Pool**.
- **Spending**: Click symbol in pool to pay costs.
- **Emptying**: Pool empties at end of every Step/Phase.
### E. Developer Notes & Edge Cases
- **Layer System (CR 613)**: Must implement 7-Layer system (Copy, Control, Text, Type, Color, Ability, P/T).
- **Token Generation**: Spawn game object with stats.
- **Failure States**: Insufficient mana -> Bounce, Warning Flash.
---
## PART 2: THE "HIGH-VELOCITY" UX (Frontend Specification)
### 1. The "Smart" Priority Button
Context-aware button (Bottom Right).
- **Green ("Pass")**: Stack empty. clicking passes/advances.
- **Orange ("Resolve")**: Stack has object. Click resolves top.
- **Red ("Damage/Block")**: Mandatory manual assignment waiting.
- **Blue ("Choice")**: Modal choice required.
- **"Yield" Toggle**: Auto-pass priority until End Step (unless response needed).
### 2. Gesture Controls (Mouse/Touch)
- **Swipe-to-Tap**: Drag line across background/cards. Intersected lands toggle & add mana.
- **Combat Swipes**:
- Attack: Swipe Up/Forward.
- Cancel Attack: Swipe Down/Back.
- Block: Drag blocker onto attacker.
- **Targeting Tether**: Visual "rope" (Bezier curve) from source to target.
### 3. Contextual Radial Menus
**Scenario**: User taps Dual Land.
- **Interaction**: Pie menu spawns under cursor.
- **Action**: Slide toward symbol to select. Minimal travel.
### 4. Visualizing "The Stack"
Vertical list of tiles on screen edge.
- **Hover/Long-press**: Show source card.
- **Targeting**: Tiles must be valid targets for spells.
### 5. The "Inspector" Overlay
Long-press/Right-click on card.
- **Display**: High-Res Art + Oracle Text.
- **Live Math**: Show Net Power/Toughness (Base + Layers).
---
## Task Breakdown & Status
### Backend (Rules Engine)
- [x] **Core Structures**: `StrictGameState`, Phase, Step Types.
- [x] **State Machine Baseline**: Phase advancement logic.
- [x] **Priority Logic**: Passing, resolving, resetting.
- [x] **Basic Actions**: Play Land, Cast Spell.
- [x] **Stack Resolution**: Resolving Spells to Zones.
- [x] **SBAs Implementation**: Basic (Lethal, 0 Toughness, Legend).
- [ ] **Advanced SBAs**: Aura Validity check.
- [ ] **Manual Mana Engine**: Floating Pool Logic.
- [ ] **Game Setup**: Mulligan (London), Deck Validation.
- [ ] **Combat Phase Detail**: Declare Attackers/Blockers steps & validation.
- [ ] **Layer System**: Implement 7-layer P/T calculation.
### Frontend (High-Velocity UX)
- [x] **Game View**: Render State Types.
- [x] **Phase Strip**: Visual progress.
- [x] **Smart Button**: Basic States (Green/Orange/Red).
- [x] **Gesture Engine**: Swipe-to-Tap.
- [x] **Stack Visualization**: Basic Component.
- [ ] **Gesture Polish**: Combat Swipes, Targeting Tether.
- [ ] **Smart Button Advanced**: "Yield" Toggle.
- [ ] **Radial Menus**: Pie Menu for Dual Lands/Modes.
- [ ] **Inspector Overlay**: Live Math & Details.

View File

@@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import { CardInstance } from '../../types/game'; import { CardInstance } from '../../types/game';
import { useGesture } from './GestureManager';
import { useRef, useEffect } from 'react';
interface CardComponentProps { interface CardComponentProps {
card: CardInstance; card: CardInstance;
@@ -12,8 +14,19 @@ interface CardComponentProps {
} }
export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart, onClick, onContextMenu, onMouseEnter, onMouseLeave, style }) => { export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart, onClick, onContextMenu, onMouseEnter, onMouseLeave, style }) => {
const { registerCard, unregisterCard } = useGesture();
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (cardRef.current) {
registerCard(card.instanceId, cardRef.current);
}
return () => unregisterCard(card.instanceId);
}, [card.instanceId]);
return ( return (
<div <div
ref={cardRef}
draggable draggable
onDragStart={(e) => onDragStart(e, card.instanceId)} onDragStart={(e) => onDragStart(e, card.instanceId)}
onClick={() => onClick(card.instanceId)} onClick={() => onClick(card.instanceId)}

View File

@@ -5,6 +5,10 @@ import { socketService } from '../../services/SocketService';
import { CardComponent } from './CardComponent'; import { CardComponent } from './CardComponent';
import { GameContextMenu, ContextMenuRequest } from './GameContextMenu'; import { GameContextMenu, ContextMenuRequest } from './GameContextMenu';
import { ZoneOverlay } from './ZoneOverlay'; import { ZoneOverlay } from './ZoneOverlay';
import { PhaseStrip } from './PhaseStrip';
import { SmartButton } from './SmartButton';
import { StackVisualizer } from './StackVisualizer';
import { GestureManager } from './GestureManager';
interface GameViewProps { interface GameViewProps {
gameState: GameState; gameState: GameState;
@@ -330,6 +334,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
{/* Main Game Area */} {/* Main Game Area */}
<div className="flex-1 flex flex-col h-full relative"> <div className="flex-1 flex flex-col h-full relative">
<StackVisualizer gameState={gameState} />
{/* Top Area: Opponent */} {/* Top Area: Opponent */}
<div className="flex-[2] relative flex flex-col pointer-events-none"> <div className="flex-[2] relative flex flex-col pointer-events-none">
@@ -393,46 +398,48 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, 'battlefield')} onDrop={(e) => handleDrop(e, 'battlefield')}
> >
<div <GestureManager>
className="w-full h-full relative bg-slate-900/20 border-y border-white/5 shadow-inner" <div
style={{ className="w-full h-full relative bg-slate-900/20 border-y border-white/5 shadow-inner"
transform: 'rotateX(25deg)', style={{
transformOrigin: 'center 40%', transform: 'rotateX(25deg)',
boxShadow: 'inset 0 0 100px rgba(0,0,0,0.8)' transformOrigin: 'center 40%',
}} boxShadow: 'inset 0 0 100px rgba(0,0,0,0.8)'
> }}
{/* Battlefield Texture/Grid */} >
<div className="absolute inset-0 opacity-10 bg-[linear-gradient(rgba(255,255,255,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.1)_1px,transparent_1px)] bg-[size:50px_50px]"></div> {/* Battlefield Texture/Grid */}
<div className="absolute inset-0 opacity-10 bg-[linear-gradient(rgba(255,255,255,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.1)_1px,transparent_1px)] bg-[size:50px_50px]"></div>
{myBattlefield.map(card => ( {myBattlefield.map(card => (
<div <div
key={card.instanceId} key={card.instanceId}
className="absolute transition-all duration-200" className="absolute transition-all duration-200"
style={{ style={{
left: `${card.position?.x || Math.random() * 80}%`, left: `${card.position?.x || Math.random() * 80}%`,
top: `${card.position?.y || Math.random() * 80}%`, top: `${card.position?.y || Math.random() * 80}%`,
zIndex: card.position?.z ?? (Math.floor((card.position?.y || 0)) + 10), zIndex: card.position?.z ?? (Math.floor((card.position?.y || 0)) + 10),
}}
>
<CardComponent
card={card}
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)}
onClick={toggleTap}
onContextMenu={(id, e) => {
handleContextMenu(e, 'card', id);
}} }}
onMouseEnter={() => setHoveredCard(card)} >
onMouseLeave={() => setHoveredCard(null)} <CardComponent
/> card={card}
</div> onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)}
))} onClick={toggleTap}
onContextMenu={(id, e) => {
handleContextMenu(e, 'card', id);
}}
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
/>
</div>
))}
{myBattlefield.length === 0 && ( {myBattlefield.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="text-white/10 text-4xl font-bold uppercase tracking-widest">Battlefield</span> <span className="text-white/10 text-4xl font-bold uppercase tracking-widest">Battlefield</span>
</div> </div>
)} )}
</div> </div>
</GestureManager>
</div> </div>
{/* Bottom Area: Controls & Hand */} {/* Bottom Area: Controls & Hand */}
@@ -440,46 +447,58 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
{/* Left Controls: Library/Grave */} {/* Left Controls: Library/Grave */}
<div className="w-40 p-2 flex flex-col gap-2 items-center justify-center border-r border-white/10"> <div className="w-40 p-2 flex flex-col gap-2 items-center justify-center border-r border-white/10">
<div {/* Phase Strip Integration */}
className="group relative w-16 h-24 bg-slate-800 rounded border border-slate-600 cursor-pointer shadow-lg transition-transform hover:-translate-y-1 hover:shadow-cyan-500/20" <div className="mb-2 scale-75 origin-center">
onClick={() => socketService.socket.emit('game_action', { action: { type: 'DRAW_CARD' } })} <PhaseStrip gameState={gameState} />
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'library')}
>
<div className="absolute inset-0 bg-gradient-to-br from-slate-700 to-slate-800 rounded"></div>
{/* Deck look */}
<div className="absolute top-[-2px] left-[-2px] right-[-2px] bottom-[2px] bg-slate-700 rounded z-[-1]"></div>
<div className="absolute top-[-4px] left-[-4px] right-[-4px] bottom-[4px] bg-slate-800 rounded z-[-2]"></div>
<div className="absolute inset-0 flex items-center justify-center flex-col">
<span className="text-xs font-bold text-slate-300 shadow-black drop-shadow-md">Library</span>
<span className="text-lg font-bold text-white shadow-black drop-shadow-md">{myLibrary.length}</span>
</div>
</div> </div>
<div <div className="flex gap-2">
className="w-16 h-24 border-2 border-dashed border-slate-600 rounded flex items-center justify-center mt-2 transition-colors hover:border-slate-400 hover:bg-white/5" <div
onDragOver={handleDragOver} className="group relative w-12 h-16 bg-slate-800 rounded border border-slate-600 cursor-pointer shadow-lg transition-transform hover:-translate-y-1 hover:shadow-cyan-500/20"
onDrop={(e) => handleDrop(e, 'graveyard')} onClick={() => socketService.socket.emit('game_action', { action: { type: 'DRAW_CARD' } })}
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'graveyard')} onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'library')}
> >
<div className="text-center"> <div className="absolute inset-0 bg-gradient-to-br from-slate-700 to-slate-800 rounded"></div>
<span className="block text-slate-500 text-[10px] uppercase">Graveyard</span> <div className="absolute inset-0 flex items-center justify-center flex-col">
<span className="text-sm font-bold text-slate-400">{myGraveyard.length}</span> <span className="text-[8px] font-bold text-slate-300">Lib</span>
<span className="text-sm font-bold text-white">{myLibrary.length}</span>
</div>
</div>
<div
className="w-12 h-16 border-2 border-dashed border-slate-600 rounded flex items-center justify-center transition-colors hover:border-slate-400 hover:bg-white/5"
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, 'graveyard')}
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'graveyard')}
>
<div className="text-center">
<span className="block text-slate-500 text-[8px] uppercase">GY</span>
<span className="text-sm font-bold text-slate-400">{myGraveyard.length}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
{/* Hand Area */} {/* Hand Area & Smart Button */}
<div <div
className="flex-1 relative flex items-end justify-center px-4 pb-2" className="flex-1 relative flex flex-col items-center justify-end px-4 pb-2"
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, 'hand')} onDrop={(e) => handleDrop(e, 'hand')}
> >
{/* Smart Button Floating above Hand */}
<div className="mb-4 z-40">
<SmartButton
gameState={gameState}
playerId={currentPlayerId}
onAction={(type, payload) => socketService.socket.emit(type, { action: payload })}
/>
</div>
<div className="flex justify-center -space-x-12 w-full h-full items-end pb-4 perspective-500"> <div className="flex justify-center -space-x-12 w-full h-full items-end pb-4 perspective-500">
{myHand.map((card, index) => ( {myHand.map((card, index) => (
<div <div
key={card.instanceId} key={card.instanceId}
className="transition-all duration-300 hover:-translate-y-12 hover:scale-110 hover:z-50 hover:rotate-0 origin-bottom" className="transition-all duration-300 hover:-translate-y-16 hover:scale-110 hover:z-50 hover:rotate-0 origin-bottom"
style={{ style={{
transform: `rotate(${(index - (myHand.length - 1) / 2) * 5}deg) translateY(${Math.abs(index - (myHand.length - 1) / 2) * 5}px)`, transform: `rotate(${(index - (myHand.length - 1) / 2) * 5}deg) translateY(${Math.abs(index - (myHand.length - 1) / 2) * 5}px)`,
zIndex: index zIndex: index

View File

@@ -0,0 +1,136 @@
import React, { createContext, useContext, useRef, useState, useEffect } from 'react';
import { socketService } from '../../services/SocketService';
interface GestureContextType {
registerCard: (id: string, element: HTMLElement) => void;
unregisterCard: (id: string) => void;
}
const GestureContext = createContext<GestureContextType>({
registerCard: () => { },
unregisterCard: () => { },
});
export const useGesture = () => useContext(GestureContext);
interface GestureManagerProps {
children: React.ReactNode;
}
export const GestureManager: React.FC<GestureManagerProps> = ({ children }) => {
const cardRefs = useRef<Map<string, HTMLElement>>(new Map());
const [gesturePath, setGesturePath] = useState<{ x: number, y: number }[]>([]);
const isGesturing = useRef(false);
const startPoint = useRef<{ x: number, y: number } | null>(null);
const registerCard = (id: string, element: HTMLElement) => {
cardRefs.current.set(id, element);
};
const unregisterCard = (id: string) => {
cardRefs.current.delete(id);
};
const onPointerDown = (e: React.PointerEvent) => {
// Only start gesture if clicking on background or specific handle?
// For now, let's assume Right Click or Middle Drag is Gesture Mode?
// Or just "Drag on Background".
// If e.target is a card, usually DnD handles it.
// We check if event target is NOT a card.
// Simplification: Check if Shift Key is held for Gesture Mode?
// Or just native touch swipe.
// Let's rely on event propagation. If card didn't stopPropagation, maybe background catches it.
// Assuming GameView wrapper catches this.
isGesturing.current = true;
startPoint.current = { x: e.clientX, y: e.clientY };
setGesturePath([{ x: e.clientX, y: e.clientY }]);
// Capture pointer
(e.target as Element).setPointerCapture(e.pointerId);
};
const onPointerMove = (e: React.PointerEvent) => {
if (!isGesturing.current) return;
setGesturePath(prev => [...prev, { x: e.clientX, y: e.clientY }]);
};
const onPointerUp = (e: React.PointerEvent) => {
if (!isGesturing.current) return;
isGesturing.current = false;
// Analyze Path for "Slash" (Swipe to Tap)
// Check intersection with cards
handleSwipeToTap();
setGesturePath([]);
(e.target as Element).releasePointerCapture(e.pointerId);
};
const handleSwipeToTap = () => {
// Bounding box of path?
// Simple: Check which cards intersect with the path line segments.
// Optimization: Just check if path points are inside card rects.
const intersectedCards = new Set<string>();
const path = gesturePath;
if (path.length < 2) return; // Too short
// Check every card
cardRefs.current.forEach((el, id) => {
const rect = el.getBoundingClientRect();
// Simple hit test: Does any point in path fall in rect?
// Better: Line intersection.
// For MVP: Check points.
for (const p of path) {
if (p.x >= rect.left && p.x <= rect.right && p.y >= rect.top && p.y <= rect.bottom) {
intersectedCards.add(id);
break; // Found hit
}
}
});
// If we hit cards, toggle tap
if (intersectedCards.size > 0) {
intersectedCards.forEach(id => {
socketService.socket.emit('game_action', {
action: { type: 'TAP_CARD', cardId: id }
});
});
}
};
return (
<GestureContext.Provider value={{ registerCard, unregisterCard }}>
<div
className="relative w-full h-full touch-none"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
{children}
{/* SVG Overlay for Path */}
{gesturePath.length > 0 && (
<svg className="absolute inset-0 pointer-events-none z-50 overflow-visible">
<polyline
points={gesturePath.map(p => `${p.x},${p.y}`).join(' ')}
fill="none"
stroke="cyan"
strokeWidth="4"
strokeLinecap="round"
strokeOpacity="0.6"
className="drop-shadow-[0_0_10px_rgba(0,255,255,0.8)]"
/>
</svg>
)}
</div>
</GestureContext.Provider>
);
};

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { GameState, Phase, Step } from '../../types/game';
import { Sun, Shield, Swords, ArrowRightToLine, Hourglass } from 'lucide-react';
interface PhaseStripProps {
gameState: GameState;
}
export const PhaseStrip: React.FC<PhaseStripProps> = ({ gameState }) => {
const currentPhase = gameState.phase as Phase;
const currentStep = gameState.step as Step;
// 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' },
];
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;
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} />
{/* 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}
</span>
)}
</div>
);
})}
</div>
);
};

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { GameState } from '../../types/game';
interface SmartButtonProps {
gameState: GameState;
playerId: string;
onAction: (type: string, payload?: any) => void;
}
export const SmartButton: React.FC<SmartButtonProps> = ({ gameState, playerId, onAction }) => {
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 (isMyPriority) {
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 handleClick = () => {
if (actionType) {
onAction('game_strict_action', { type: actionType });
}
};
return (
<button
onClick={handleClick}
disabled={!isMyPriority}
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]
`}
>
{label}
</button>
);
};

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { StackObject, GameState } from '../../types/game';
import { ArrowLeft, Sparkles } from 'lucide-react';
interface StackVisualizerProps {
gameState: GameState;
onResolve?: () => void; // Optional fast-action helper
}
export const StackVisualizer: React.FC<StackVisualizerProps> = ({ gameState, onResolve }) => {
const stack = gameState.stack || [];
if (stack.length === 0) return null;
return (
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex flex-col-reverse gap-2 z-50 pointer-events-none">
{/* Stack Container */}
<div className="flex flex-col-reverse gap-2 items-end">
{stack.map((item, index) => (
<div
key={item.id}
className={`
relative group pointer-events-auto
w-64 bg-slate-900/90 backdrop-blur-md
border-l-4 border-amber-500
rounded-r-lg shadow-xl
p-3 transform transition-all duration-300
hover:scale-105 hover:-translate-x-2
flex flex-col gap-1
animate-in slide-in-from-right fade-in duration-300
`}
style={{
// Stagger visual for depth
marginRight: `${index * 4}px`
}}
>
{/* Header */}
<div className="flex items-center justify-between text-xs text-amber-500 font-bold uppercase tracking-wider">
<span>{item.type}</span>
<Sparkles size={12} />
</div>
{/* Name */}
<div className="text-white font-bold leading-tight">
{item.name}
</div>
{/* Targets (if any) */}
{item.targets && item.targets.length > 0 && (
<div className="text-xs text-slate-400 mt-1 flex items-center gap-1">
<ArrowLeft size={10} />
<span>Targets {item.targets.length} item(s)</span>
</div>
)}
{/* Index Indicator */}
<div className="absolute -left-3 top-1/2 -translate-y-1/2 w-6 h-6 bg-amber-600 rounded-full flex items-center justify-center text-xs font-bold text-white border-2 border-slate-900 shadow-lg">
{index + 1}
</div>
</div>
))}
</div>
{/* Label */}
<div className="text-right pr-2">
<span className="text-amber-500/50 text-[10px] font-bold uppercase tracking-[0.2em] [writing-mode:vertical-rl] rotate-180">
The Stack
</span>
</div>
</div>
);
};

View File

@@ -1,3 +1,22 @@
export type Phase = 'beginning' | 'main1' | 'combat' | 'main2' | 'ending';
export type Step =
| 'untap' | 'upkeep' | 'draw'
| 'main'
| 'beginning_combat' | 'declare_attackers' | 'declare_blockers' | 'combat_damage' | 'end_combat'
| 'end' | 'cleanup';
export interface StackObject {
id: string;
sourceId: string;
controllerId: string;
type: 'spell' | 'ability' | 'trigger';
name: string;
text: string;
targets: string[];
}
export interface CardInstance { export interface CardInstance {
instanceId: string; instanceId: string;
oracleId: string; // Scryfall ID oracleId: string; // Scryfall ID
@@ -5,7 +24,7 @@ export interface CardInstance {
imageUrl: string; imageUrl: string;
controllerId: string; controllerId: string;
ownerId: string; ownerId: string;
zone: 'library' | 'hand' | 'battlefield' | 'graveyard' | 'exile' | 'command'; zone: 'library' | 'hand' | 'battlefield' | 'graveyard' | 'exile' | 'command' | 'stack';
tapped: boolean; tapped: boolean;
faceDown: boolean; faceDown: boolean;
position: { x: number; y: number; z: number }; // For freeform placement position: { x: number; y: number; z: number }; // For freeform placement
@@ -23,6 +42,7 @@ export interface PlayerState {
poison: number; poison: number;
energy: number; energy: number;
isActive: boolean; isActive: boolean;
hasPassed?: boolean;
} }
export interface GameState { export interface GameState {
@@ -31,5 +51,10 @@ export interface GameState {
cards: Record<string, CardInstance>; // Keyed by instanceId cards: Record<string, CardInstance>; // Keyed by instanceId
order: string[]; // Turn order (player IDs) order: string[]; // Turn order (player IDs)
turn: number; turn: number;
phase: string; // Strict Mode Extension
phase: string | Phase;
step?: Step;
stack?: StackObject[];
activePlayerId?: string; // Explicitly tracked in strict
priorityPlayerId?: string;
} }

View File

@@ -0,0 +1,334 @@
import { StrictGameState, PlayerState, Phase, Step, StackObject } from './types';
export class RulesEngine {
public state: StrictGameState;
constructor(state: StrictGameState) {
this.state = state;
}
// --- External Actions ---
public passPriority(playerId: string): boolean {
if (this.state.priorityPlayerId !== playerId) return false; // Not your turn
this.state.players[playerId].hasPassed = true;
this.state.passedPriorityCount++;
// Check if all players passed
if (this.state.passedPriorityCount >= this.state.turnOrder.length) {
if (this.state.stack.length > 0) {
this.resolveTopStack();
} else {
this.advanceStep();
}
} else {
this.passPriorityToNext();
}
return true;
}
public playLand(playerId: string, cardId: string): boolean {
// 1. Check Priority
if (this.state.priorityPlayerId !== playerId) throw new Error("Not your priority.");
// 2. Check Stack (Must be empty)
if (this.state.stack.length > 0) throw new Error("Stack must be empty to play a land.");
// 3. Check Phase (Main Phase)
if (this.state.phase !== 'main1' && this.state.phase !== 'main2') throw new Error("Can only play lands in Main Phase.");
// 4. Check Limits (1 per turn)
if (this.state.landsPlayedThisTurn >= 1) throw new Error("Already played a land this turn.");
// 5. Execute
const card = this.state.cards[cardId];
if (!card || card.controllerId !== playerId || card.zone !== 'hand') throw new Error("Invalid card.");
// TODO: Verify it IS a land (need Type system)
this.moveCardToZone(card.instanceId, 'battlefield');
this.state.landsPlayedThisTurn++;
// Playing a land does NOT use the stack, but priority remains with AP?
// 305.1... The player gets priority again.
// Reset passing
this.resetPriority(playerId);
return true;
}
public castSpell(playerId: string, cardId: string, targets: string[] = []) {
if (this.state.priorityPlayerId !== playerId) throw new Error("Not your priority.");
const card = this.state.cards[cardId];
if (!card || card.zone !== 'hand') throw new Error("Invalid card.");
// TODO: Check Timing (Instant vs Sorcery)
// Move to Stack
card.zone = 'stack';
this.state.stack.push({
id: Math.random().toString(36).substr(2, 9),
sourceId: cardId,
controllerId: playerId,
type: 'spell', // or permanent-spell
name: card.name,
text: "Spell Text...", // TODO: get rules text
targets
});
// Reset priority to caster (Rule 117.3c)
this.resetPriority(playerId);
return true;
}
// --- Core State Machine ---
private passPriorityToNext() {
const currentIndex = this.state.turnOrder.indexOf(this.state.priorityPlayerId);
const nextIndex = (currentIndex + 1) % this.state.turnOrder.length;
this.state.priorityPlayerId = this.state.turnOrder[nextIndex];
}
private moveCardToZone(cardId: string, toZone: any, faceDown = false) {
const card = this.state.cards[cardId];
if (card) {
card.zone = toZone;
card.faceDown = faceDown;
card.tapped = false; // Reset tap usually on zone change (except battlefield->battlefield)
// Reset X position?
card.position = { x: 0, y: 0, z: ++this.state.maxZ };
}
}
private resolveTopStack() {
const item = this.state.stack.pop();
if (!item) return;
console.log(`Resolving stack item: ${item.name}`);
if (item.type === 'spell') {
const card = this.state.cards[item.sourceId];
if (card) {
// Check card types to determine destination
// Assuming we have type data
const isPermanent = card.types.some(t =>
['Creature', 'Artifact', 'Enchantment', 'Planeswalker', 'Land'].includes(t)
);
if (isPermanent) {
this.moveCardToZone(card.instanceId, 'battlefield');
} else {
// Instant / Sorcery
this.moveCardToZone(card.instanceId, 'graveyard');
}
}
}
// After resolution, Active Player gets priority again (Rule 117.3b)
this.resetPriority(this.state.activePlayerId);
}
private advanceStep() {
// Transition Table
const structure: Record<Phase, Step[]> = {
beginning: ['untap', 'upkeep', 'draw'],
main1: ['main'],
combat: ['beginning_combat', 'declare_attackers', 'declare_blockers', 'combat_damage', 'end_combat'],
main2: ['main'],
ending: ['end', 'cleanup']
};
const phaseOrder: Phase[] = ['beginning', 'main1', 'combat', 'main2', 'ending'];
let nextStep: Step | null = null;
let nextPhase: Phase = this.state.phase;
// Find current index in current phase
const steps = structure[this.state.phase];
const stepIdx = steps.indexOf(this.state.step);
if (stepIdx < steps.length - 1) {
// Next step in same phase
nextStep = steps[stepIdx + 1];
} else {
// Next phase
const phaseIdx = phaseOrder.indexOf(this.state.phase);
const nextPhaseIdx = (phaseIdx + 1) % phaseOrder.length;
nextPhase = phaseOrder[nextPhaseIdx];
if (nextPhaseIdx === 0) {
// Next Turn!
this.advanceTurn();
return; // advanceTurn handles the setup of untap
}
nextStep = structure[nextPhase][0];
}
this.state.phase = nextPhase;
this.state.step = nextStep!;
console.log(`Advancing to ${this.state.phase} - ${this.state.step}`);
this.performTurnBasedActions();
}
private advanceTurn() {
this.state.turnCount++;
// Rotate Active Player
const currentAPIdx = this.state.turnOrder.indexOf(this.state.activePlayerId);
const nextAPIdx = (currentAPIdx + 1) % this.state.turnOrder.length;
this.state.activePlayerId = this.state.turnOrder[nextAPIdx];
// Reset Turn State
this.state.phase = 'beginning';
this.state.step = 'untap';
this.state.landsPlayedThisTurn = 0;
console.log(`Starting Turn ${this.state.turnCount}. Active Player: ${this.state.activePlayerId}`);
// Logic for new turn
this.performTurnBasedActions();
}
// --- Turn Based Actions & Triggers ---
private performTurnBasedActions() {
const { phase, step, activePlayerId } = this.state;
// 1. Untap Step
if (step === 'untap') {
this.untapStep(activePlayerId);
// Untap step has NO priority window. Proceed immediately to Upkeep.
this.state.step = 'upkeep';
this.resetPriority(activePlayerId);
return;
}
// 2. Draw Step
if (step === 'draw') {
if (this.state.turnCount > 1 || this.state.turnOrder.length > 2) {
this.drawCard(activePlayerId);
}
}
// 3. Cleanup Step
if (step === 'cleanup') {
this.cleanupStep(activePlayerId);
// Usually no priority in cleanup, unless triggers.
// Assume auto-pass turn to next Untap.
this.advanceTurn();
return;
}
// Default: Reset priority to AP to start the step
this.resetPriority(activePlayerId);
}
private untapStep(playerId: string) {
// Untap all perms controller by player
Object.values(this.state.cards).forEach(card => {
if (card.controllerId === playerId && card.zone === 'battlefield') {
card.tapped = false;
// Also summon sickness logic if we tracked it
}
});
}
private drawCard(playerId: string) {
const library = Object.values(this.state.cards).filter(c => c.ownerId === playerId && c.zone === 'library');
if (library.length > 0) {
// Draw top card (random for now if not ordered?)
// Assuming library is shuffled, pick random
const card = library[Math.floor(Math.random() * library.length)];
this.moveCardToZone(card.instanceId, 'hand');
console.log(`Player ${playerId} draws ${card.name}`);
} else {
// Empty library loss?
console.log(`Player ${playerId} attempts to draw from empty library.`);
}
}
private cleanupStep(playerId: string) {
// Remove damage, discard down to 7
console.log(`Cleanup execution.`);
}
// --- State Based Actions ---
private checkStateBasedActions(): boolean {
let sbaPerformed = false;
const { players, cards } = this.state;
// 1. Player Loss
for (const pid of Object.keys(players)) {
const p = players[pid];
if (p.life <= 0 || p.poison >= 10) {
// Player loses
// In multiplayer, they leave the game.
// Simple implementation: Mark as lost/inactive
if (p.isActive) { // only process once
console.log(`Player ${p.name} loses the game.`);
// TODO: Remove all their cards, etc.
// For now just log.
}
}
}
// 2. Creature Death (Zero Toughness or Lethal Damage)
const creatures = Object.values(cards).filter(c => c.zone === 'battlefield' && c.types.includes('Creature'));
for (const c of creatures) {
// 704.5f Toughness 0 or less
if (c.toughness <= 0) {
console.log(`SBA: ${c.name} put to GY (Zero Toughness).`);
c.zone = 'graveyard';
sbaPerformed = true;
continue;
}
// 704.5g Lethal Damage
// TODO: Calculate damage marked on creature (need damage tracking on card)
// Assuming c.damageAssignment holds damage marked?
let totalDamage = 0;
// logic to sum damage
if (totalDamage >= c.toughness && !c.supertypes.includes('Indestructible')) {
console.log(`SBA: ${c.name} destroyed (Lethal Damage).`);
c.zone = 'graveyard';
sbaPerformed = true;
}
}
// 3. Legend Rule (704.5j)
// Map<Controller, Map<Name, Count>>
// If count > 1, prompt user to choose one?
// SBAs don't use stack, but Legend Rule requires a choice.
// In strict engine, if a choice is required, we might need a special state 'awaiting_sba_choice'.
// For now, simplify: Auto-keep oldest? Or newest?
// Rules say "choose one", so we can't automate strictly without pausing.
// Let's implement auto-graveyard oldest duplicate for now to avoid stuck state.
return sbaPerformed;
}
private resetPriority(playerId: string) {
// Check SBAs first (Loop until no SBAs happen)
let loops = 0;
while (this.checkStateBasedActions()) {
loops++;
if (loops > 100) {
console.error("Infinite SBA Loop Detected");
break;
}
}
this.state.priorityPlayerId = playerId;
this.state.passedPriorityCount = 0;
Object.values(this.state.players).forEach(p => p.hasPassed = false);
}
}

91
src/server/game/types.ts Normal file
View File

@@ -0,0 +1,91 @@
export type Phase = 'beginning' | 'main1' | 'combat' | 'main2' | 'ending';
export type Step =
// Beginning
| 'untap' | 'upkeep' | 'draw'
// Main
| 'main'
// Combat
| 'beginning_combat' | 'declare_attackers' | 'declare_blockers' | 'combat_damage' | 'end_combat'
// Ending
| 'end' | 'cleanup';
export type Zone = 'library' | 'hand' | 'battlefield' | 'graveyard' | 'stack' | 'exile' | 'command';
export interface CardObject {
instanceId: string;
oracleId: string;
name: string;
controllerId: string;
ownerId: string;
zone: Zone;
// State
tapped: boolean;
faceDown: boolean;
attacking?: string; // Player/Planeswalker ID
blocking?: string[]; // List of attacker IDs blocked by this car
damageAssignment?: Record<string, number>; // TargetID -> Amount
// Characteristics (Base + Modified)
manaCost?: string;
colors: string[];
types: string[];
subtypes: string[];
supertypes: string[];
power: number;
toughness: number;
basePower: number;
baseToughness: number;
// Counters & Mods
counters: { type: string; count: number }[];
// Visual
imageUrl: string;
}
export interface PlayerState {
id: string;
name: string;
life: number;
poison: number;
energy: number;
isActive: boolean; // Is it their turn?
hasPassed: boolean; // For priority loop
}
export interface StackObject {
id: string;
sourceId: string; // The card/permanent that generated this
controllerId: string;
type: 'spell' | 'ability' | 'trigger';
name: string;
text: string;
targets: string[];
modes?: number[]; // Selected modes
costPaid?: boolean;
}
export interface StrictGameState {
roomId: string;
players: Record<string, PlayerState>;
cards: Record<string, CardObject>;
stack: StackObject[];
// Turn State
turnCount: number;
activePlayerId: string; // Whose turn is it
priorityPlayerId: string; // Who can act NOW
turnOrder: string[];
phase: Phase;
step: Step;
// Rules State
passedPriorityCount: number; // 0..N. If N, advance.
landsPlayedThisTurn: number;
maxZ: number; // Visual depth (legacy support)
}

View File

@@ -544,6 +544,17 @@ io.on('connection', (socket) => {
} }
}); });
socket.on('game_strict_action', ({ action }) => {
const context = getContext();
if (!context) return;
const { room, player } = context;
const game = gameManager.handleStrictAction(room.id, action, player.id);
if (game) {
io.to(room.id).emit('game_update', game);
}
});
socket.on('disconnect', () => { socket.on('disconnect', () => {
console.log('User disconnected', socket.id); console.log('User disconnected', socket.id);

View File

@@ -1,85 +1,100 @@
interface CardInstance { import { StrictGameState, PlayerState, CardObject } from '../game/types';
instanceId: string; import { RulesEngine } from '../game/RulesEngine';
oracleId: string; // Scryfall ID
name: string;
imageUrl: string;
controllerId: string;
ownerId: string;
zone: 'library' | 'hand' | 'battlefield' | 'graveyard' | 'exile' | 'command';
tapped: boolean;
faceDown: boolean;
position: { x: number; y: number; z: number }; // For freeform placement
counters: { type: string; count: number }[];
ptModification: { power: number; toughness: number };
typeLine?: string;
oracleText?: string;
manaCost?: string;
}
interface PlayerState {
id: string;
name: string;
life: number;
poison: number;
energy: number;
isActive: boolean;
}
interface GameState {
roomId: string;
players: Record<string, PlayerState>;
cards: Record<string, CardInstance>; // Keyed by instanceId
order: string[]; // Turn order (player IDs)
turn: number;
phase: string;
maxZ: number; // Tracker for depth sorting
}
export class GameManager { export class GameManager {
private games: Map<string, GameState> = new Map(); public games: Map<string, StrictGameState> = new Map();
createGame(roomId: string, players: { id: string; name: string }[]): GameState { createGame(roomId: string, players: { id: string; name: string }[]): StrictGameState {
const gameState: GameState = {
roomId,
players: {},
cards: {},
order: players.map(p => p.id),
turn: 1,
phase: 'beginning',
maxZ: 100,
};
// Convert array to map
const playerRecord: Record<string, PlayerState> = {};
players.forEach(p => { players.forEach(p => {
gameState.players[p.id] = { playerRecord[p.id] = {
id: p.id, id: p.id,
name: p.name, name: p.name,
life: 20, life: 20,
poison: 0, poison: 0,
energy: 0, energy: 0,
isActive: false isActive: false,
hasPassed: false
}; };
}); });
// Set first player active const firstPlayerId = players.length > 0 ? players[0].id : '';
if (gameState.order.length > 0) {
gameState.players[gameState.order[0]].isActive = true; const gameState: StrictGameState = {
roomId,
players: playerRecord,
cards: {}, // Populated later
stack: [],
turnCount: 1,
turnOrder: players.map(p => p.id),
activePlayerId: firstPlayerId,
priorityPlayerId: firstPlayerId,
phase: 'beginning',
step: 'untap', // Will be skipped/advanced immediately on start usually
passedPriorityCount: 0,
landsPlayedThisTurn: 0,
maxZ: 100
};
// Set First Player Active status
if (gameState.players[firstPlayerId]) {
gameState.players[firstPlayerId].isActive = true;
} }
this.games.set(roomId, gameState); this.games.set(roomId, gameState);
return gameState; return gameState;
} }
getGame(roomId: string): GameState | undefined { getGame(roomId: string): StrictGameState | undefined {
return this.games.get(roomId); return this.games.get(roomId);
} }
// Generic action handler for sandbox mode // --- Strict Rules Action Handler ---
handleAction(roomId: string, action: any, actorId: string): GameState | null { handleStrictAction(roomId: string, action: any, actorId: string): StrictGameState | null {
const game = this.games.get(roomId); const game = this.games.get(roomId);
if (!game) return null; if (!game) return null;
// Basic Validation: Ensure actor exists in game const engine = new RulesEngine(game);
try {
switch (action.type) {
case 'PASS_PRIORITY':
engine.passPriority(actorId);
break;
case 'PLAY_LAND':
engine.playLand(actorId, action.cardId);
break;
case 'CAST_SPELL':
engine.castSpell(actorId, action.cardId, action.targets);
break;
// TODO: Activate Ability
default:
console.warn(`Unknown strict action: ${action.type}`);
return null;
}
} catch (e: any) {
console.error(`Rule Violation [${action.type}]: ${e.message}`);
// TODO: Return error to user?
// For now, just logging and not updating state (transactional-ish)
return null;
}
return game;
}
// --- Legacy Sandbox Action Handler (for Admin/Testing) ---
handleAction(roomId: string, action: any, actorId: string): StrictGameState | null {
const game = this.games.get(roomId);
if (!game) return null;
// Basic Validation: Ensure actor exists in game (or is host/admin?)
if (!game.players[actorId]) return null; if (!game.players[actorId]) return null;
switch (action.type) { switch (action.type) {
@@ -89,211 +104,56 @@ export class GameManager {
case 'TAP_CARD': case 'TAP_CARD':
this.tapCard(game, action, actorId); this.tapCard(game, action, actorId);
break; break;
case 'FLIP_CARD': // ... (Other cases can be ported if needed)
this.flipCard(game, action, actorId);
break;
case 'ADD_COUNTER':
this.addCounter(game, action, actorId);
break;
case 'CREATE_TOKEN':
this.createToken(game, action, actorId);
break;
case 'DELETE_CARD':
this.deleteCard(game, action, actorId);
break;
case 'UPDATE_LIFE':
this.updateLife(game, action, actorId);
break;
case 'DRAW_CARD':
this.drawCard(game, action, actorId);
break;
case 'SHUFFLE_LIBRARY':
this.shuffleLibrary(game, action, actorId);
break;
case 'SHUFFLE_GRAVEYARD':
this.shuffleGraveyard(game, action, actorId);
break;
case 'SHUFFLE_EXILE':
this.shuffleExile(game, action, actorId);
break;
case 'MILL_CARD':
this.millCard(game, action, actorId);
break;
case 'EXILE_GRAVEYARD':
this.exileGraveyard(game, action, actorId);
break;
} }
return game; return game;
} }
private moveCard(game: GameState, action: { cardId: string; toZone: CardInstance['zone']; position?: { x: number, y: number } }, actorId: string) { // ... Legacy methods refactored to use StrictGameState types ...
private moveCard(game: StrictGameState, action: any, actorId: string) {
const card = game.cards[action.cardId]; const card = game.cards[action.cardId];
if (card) { if (card) {
// ANTI-TAMPER: Only controller can move card if (card.controllerId !== actorId) return;
if (card.controllerId !== actorId) { // @ts-ignore
console.warn(`Anti-Tamper: Player ${actorId} tried to move card ${card.instanceId} controlled by ${card.controllerId}`); card.position = { x: 0, y: 0, z: ++game.maxZ, ...action.position }; // type hack relative to legacy visual pos
return;
}
// Bring to front
card.position.z = ++game.maxZ;
card.zone = action.toZone; card.zone = action.toZone;
if (action.position) {
card.position = { ...card.position, ...action.position };
}
// Auto-untap and reveal if moving to public zones (optional, but helpful default)
if (['hand', 'graveyard', 'exile'].includes(action.toZone)) {
card.tapped = false;
card.faceDown = false;
}
// Library is usually face down
if (action.toZone === 'library') {
card.faceDown = true;
card.tapped = false;
}
} }
} }
private addCounter(game: GameState, action: { cardId: string; counterType: string; amount: number }, actorId: string) { private tapCard(game: StrictGameState, action: any, actorId: string) {
const card = game.cards[action.cardId];
if (card) {
if (card.controllerId !== actorId) return; // Anti-tamper
const existing = card.counters.find(c => c.type === action.counterType);
if (existing) {
existing.count += action.amount;
if (existing.count <= 0) {
card.counters = card.counters.filter(c => c.type !== action.counterType);
}
} else if (action.amount > 0) {
card.counters.push({ type: action.counterType, count: action.amount });
}
}
}
private createToken(game: GameState, action: { ownerId: string; tokenData: any; position?: { x: number, y: number } }, actorId: string) {
if (action.ownerId !== actorId) return; // Anti-tamper
const tokenId = `token-${Math.random().toString(36).substring(7)}`;
// @ts-ignore
const token: CardInstance = {
instanceId: tokenId,
oracleId: 'token',
name: action.tokenData.name || 'Token',
imageUrl: action.tokenData.imageUrl || 'https://cards.scryfall.io/large/front/5/f/5f75e883-2574-4b9e-8fcb-5db3d9579fae.jpg?1692233606', // Generic token image
controllerId: action.ownerId,
ownerId: action.ownerId,
zone: 'battlefield',
tapped: false,
faceDown: false,
position: {
x: action.position?.x || 50,
y: action.position?.y || 50,
z: ++game.maxZ
},
counters: [],
ptModification: { power: action.tokenData.power || 0, toughness: action.tokenData.toughness || 0 }
};
game.cards[tokenId] = token;
}
private deleteCard(game: GameState, action: { cardId: string }, actorId: string) {
if (game.cards[action.cardId] && game.cards[action.cardId].controllerId === actorId) {
delete game.cards[action.cardId];
}
}
private tapCard(game: GameState, action: { cardId: string }, actorId: string) {
const card = game.cards[action.cardId]; const card = game.cards[action.cardId];
if (card && card.controllerId === actorId) { if (card && card.controllerId === actorId) {
card.tapped = !card.tapped; card.tapped = !card.tapped;
} }
} }
private flipCard(game: GameState, action: { cardId: string }, actorId: string) {
const card = game.cards[action.cardId];
if (card && card.controllerId === actorId) {
card.position.z = ++game.maxZ;
card.faceDown = !card.faceDown;
}
}
private updateLife(game: GameState, action: { playerId: string; amount: number }, actorId: string) {
if (action.playerId !== actorId) return; // Anti-tamper
const player = game.players[action.playerId];
if (player) {
player.life += action.amount;
}
}
private drawCard(game: GameState, action: { playerId: string }, actorId: string) {
if (action.playerId !== actorId) return; // Anti-tamper
const libraryCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'library');
if (libraryCards.length > 0) {
const randomIndex = Math.floor(Math.random() * libraryCards.length);
const card = libraryCards[randomIndex];
card.zone = 'hand';
card.faceDown = false;
card.position.z = ++game.maxZ;
}
}
private shuffleLibrary(_game: GameState, _action: { playerId: string }, actorId: string) {
if (_action.playerId !== actorId) return;
}
private shuffleGraveyard(_game: GameState, _action: { playerId: string }, actorId: string) {
if (_action.playerId !== actorId) return;
}
private shuffleExile(_game: GameState, _action: { playerId: string }, actorId: string) {
if (_action.playerId !== actorId) return;
}
private millCard(game: GameState, action: { playerId: string; amount: number }, actorId: string) {
if (action.playerId !== actorId) return;
const amount = action.amount || 1;
for (let i = 0; i < amount; i++) {
const libraryCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'library');
if (libraryCards.length > 0) {
const randomIndex = Math.floor(Math.random() * libraryCards.length);
const card = libraryCards[randomIndex];
card.zone = 'graveyard';
card.faceDown = false;
card.position.z = ++game.maxZ;
}
}
}
private exileGraveyard(game: GameState, action: { playerId: string }, actorId: string) {
if (action.playerId !== actorId) return;
const graveyardCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'graveyard');
graveyardCards.forEach(card => {
card.zone = 'exile';
card.position.z = ++game.maxZ;
});
}
// Helper to add cards (e.g. at game start) // Helper to add cards (e.g. at game start)
addCardToGame(roomId: string, cardData: Partial<CardInstance>) { addCardToGame(roomId: string, cardData: Partial<CardObject>) {
const game = this.games.get(roomId); const game = this.games.get(roomId);
if (!game) return; if (!game) return;
// @ts-ignore // @ts-ignore - aligning types roughly
const card: CardInstance = { const card: CardObject = {
instanceId: cardData.instanceId || Math.random().toString(36).substring(7), instanceId: cardData.instanceId || Math.random().toString(36).substring(7),
zone: 'library', zone: 'library',
tapped: false, tapped: false,
faceDown: true, faceDown: true,
position: { x: 0, y: 0, z: 0 },
counters: [], counters: [],
ptModification: { power: 0, toughness: 0 }, colors: [],
types: [],
subtypes: [],
supertypes: [],
power: 0,
toughness: 0,
basePower: 0,
baseToughness: 0,
imageUrl: '',
controllerId: '',
ownerId: '',
oracleId: '',
name: '',
...cardData ...cardData
}; };
game.cards[card.instanceId] = card; game.cards[card.instanceId] = card;