feat: Implement draft and game phases with client views, dedicated managers, and server-side card image caching.

This commit is contained in:
2025-12-14 22:23:23 +01:00
parent a2a8b33368
commit 9ff305f1ba
18 changed files with 1289 additions and 18 deletions

View File

@@ -0,0 +1,166 @@
import { EventEmitter } from 'events';
interface Card {
id: string; // instanceid or scryfall id
name: string;
image_uris?: { normal: string };
card_faces?: { image_uris: { normal: string } }[];
// ... other props
}
interface Pack {
id: string;
cards: Card[];
}
interface DraftState {
roomId: string;
seats: string[]; // PlayerIDs in seating order
packNumber: number; // 1, 2, 3
// State per player
players: Record<string, {
id: string;
queue: Pack[]; // Packs passed to this player waiting to be viewed
activePack: Pack | null; // The pack currently being looked at
pool: Card[]; // Picked cards
unopenedPacks: Pack[]; // Pack 2 and 3 kept aside
isWaiting: boolean; // True if finished current pack round
}>;
status: 'drafting' | 'deck_building' | 'complete';
startTime?: number; // For timer
}
export class DraftManager extends EventEmitter {
private drafts: Map<string, DraftState> = new Map();
createDraft(roomId: string, players: string[], allPacks: Pack[]): DraftState {
// Distribute 3 packs to each player
const numPlayers = players.length;
// Assume allPacks contains (3 * numPlayers) packs
// Shuffle packs just in case (optional, but good practice)
const shuffledPacks = [...allPacks].sort(() => Math.random() - 0.5);
const draftState: DraftState = {
roomId,
seats: players, // Assume order is randomized or fixed
packNumber: 1,
players: {},
status: 'drafting',
startTime: Date.now()
};
players.forEach((pid, index) => {
const playerPacks = shuffledPacks.slice(index * 3, (index + 1) * 3);
const firstPack = playerPacks.shift(); // Open Pack 1 immediately
draftState.players[pid] = {
id: pid,
queue: [],
activePack: firstPack || null,
pool: [],
unopenedPacks: playerPacks,
isWaiting: false
};
});
this.drafts.set(roomId, draftState);
return draftState;
}
getDraft(roomId: string): DraftState | undefined {
return this.drafts.get(roomId);
}
pickCard(roomId: string, playerId: string, cardId: string): DraftState | null {
const draft = this.drafts.get(roomId);
if (!draft) return null;
const playerState = draft.players[playerId];
if (!playerState || !playerState.activePack) return null;
// Find card
const cardIndex = playerState.activePack.cards.findIndex(c => c.id === cardId || (c as any).uniqueId === cardId);
// uniqueId check implies if cards have unique instance IDs in pack, if not we rely on strict equality or assume 1 instance per pack
// Fallback: If we can't find by ID (if Scryfall ID generic), just pick the first matching ID?
// We should ideally assume the frontend sends the exact card object or unique index.
// For now assuming cardId is unique enough or we pick first match.
// Better: In a draft, a pack might have 2 duplicates. We need index or unique ID.
// Let's assume the pack generation gave unique IDs or we just pick by index.
// I'll stick to ID for now, assuming unique.
const card = playerState.activePack.cards.find(c => c.id === cardId);
if (!card) return null;
// 1. Add to pool
playerState.pool.push(card);
// 2. Remove from pack
playerState.activePack.cards = playerState.activePack.cards.filter(c => c !== card);
const passedPack = playerState.activePack;
playerState.activePack = null;
// 3. Logic for Passing or Discarding (End of Pack)
if (passedPack.cards.length > 0) {
// Pass to neighbor
const seatIndex = draft.seats.indexOf(playerId);
let nextSeatIndex;
// Pack 1: Left (Increase Index), Pack 2: Right (Decrease), Pack 3: Left
if (draft.packNumber === 2) {
nextSeatIndex = (seatIndex - 1 + draft.seats.length) % draft.seats.length;
} else {
nextSeatIndex = (seatIndex + 1) % draft.seats.length;
}
const neighborId = draft.seats[nextSeatIndex];
draft.players[neighborId].queue.push(passedPack);
// Try to assign active pack for neighbor if they are empty
this.processQueue(draft, neighborId);
} else {
// Pack is empty/exhausted
playerState.isWaiting = true;
this.checkRoundCompletion(draft);
}
// 4. Try to assign new active pack for self from queue
this.processQueue(draft, playerId);
return draft;
}
private processQueue(draft: DraftState, playerId: string) {
const p = draft.players[playerId];
if (!p.activePack && p.queue.length > 0) {
p.activePack = p.queue.shift()!;
}
}
private checkRoundCompletion(draft: DraftState) {
const allWaiting = Object.values(draft.players).every(p => p.isWaiting);
if (allWaiting) {
// Start Next Round
if (draft.packNumber < 3) {
draft.packNumber++;
// Open next pack for everyone
Object.values(draft.players).forEach(p => {
p.isWaiting = false;
const nextPack = p.unopenedPacks.shift();
if (nextPack) {
p.activePack = nextPack;
}
});
} else {
// Draft Complete
draft.status = 'deck_building';
draft.startTime = Date.now(); // Start deck building timer
}
}
}
}

View File

@@ -0,0 +1,165 @@
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 };
}
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;
}
export class GameManager {
private games: Map<string, GameState> = 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',
};
players.forEach(p => {
gameState.players[p.id] = {
id: p.id,
name: p.name,
life: 20,
poison: 0,
energy: 0,
isActive: false
};
});
// Set first player active
if (gameState.order.length > 0) {
gameState.players[gameState.order[0]].isActive = true;
}
// TODO: Load decks here. For now, we start with empty board/library.
this.games.set(roomId, gameState);
return gameState;
}
getGame(roomId: string): GameState | undefined {
return this.games.get(roomId);
}
// Generic action handler for sandbox mode
handleAction(roomId: string, action: any): GameState | null {
const game = this.games.get(roomId);
if (!game) return null;
switch (action.type) {
case 'MOVE_CARD':
this.moveCard(game, action);
break;
case 'TAP_CARD':
this.tapCard(game, action);
break;
case 'UPDATE_LIFE':
this.updateLife(game, action);
break;
case 'DRAW_CARD':
this.drawCard(game, action);
break;
case 'SHUFFLE_LIBRARY':
this.shuffleLibrary(game, action); // Placeholder logic
break;
}
return game;
}
private moveCard(game: GameState, action: { cardId: string; toZone: CardInstance['zone']; position?: { x: number, y: number } }) {
const card = game.cards[action.cardId];
if (card) {
card.zone = action.toZone;
if (action.position) {
card.position = { ...card.position, ...action.position };
}
// Reset tapped state if moving to hand/library/graveyard?
if (['hand', 'library', 'graveyard', 'exile'].includes(action.toZone)) {
card.tapped = false;
card.faceDown = action.toZone === 'library';
}
}
}
private tapCard(game: GameState, action: { cardId: string }) {
const card = game.cards[action.cardId];
if (card) {
card.tapped = !card.tapped;
}
}
private updateLife(game: GameState, action: { playerId: string; amount: number }) {
const player = game.players[action.playerId];
if (player) {
player.life += action.amount;
}
}
private drawCard(game: GameState, action: { playerId: string }) {
// Find top card of library for this player
const libraryCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'library');
if (libraryCards.length > 0) {
// In a real implementation this should be ordered.
// For now, just pick one (random or first).
const card = libraryCards[0];
card.zone = 'hand';
card.faceDown = false;
}
}
private shuffleLibrary(game: GameState, action: { playerId: string }) {
// In a real implementation we would shuffle the order array.
// Since we retrieve by filtering currently, we don't have order.
// We need to implement order index if we want shuffling.
}
// Helper to add cards (e.g. at game start)
addCardToGame(roomId: string, cardData: Partial<CardInstance>) {
const game = this.games.get(roomId);
if (!game) return;
// @ts-ignore
const card: CardInstance = {
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 },
...cardData
};
game.cards[card.instanceId] = card;
}
}

View File

@@ -17,7 +17,7 @@ interface Room {
hostId: string;
players: Player[];
packs: any[]; // Store generated packs (JSON)
status: 'waiting' | 'drafting' | 'finished';
status: 'waiting' | 'drafting' | 'deck_building' | 'finished';
messages: ChatMessage[];
maxPlayers: number;
}