feat: Implement core game engine logic, high-velocity UX, and new UI components including radial menu, inspector overlay, and mulligan view.

This commit is contained in:
2025-12-18 18:45:24 +01:00
parent 842beae419
commit ca7b5bf7fa
23 changed files with 1550 additions and 169 deletions

View File

@@ -29,7 +29,7 @@ export class RulesEngine {
return true;
}
public playLand(playerId: string, cardId: string): boolean {
public playLand(playerId: string, cardId: string, position?: { x: number, y: number }): boolean {
// 1. Check Priority
if (this.state.priorityPlayerId !== playerId) throw new Error("Not your priority.");
@@ -46,9 +46,10 @@ export class RulesEngine {
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)
// Verify it IS a land
if (!card.typeLine?.includes('Land') && !card.types.includes('Land')) throw new Error("Not a land card.");
this.moveCardToZone(card.instanceId, 'battlefield');
this.moveCardToZone(card.instanceId, 'battlefield', false, position);
this.state.landsPlayedThisTurn++;
// Playing a land does NOT use the stack, but priority remains with AP?
@@ -59,7 +60,7 @@ export class RulesEngine {
return true;
}
public castSpell(playerId: string, cardId: string, targets: string[] = []) {
public castSpell(playerId: string, cardId: string, targets: string[] = [], position?: { x: number, y: number }) {
if (this.state.priorityPlayerId !== playerId) throw new Error("Not your priority.");
const card = this.state.cards[cardId];
@@ -76,8 +77,9 @@ export class RulesEngine {
controllerId: playerId,
type: 'spell', // or permanent-spell
name: card.name,
text: "Spell Text...", // TODO: get rules text
targets
text: card.oracleText || "",
targets,
resolutionPosition: position
});
// Reset priority to caster (Rule 117.3c)
@@ -85,6 +87,185 @@ export class RulesEngine {
return true;
}
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.
// For manual engine, we assume priority or loose check.
// Validate Color
const validColors = ['W', 'U', 'B', 'R', 'G', 'C'];
if (!validColors.includes(mana.color)) throw new Error("Invalid mana color.");
const player = this.state.players[playerId];
if (!player) throw new Error("Invalid player.");
if (!player.manaPool) player.manaPool = { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 };
player.manaPool[mana.color] = (player.manaPool[mana.color] || 0) + mana.amount;
console.log(`Player ${playerId} added ${mana.amount}${mana.color} to pool.`, player.manaPool);
return true;
}
public declareAttackers(playerId: string, attackers: { attackerId: string, targetId: string }[]) {
// 508.1. Declare Attackers Step
if (this.state.phase !== 'combat' || this.state.step !== 'declare_attackers') throw new Error("Not Declare Attackers step.");
if (this.state.activePlayerId !== playerId) throw new Error("Only Active Player can declare attackers.");
// Validate and Process
attackers.forEach(({ attackerId, targetId }) => {
const card = this.state.cards[attackerId];
if (!card || card.controllerId !== playerId || card.zone !== 'battlefield') throw new Error(`Invalid attacker ${attackerId}`);
if (!card.types.includes('Creature')) throw new Error(`${card.name} is not a creature.`);
// Summoning Sickness
const hasHaste = card.keywords.includes('Haste'); // Simple string check
if (card.controlledSinceTurn === this.state.turnCount && !hasHaste) {
throw new Error(`${card.name} has Summoning Sickness.`);
}
// Tap if not Vigilance
const hasVigilance = card.keywords.includes('Vigilance');
if (card.tapped && !hasVigilance) throw new Error(`${card.name} is tapped.`);
if (!hasVigilance) {
card.tapped = true;
}
card.attacking = targetId;
});
console.log(`Player ${playerId} declared ${attackers.length} attackers.`);
// 508.2. Active Player gets priority
// But usually passing happens immediately after declaration in digital?
// We will reset priority to AP.
this.resetPriority(playerId);
}
public declareBlockers(playerId: string, blockers: { blockerId: string, attackerId: string }[]) {
if (this.state.phase !== 'combat' || this.state.step !== 'declare_blockers') throw new Error("Not Declare Blockers step.");
if (this.state.activePlayerId === playerId) throw new Error("Active Player cannot declare blockers.");
blockers.forEach(({ blockerId, attackerId }) => {
const blocker = this.state.cards[blockerId];
const attacker = this.state.cards[attackerId];
if (!blocker || blocker.controllerId !== playerId || blocker.zone !== 'battlefield') throw new Error(`Invalid blocker ${blockerId}`);
if (blocker.tapped) throw new Error(`${blocker.name} is tapped.`);
if (!attacker || !attacker.attacking) throw new Error(`Invalid attacker target ${attackerId}`);
if (!blocker.blocking) blocker.blocking = [];
blocker.blocking.push(attackerId);
// Note: 509.2. Damage Assignment Order (if multiple blockers)
});
console.log(`Player ${playerId} declared ${blockers.length} blockers.`);
// Priority goes to Active Player first after blockers declared
this.resetPriority(this.state.activePlayerId);
}
public resolveMulligan(playerId: string, keep: boolean, cardsToBottom: string[] = []) {
if (this.state.step !== 'mulligan') throw new Error("Not mulligan step");
const player = this.state.players[playerId];
if (player.handKept) throw new Error("Already kept hand");
if (keep) {
// Validate Cards to Bottom
// London Mulligan: Draw 7, put X on bottom. X = mulliganCount.
const currentMulls = player.mulliganCount || 0;
if (cardsToBottom.length !== currentMulls) {
throw new Error(`Must put ${currentMulls} cards to bottom.`);
}
// Move cards to library bottom
cardsToBottom.forEach(cid => {
const c = this.state.cards[cid];
if (c && c.ownerId === playerId && c.zone === 'hand') {
// Move to library
// We don't have explicit "bottom", just library?
// In random fetch, it doesn't matter. But strictly...
// Let's just put them in 'library' zone.
this.moveCardToZone(cid, 'library');
}
});
player.handKept = true;
console.log(`Player ${playerId} kept hand with ${cardsToBottom.length} on bottom.`);
// Trigger check
this.performTurnBasedActions();
} else {
// Take Mulligan
// 1. Hand -> Library
const hand = Object.values(this.state.cards).filter(c => c.ownerId === playerId && c.zone === 'hand');
hand.forEach(c => this.moveCardToZone(c.instanceId, 'library'));
// 2. Shuffle (noop here as library is bag)
// 3. Draw 7
for (let i = 0; i < 7; i++) {
this.drawCard(playerId);
}
// 4. Increment count
player.mulliganCount = (player.mulliganCount || 0) + 1;
console.log(`Player ${playerId} took mulligan. Count: ${player.mulliganCount}`);
// Wait for next decision
}
}
public createToken(playerId: string, definition: {
name: string,
colors: string[],
types: string[],
subtypes: string[],
power: number,
toughness: number,
keywords?: string[],
imageUrl?: string
}) {
const token: any = { // Using any allowing partial CardObject construction
instanceId: Math.random().toString(36).substring(7),
oracleId: 'token-' + Math.random(),
name: definition.name,
controllerId: playerId,
ownerId: playerId,
zone: 'battlefield',
tapped: false,
faceDown: false,
counters: [],
keywords: definition.keywords || [],
modifiers: [],
colors: definition.colors,
types: definition.types,
subtypes: definition.subtypes,
supertypes: [], // e.g. Legendary?
basePower: definition.power,
baseToughness: definition.toughness,
power: definition.power, // Will be recalc-ed by layers
toughness: definition.toughness,
imageUrl: definition.imageUrl || '',
damageMarked: 0,
controlledSinceTurn: this.state.turnCount,
position: { x: Math.random() * 80, y: Math.random() * 80, z: ++this.state.maxZ }
};
// Type-safe assignment
this.state.cards[token.instanceId] = token;
// Recalculate layers immediately
this.recalculateLayers();
console.log(`Created token ${definition.name} for ${playerId}`);
}
// --- Core State Machine ---
private passPriorityToNext() {
@@ -93,14 +274,24 @@ export class RulesEngine {
this.state.priorityPlayerId = this.state.turnOrder[nextIndex];
}
private moveCardToZone(cardId: string, toZone: any, faceDown = false) {
private moveCardToZone(cardId: string, toZone: any, faceDown = false, position?: { x: number, y: number }) {
const card = this.state.cards[cardId];
if (card) {
if (toZone === 'battlefield' && card.zone !== 'battlefield') {
card.controlledSinceTurn = this.state.turnCount;
}
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 };
if (position) {
card.position = { ...position, z: ++this.state.maxZ };
} else {
// Reset X position?
card.position = { x: 0, y: 0, z: ++this.state.maxZ };
}
}
}
@@ -120,7 +311,7 @@ export class RulesEngine {
);
if (isPermanent) {
this.moveCardToZone(card.instanceId, 'battlefield');
this.moveCardToZone(card.instanceId, 'battlefield', false, item.resolutionPosition);
} else {
// Instant / Sorcery
this.moveCardToZone(card.instanceId, 'graveyard');
@@ -135,6 +326,7 @@ export class RulesEngine {
private advanceStep() {
// Transition Table
const structure: Record<Phase, Step[]> = {
setup: ['mulligan'],
beginning: ['untap', 'upkeep', 'draw'],
main1: ['main'],
combat: ['beginning_combat', 'declare_attackers', 'declare_blockers', 'combat_damage', 'end_combat'],
@@ -142,7 +334,7 @@ export class RulesEngine {
ending: ['end', 'cleanup']
};
const phaseOrder: Phase[] = ['beginning', 'main1', 'combat', 'main2', 'ending'];
const phaseOrder: Phase[] = ['setup', 'beginning', 'main1', 'combat', 'main2', 'ending'];
let nextStep: Step | null = null;
let nextPhase: Phase = this.state.phase;
@@ -169,6 +361,9 @@ export class RulesEngine {
nextStep = structure[nextPhase][0];
}
// Rule 500.4: Mana empties at end of each step and phase
this.emptyManaPools();
this.state.phase = nextPhase;
this.state.step = nextStep!;
@@ -199,7 +394,30 @@ export class RulesEngine {
// --- Turn Based Actions & Triggers ---
private performTurnBasedActions() {
const { phase, step, activePlayerId } = this.state;
const { step, activePlayerId } = this.state;
// 0. Mulligan Step
if (step === 'mulligan') {
// Draw 7 for everyone if they have 0 cards in hand and haven't kept
Object.values(this.state.players).forEach(p => {
const hand = Object.values(this.state.cards).filter(c => c.ownerId === p.id && c.zone === 'hand');
if (hand.length === 0 && !p.handKept) {
// Initial Draw
for (let i = 0; i < 7; i++) {
this.drawCard(p.id);
}
}
});
// Check if all kept
const allKept = Object.values(this.state.players).every(p => p.handKept);
if (allKept) {
console.log("All players kept hand. Starting game.");
// Normally untap is automatic?
// advanceStep will go to beginning/untap
this.advanceStep();
}
return; // Wait for actions
}
// 1. Untap Step
if (step === 'untap') {
@@ -226,8 +444,73 @@ export class RulesEngine {
return;
}
// 4. Combat Steps requiring declaration (Pause for External Action)
if (step === 'declare_attackers') {
// WAITING for declareAttackers() from Client
// Do NOT reset priority yet.
// TODO: Maybe set a timeout or auto-skip if no creatures?
return;
}
if (step === 'declare_blockers') {
// WAITING for declareBlockers() from Client (Defending Player)
// Do NOT reset priority yet.
return;
}
// 5. Combat Damage Step
if (step === 'combat_damage') {
this.resolveCombatDamage();
this.resetPriority(activePlayerId);
return;
}
// Default: Reset priority to AP to start the step
this.resetPriority(activePlayerId);
// Empty Mana Pools at end of steps?
// Actually, mana empties at the END of steps/phases.
// Since we are STARTING a step here, we should have emptied prev step mana before transition.
// Let's do it in advanceStep() immediately before changing steps.
}
// --- Combat Logic ---
// --- Combat Logic ---
private resolveCombatDamage() {
console.log("Resolving Combat Damage...");
const attackers = Object.values(this.state.cards).filter(c => !!c.attacking);
for (const attacker of attackers) {
const blockers = Object.values(this.state.cards).filter(c => c.blocking?.includes(attacker.instanceId));
// 1. Assign Damage
if (blockers.length > 0) {
// Blocked
// Logically: Attacker deals damage to blockers, Blockers deal damage to attacker.
// Simple: 1v1 blocking
const blocker = blockers[0];
// Attacker -> Blocker
console.log(`${attacker.name} deals ${attacker.power} damage to ${blocker.name}`);
blocker.damageMarked = (blocker.damageMarked || 0) + attacker.power;
// Blocker -> Attacker
console.log(`${blocker.name} deals ${blocker.power} damage to ${attacker.name}`);
attacker.damageMarked = (attacker.damageMarked || 0) + blocker.power;
} else {
// Unblocked -> Player/PW
const targetId = attacker.attacking!;
const targetPlayer = this.state.players[targetId];
if (targetPlayer) {
console.log(`${attacker.name} deals ${attacker.power} damage to Player ${targetPlayer.name}`);
targetPlayer.life -= attacker.power;
}
}
}
}
private untapStep(playerId: string) {
@@ -257,6 +540,12 @@ export class RulesEngine {
private cleanupStep(playerId: string) {
// Remove damage, discard down to 7
console.log(`Cleanup execution.`);
Object.values(this.state.cards).forEach(c => {
c.damageMarked = 0;
if (c.modifiers) {
c.modifiers = c.modifiers.filter(m => !m.untilEndOfTurn);
}
});
}
// --- State Based Actions ---
@@ -293,12 +582,8 @@ export class RulesEngine {
}
// 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).`);
if (c.damageMarked >= c.toughness && !c.supertypes.includes('Indestructible')) {
console.log(`SBA: ${c.name} destroyed (Lethal Damage: ${c.damageMarked}/${c.toughness}).`);
c.zone = 'graveyard';
sbaPerformed = true;
}
@@ -306,18 +591,38 @@ export class RulesEngine {
// 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.
// 4. Aura Validity (704.5n)
Object.values(cards).forEach(c => {
if (c.zone === 'battlefield' && c.types.includes('Enchantment') && c.subtypes.includes('Aura')) {
// If not attached to anything, or attached to invalid thing (not checking validity yet, just existence)
if (!c.attachedTo) {
console.log(`SBA: ${c.name} (Aura) unattached. Destroyed.`);
c.zone = 'graveyard';
sbaPerformed = true;
} else {
const target = cards[c.attachedTo];
// If target is gone or no longer on battlefield
if (!target || target.zone !== 'battlefield') {
console.log(`SBA: ${c.name} (Aura) target invalid. Destroyed.`);
c.zone = 'graveyard';
sbaPerformed = true;
}
}
}
});
return sbaPerformed;
}
private resetPriority(playerId: string) {
// Check SBAs first (Loop until no SBAs happen)
// This method encapsulates the SBA loop and recalculation of layers
private processStateBasedActions() {
this.recalculateLayers();
let loops = 0;
while (this.checkStateBasedActions()) {
loops++;
@@ -325,10 +630,82 @@ export class RulesEngine {
console.error("Infinite SBA Loop Detected");
break;
}
this.recalculateLayers();
}
}
public resetPriority(playerId: string) {
this.processStateBasedActions();
this.state.priorityPlayerId = playerId;
this.state.passedPriorityCount = 0;
Object.values(this.state.players).forEach(p => p.hasPassed = false);
}
private emptyManaPools() {
Object.values(this.state.players).forEach(p => {
if (p.manaPool) {
p.manaPool = { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 };
}
});
}
private recalculateLayers() {
// Basic Layer System Implementation (7. Interaction of Continuous Effects)
Object.values(this.state.cards).forEach(card => {
// Only process battlefield
if (card.zone !== 'battlefield') {
card.power = card.basePower;
card.toughness = card.baseToughness;
return;
}
// Layer 7a: Characteristic-Defining Abilities (CDA) - skipped for now
let p = card.basePower;
let t = card.baseToughness;
// Layer 7b: Effects that set power and/or toughness to a specific number
// e.g. "Become 0/1"
if (card.modifiers) {
card.modifiers.filter(m => m.type === 'set_pt').forEach(mod => {
if (mod.value.power !== undefined) p = mod.value.power;
if (mod.value.toughness !== undefined) t = mod.value.toughness;
});
}
// Layer 7c: Effects that modify power and/or toughness (+X/+Y)
// e.g. Giant Growth, Anthems
if (card.modifiers) {
card.modifiers.filter(m => m.type === 'pt_boost').forEach(mod => {
p += (mod.value.power || 0);
t += (mod.value.toughness || 0);
});
}
// Layer 7d: Counters (+1/+1, -1/-1)
if (card.counters) {
card.counters.forEach(c => {
if (c.type === '+1/+1') {
p += c.count;
t += c.count;
} else if (c.type === '-1/-1') {
p -= c.count;
t -= c.count;
}
});
}
// 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;
});
}
}

View File

@@ -1,7 +1,8 @@
export type Phase = 'beginning' | 'main1' | 'combat' | 'main2' | 'ending';
export type Phase = 'setup' | 'beginning' | 'main1' | 'combat' | 'main2' | 'ending';
export type Step =
| 'mulligan' // Setup
// Beginning
| 'untap' | 'upkeep' | 'draw'
// Main
@@ -26,6 +27,7 @@ export interface CardObject {
faceDown: boolean;
attacking?: string; // Player/Planeswalker ID
blocking?: string[]; // List of attacker IDs blocked by this car
attachedTo?: string; // ID of card/player this aura/equipment is attached to
damageAssignment?: Record<string, number>; // TargetID -> Amount
// Characteristics (Base + Modified)
@@ -38,12 +40,28 @@ export interface CardObject {
toughness: number;
basePower: number;
baseToughness: number;
damageMarked: number;
// Counters & Mods
counters: { type: string; count: number }[];
keywords: string[]; // e.g. ["Haste", "Flying"]
// Continuous Effects (Layers)
modifiers: {
sourceId: string;
type: 'pt_boost' | 'set_pt' | 'ability_grant' | 'type_change';
value: any; // ({power: +3, toughness: +3} or "Flying")
untilEndOfTurn: boolean;
}[];
// Visual
imageUrl: string;
typeLine?: string;
oracleText?: string;
position?: { x: number; y: number; z: number };
// Metadata
controlledSinceTurn: number; // For Summoning Sickness check
}
export interface PlayerState {
@@ -54,6 +72,9 @@ export interface PlayerState {
energy: number;
isActive: boolean; // Is it their turn?
hasPassed: boolean; // For priority loop
handKept?: boolean; // For Mulligan phase
mulliganCount?: number;
manaPool: Record<string, number>; // { W: 0, U: 1, ... }
}
export interface StackObject {
@@ -66,6 +87,7 @@ export interface StackObject {
targets: string[];
modes?: number[]; // Selected modes
costPaid?: boolean;
resolutionPosition?: { x: number, y: number };
}
export interface StrictGameState {

View File

@@ -247,7 +247,10 @@ const draftInterval = setInterval(() => {
zone: 'library',
typeLine: card.typeLine || card.type_line || '',
oracleText: card.oracleText || card.oracle_text || '',
manaCost: card.manaCost || card.mana_cost || ''
manaCost: card.manaCost || card.mana_cost || '',
keywords: card.keywords || [],
damageMarked: 0,
controlledSinceTurn: 0
});
});
}
@@ -466,7 +469,10 @@ io.on('connection', (socket) => {
zone: 'library',
typeLine: card.typeLine || card.type_line || '',
oracleText: card.oracleText || card.oracle_text || '',
manaCost: card.manaCost || card.mana_cost || ''
manaCost: card.manaCost || card.mana_cost || '',
keywords: card.keywords || [],
damageMarked: 0,
controlledSinceTurn: 0
});
});
}
@@ -493,7 +499,10 @@ io.on('connection', (socket) => {
zone: 'library',
typeLine: card.typeLine || card.type_line || '',
oracleText: card.oracleText || card.oracle_text || '',
manaCost: card.manaCost || card.mana_cost || ''
manaCost: card.manaCost || card.mana_cost || '',
keywords: card.keywords || [],
damageMarked: 0,
controlledSinceTurn: 0
});
});
}
@@ -524,7 +533,10 @@ io.on('connection', (socket) => {
zone: 'library',
typeLine: card.typeLine || card.type_line || '',
oracleText: card.oracleText || card.oracle_text || '',
manaCost: card.manaCost || card.mana_cost || ''
manaCost: card.manaCost || card.mana_cost || '',
keywords: card.keywords || [],
damageMarked: 0,
controlledSinceTurn: 0
});
});
});

View File

@@ -17,7 +17,8 @@ export class GameManager {
poison: 0,
energy: 0,
isActive: false,
hasPassed: false
hasPassed: false,
manaPool: { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 }
};
});
@@ -34,8 +35,8 @@ export class GameManager {
activePlayerId: firstPlayerId,
priorityPlayerId: firstPlayerId,
phase: 'beginning',
step: 'untap', // Will be skipped/advanced immediately on start usually
phase: 'setup',
step: 'mulligan',
passedPriorityCount: 0,
landsPlayedThisTurn: 0,
@@ -69,10 +70,25 @@ export class GameManager {
engine.passPriority(actorId);
break;
case 'PLAY_LAND':
engine.playLand(actorId, action.cardId);
engine.playLand(actorId, action.cardId, action.position);
break;
case 'ADD_MANA':
engine.addMana(actorId, action.mana); // action.mana = { color: 'R', amount: 1 }
break;
case 'CAST_SPELL':
engine.castSpell(actorId, action.cardId, action.targets);
engine.castSpell(actorId, action.cardId, action.targets, action.position);
break;
case 'DECLARE_ATTACKERS':
engine.declareAttackers(actorId, action.attackers);
break;
case 'DECLARE_BLOCKERS':
engine.declareBlockers(actorId, action.blockers);
break;
case 'CREATE_TOKEN':
engine.createToken(actorId, action.definition);
break;
case 'MULLIGAN_DECISION':
engine.resolveMulligan(actorId, action.keep, action.cardsToBottom);
break;
// TODO: Activate Ability
default:
@@ -125,7 +141,21 @@ export class GameManager {
private tapCard(game: StrictGameState, action: any, actorId: string) {
const card = game.cards[action.cardId];
if (card && card.controllerId === actorId) {
const wuzUntapped = !card.tapped;
card.tapped = !card.tapped;
// Auto-Add Mana for Basic Lands if we just tapped it
if (wuzUntapped && card.tapped && card.typeLine?.includes('Land')) {
const engine = new RulesEngine(game); // Re-instantiate engine just for this helper
// Infer color from type or oracle text or name?
// Simple: Basic Land Types
if (card.typeLine.includes('Plains')) engine.addMana(actorId, { color: 'W', amount: 1 });
else if (card.typeLine.includes('Island')) engine.addMana(actorId, { color: 'U', amount: 1 });
else if (card.typeLine.includes('Swamp')) engine.addMana(actorId, { color: 'B', amount: 1 });
else if (card.typeLine.includes('Mountain')) engine.addMana(actorId, { color: 'R', amount: 1 });
else if (card.typeLine.includes('Forest')) engine.addMana(actorId, { color: 'G', amount: 1 });
// TODO: Non-basic lands?
}
}
}
@@ -141,6 +171,8 @@ export class GameManager {
tapped: false,
faceDown: true,
counters: [],
keywords: [], // Default empty
modifiers: [],
colors: [],
types: [],
subtypes: [],
@@ -154,7 +186,9 @@ export class GameManager {
ownerId: '',
oracleId: '',
name: '',
...cardData
...cardData,
damageMarked: 0,
controlledSinceTurn: 0 // Will be updated on draw/play
};
game.cards[card.instanceId] = card;
}

View File

@@ -106,6 +106,8 @@ export class PackGeneratorService {
finish: cardData.finish || 'normal',
oracleText: cardData.oracle_text || cardData.card_faces?.[0]?.oracle_text || '',
manaCost: cardData.mana_cost || cardData.card_faces?.[0]?.mana_cost || '',
damageMarked: 0,
controlledSinceTurn: 0
};
// Add to pools