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:
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
9250
docs/development/mtg-rulebook/MagicCompRules20251114.txt
Normal file
9250
docs/development/mtg-rulebook/MagicCompRules20251114.txt
Normal file
File diff suppressed because it is too large
Load Diff
146
docs/development/plans/mtg-engine-and-ux.md
Normal file
146
docs/development/plans/mtg-engine-and-ux.md
Normal 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.
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import { CardInstance } from '../../types/game';
|
||||
import { useGesture } from './GestureManager';
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
interface CardComponentProps {
|
||||
card: CardInstance;
|
||||
@@ -12,8 +14,19 @@ interface CardComponentProps {
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
ref={cardRef}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, card.instanceId)}
|
||||
onClick={() => onClick(card.instanceId)}
|
||||
|
||||
@@ -5,6 +5,10 @@ import { socketService } from '../../services/SocketService';
|
||||
import { CardComponent } from './CardComponent';
|
||||
import { GameContextMenu, ContextMenuRequest } from './GameContextMenu';
|
||||
import { ZoneOverlay } from './ZoneOverlay';
|
||||
import { PhaseStrip } from './PhaseStrip';
|
||||
import { SmartButton } from './SmartButton';
|
||||
import { StackVisualizer } from './StackVisualizer';
|
||||
import { GestureManager } from './GestureManager';
|
||||
|
||||
interface GameViewProps {
|
||||
gameState: GameState;
|
||||
@@ -330,6 +334,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
|
||||
{/* Main Game Area */}
|
||||
<div className="flex-1 flex flex-col h-full relative">
|
||||
<StackVisualizer gameState={gameState} />
|
||||
|
||||
{/* Top Area: Opponent */}
|
||||
<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}
|
||||
onDrop={(e) => handleDrop(e, 'battlefield')}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full relative bg-slate-900/20 border-y border-white/5 shadow-inner"
|
||||
style={{
|
||||
transform: 'rotateX(25deg)',
|
||||
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>
|
||||
<GestureManager>
|
||||
<div
|
||||
className="w-full h-full relative bg-slate-900/20 border-y border-white/5 shadow-inner"
|
||||
style={{
|
||||
transform: 'rotateX(25deg)',
|
||||
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>
|
||||
|
||||
{myBattlefield.map(card => (
|
||||
<div
|
||||
key={card.instanceId}
|
||||
className="absolute transition-all duration-200"
|
||||
style={{
|
||||
left: `${card.position?.x || Math.random() * 80}%`,
|
||||
top: `${card.position?.y || Math.random() * 80}%`,
|
||||
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);
|
||||
{myBattlefield.map(card => (
|
||||
<div
|
||||
key={card.instanceId}
|
||||
className="absolute transition-all duration-200"
|
||||
style={{
|
||||
left: `${card.position?.x || Math.random() * 80}%`,
|
||||
top: `${card.position?.y || Math.random() * 80}%`,
|
||||
zIndex: card.position?.z ?? (Math.floor((card.position?.y || 0)) + 10),
|
||||
}}
|
||||
onMouseEnter={() => setHoveredCard(card)}
|
||||
onMouseLeave={() => setHoveredCard(null)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
>
|
||||
<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)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{myBattlefield.length === 0 && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{myBattlefield.length === 0 && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GestureManager>
|
||||
</div>
|
||||
|
||||
{/* Bottom Area: Controls & Hand */}
|
||||
@@ -440,46 +447,58 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
|
||||
{/* 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="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"
|
||||
onClick={() => socketService.socket.emit('game_action', { action: { type: 'DRAW_CARD' } })}
|
||||
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>
|
||||
{/* Phase Strip Integration */}
|
||||
<div className="mb-2 scale-75 origin-center">
|
||||
<PhaseStrip gameState={gameState} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
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"
|
||||
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-[10px] uppercase">Graveyard</span>
|
||||
<span className="text-sm font-bold text-slate-400">{myGraveyard.length}</span>
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
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"
|
||||
onClick={() => socketService.socket.emit('game_action', { action: { type: 'DRAW_CARD' } })}
|
||||
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'library')}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-700 to-slate-800 rounded"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center flex-col">
|
||||
<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>
|
||||
|
||||
{/* Hand Area */}
|
||||
{/* Hand Area & Smart Button */}
|
||||
<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}
|
||||
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">
|
||||
{myHand.map((card, index) => (
|
||||
<div
|
||||
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={{
|
||||
transform: `rotate(${(index - (myHand.length - 1) / 2) * 5}deg) translateY(${Math.abs(index - (myHand.length - 1) / 2) * 5}px)`,
|
||||
zIndex: index
|
||||
|
||||
136
src/client/src/modules/game/GestureManager.tsx
Normal file
136
src/client/src/modules/game/GestureManager.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
50
src/client/src/modules/game/PhaseStrip.tsx
Normal file
50
src/client/src/modules/game/PhaseStrip.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
61
src/client/src/modules/game/SmartButton.tsx
Normal file
61
src/client/src/modules/game/SmartButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
76
src/client/src/modules/game/StackVisualizer.tsx
Normal file
76
src/client/src/modules/game/StackVisualizer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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 {
|
||||
instanceId: string;
|
||||
oracleId: string; // Scryfall ID
|
||||
@@ -5,7 +24,7 @@ export interface CardInstance {
|
||||
imageUrl: string;
|
||||
controllerId: string;
|
||||
ownerId: string;
|
||||
zone: 'library' | 'hand' | 'battlefield' | 'graveyard' | 'exile' | 'command';
|
||||
zone: 'library' | 'hand' | 'battlefield' | 'graveyard' | 'exile' | 'command' | 'stack';
|
||||
tapped: boolean;
|
||||
faceDown: boolean;
|
||||
position: { x: number; y: number; z: number }; // For freeform placement
|
||||
@@ -23,6 +42,7 @@ export interface PlayerState {
|
||||
poison: number;
|
||||
energy: number;
|
||||
isActive: boolean;
|
||||
hasPassed?: boolean;
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
@@ -31,5 +51,10 @@ export interface GameState {
|
||||
cards: Record<string, CardInstance>; // Keyed by instanceId
|
||||
order: string[]; // Turn order (player IDs)
|
||||
turn: number;
|
||||
phase: string;
|
||||
// Strict Mode Extension
|
||||
phase: string | Phase;
|
||||
step?: Step;
|
||||
stack?: StackObject[];
|
||||
activePlayerId?: string; // Explicitly tracked in strict
|
||||
priorityPlayerId?: string;
|
||||
}
|
||||
|
||||
334
src/server/game/RulesEngine.ts
Normal file
334
src/server/game/RulesEngine.ts
Normal 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
91
src/server/game/types.ts
Normal 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)
|
||||
}
|
||||
@@ -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', () => {
|
||||
console.log('User disconnected', socket.id);
|
||||
|
||||
|
||||
@@ -1,85 +1,100 @@
|
||||
|
||||
interface CardInstance {
|
||||
instanceId: string;
|
||||
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
|
||||
}
|
||||
import { StrictGameState, PlayerState, CardObject } from '../game/types';
|
||||
import { RulesEngine } from '../game/RulesEngine';
|
||||
|
||||
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 {
|
||||
const gameState: GameState = {
|
||||
roomId,
|
||||
players: {},
|
||||
cards: {},
|
||||
order: players.map(p => p.id),
|
||||
turn: 1,
|
||||
phase: 'beginning',
|
||||
maxZ: 100,
|
||||
};
|
||||
createGame(roomId: string, players: { id: string; name: string }[]): StrictGameState {
|
||||
|
||||
// Convert array to map
|
||||
const playerRecord: Record<string, PlayerState> = {};
|
||||
players.forEach(p => {
|
||||
gameState.players[p.id] = {
|
||||
playerRecord[p.id] = {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
life: 20,
|
||||
poison: 0,
|
||||
energy: 0,
|
||||
isActive: false
|
||||
isActive: false,
|
||||
hasPassed: false
|
||||
};
|
||||
});
|
||||
|
||||
// Set first player active
|
||||
if (gameState.order.length > 0) {
|
||||
gameState.players[gameState.order[0]].isActive = true;
|
||||
const firstPlayerId = players.length > 0 ? players[0].id : '';
|
||||
|
||||
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);
|
||||
return gameState;
|
||||
}
|
||||
|
||||
getGame(roomId: string): GameState | undefined {
|
||||
getGame(roomId: string): StrictGameState | undefined {
|
||||
return this.games.get(roomId);
|
||||
}
|
||||
|
||||
// Generic action handler for sandbox mode
|
||||
handleAction(roomId: string, action: any, actorId: string): GameState | null {
|
||||
// --- Strict Rules Action Handler ---
|
||||
handleStrictAction(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
|
||||
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;
|
||||
|
||||
switch (action.type) {
|
||||
@@ -89,211 +104,56 @@ export class GameManager {
|
||||
case 'TAP_CARD':
|
||||
this.tapCard(game, action, actorId);
|
||||
break;
|
||||
case 'FLIP_CARD':
|
||||
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;
|
||||
// ... (Other cases can be ported if needed)
|
||||
}
|
||||
|
||||
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];
|
||||
if (card) {
|
||||
// ANTI-TAMPER: Only controller can move card
|
||||
if (card.controllerId !== actorId) {
|
||||
console.warn(`Anti-Tamper: Player ${actorId} tried to move card ${card.instanceId} controlled by ${card.controllerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bring to front
|
||||
card.position.z = ++game.maxZ;
|
||||
|
||||
if (card.controllerId !== actorId) return;
|
||||
// @ts-ignore
|
||||
card.position = { x: 0, y: 0, z: ++game.maxZ, ...action.position }; // type hack relative to legacy visual pos
|
||||
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) {
|
||||
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) {
|
||||
private tapCard(game: StrictGameState, action: any, actorId: string) {
|
||||
const card = game.cards[action.cardId];
|
||||
if (card && card.controllerId === actorId) {
|
||||
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)
|
||||
addCardToGame(roomId: string, cardData: Partial<CardInstance>) {
|
||||
addCardToGame(roomId: string, cardData: Partial<CardObject>) {
|
||||
const game = this.games.get(roomId);
|
||||
if (!game) return;
|
||||
|
||||
// @ts-ignore
|
||||
const card: CardInstance = {
|
||||
// @ts-ignore - aligning types roughly
|
||||
const card: CardObject = {
|
||||
instanceId: cardData.instanceId || Math.random().toString(36).substring(7),
|
||||
zone: 'library',
|
||||
tapped: false,
|
||||
faceDown: true,
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
counters: [],
|
||||
ptModification: { power: 0, toughness: 0 },
|
||||
colors: [],
|
||||
types: [],
|
||||
subtypes: [],
|
||||
supertypes: [],
|
||||
power: 0,
|
||||
toughness: 0,
|
||||
basePower: 0,
|
||||
baseToughness: 0,
|
||||
imageUrl: '',
|
||||
controllerId: '',
|
||||
ownerId: '',
|
||||
oracleId: '',
|
||||
name: '',
|
||||
...cardData
|
||||
};
|
||||
game.cards[card.instanceId] = card;
|
||||
|
||||
Reference in New Issue
Block a user