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:
@@ -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