feat: Implement game and server persistence using Redis and file storage, and add a collapsible, resizable card preview sidebar to the game view.
This commit is contained in:
334
src/server/game/RulesEngine.ts
Normal file
334
src/server/game/RulesEngine.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
|
||||
import { StrictGameState, PlayerState, Phase, Step, StackObject } from './types';
|
||||
|
||||
export class RulesEngine {
|
||||
public state: StrictGameState;
|
||||
|
||||
constructor(state: StrictGameState) {
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
// --- External Actions ---
|
||||
|
||||
public passPriority(playerId: string): boolean {
|
||||
if (this.state.priorityPlayerId !== playerId) return false; // Not your turn
|
||||
|
||||
this.state.players[playerId].hasPassed = true;
|
||||
this.state.passedPriorityCount++;
|
||||
|
||||
// Check if all players passed
|
||||
if (this.state.passedPriorityCount >= this.state.turnOrder.length) {
|
||||
if (this.state.stack.length > 0) {
|
||||
this.resolveTopStack();
|
||||
} else {
|
||||
this.advanceStep();
|
||||
}
|
||||
} else {
|
||||
this.passPriorityToNext();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public playLand(playerId: string, cardId: string): boolean {
|
||||
// 1. Check Priority
|
||||
if (this.state.priorityPlayerId !== playerId) throw new Error("Not your priority.");
|
||||
|
||||
// 2. Check Stack (Must be empty)
|
||||
if (this.state.stack.length > 0) throw new Error("Stack must be empty to play a land.");
|
||||
|
||||
// 3. Check Phase (Main Phase)
|
||||
if (this.state.phase !== 'main1' && this.state.phase !== 'main2') throw new Error("Can only play lands in Main Phase.");
|
||||
|
||||
// 4. Check Limits (1 per turn)
|
||||
if (this.state.landsPlayedThisTurn >= 1) throw new Error("Already played a land this turn.");
|
||||
|
||||
// 5. Execute
|
||||
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)
|
||||
|
||||
this.moveCardToZone(card.instanceId, 'battlefield');
|
||||
this.state.landsPlayedThisTurn++;
|
||||
|
||||
// Playing a land does NOT use the stack, but priority remains with AP?
|
||||
// 305.1... The player gets priority again.
|
||||
// Reset passing
|
||||
this.resetPriority(playerId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public castSpell(playerId: string, cardId: string, targets: string[] = []) {
|
||||
if (this.state.priorityPlayerId !== playerId) throw new Error("Not your priority.");
|
||||
|
||||
const card = this.state.cards[cardId];
|
||||
if (!card || card.zone !== 'hand') throw new Error("Invalid card.");
|
||||
|
||||
// TODO: Check Timing (Instant vs Sorcery)
|
||||
|
||||
// Move to Stack
|
||||
card.zone = 'stack';
|
||||
|
||||
this.state.stack.push({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
sourceId: cardId,
|
||||
controllerId: playerId,
|
||||
type: 'spell', // or permanent-spell
|
||||
name: card.name,
|
||||
text: "Spell Text...", // TODO: get rules text
|
||||
targets
|
||||
});
|
||||
|
||||
// Reset priority to caster (Rule 117.3c)
|
||||
this.resetPriority(playerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Core State Machine ---
|
||||
|
||||
private passPriorityToNext() {
|
||||
const currentIndex = this.state.turnOrder.indexOf(this.state.priorityPlayerId);
|
||||
const nextIndex = (currentIndex + 1) % this.state.turnOrder.length;
|
||||
this.state.priorityPlayerId = this.state.turnOrder[nextIndex];
|
||||
}
|
||||
|
||||
private moveCardToZone(cardId: string, toZone: any, faceDown = false) {
|
||||
const card = this.state.cards[cardId];
|
||||
if (card) {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
private resolveTopStack() {
|
||||
const item = this.state.stack.pop();
|
||||
if (!item) return;
|
||||
|
||||
console.log(`Resolving stack item: ${item.name}`);
|
||||
|
||||
if (item.type === 'spell') {
|
||||
const card = this.state.cards[item.sourceId];
|
||||
if (card) {
|
||||
// Check card types to determine destination
|
||||
// Assuming we have type data
|
||||
const isPermanent = card.types.some(t =>
|
||||
['Creature', 'Artifact', 'Enchantment', 'Planeswalker', 'Land'].includes(t)
|
||||
);
|
||||
|
||||
if (isPermanent) {
|
||||
this.moveCardToZone(card.instanceId, 'battlefield');
|
||||
} else {
|
||||
// Instant / Sorcery
|
||||
this.moveCardToZone(card.instanceId, 'graveyard');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After resolution, Active Player gets priority again (Rule 117.3b)
|
||||
this.resetPriority(this.state.activePlayerId);
|
||||
}
|
||||
|
||||
private advanceStep() {
|
||||
// Transition Table
|
||||
const structure: Record<Phase, Step[]> = {
|
||||
beginning: ['untap', 'upkeep', 'draw'],
|
||||
main1: ['main'],
|
||||
combat: ['beginning_combat', 'declare_attackers', 'declare_blockers', 'combat_damage', 'end_combat'],
|
||||
main2: ['main'],
|
||||
ending: ['end', 'cleanup']
|
||||
};
|
||||
|
||||
const phaseOrder: Phase[] = ['beginning', 'main1', 'combat', 'main2', 'ending'];
|
||||
|
||||
let nextStep: Step | null = null;
|
||||
let nextPhase: Phase = this.state.phase;
|
||||
|
||||
// Find current index in current phase
|
||||
const steps = structure[this.state.phase];
|
||||
const stepIdx = steps.indexOf(this.state.step);
|
||||
|
||||
if (stepIdx < steps.length - 1) {
|
||||
// Next step in same phase
|
||||
nextStep = steps[stepIdx + 1];
|
||||
} else {
|
||||
// Next phase
|
||||
const phaseIdx = phaseOrder.indexOf(this.state.phase);
|
||||
const nextPhaseIdx = (phaseIdx + 1) % phaseOrder.length;
|
||||
nextPhase = phaseOrder[nextPhaseIdx];
|
||||
|
||||
if (nextPhaseIdx === 0) {
|
||||
// Next Turn!
|
||||
this.advanceTurn();
|
||||
return; // advanceTurn handles the setup of untap
|
||||
}
|
||||
|
||||
nextStep = structure[nextPhase][0];
|
||||
}
|
||||
|
||||
this.state.phase = nextPhase;
|
||||
this.state.step = nextStep!;
|
||||
|
||||
console.log(`Advancing to ${this.state.phase} - ${this.state.step}`);
|
||||
|
||||
this.performTurnBasedActions();
|
||||
}
|
||||
|
||||
private advanceTurn() {
|
||||
this.state.turnCount++;
|
||||
|
||||
// Rotate Active Player
|
||||
const currentAPIdx = this.state.turnOrder.indexOf(this.state.activePlayerId);
|
||||
const nextAPIdx = (currentAPIdx + 1) % this.state.turnOrder.length;
|
||||
this.state.activePlayerId = this.state.turnOrder[nextAPIdx];
|
||||
|
||||
// Reset Turn State
|
||||
this.state.phase = 'beginning';
|
||||
this.state.step = 'untap';
|
||||
this.state.landsPlayedThisTurn = 0;
|
||||
|
||||
console.log(`Starting Turn ${this.state.turnCount}. Active Player: ${this.state.activePlayerId}`);
|
||||
|
||||
// Logic for new turn
|
||||
this.performTurnBasedActions();
|
||||
}
|
||||
|
||||
// --- Turn Based Actions & Triggers ---
|
||||
|
||||
private performTurnBasedActions() {
|
||||
const { phase, step, activePlayerId } = this.state;
|
||||
|
||||
// 1. Untap Step
|
||||
if (step === 'untap') {
|
||||
this.untapStep(activePlayerId);
|
||||
// Untap step has NO priority window. Proceed immediately to Upkeep.
|
||||
this.state.step = 'upkeep';
|
||||
this.resetPriority(activePlayerId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Draw Step
|
||||
if (step === 'draw') {
|
||||
if (this.state.turnCount > 1 || this.state.turnOrder.length > 2) {
|
||||
this.drawCard(activePlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Cleanup Step
|
||||
if (step === 'cleanup') {
|
||||
this.cleanupStep(activePlayerId);
|
||||
// Usually no priority in cleanup, unless triggers.
|
||||
// Assume auto-pass turn to next Untap.
|
||||
this.advanceTurn();
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: Reset priority to AP to start the step
|
||||
this.resetPriority(activePlayerId);
|
||||
}
|
||||
|
||||
private untapStep(playerId: string) {
|
||||
// Untap all perms controller by player
|
||||
Object.values(this.state.cards).forEach(card => {
|
||||
if (card.controllerId === playerId && card.zone === 'battlefield') {
|
||||
card.tapped = false;
|
||||
// Also summon sickness logic if we tracked it
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private 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?)
|
||||
// Assuming library is shuffled, pick random
|
||||
const card = library[Math.floor(Math.random() * library.length)];
|
||||
this.moveCardToZone(card.instanceId, 'hand');
|
||||
console.log(`Player ${playerId} draws ${card.name}`);
|
||||
} else {
|
||||
// Empty library loss?
|
||||
console.log(`Player ${playerId} attempts to draw from empty library.`);
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupStep(playerId: string) {
|
||||
// Remove damage, discard down to 7
|
||||
console.log(`Cleanup execution.`);
|
||||
}
|
||||
|
||||
// --- State Based Actions ---
|
||||
|
||||
private checkStateBasedActions(): boolean {
|
||||
let sbaPerformed = false;
|
||||
const { players, cards } = this.state;
|
||||
|
||||
// 1. Player Loss
|
||||
for (const pid of Object.keys(players)) {
|
||||
const p = players[pid];
|
||||
if (p.life <= 0 || p.poison >= 10) {
|
||||
// Player loses
|
||||
// In multiplayer, they leave the game.
|
||||
// Simple implementation: Mark as lost/inactive
|
||||
if (p.isActive) { // only process once
|
||||
console.log(`Player ${p.name} loses the game.`);
|
||||
// TODO: Remove all their cards, etc.
|
||||
// For now just log.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Creature Death (Zero Toughness or Lethal Damage)
|
||||
const creatures = Object.values(cards).filter(c => c.zone === 'battlefield' && c.types.includes('Creature'));
|
||||
|
||||
for (const c of creatures) {
|
||||
// 704.5f Toughness 0 or less
|
||||
if (c.toughness <= 0) {
|
||||
console.log(`SBA: ${c.name} put to GY (Zero Toughness).`);
|
||||
c.zone = 'graveyard';
|
||||
sbaPerformed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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).`);
|
||||
c.zone = 'graveyard';
|
||||
sbaPerformed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
return sbaPerformed;
|
||||
}
|
||||
|
||||
private resetPriority(playerId: string) {
|
||||
// Check SBAs first (Loop until no SBAs happen)
|
||||
let loops = 0;
|
||||
while (this.checkStateBasedActions()) {
|
||||
loops++;
|
||||
if (loops > 100) {
|
||||
console.error("Infinite SBA Loop Detected");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.state.priorityPlayerId = playerId;
|
||||
this.state.passedPriorityCount = 0;
|
||||
Object.values(this.state.players).forEach(p => p.hasPassed = false);
|
||||
}
|
||||
}
|
||||
91
src/server/game/types.ts
Normal file
91
src/server/game/types.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
|
||||
export type Phase = 'beginning' | 'main1' | 'combat' | 'main2' | 'ending';
|
||||
|
||||
export type Step =
|
||||
// Beginning
|
||||
| 'untap' | 'upkeep' | 'draw'
|
||||
// Main
|
||||
| 'main'
|
||||
// Combat
|
||||
| 'beginning_combat' | 'declare_attackers' | 'declare_blockers' | 'combat_damage' | 'end_combat'
|
||||
// Ending
|
||||
| 'end' | 'cleanup';
|
||||
|
||||
export type Zone = 'library' | 'hand' | 'battlefield' | 'graveyard' | 'stack' | 'exile' | 'command';
|
||||
|
||||
export interface CardObject {
|
||||
instanceId: string;
|
||||
oracleId: string;
|
||||
name: string;
|
||||
controllerId: string;
|
||||
ownerId: string;
|
||||
zone: Zone;
|
||||
|
||||
// State
|
||||
tapped: boolean;
|
||||
faceDown: boolean;
|
||||
attacking?: string; // Player/Planeswalker ID
|
||||
blocking?: string[]; // List of attacker IDs blocked by this car
|
||||
damageAssignment?: Record<string, number>; // TargetID -> Amount
|
||||
|
||||
// Characteristics (Base + Modified)
|
||||
manaCost?: string;
|
||||
colors: string[];
|
||||
types: string[];
|
||||
subtypes: string[];
|
||||
supertypes: string[];
|
||||
power: number;
|
||||
toughness: number;
|
||||
basePower: number;
|
||||
baseToughness: number;
|
||||
|
||||
// Counters & Mods
|
||||
counters: { type: string; count: number }[];
|
||||
|
||||
// Visual
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
id: string;
|
||||
name: string;
|
||||
life: number;
|
||||
poison: number;
|
||||
energy: number;
|
||||
isActive: boolean; // Is it their turn?
|
||||
hasPassed: boolean; // For priority loop
|
||||
}
|
||||
|
||||
export interface StackObject {
|
||||
id: string;
|
||||
sourceId: string; // The card/permanent that generated this
|
||||
controllerId: string;
|
||||
type: 'spell' | 'ability' | 'trigger';
|
||||
name: string;
|
||||
text: string;
|
||||
targets: string[];
|
||||
modes?: number[]; // Selected modes
|
||||
costPaid?: boolean;
|
||||
}
|
||||
|
||||
export interface StrictGameState {
|
||||
roomId: string;
|
||||
players: Record<string, PlayerState>;
|
||||
cards: Record<string, CardObject>;
|
||||
stack: StackObject[];
|
||||
|
||||
// Turn State
|
||||
turnCount: number;
|
||||
activePlayerId: string; // Whose turn is it
|
||||
priorityPlayerId: string; // Who can act NOW
|
||||
turnOrder: string[];
|
||||
|
||||
phase: Phase;
|
||||
step: Step;
|
||||
|
||||
// Rules State
|
||||
passedPriorityCount: number; // 0..N. If N, advance.
|
||||
landsPlayedThisTurn: number;
|
||||
|
||||
maxZ: number; // Visual depth (legacy support)
|
||||
}
|
||||
@@ -544,6 +544,17 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('game_strict_action', ({ action }) => {
|
||||
const context = getContext();
|
||||
if (!context) return;
|
||||
const { room, player } = context;
|
||||
|
||||
const game = gameManager.handleStrictAction(room.id, action, player.id);
|
||||
if (game) {
|
||||
io.to(room.id).emit('game_update', game);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('User disconnected', socket.id);
|
||||
|
||||
|
||||
@@ -1,85 +1,100 @@
|
||||
|
||||
interface CardInstance {
|
||||
instanceId: string;
|
||||
oracleId: string; // Scryfall ID
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
controllerId: string;
|
||||
ownerId: string;
|
||||
zone: 'library' | 'hand' | 'battlefield' | 'graveyard' | 'exile' | 'command';
|
||||
tapped: boolean;
|
||||
faceDown: boolean;
|
||||
position: { x: number; y: number; z: number }; // For freeform placement
|
||||
counters: { type: string; count: number }[];
|
||||
ptModification: { power: number; toughness: number };
|
||||
typeLine?: string;
|
||||
oracleText?: string;
|
||||
manaCost?: string;
|
||||
}
|
||||
|
||||
interface PlayerState {
|
||||
id: string;
|
||||
name: string;
|
||||
life: number;
|
||||
poison: number;
|
||||
energy: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface GameState {
|
||||
roomId: string;
|
||||
players: Record<string, PlayerState>;
|
||||
cards: Record<string, CardInstance>; // Keyed by instanceId
|
||||
order: string[]; // Turn order (player IDs)
|
||||
turn: number;
|
||||
phase: string;
|
||||
maxZ: number; // Tracker for depth sorting
|
||||
}
|
||||
import { StrictGameState, PlayerState, CardObject } from '../game/types';
|
||||
import { RulesEngine } from '../game/RulesEngine';
|
||||
|
||||
export class GameManager {
|
||||
private games: Map<string, GameState> = new Map();
|
||||
public games: Map<string, StrictGameState> = new Map();
|
||||
|
||||
createGame(roomId: string, players: { id: string; name: string }[]): GameState {
|
||||
const gameState: GameState = {
|
||||
roomId,
|
||||
players: {},
|
||||
cards: {},
|
||||
order: players.map(p => p.id),
|
||||
turn: 1,
|
||||
phase: 'beginning',
|
||||
maxZ: 100,
|
||||
};
|
||||
createGame(roomId: string, players: { id: string; name: string }[]): StrictGameState {
|
||||
|
||||
// Convert array to map
|
||||
const playerRecord: Record<string, PlayerState> = {};
|
||||
players.forEach(p => {
|
||||
gameState.players[p.id] = {
|
||||
playerRecord[p.id] = {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
life: 20,
|
||||
poison: 0,
|
||||
energy: 0,
|
||||
isActive: false
|
||||
isActive: false,
|
||||
hasPassed: false
|
||||
};
|
||||
});
|
||||
|
||||
// Set first player active
|
||||
if (gameState.order.length > 0) {
|
||||
gameState.players[gameState.order[0]].isActive = true;
|
||||
const firstPlayerId = players.length > 0 ? players[0].id : '';
|
||||
|
||||
const gameState: StrictGameState = {
|
||||
roomId,
|
||||
players: playerRecord,
|
||||
cards: {}, // Populated later
|
||||
stack: [],
|
||||
|
||||
turnCount: 1,
|
||||
turnOrder: players.map(p => p.id),
|
||||
activePlayerId: firstPlayerId,
|
||||
priorityPlayerId: firstPlayerId,
|
||||
|
||||
phase: 'beginning',
|
||||
step: 'untap', // Will be skipped/advanced immediately on start usually
|
||||
|
||||
passedPriorityCount: 0,
|
||||
landsPlayedThisTurn: 0,
|
||||
|
||||
maxZ: 100
|
||||
};
|
||||
|
||||
// Set First Player Active status
|
||||
if (gameState.players[firstPlayerId]) {
|
||||
gameState.players[firstPlayerId].isActive = true;
|
||||
}
|
||||
|
||||
this.games.set(roomId, gameState);
|
||||
return gameState;
|
||||
}
|
||||
|
||||
getGame(roomId: string): GameState | undefined {
|
||||
getGame(roomId: string): StrictGameState | undefined {
|
||||
return this.games.get(roomId);
|
||||
}
|
||||
|
||||
// Generic action handler for sandbox mode
|
||||
handleAction(roomId: string, action: any, actorId: string): GameState | null {
|
||||
// --- Strict Rules Action Handler ---
|
||||
handleStrictAction(roomId: string, action: any, actorId: string): StrictGameState | null {
|
||||
const game = this.games.get(roomId);
|
||||
if (!game) return null;
|
||||
|
||||
// Basic Validation: Ensure actor exists in game
|
||||
const engine = new RulesEngine(game);
|
||||
|
||||
try {
|
||||
switch (action.type) {
|
||||
case 'PASS_PRIORITY':
|
||||
engine.passPriority(actorId);
|
||||
break;
|
||||
case 'PLAY_LAND':
|
||||
engine.playLand(actorId, action.cardId);
|
||||
break;
|
||||
case 'CAST_SPELL':
|
||||
engine.castSpell(actorId, action.cardId, action.targets);
|
||||
break;
|
||||
// TODO: Activate Ability
|
||||
default:
|
||||
console.warn(`Unknown strict action: ${action.type}`);
|
||||
return null;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(`Rule Violation [${action.type}]: ${e.message}`);
|
||||
// TODO: Return error to user?
|
||||
// For now, just logging and not updating state (transactional-ish)
|
||||
return null;
|
||||
}
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
// --- Legacy Sandbox Action Handler (for Admin/Testing) ---
|
||||
handleAction(roomId: string, action: any, actorId: string): StrictGameState | null {
|
||||
const game = this.games.get(roomId);
|
||||
if (!game) return null;
|
||||
|
||||
// Basic Validation: Ensure actor exists in game (or is host/admin?)
|
||||
if (!game.players[actorId]) return null;
|
||||
|
||||
switch (action.type) {
|
||||
@@ -89,211 +104,56 @@ export class GameManager {
|
||||
case 'TAP_CARD':
|
||||
this.tapCard(game, action, actorId);
|
||||
break;
|
||||
case 'FLIP_CARD':
|
||||
this.flipCard(game, action, actorId);
|
||||
break;
|
||||
case 'ADD_COUNTER':
|
||||
this.addCounter(game, action, actorId);
|
||||
break;
|
||||
case 'CREATE_TOKEN':
|
||||
this.createToken(game, action, actorId);
|
||||
break;
|
||||
case 'DELETE_CARD':
|
||||
this.deleteCard(game, action, actorId);
|
||||
break;
|
||||
case 'UPDATE_LIFE':
|
||||
this.updateLife(game, action, actorId);
|
||||
break;
|
||||
case 'DRAW_CARD':
|
||||
this.drawCard(game, action, actorId);
|
||||
break;
|
||||
case 'SHUFFLE_LIBRARY':
|
||||
this.shuffleLibrary(game, action, actorId);
|
||||
break;
|
||||
case 'SHUFFLE_GRAVEYARD':
|
||||
this.shuffleGraveyard(game, action, actorId);
|
||||
break;
|
||||
case 'SHUFFLE_EXILE':
|
||||
this.shuffleExile(game, action, actorId);
|
||||
break;
|
||||
case 'MILL_CARD':
|
||||
this.millCard(game, action, actorId);
|
||||
break;
|
||||
case 'EXILE_GRAVEYARD':
|
||||
this.exileGraveyard(game, action, actorId);
|
||||
break;
|
||||
// ... (Other cases can be ported if needed)
|
||||
}
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
private moveCard(game: GameState, action: { cardId: string; toZone: CardInstance['zone']; position?: { x: number, y: number } }, actorId: string) {
|
||||
// ... Legacy methods refactored to use StrictGameState types ...
|
||||
|
||||
private moveCard(game: StrictGameState, action: any, actorId: string) {
|
||||
const card = game.cards[action.cardId];
|
||||
if (card) {
|
||||
// ANTI-TAMPER: Only controller can move card
|
||||
if (card.controllerId !== actorId) {
|
||||
console.warn(`Anti-Tamper: Player ${actorId} tried to move card ${card.instanceId} controlled by ${card.controllerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bring to front
|
||||
card.position.z = ++game.maxZ;
|
||||
|
||||
if (card.controllerId !== actorId) return;
|
||||
// @ts-ignore
|
||||
card.position = { x: 0, y: 0, z: ++game.maxZ, ...action.position }; // type hack relative to legacy visual pos
|
||||
card.zone = action.toZone;
|
||||
if (action.position) {
|
||||
card.position = { ...card.position, ...action.position };
|
||||
}
|
||||
|
||||
// Auto-untap and reveal if moving to public zones (optional, but helpful default)
|
||||
if (['hand', 'graveyard', 'exile'].includes(action.toZone)) {
|
||||
card.tapped = false;
|
||||
card.faceDown = false;
|
||||
}
|
||||
// Library is usually face down
|
||||
if (action.toZone === 'library') {
|
||||
card.faceDown = true;
|
||||
card.tapped = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addCounter(game: GameState, action: { cardId: string; counterType: string; amount: number }, actorId: string) {
|
||||
const card = game.cards[action.cardId];
|
||||
if (card) {
|
||||
if (card.controllerId !== actorId) return; // Anti-tamper
|
||||
const existing = card.counters.find(c => c.type === action.counterType);
|
||||
if (existing) {
|
||||
existing.count += action.amount;
|
||||
if (existing.count <= 0) {
|
||||
card.counters = card.counters.filter(c => c.type !== action.counterType);
|
||||
}
|
||||
} else if (action.amount > 0) {
|
||||
card.counters.push({ type: action.counterType, count: action.amount });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createToken(game: GameState, action: { ownerId: string; tokenData: any; position?: { x: number, y: number } }, actorId: string) {
|
||||
if (action.ownerId !== actorId) return; // Anti-tamper
|
||||
|
||||
const tokenId = `token-${Math.random().toString(36).substring(7)}`;
|
||||
// @ts-ignore
|
||||
const token: CardInstance = {
|
||||
instanceId: tokenId,
|
||||
oracleId: 'token',
|
||||
name: action.tokenData.name || 'Token',
|
||||
imageUrl: action.tokenData.imageUrl || 'https://cards.scryfall.io/large/front/5/f/5f75e883-2574-4b9e-8fcb-5db3d9579fae.jpg?1692233606', // Generic token image
|
||||
controllerId: action.ownerId,
|
||||
ownerId: action.ownerId,
|
||||
zone: 'battlefield',
|
||||
tapped: false,
|
||||
faceDown: false,
|
||||
position: {
|
||||
x: action.position?.x || 50,
|
||||
y: action.position?.y || 50,
|
||||
z: ++game.maxZ
|
||||
},
|
||||
counters: [],
|
||||
ptModification: { power: action.tokenData.power || 0, toughness: action.tokenData.toughness || 0 }
|
||||
};
|
||||
game.cards[tokenId] = token;
|
||||
}
|
||||
|
||||
private deleteCard(game: GameState, action: { cardId: string }, actorId: string) {
|
||||
if (game.cards[action.cardId] && game.cards[action.cardId].controllerId === actorId) {
|
||||
delete game.cards[action.cardId];
|
||||
}
|
||||
}
|
||||
|
||||
private tapCard(game: GameState, action: { cardId: string }, actorId: string) {
|
||||
private tapCard(game: StrictGameState, action: any, actorId: string) {
|
||||
const card = game.cards[action.cardId];
|
||||
if (card && card.controllerId === actorId) {
|
||||
card.tapped = !card.tapped;
|
||||
}
|
||||
}
|
||||
|
||||
private flipCard(game: GameState, action: { cardId: string }, actorId: string) {
|
||||
const card = game.cards[action.cardId];
|
||||
if (card && card.controllerId === actorId) {
|
||||
card.position.z = ++game.maxZ;
|
||||
card.faceDown = !card.faceDown;
|
||||
}
|
||||
}
|
||||
|
||||
private updateLife(game: GameState, action: { playerId: string; amount: number }, actorId: string) {
|
||||
if (action.playerId !== actorId) return; // Anti-tamper
|
||||
const player = game.players[action.playerId];
|
||||
if (player) {
|
||||
player.life += action.amount;
|
||||
}
|
||||
}
|
||||
|
||||
private drawCard(game: GameState, action: { playerId: string }, actorId: string) {
|
||||
if (action.playerId !== actorId) return; // Anti-tamper
|
||||
|
||||
const libraryCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'library');
|
||||
if (libraryCards.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * libraryCards.length);
|
||||
const card = libraryCards[randomIndex];
|
||||
|
||||
card.zone = 'hand';
|
||||
card.faceDown = false;
|
||||
card.position.z = ++game.maxZ;
|
||||
}
|
||||
}
|
||||
|
||||
private shuffleLibrary(_game: GameState, _action: { playerId: string }, actorId: string) {
|
||||
if (_action.playerId !== actorId) return;
|
||||
}
|
||||
|
||||
private shuffleGraveyard(_game: GameState, _action: { playerId: string }, actorId: string) {
|
||||
if (_action.playerId !== actorId) return;
|
||||
}
|
||||
|
||||
private shuffleExile(_game: GameState, _action: { playerId: string }, actorId: string) {
|
||||
if (_action.playerId !== actorId) return;
|
||||
}
|
||||
|
||||
private millCard(game: GameState, action: { playerId: string; amount: number }, actorId: string) {
|
||||
if (action.playerId !== actorId) return;
|
||||
|
||||
const amount = action.amount || 1;
|
||||
for (let i = 0; i < amount; i++) {
|
||||
const libraryCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'library');
|
||||
if (libraryCards.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * libraryCards.length);
|
||||
const card = libraryCards[randomIndex];
|
||||
card.zone = 'graveyard';
|
||||
card.faceDown = false;
|
||||
card.position.z = ++game.maxZ;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private exileGraveyard(game: GameState, action: { playerId: string }, actorId: string) {
|
||||
if (action.playerId !== actorId) return;
|
||||
|
||||
const graveyardCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'graveyard');
|
||||
graveyardCards.forEach(card => {
|
||||
card.zone = 'exile';
|
||||
card.position.z = ++game.maxZ;
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to add cards (e.g. at game start)
|
||||
addCardToGame(roomId: string, cardData: Partial<CardInstance>) {
|
||||
addCardToGame(roomId: string, cardData: Partial<CardObject>) {
|
||||
const game = this.games.get(roomId);
|
||||
if (!game) return;
|
||||
|
||||
// @ts-ignore
|
||||
const card: CardInstance = {
|
||||
// @ts-ignore - aligning types roughly
|
||||
const card: CardObject = {
|
||||
instanceId: cardData.instanceId || Math.random().toString(36).substring(7),
|
||||
zone: 'library',
|
||||
tapped: false,
|
||||
faceDown: true,
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
counters: [],
|
||||
ptModification: { power: 0, toughness: 0 },
|
||||
colors: [],
|
||||
types: [],
|
||||
subtypes: [],
|
||||
supertypes: [],
|
||||
power: 0,
|
||||
toughness: 0,
|
||||
basePower: 0,
|
||||
baseToughness: 0,
|
||||
imageUrl: '',
|
||||
controllerId: '',
|
||||
ownerId: '',
|
||||
oracleId: '',
|
||||
name: '',
|
||||
...cardData
|
||||
};
|
||||
game.cards[card.instanceId] = card;
|
||||
|
||||
Reference in New Issue
Block a user