feat: Implement game restart, battlefield styling with art crops and tapped stacks, and initial draw fixes.
Some checks failed
Build and Deploy / build (push) Failing after 1m10s

This commit is contained in:
2025-12-18 20:26:42 +01:00
parent ca7b5bf7fa
commit bc5eda5e2a
35 changed files with 1337 additions and 634 deletions

View File

@@ -60,6 +60,14 @@ export class RulesEngine {
return true;
}
public startGame() {
console.log("RulesEngine: Starting Game...");
// Ensure specific setup if needed (life total, etc is done elsewhere)
// Trigger Initial Draw
this.performTurnBasedActions();
}
public castSpell(playerId: string, cardId: string, targets: string[] = [], position?: { x: number, y: number }) {
if (this.state.priorityPlayerId !== playerId) throw new Error("Not your priority.");
@@ -136,6 +144,7 @@ export class RulesEngine {
});
console.log(`Player ${playerId} declared ${attackers.length} attackers.`);
this.state.attackersDeclared = true; // Flag for UI/Engine state
// 508.2. Active Player gets priority
// But usually passing happens immediately after declaration in digital?
@@ -361,6 +370,21 @@ export class RulesEngine {
nextStep = structure[nextPhase][0];
}
// SKIP Logic for Combat
// 508.8. If no creatures are declared as attackers... skip declare blockers/combat damage steps.
if (this.state.phase === 'combat') {
const attackers = Object.values(this.state.cards).filter(c => !!c.attacking);
// If we are about to enter declare_blockers or combat_damage and NO attackers exist
// Note: We check 'attacking' status. If we just finished declare_attackers, we might have reset it?
// No, 'attacking' property persists until end of combat.
if (nextStep === 'declare_blockers' && attackers.length === 0) {
console.log("No attackers. Skipping directly to End of Combat.");
nextStep = 'end_combat';
}
}
// Rule 500.4: Mana empties at end of each step and phase
this.emptyManaPools();
@@ -523,7 +547,7 @@ export class RulesEngine {
});
}
private drawCard(playerId: string) {
public 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?)
@@ -546,6 +570,9 @@ export class RulesEngine {
c.modifiers = c.modifiers.filter(m => !m.untilEndOfTurn);
}
});
this.state.attackersDeclared = false;
this.state.blockersDeclared = false;
}
// --- State Based Actions ---

View File

@@ -62,6 +62,7 @@ export interface CardObject {
// Metadata
controlledSinceTurn: number; // For Summoning Sickness check
definition?: any;
}
export interface PlayerState {
@@ -108,6 +109,8 @@ export interface StrictGameState {
// Rules State
passedPriorityCount: number; // 0..N. If N, advance.
landsPlayedThisTurn: number;
attackersDeclared?: boolean;
blockersDeclared?: boolean;
maxZ: number; // Visual depth (legacy support)
}

View File

@@ -11,6 +11,7 @@ import { ScryfallService } from './services/ScryfallService';
import { PackGeneratorService } from './services/PackGeneratorService';
import { CardParserService } from './services/CardParserService';
import { PersistenceManager } from './managers/PersistenceManager';
import { RulesEngine } from './game/RulesEngine';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -255,6 +256,11 @@ const draftInterval = setInterval(() => {
});
}
});
// Initialize Game State (Draw Hands)
const engine = new RulesEngine(game);
engine.startGame();
io.to(roomId).emit('game_update', game);
}
}
@@ -471,12 +477,19 @@ io.on('connection', (socket) => {
oracleText: card.oracleText || card.oracle_text || '',
manaCost: card.manaCost || card.mana_cost || '',
keywords: card.keywords || [],
power: card.power, // Add Power
toughness: card.toughness, // Add Toughness
damageMarked: 0,
controlledSinceTurn: 0
});
});
}
});
// Initialize Game State (Draw Hands)
const engine = new RulesEngine(game);
engine.startGame();
io.to(room.id).emit('game_update', game);
}
}
@@ -501,11 +514,18 @@ io.on('connection', (socket) => {
oracleText: card.oracleText || card.oracle_text || '',
manaCost: card.manaCost || card.mana_cost || '',
keywords: card.keywords || [],
power: card.power,
toughness: card.toughness,
damageMarked: 0,
controlledSinceTurn: 0
});
});
}
// Initialize Game State (Draw Hands)
const engine = new RulesEngine(game);
engine.startGame();
callback({ success: true, room, game });
io.to(room.id).emit('room_update', room);
io.to(room.id).emit('game_update', game);
@@ -535,12 +555,19 @@ io.on('connection', (socket) => {
oracleText: card.oracleText || card.oracle_text || '',
manaCost: card.manaCost || card.mana_cost || '',
keywords: card.keywords || [],
power: card.power,
toughness: card.toughness,
damageMarked: 0,
controlledSinceTurn: 0
});
});
});
}
// Initialize Game State (Draw Hands)
const engine = new RulesEngine(game);
engine.startGame();
io.to(room.id).emit('game_update', game);
}
});

View File

@@ -96,7 +96,7 @@ export class GameManager {
return null;
}
} catch (e: any) {
console.error(`Rule Violation [${action.type}]: ${e.message}`);
console.error(`Rule Violation [${action?.type || 'UNKNOWN'}]: ${e.message}`);
// TODO: Return error to user?
// For now, just logging and not updating state (transactional-ish)
return null;
@@ -111,16 +111,32 @@ export class GameManager {
if (!game) return null;
// Basic Validation: Ensure actor exists in game (or is host/admin?)
if (!game.players[actorId]) return null;
if (!game.players[actorId]) {
console.warn(`handleAction: Player ${actorId} not found in room ${roomId}`);
return null;
}
console.log(`[GameManager] Handling Action: ${action.type} for ${roomId} by ${actorId}`);
switch (action.type) {
case 'UPDATE_LIFE':
if (game.players[actorId]) {
game.players[actorId].life += (action.amount || 0);
}
break;
case 'MOVE_CARD':
this.moveCard(game, action, actorId);
break;
case 'TAP_CARD':
this.tapCard(game, action, actorId);
break;
// ... (Other cases can be ported if needed)
case 'DRAW_CARD':
const engine = new RulesEngine(game);
engine.drawCard(actorId);
break;
case 'RESTART_GAME':
this.restartGame(roomId);
break;
}
return game;
@@ -188,8 +204,100 @@ export class GameManager {
name: '',
...cardData,
damageMarked: 0,
controlledSinceTurn: 0 // Will be updated on draw/play
controlledSinceTurn: 0, // Will be updated on draw/play
definition: cardData.definition // Ensure definition is passed
};
// Auto-Parse Types if missing
if (card.types.length === 0 && card.typeLine) {
const [typePart, subtypePart] = card.typeLine.split('—').map(s => s.trim());
const typeWords = typePart.split(' ');
const supertypeList = ['Legendary', 'Basic', 'Snow', 'World'];
const typeList = ['Land', 'Creature', 'Artifact', 'Enchantment', 'Planeswalker', 'Instant', 'Sorcery', 'Tribal', 'Battle', 'Kindred']; // Kindred = Tribal
card.supertypes = typeWords.filter(w => supertypeList.includes(w));
card.types = typeWords.filter(w => typeList.includes(w));
if (subtypePart) {
card.subtypes = subtypePart.split(' ');
}
}
// Auto-Parse P/T from cardData if provided specifically as strings or numbers, ensuring numbers
if (cardData.power !== undefined) card.basePower = Number(cardData.power);
if (cardData.toughness !== undefined) card.baseToughness = Number(cardData.toughness);
// Set current values to base
card.power = card.basePower;
card.toughness = card.baseToughness;
game.cards[card.instanceId] = card;
}
private restartGame(roomId: string) {
const game = this.games.get(roomId);
if (!game) return;
// 1. Reset Game Global State
game.turnCount = 1;
game.phase = 'setup';
game.step = 'mulligan';
game.stack = [];
game.activePlayerId = game.turnOrder[0];
game.priorityPlayerId = game.activePlayerId;
game.passedPriorityCount = 0;
game.landsPlayedThisTurn = 0;
game.attackersDeclared = false;
game.blockersDeclared = false;
game.maxZ = 100;
// 2. Reset Players
Object.keys(game.players).forEach(pid => {
const p = game.players[pid];
p.life = 20;
p.poison = 0;
p.energy = 0;
p.isActive = (pid === game.activePlayerId);
p.hasPassed = false;
p.manaPool = { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 };
p.handKept = false;
p.mulliganCount = 0;
});
// 3. Reset Cards
const tokensToRemove: string[] = [];
Object.values(game.cards).forEach(c => {
if (c.oracleId.startsWith('token-')) {
tokensToRemove.push(c.instanceId);
} else {
// Move to Library
c.zone = 'library';
c.tapped = false;
c.faceDown = true;
c.counters = [];
c.modifiers = [];
c.damageMarked = 0;
c.controlledSinceTurn = 0;
c.power = c.basePower;
c.toughness = c.baseToughness;
c.attachedTo = undefined;
c.blocking = undefined;
c.attacking = undefined;
// Reset position?
c.position = undefined;
}
});
// Remove tokens
tokensToRemove.forEach(id => {
delete game.cards[id];
});
console.log(`Game ${roomId} restarted.`);
// 4. Trigger Start Game (Draw Hands via Rules Engine)
const engine = new RulesEngine(game);
engine.startGame();
}
}

View File

@@ -46,29 +46,56 @@ export class CardService {
imageUrl = card.card_faces[0].image_uris?.normal;
}
if (!imageUrl) continue;
const filePath = path.join(this.imagesDir, setCode, `${uuid}.jpg`);
// Check if exists
if (await fileStorageManager.exists(filePath)) {
continue;
// Check for art crop
let cropUrl = card.image_uris?.art_crop;
if (!cropUrl && card.card_faces && card.card_faces.length > 0) {
cropUrl = card.card_faces[0].image_uris?.art_crop;
}
try {
// Download
const response = await fetch(imageUrl);
if (response.ok) {
const buffer = await response.arrayBuffer();
await fileStorageManager.saveFile(filePath, Buffer.from(buffer));
downloadedCount++;
console.log(`Cached image: ${setCode}/${uuid}.jpg`);
} else {
console.error(`Failed to download ${imageUrl}: ${response.statusText}`);
}
} catch (err) {
console.error(`Error downloading image for ${uuid}:`, err);
const tasks: Promise<void>[] = [];
// Task 1: Normal Image (art_full)
if (imageUrl) {
const filePath = path.join(this.imagesDir, setCode, 'art_full', `${uuid}.jpg`);
tasks.push((async () => {
if (await fileStorageManager.exists(filePath)) return;
try {
const response = await fetch(imageUrl);
if (response.ok) {
const buffer = await response.arrayBuffer();
await fileStorageManager.saveFile(filePath, Buffer.from(buffer));
downloadedCount++;
console.log(`Cached art_full: ${setCode}/${uuid}.jpg`);
} else {
console.error(`Failed to download art_full ${imageUrl}: ${response.statusText}`);
}
} catch (err) {
console.error(`Error downloading art_full for ${uuid}:`, err);
}
})());
}
// Task 2: Art Crop (art_crop)
if (cropUrl) {
const cropPath = path.join(this.imagesDir, setCode, 'art_crop', `${uuid}.jpg`);
tasks.push((async () => {
if (await fileStorageManager.exists(cropPath)) return;
try {
const response = await fetch(cropUrl);
if (response.ok) {
const buffer = await response.arrayBuffer();
await fileStorageManager.saveFile(cropPath, Buffer.from(buffer));
console.log(`Cached art_crop: ${setCode}/${uuid}.jpg`);
} else {
console.error(`Failed to download art_crop ${cropUrl}: ${response.statusText}`);
}
} catch (err) {
console.error(`Error downloading art_crop for ${uuid}:`, err);
}
})());
}
await Promise.all(tasks);
}
};