From 41be1d49c493e05304c3d3d58904542fa62a6ddf Mon Sep 17 00:00:00 2001 From: dnviti Date: Mon, 22 Dec 2025 23:27:01 +0100 Subject: [PATCH] feat: Implement mana cost payment logic in the rules engine and emit client-side notifications for rule violation errors. --- src/client/src/modules/lobby/GameRoom.tsx | 19 ++- src/server/game/RulesEngine.ts | 142 +++++++++++++++++++++- src/server/managers/GameManager.ts | 4 +- 3 files changed, 161 insertions(+), 4 deletions(-) diff --git a/src/client/src/modules/lobby/GameRoom.tsx b/src/client/src/modules/lobby/GameRoom.tsx index 63b118f..ddfaf5c 100644 --- a/src/client/src/modules/lobby/GameRoom.tsx +++ b/src/client/src/modules/lobby/GameRoom.tsx @@ -208,9 +208,26 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl socket.off('game_update', handleGameUpdate); socket.off('tournament_update', handleTournamentUpdate); socket.off('tournament_finished', handleTournamentFinished); + socket.off('tournament_finished', handleTournamentFinished); socket.off('match_start'); + socket.off('game_error'); }; - }, []); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Add a specific effect for game_error if avoiding the big dependency array is desired, + // or just append to the existing effect content. + useEffect(() => { + const socket = socketService.socket; + const handleGameError = (data: { message: string, userId?: string }) => { + // Only show error if it's for me, or maybe generic "Action Failed" + if (data.userId && data.userId !== currentPlayerId) return; // Don't spam others errors? + + showToast(data.message, 'error'); + }; + + socket.on('game_error', handleGameError); + return () => { socket.off('game_error', handleGameError); }; + }, [currentPlayerId, showToast]); const sendMessage = (e: React.FormEvent) => { e.preventDefault(); diff --git a/src/server/game/RulesEngine.ts b/src/server/game/RulesEngine.ts index 6ea04c7..0e19538 100644 --- a/src/server/game/RulesEngine.ts +++ b/src/server/game/RulesEngine.ts @@ -81,7 +81,10 @@ export class RulesEngine { const card = this.state.cards[cardId]; if (!card || card.zone !== 'hand') throw new Error("Invalid card."); - // TODO: Check Timing (Instant vs Sorcery) + // Validate Mana Cost + if (card.manaCost) { + this.payManaCost(playerId, card.manaCost); + } // Move to Stack card.zone = 'stack'; @@ -102,6 +105,143 @@ export class RulesEngine { return true; } + // --- Mana System --- + + private parseManaCost(manaCost: string): { generic: number, colors: Record } { + const cost = { generic: 0, colors: { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 } as Record }; + + if (!manaCost) return cost; + + // Use regex to match {X} blocks + const matches = manaCost.match(/{[^{}]+}/g); + if (!matches) return cost; + + matches.forEach(symbol => { + const content = symbol.replace(/[{}]/g, ''); + + // Check for generic number + if (!isNaN(Number(content))) { + cost.generic += Number(content); + } + // check for hybrid/phyrexian later if needed, for now exact colors + else { + // Standard colors + if (['W', 'U', 'B', 'R', 'G', 'C'].includes(content)) { + cost.colors[content]++; + } else { + // Handle 'X' or other symbols if necessary, currently ignored as 0 cost + } + } + }); + + return cost; + } + + private getLandColor(card: any): string | null { + if (!card.typeLine?.includes('Land') && !card.types.includes('Land')) return null; + + // Basic heuristic based on type line names + if (card.typeLine.includes('Plains')) return 'W'; + if (card.typeLine.includes('Island')) return 'U'; + if (card.typeLine.includes('Swamp')) return 'B'; + if (card.typeLine.includes('Mountain')) return 'R'; + if (card.typeLine.includes('Forest')) return 'G'; + + // Fallback: Wastes or special lands? + // For MVP, we assume typed basic lands or nothing. + return null; + } + + private payManaCost(playerId: string, manaCostStr: string) { + const player = this.state.players[playerId]; + const cost = this.parseManaCost(manaCostStr); + + // 1. Gather Resources + const pool = { ...player.manaPool }; // Copy pool + const lands = Object.values(this.state.cards).filter(c => + c.controllerId === playerId && + c.zone === 'battlefield' && + !c.tapped && + (c.types.includes('Land') || c.typeLine?.includes('Land')) + ); + + const landsToTap: string[] = []; // List of IDs + + // 2. Pay Colored Costs + for (const color of ['W', 'U', 'B', 'R', 'G', 'C']) { + let required = cost.colors[color]; + if (required <= 0) continue; + + // a. Pay from Pool first + if (pool[color] >= required) { + pool[color] -= required; + required = 0; + } else { + required -= pool[color]; + pool[color] = 0; + } + + // b. Pay from Lands + if (required > 0) { + // Find lands producing this color + const producers = lands.filter(l => !landsToTap.includes(l.instanceId) && this.getLandColor(l) === color); + + if (producers.length >= required) { + // Mark first N as used + for (let i = 0; i < required; i++) { + landsToTap.push(producers[i].instanceId); + } + required = 0; + } else { + // Use all we have, but it's not enough + throw new Error(`Insufficient ${color} mana.`); + } + } + } + + // 3. Pay Generic Cost + let genericRequired = cost.generic; + + if (genericRequired > 0) { + // a. Consume any remaining pools (greedy, order doesn't matter for generic usually, but maybe keep 'better' colors? No, random for now) + for (const color of Object.keys(pool)) { + if (genericRequired <= 0) break; + const available = pool[color]; + if (available > 0) { + const params = Math.min(available, genericRequired); + pool[color] -= params; + genericRequired -= params; + } + } + + // b. Tap remaining unused lands + if (genericRequired > 0) { + // Filter lands not yet marked for tap + const unusedLands = lands.filter(l => !landsToTap.includes(l.instanceId) && this.getLandColor(l) !== null); + + if (unusedLands.length >= genericRequired) { + for (let i = 0; i < genericRequired; i++) { + landsToTap.push(unusedLands[i].instanceId); + } + genericRequired = 0; + } else { + throw new Error("Insufficient mana for generic cost."); + } + } + } + + // 4. Commit Payments + // Update Pool + player.manaPool = pool; + // Tap Lands + landsToTap.forEach(lid => { + const land = this.state.cards[lid]; + land.tapped = true; + console.log(`Auto-tapped ${land.name} for mana.`); + }); + console.log(`Paid mana cost ${manaCostStr}. Remaining Pool:`, pool); + } + public addMana(playerId: string, mana: { color: string, amount: number }) { // Check if player has priority or if checking for mana abilities? // 605.3a: Player may activate mana ability whenever they have priority... or when rule/effect asks for mana payment. diff --git a/src/server/managers/GameManager.ts b/src/server/managers/GameManager.ts index afe1fcc..b8b17ad 100644 --- a/src/server/managers/GameManager.ts +++ b/src/server/managers/GameManager.ts @@ -173,8 +173,8 @@ export class GameManager extends EventEmitter { } } catch (e: any) { console.error(`Rule Violation [${action?.type || 'UNKNOWN'}]: ${e.message}`); - // TODO: Return error to user? - // For now, just logging and not updating state (transactional-ish) + // Notify the user (and others?) about the error + this.emit('game_error', roomId, { message: e.message, userId: actorId }); return null; }