feat: Implement client-side equip functionality and visual attachment rendering, and server-side Aura/Equipment attachment resolution.
This commit is contained in:
@@ -498,6 +498,18 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle Equipment / Ability on Battlefield
|
||||||
|
if (card.zone === 'battlefield') {
|
||||||
|
const isEquipment = card.types?.includes('Artifact') && card.subtypes?.includes('Equipment');
|
||||||
|
|
||||||
|
if (isEquipment && over.data.current.type === 'card') { // Equip only targets cards (creatures)
|
||||||
|
socketService.socket.emit('game_strict_action', {
|
||||||
|
action: { type: 'ACTIVATE_ABILITY', abilityIndex: 0, sourceId: cardId, targets: [targetId] }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Default Cast with Target
|
// Default Cast with Target
|
||||||
if (card.zone === 'hand') {
|
if (card.zone === 'hand') {
|
||||||
socketService.socket.emit('game_strict_action', {
|
socketService.socket.emit('game_strict_action', {
|
||||||
@@ -702,9 +714,23 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
|
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const creatures = myBattlefield.filter(c => c.types?.includes('Creature'));
|
// Separate Roots and Attachments
|
||||||
const allLands = myBattlefield.filter(c => c.types?.includes('Land') && !c.types?.includes('Creature'));
|
const attachments = myBattlefield.filter(c => c.attachedTo);
|
||||||
const others = myBattlefield.filter(c => !c.types?.includes('Creature') && !c.types?.includes('Land'));
|
const unattached = myBattlefield.filter(c => !c.attachedTo);
|
||||||
|
|
||||||
|
const creatures = unattached.filter(c => c.types?.includes('Creature'));
|
||||||
|
const allLands = unattached.filter(c => c.types?.includes('Land') && !c.types?.includes('Creature'));
|
||||||
|
const others = unattached.filter(c => !c.types?.includes('Creature') && !c.types?.includes('Land'));
|
||||||
|
|
||||||
|
// Map Attachments to Hosts
|
||||||
|
const attachmentsMap = attachments.reduce((acc, c) => {
|
||||||
|
const target = c.attachedTo;
|
||||||
|
if (target) {
|
||||||
|
if (!acc[target]) acc[target] = [];
|
||||||
|
acc[target].push(c);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, CardInstance[]>);
|
||||||
|
|
||||||
const landGroups = allLands.reduce((acc, card) => {
|
const landGroups = allLands.reduce((acc, card) => {
|
||||||
const key = card.name || 'Unknown Land';
|
const key = card.name || 'Unknown Land';
|
||||||
@@ -713,17 +739,17 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, CardInstance[]>);
|
}, {} as Record<string, CardInstance[]>);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const renderCard = (card: CardInstance) => {
|
const renderCard = (card: CardInstance) => {
|
||||||
const isAttacking = proposedAttackers.has(card.instanceId);
|
const isAttacking = proposedAttackers.has(card.instanceId);
|
||||||
const blockingTargetId = proposedBlockers.get(card.instanceId);
|
const blockingTargetId = proposedBlockers.get(card.instanceId);
|
||||||
const isPreviewTapped = previewTappedIds.has(card.instanceId);
|
const isPreviewTapped = previewTappedIds.has(card.instanceId);
|
||||||
|
|
||||||
|
const attachedCards = attachmentsMap[card.instanceId] || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={card.instanceId}
|
key={card.instanceId}
|
||||||
className="relative transition-all duration-300"
|
className="relative transition-all duration-300 group"
|
||||||
style={{
|
style={{
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
transform: isAttacking
|
transform: isAttacking
|
||||||
@@ -738,6 +764,28 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DraggableCardWrapper card={card} disabled={!hasPriority}>
|
<DraggableCardWrapper card={card} disabled={!hasPriority}>
|
||||||
|
{/* Render Attachments UNDER the card */}
|
||||||
|
{attachedCards.length > 0 && (
|
||||||
|
<div className="absolute -bottom-4 left-1/2 -translate-x-1/2 flex flex-col items-center -space-y-16 hover:space-y-4 hover:bottom-[-200px] transition-all duration-300 z-[-1] hover:z-50">
|
||||||
|
{attachedCards.map((att, idx) => (
|
||||||
|
<div key={att.instanceId} className="relative transition-transform hover:scale-110" style={{ zIndex: idx }}>
|
||||||
|
<CardComponent
|
||||||
|
card={att}
|
||||||
|
viewMode="cutout"
|
||||||
|
onClick={() => { }}
|
||||||
|
onDragStart={() => { }}
|
||||||
|
className="w-16 h-16 opacity-90 hover:opacity-100 shadow-md border border-slate-600 rounded"
|
||||||
|
/>
|
||||||
|
{/* Allow dragging attachment off? Need separate Draggable wrapper for it OR handle logic */}
|
||||||
|
{/* For now, just visual representation. Use main logic to drag OFF if needed, but nested dragging is complex.
|
||||||
|
Ideally, we define DraggableCardWrapper around THIS too?
|
||||||
|
GameView dnd uses ID. If we use DraggableCardWrapper here, it should work.
|
||||||
|
*/}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<CardComponent
|
<CardComponent
|
||||||
card={card}
|
card={card}
|
||||||
viewMode="cutout"
|
viewMode="cutout"
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ export class RulesEngine {
|
|||||||
const card = this.state.cards[cardId];
|
const card = this.state.cards[cardId];
|
||||||
if (!card || card.zone !== 'hand') throw new Error("Invalid card.");
|
if (!card || card.zone !== 'hand') throw new Error("Invalid card.");
|
||||||
|
|
||||||
|
// Validate Status (Aura needs target)
|
||||||
|
if (this.isAura(card)) {
|
||||||
|
if (targets.length === 0) throw new Error("Aura requires a target.");
|
||||||
|
// Note: We don't strictly validate target legality here for "Casting",
|
||||||
|
// but usually you can't cast if no legal target exists.
|
||||||
|
// We'll trust the input for now, but `resolveTopStack` will verify correctness.
|
||||||
|
}
|
||||||
|
|
||||||
// Validate Mana Cost
|
// Validate Mana Cost
|
||||||
if (card.manaCost) {
|
if (card.manaCost) {
|
||||||
this.payManaCost(playerId, card.manaCost);
|
this.payManaCost(playerId, card.manaCost);
|
||||||
@@ -105,6 +113,58 @@ export class RulesEngine {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public activateAbility(playerId: string, sourceId: string, _abilityIndex: number, targets: string[] = []) {
|
||||||
|
if (this.state.priorityPlayerId !== playerId) throw new Error("Not your priority.");
|
||||||
|
|
||||||
|
const source = this.state.cards[sourceId];
|
||||||
|
if (!source) throw new Error("Invalid source.");
|
||||||
|
|
||||||
|
// TODO: Generic Ability System
|
||||||
|
// For now, we hardcode "Equip" as a special case if the card is Equipment.
|
||||||
|
// In a real engine, we'd look up source.abilities[abilityIndex].
|
||||||
|
|
||||||
|
if (this.isEquipment(source) && source.zone === 'battlefield') {
|
||||||
|
// Equip Ability
|
||||||
|
// Cost: Usually generic. For MVP, assume {1} or free? Or look at card text?
|
||||||
|
// Let's assume generic cost {1} for demo purposes unless defined.
|
||||||
|
// 702.6a Equip [cost]: [Cost]: Attach to target creature you control. Sorcery speed.
|
||||||
|
|
||||||
|
// Speed Check (Sorcery)
|
||||||
|
if (this.state.stack.length > 0 || (this.state.phase !== 'main1' && this.state.phase !== 'main2')) {
|
||||||
|
throw new Error("Equip can only be used as a sorcery.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target Check
|
||||||
|
if (targets.length !== 1) throw new Error("Equip requires exactly one target.");
|
||||||
|
const targetId = targets[0];
|
||||||
|
const target = this.state.cards[targetId];
|
||||||
|
|
||||||
|
if (!target || target.zone !== 'battlefield' || !this.isCreature(target) || target.controllerId !== playerId) {
|
||||||
|
throw new Error("Invalid Equip target. Must be a creature you control.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pay Cost (Mock: {1})
|
||||||
|
// this.payManaCost(playerId, "{1}");
|
||||||
|
|
||||||
|
// Resolve Immediately (Simple) or Stack (Correct)?
|
||||||
|
// Activated abilities go on stack.
|
||||||
|
this.state.stack.push({
|
||||||
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
|
sourceId: sourceId,
|
||||||
|
controllerId: playerId,
|
||||||
|
type: 'ability',
|
||||||
|
name: `Equip ${source.name} to ${target.name}`,
|
||||||
|
text: `Attach ${source.name} to ${target.name}`,
|
||||||
|
targets: [targetId]
|
||||||
|
});
|
||||||
|
|
||||||
|
this.resetPriority(playerId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Ability not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
// --- Mana System ---
|
// --- Mana System ---
|
||||||
|
|
||||||
private parseManaCost(manaCost: string): { generic: number, colors: Record<string, number>, hybrids: string[][] } {
|
private parseManaCost(manaCost: string): { generic: number, colors: Record<string, number>, hybrids: string[][] } {
|
||||||
@@ -520,18 +580,87 @@ export class RulesEngine {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (isPermanent) {
|
if (isPermanent) {
|
||||||
this.moveCardToZone(card.instanceId, 'battlefield', false, item.resolutionPosition);
|
if (this.isAura(card)) {
|
||||||
|
// Aura Resolution
|
||||||
|
const targetId = item.targets[0];
|
||||||
|
const target = this.state.cards[targetId];
|
||||||
|
if (target && target.zone === 'battlefield' && this.canAttach(card, target)) {
|
||||||
|
this.moveCardToZone(card.instanceId, 'battlefield', false, item.resolutionPosition);
|
||||||
|
card.attachedTo = target.instanceId;
|
||||||
|
console.log(`${card.name} enters attached to ${target.name}`);
|
||||||
|
} else {
|
||||||
|
// 303.4g If an Aura is entering the battlefield and there is no legal object or player for it to enchant... it is put into its owner's graveyard.
|
||||||
|
console.log(`${card.name} failed to attach. Putting into GY.`);
|
||||||
|
this.moveCardToZone(card.instanceId, 'graveyard');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal Permanent
|
||||||
|
this.moveCardToZone(card.instanceId, 'battlefield', false, item.resolutionPosition);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Instant / Sorcery
|
// Instant / Sorcery
|
||||||
this.moveCardToZone(card.instanceId, 'graveyard');
|
this.moveCardToZone(card.instanceId, 'graveyard');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (item.type === 'ability') {
|
||||||
|
// Handle Ability Resolution (Equip or other Generic)
|
||||||
|
const source = this.state.cards[item.sourceId];
|
||||||
|
if (source && source.zone === 'battlefield') {
|
||||||
|
// Equipment Logic
|
||||||
|
if (this.isEquipment(source)) {
|
||||||
|
const targetId = item.targets[0];
|
||||||
|
const target = this.state.cards[targetId];
|
||||||
|
|
||||||
|
// Generic Validation Check for "Attach"
|
||||||
|
if (target && target.zone === 'battlefield' && this.validateTarget(source, target)) {
|
||||||
|
source.attachedTo = target.instanceId;
|
||||||
|
console.log(`Equipped ${source.name} to ${target.name}`);
|
||||||
|
} else {
|
||||||
|
console.log(`Equip failed: Target invalid.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// After resolution, Active Player gets priority again (Rule 117.3b)
|
// After resolution, Active Player gets priority again (Rule 117.3b)
|
||||||
this.resetPriority(this.state.activePlayerId);
|
this.resetPriority(this.state.activePlayerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Targeting System (Generic) ---
|
||||||
|
|
||||||
|
public validateTarget(source: any, target: any, actionType: 'cast' | 'equip' | 'ability' = 'ability'): boolean {
|
||||||
|
// 1. Basic Existence
|
||||||
|
if (!target || target.zone !== 'battlefield') return false;
|
||||||
|
|
||||||
|
// 2. Protection (Simple Check)
|
||||||
|
if (target.pro_protection) {
|
||||||
|
// Check if source characteristics match protection
|
||||||
|
// E.g. Protection from White -> if source is White, return false.
|
||||||
|
// Not fully implemented yet, but placeholder is here.
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Specific Restrictions based on Source Type
|
||||||
|
if (this.isAura(source) || actionType === 'equip') {
|
||||||
|
// "Enchant/Equip Creature" is default for MVP unless specified otherwise
|
||||||
|
// Parse Text or Definition if available
|
||||||
|
// For now, strict Creature check for Equip/Aura (unless Aura says otherwise in text)
|
||||||
|
if (this.isEquipment(source) && !this.isCreature(target)) return false;
|
||||||
|
if (this.isAura(source)) {
|
||||||
|
// Example parsing: "Enchant land"
|
||||||
|
if (source.oracleText?.toLowerCase().includes('enchant land')) {
|
||||||
|
if (!target.types.includes('Land')) return false;
|
||||||
|
} else {
|
||||||
|
// Default Aura -> Creature
|
||||||
|
if (!target.types.includes('Creature')) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shroud / Hexproof check would go here
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private advanceStep() {
|
private advanceStep() {
|
||||||
// Transition Table
|
// Transition Table
|
||||||
const structure: Record<Phase, Step[]> = {
|
const structure: Record<Phase, Step[]> = {
|
||||||
@@ -890,6 +1019,18 @@ export class RulesEngine {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 5. Equipment Validity
|
||||||
|
Object.values(cards).forEach(c => {
|
||||||
|
if (c.zone === 'battlefield' && this.isEquipment(c) && c.attachedTo) {
|
||||||
|
const target = cards[c.attachedTo];
|
||||||
|
if (!target || target.zone !== 'battlefield') {
|
||||||
|
console.log(`SBA: ${c.name} (Equipment) detached (Host invalid).`);
|
||||||
|
c.attachedTo = undefined;
|
||||||
|
sbaPerformed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return sbaPerformed;
|
return sbaPerformed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,15 +1113,30 @@ export class RulesEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layer 7e: Switch Power/Toughness - skipped for now
|
|
||||||
|
|
||||||
// Final Floor rule: T cannot be less than 0 for logic? No, T can be negative for calculation, but usually treated as 0 for damage?
|
|
||||||
// Actually CR says negative numbers are real in calculation, but treated as 0 for dealing damage.
|
|
||||||
// We store true values.
|
|
||||||
|
|
||||||
card.power = p;
|
card.power = p;
|
||||||
card.toughness = t;
|
card.toughness = t;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
private isAura(card: any): boolean {
|
||||||
|
return card.types && card.types.includes('Enchantment') && card.subtypes && card.subtypes.includes('Aura');
|
||||||
|
}
|
||||||
|
|
||||||
|
private isEquipment(card: any): boolean {
|
||||||
|
return card.types && card.types.includes('Artifact') && card.subtypes && card.subtypes.includes('Equipment');
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCreature(card: any): boolean {
|
||||||
|
return card.types && card.types.includes('Creature');
|
||||||
|
}
|
||||||
|
|
||||||
|
private canAttach(source: any, target: any): boolean {
|
||||||
|
if (this.isAura(source)) {
|
||||||
|
if (source.oracleText?.toLowerCase().includes('enchant land')) return target.types.includes('Land');
|
||||||
|
return target.types.includes('Creature');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user