feat: Implement client-side equip functionality and visual attachment rendering, and server-side Aura/Equipment attachment resolution.

This commit is contained in:
2025-12-23 00:03:35 +01:00
parent 7aa34adf95
commit 35407a5cd4
2 changed files with 217 additions and 13 deletions

View File

@@ -498,6 +498,18 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
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
if (card.zone === 'hand') {
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'));
const allLands = myBattlefield.filter(c => c.types?.includes('Land') && !c.types?.includes('Creature'));
const others = myBattlefield.filter(c => !c.types?.includes('Creature') && !c.types?.includes('Land'));
// Separate Roots and Attachments
const attachments = myBattlefield.filter(c => c.attachedTo);
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 key = card.name || 'Unknown Land';
@@ -713,17 +739,17 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
return acc;
}, {} as Record<string, CardInstance[]>);
const renderCard = (card: CardInstance) => {
const isAttacking = proposedAttackers.has(card.instanceId);
const blockingTargetId = proposedBlockers.get(card.instanceId);
const isPreviewTapped = previewTappedIds.has(card.instanceId);
const attachedCards = attachmentsMap[card.instanceId] || [];
return (
<div
key={card.instanceId}
className="relative transition-all duration-300"
className="relative transition-all duration-300 group"
style={{
zIndex: 10,
transform: isAttacking
@@ -738,6 +764,28 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
}}
>
<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
card={card}
viewMode="cutout"

View File

@@ -81,6 +81,14 @@ export class RulesEngine {
const card = this.state.cards[cardId];
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
if (card.manaCost) {
this.payManaCost(playerId, card.manaCost);
@@ -105,6 +113,58 @@ export class RulesEngine {
return true;
}
public activateAbility(playerId: string, sourceId: string, _abilityIndex: number, targets: string[] = []) {
if (this.state.priorityPlayerId !== playerId) throw new Error("Not your priority.");
const source = this.state.cards[sourceId];
if (!source) throw new Error("Invalid source.");
// TODO: Generic Ability System
// For now, we hardcode "Equip" as a special case if the card is Equipment.
// In a real engine, we'd look up source.abilities[abilityIndex].
if (this.isEquipment(source) && source.zone === 'battlefield') {
// Equip Ability
// Cost: Usually generic. For MVP, assume {1} or free? Or look at card text?
// Let's assume generic cost {1} for demo purposes unless defined.
// 702.6a Equip [cost]: [Cost]: Attach to target creature you control. Sorcery speed.
// Speed Check (Sorcery)
if (this.state.stack.length > 0 || (this.state.phase !== 'main1' && this.state.phase !== 'main2')) {
throw new Error("Equip can only be used as a sorcery.");
}
// Target Check
if (targets.length !== 1) throw new Error("Equip requires exactly one target.");
const targetId = targets[0];
const target = this.state.cards[targetId];
if (!target || target.zone !== 'battlefield' || !this.isCreature(target) || target.controllerId !== playerId) {
throw new Error("Invalid Equip target. Must be a creature you control.");
}
// Pay Cost (Mock: {1})
// this.payManaCost(playerId, "{1}");
// Resolve Immediately (Simple) or Stack (Correct)?
// Activated abilities go on stack.
this.state.stack.push({
id: Math.random().toString(36).substr(2, 9),
sourceId: sourceId,
controllerId: playerId,
type: 'ability',
name: `Equip ${source.name} to ${target.name}`,
text: `Attach ${source.name} to ${target.name}`,
targets: [targetId]
});
this.resetPriority(playerId);
return true;
}
throw new Error("Ability not implemented.");
}
// --- Mana System ---
private parseManaCost(manaCost: string): { generic: number, colors: Record<string, number>, hybrids: string[][] } {
@@ -520,18 +580,87 @@ export class RulesEngine {
);
if (isPermanent) {
if (this.isAura(card)) {
// Aura Resolution
const targetId = item.targets[0];
const target = this.state.cards[targetId];
if (target && target.zone === 'battlefield' && this.canAttach(card, target)) {
this.moveCardToZone(card.instanceId, 'battlefield', false, item.resolutionPosition);
card.attachedTo = target.instanceId;
console.log(`${card.name} enters attached to ${target.name}`);
} else {
// 303.4g If an Aura is entering the battlefield and there is no legal object or player for it to enchant... it is put into its owner's graveyard.
console.log(`${card.name} failed to attach. Putting into GY.`);
this.moveCardToZone(card.instanceId, 'graveyard');
}
} else {
// Normal Permanent
this.moveCardToZone(card.instanceId, 'battlefield', false, item.resolutionPosition);
}
} else {
// Instant / Sorcery
this.moveCardToZone(card.instanceId, 'graveyard');
}
}
} else if (item.type === 'ability') {
// Handle Ability Resolution (Equip or other Generic)
const source = this.state.cards[item.sourceId];
if (source && source.zone === 'battlefield') {
// Equipment Logic
if (this.isEquipment(source)) {
const targetId = item.targets[0];
const target = this.state.cards[targetId];
// Generic Validation Check for "Attach"
if (target && target.zone === 'battlefield' && this.validateTarget(source, target)) {
source.attachedTo = target.instanceId;
console.log(`Equipped ${source.name} to ${target.name}`);
} else {
console.log(`Equip failed: Target invalid.`);
}
}
}
}
// After resolution, Active Player gets priority again (Rule 117.3b)
this.resetPriority(this.state.activePlayerId);
}
// --- Targeting System (Generic) ---
public validateTarget(source: any, target: any, actionType: 'cast' | 'equip' | 'ability' = 'ability'): boolean {
// 1. Basic Existence
if (!target || target.zone !== 'battlefield') return false;
// 2. Protection (Simple Check)
if (target.pro_protection) {
// Check if source characteristics match protection
// E.g. Protection from White -> if source is White, return false.
// Not fully implemented yet, but placeholder is here.
}
// 3. Specific Restrictions based on Source Type
if (this.isAura(source) || actionType === 'equip') {
// "Enchant/Equip Creature" is default for MVP unless specified otherwise
// Parse Text or Definition if available
// For now, strict Creature check for Equip/Aura (unless Aura says otherwise in text)
if (this.isEquipment(source) && !this.isCreature(target)) return false;
if (this.isAura(source)) {
// Example parsing: "Enchant land"
if (source.oracleText?.toLowerCase().includes('enchant land')) {
if (!target.types.includes('Land')) return false;
} else {
// Default Aura -> Creature
if (!target.types.includes('Creature')) return false;
}
}
// Shroud / Hexproof check would go here
}
return true;
}
private advanceStep() {
// Transition Table
const structure: Record<Phase, Step[]> = {
@@ -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;
}
@@ -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.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;
}
}