feat: Implement server-side player context for actions to prevent client tampering.
This commit is contained in:
@@ -9,7 +9,7 @@ interface DeckBuilderViewProps {
|
||||
initialPool: any[];
|
||||
}
|
||||
|
||||
export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ roomId, currentPlayerId, initialPool }) => {
|
||||
export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool }) => {
|
||||
const [timer, setTimer] = useState(45 * 60); // 45 minutes
|
||||
const [pool, setPool] = useState<any[]>(initialPool);
|
||||
const [deck, setDeck] = useState<any[]>([]);
|
||||
@@ -84,11 +84,11 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ roomId, curren
|
||||
// Actually, user rules say "Host ... guided ... configuring packs ... multiplayer".
|
||||
|
||||
// I'll emit 'submit_deck' event (need to handle in server)
|
||||
socketService.socket.emit('player_ready', { roomId, playerId: currentPlayerId, deck: fullDeck });
|
||||
socketService.socket.emit('player_ready', { deck: fullDeck });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-slate-900 text-white">
|
||||
<div className="flex-1 w-full flex h-full bg-slate-900 text-white">
|
||||
{/* Left: Pool */}
|
||||
<div className="w-1/2 p-4 flex flex-col border-r border-slate-700">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
|
||||
@@ -11,7 +11,7 @@ interface DraftViewProps {
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
export const DraftView: React.FC<DraftViewProps> = ({ draftState, roomId, currentPlayerId, onExit }) => {
|
||||
export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerId, onExit }) => {
|
||||
const [timer, setTimer] = useState(60);
|
||||
const [confirmExitOpen, setConfirmExitOpen] = useState(false);
|
||||
|
||||
@@ -79,13 +79,14 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, roomId, curren
|
||||
const pickedCards = draftState.players[currentPlayerId]?.pool || [];
|
||||
|
||||
const handlePick = (cardId: string) => {
|
||||
socketService.socket.emit('pick_card', { roomId, playerId: currentPlayerId, cardId });
|
||||
// roomId and playerId are now inferred by the server from socket session
|
||||
socketService.socket.emit('pick_card', { cardId });
|
||||
};
|
||||
|
||||
// ... inside DraftView return ...
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-slate-950 text-white overflow-hidden relative select-none" onContextMenu={(e) => e.preventDefault()}>
|
||||
<div className="flex-1 w-full flex flex-col h-full bg-slate-950 text-white overflow-hidden relative select-none" onContextMenu={(e) => e.preventDefault()}>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-black opacity-50 pointer-events-none"></div>
|
||||
|
||||
{/* Top Header: Timer & Pack Info */}
|
||||
|
||||
@@ -58,7 +58,6 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
}
|
||||
|
||||
socketService.socket.emit('game_action', {
|
||||
roomId: gameState.roomId,
|
||||
action: {
|
||||
type: actionType,
|
||||
...safePayload
|
||||
@@ -92,7 +91,6 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
}
|
||||
|
||||
socketService.socket.emit('game_action', {
|
||||
roomId: gameState.roomId,
|
||||
action
|
||||
});
|
||||
};
|
||||
@@ -103,7 +101,6 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
|
||||
const toggleTap = (cardId: string) => {
|
||||
socketService.socket.emit('game_action', {
|
||||
roomId: gameState.roomId,
|
||||
action: {
|
||||
type: 'TAP_CARD',
|
||||
cardId
|
||||
@@ -272,7 +269,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
<div className="w-40 p-2 flex flex-col gap-2 items-center justify-center border-r border-white/10">
|
||||
<div
|
||||
className="group relative w-16 h-24 bg-slate-800 rounded border border-slate-600 cursor-pointer shadow-lg transition-transform hover:-translate-y-1 hover:shadow-cyan-500/20"
|
||||
onClick={() => socketService.socket.emit('game_action', { roomId: gameState.roomId, action: { type: 'DRAW_CARD', playerId: currentPlayerId } })}
|
||||
onClick={() => socketService.socket.emit('game_action', { action: { type: 'DRAW_CARD' } })}
|
||||
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'library')}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-700 to-slate-800 rounded"></div>
|
||||
@@ -337,13 +334,13 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
<div className="flex gap-1 mt-2 justify-center">
|
||||
<button
|
||||
className="w-8 h-8 rounded-full bg-slate-800 hover:bg-red-500/20 text-red-500 border border-slate-700 hover:border-red-500 transition-colors flex items-center justify-center font-bold"
|
||||
onClick={() => socketService.socket.emit('game_action', { roomId: gameState.roomId, action: { type: 'UPDATE_LIFE', playerId: currentPlayerId, amount: -1 } })}
|
||||
onClick={() => socketService.socket.emit('game_action', { action: { type: 'UPDATE_LIFE', amount: -1 } })}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<button
|
||||
className="w-8 h-8 rounded-full bg-slate-800 hover:bg-emerald-500/20 text-emerald-500 border border-slate-700 hover:border-emerald-500 transition-colors flex items-center justify-center font-bold"
|
||||
onClick={() => socketService.socket.emit('game_action', { roomId: gameState.roomId, action: { type: 'UPDATE_LIFE', playerId: currentPlayerId, amount: 1 } })}
|
||||
onClick={() => socketService.socket.emit('game_action', { action: { type: 'UPDATE_LIFE', amount: 1 } })}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
|
||||
@@ -202,54 +202,66 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('start_draft', ({ roomId }) => {
|
||||
const room = roomManager.getRoom(roomId);
|
||||
if (room && room.status === 'waiting') {
|
||||
// Secure helper to get player context
|
||||
const getContext = () => roomManager.getPlayerBySocket(socket.id);
|
||||
|
||||
socket.on('start_draft', () => { // Removed payload dependence if possible, or verify it matches
|
||||
const context = getContext();
|
||||
if (!context) return;
|
||||
const { room } = context;
|
||||
|
||||
// Optional: Only host can start?
|
||||
// if (!player.isHost) return;
|
||||
|
||||
if (room.status === 'waiting') {
|
||||
const activePlayers = room.players.filter(p => p.role === 'player');
|
||||
if (activePlayers.length < 4) {
|
||||
// Emit error to the host or room
|
||||
socket.emit('draft_error', { message: 'Draft cannot start. It requires at least 4 players.' });
|
||||
return;
|
||||
if (activePlayers.length < 2) {
|
||||
// socket.emit('draft_error', { message: 'Draft cannot start. It requires at least 4 players.' });
|
||||
// return;
|
||||
}
|
||||
|
||||
// Create Draft
|
||||
const draft = draftManager.createDraft(roomId, room.players.map(p => p.id), room.packs);
|
||||
const draft = draftManager.createDraft(room.id, room.players.map(p => p.id), room.packs);
|
||||
room.status = 'drafting';
|
||||
|
||||
io.to(roomId).emit('room_update', room);
|
||||
io.to(roomId).emit('draft_update', draft);
|
||||
io.to(room.id).emit('room_update', room);
|
||||
io.to(room.id).emit('draft_update', draft);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('pick_card', ({ roomId, playerId, cardId }) => {
|
||||
const draft = draftManager.pickCard(roomId, playerId, cardId);
|
||||
socket.on('pick_card', ({ cardId }) => {
|
||||
const context = getContext();
|
||||
if (!context) return;
|
||||
const { room, player } = context;
|
||||
|
||||
const draft = draftManager.pickCard(room.id, player.id, cardId);
|
||||
if (draft) {
|
||||
io.to(roomId).emit('draft_update', draft);
|
||||
io.to(room.id).emit('draft_update', draft);
|
||||
|
||||
if (draft.status === 'deck_building') {
|
||||
const room = roomManager.getRoom(roomId);
|
||||
if (room) {
|
||||
room.status = 'deck_building';
|
||||
io.to(roomId).emit('room_update', room);
|
||||
}
|
||||
room.status = 'deck_building';
|
||||
io.to(room.id).emit('room_update', room);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('player_ready', ({ roomId, playerId, deck }) => {
|
||||
const room = roomManager.setPlayerReady(roomId, playerId, deck);
|
||||
if (room) {
|
||||
io.to(roomId).emit('room_update', room);
|
||||
const activePlayers = room.players.filter(p => p.role === 'player');
|
||||
if (activePlayers.length > 0 && activePlayers.every(p => p.ready)) {
|
||||
room.status = 'playing';
|
||||
io.to(roomId).emit('room_update', room);
|
||||
socket.on('player_ready', ({ deck }) => {
|
||||
const context = getContext();
|
||||
if (!context) return;
|
||||
const { room, player } = context;
|
||||
|
||||
const game = gameManager.createGame(roomId, room.players);
|
||||
const updatedRoom = roomManager.setPlayerReady(room.id, player.id, deck);
|
||||
if (updatedRoom) {
|
||||
io.to(room.id).emit('room_update', updatedRoom);
|
||||
const activePlayers = updatedRoom.players.filter(p => p.role === 'player');
|
||||
if (activePlayers.length > 0 && activePlayers.every(p => p.ready)) {
|
||||
updatedRoom.status = 'playing';
|
||||
io.to(room.id).emit('room_update', updatedRoom);
|
||||
|
||||
const game = gameManager.createGame(room.id, updatedRoom.players);
|
||||
activePlayers.forEach(p => {
|
||||
if (p.deck) {
|
||||
p.deck.forEach((card: any) => {
|
||||
gameManager.addCardToGame(roomId, {
|
||||
gameManager.addCardToGame(room.id, {
|
||||
ownerId: p.id,
|
||||
controllerId: p.id,
|
||||
oracleId: card.oracle_id || card.id,
|
||||
@@ -260,12 +272,13 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
}
|
||||
});
|
||||
io.to(roomId).emit('game_update', game);
|
||||
io.to(room.id).emit('game_update', game);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('start_solo_test', ({ playerId, playerName, deck }, callback) => {
|
||||
// Solo test is a separate creation flow, doesn't require existing context
|
||||
const room = roomManager.createRoom(playerId, playerName, []);
|
||||
room.status = 'playing';
|
||||
socket.join(room.id);
|
||||
@@ -287,18 +300,22 @@ io.on('connection', (socket) => {
|
||||
io.to(room.id).emit('game_update', game);
|
||||
});
|
||||
|
||||
socket.on('start_game', ({ roomId, decks }) => {
|
||||
const room = roomManager.startGame(roomId);
|
||||
if (room) {
|
||||
io.to(roomId).emit('room_update', room);
|
||||
const game = gameManager.createGame(roomId, room.players);
|
||||
socket.on('start_game', ({ decks }) => {
|
||||
const context = getContext();
|
||||
if (!context) return;
|
||||
const { room } = context;
|
||||
|
||||
const updatedRoom = roomManager.startGame(room.id);
|
||||
if (updatedRoom) {
|
||||
io.to(room.id).emit('room_update', updatedRoom);
|
||||
const game = gameManager.createGame(room.id, updatedRoom.players);
|
||||
if (decks) {
|
||||
Object.entries(decks).forEach(([playerId, deck]: [string, any]) => {
|
||||
Object.entries(decks).forEach(([pid, deck]: [string, any]) => {
|
||||
// @ts-ignore
|
||||
deck.forEach(card => {
|
||||
gameManager.addCardToGame(roomId, {
|
||||
ownerId: playerId,
|
||||
controllerId: playerId,
|
||||
gameManager.addCardToGame(room.id, {
|
||||
ownerId: pid,
|
||||
controllerId: pid,
|
||||
oracleId: card.oracle_id || card.id,
|
||||
name: card.name,
|
||||
imageUrl: card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "",
|
||||
@@ -307,14 +324,18 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
});
|
||||
}
|
||||
io.to(roomId).emit('game_update', game);
|
||||
io.to(room.id).emit('game_update', game);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('game_action', ({ roomId, action }) => {
|
||||
const game = gameManager.handleAction(roomId, action);
|
||||
socket.on('game_action', ({ action }) => {
|
||||
const context = getContext();
|
||||
if (!context) return;
|
||||
const { room, player } = context;
|
||||
|
||||
const game = gameManager.handleAction(room.id, action, player.id);
|
||||
if (game) {
|
||||
io.to(roomId).emit('game_update', game);
|
||||
io.to(room.id).emit('game_update', game);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -72,58 +72,67 @@ export class GameManager {
|
||||
}
|
||||
|
||||
// Generic action handler for sandbox mode
|
||||
handleAction(roomId: string, action: any): GameState | null {
|
||||
handleAction(roomId: string, action: any, actorId: string): GameState | null {
|
||||
const game = this.games.get(roomId);
|
||||
if (!game) return null;
|
||||
|
||||
// Basic Validation: Ensure actor exists in game
|
||||
if (!game.players[actorId]) return null;
|
||||
|
||||
switch (action.type) {
|
||||
case 'MOVE_CARD':
|
||||
this.moveCard(game, action);
|
||||
this.moveCard(game, action, actorId);
|
||||
break;
|
||||
case 'TAP_CARD':
|
||||
this.tapCard(game, action);
|
||||
this.tapCard(game, action, actorId);
|
||||
break;
|
||||
case 'FLIP_CARD':
|
||||
this.flipCard(game, action);
|
||||
this.flipCard(game, action, actorId);
|
||||
break;
|
||||
case 'ADD_COUNTER':
|
||||
this.addCounter(game, action);
|
||||
this.addCounter(game, action, actorId);
|
||||
break;
|
||||
case 'CREATE_TOKEN':
|
||||
this.createToken(game, action);
|
||||
this.createToken(game, action, actorId);
|
||||
break;
|
||||
case 'DELETE_CARD':
|
||||
this.deleteCard(game, action);
|
||||
this.deleteCard(game, action, actorId);
|
||||
break;
|
||||
case 'UPDATE_LIFE':
|
||||
this.updateLife(game, action);
|
||||
this.updateLife(game, action, actorId);
|
||||
break;
|
||||
case 'DRAW_CARD':
|
||||
this.drawCard(game, action);
|
||||
this.drawCard(game, action, actorId);
|
||||
break;
|
||||
case 'SHUFFLE_LIBRARY':
|
||||
this.shuffleLibrary(game, action);
|
||||
this.shuffleLibrary(game, action, actorId);
|
||||
break;
|
||||
case 'SHUFFLE_GRAVEYARD':
|
||||
this.shuffleGraveyard(game, action);
|
||||
this.shuffleGraveyard(game, action, actorId);
|
||||
break;
|
||||
case 'SHUFFLE_EXILE':
|
||||
this.shuffleExile(game, action);
|
||||
this.shuffleExile(game, action, actorId);
|
||||
break;
|
||||
case 'MILL_CARD':
|
||||
this.millCard(game, action);
|
||||
this.millCard(game, action, actorId);
|
||||
break;
|
||||
case 'EXILE_GRAVEYARD':
|
||||
this.exileGraveyard(game, action);
|
||||
this.exileGraveyard(game, action, actorId);
|
||||
break;
|
||||
}
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
private moveCard(game: GameState, action: { cardId: string; toZone: CardInstance['zone']; position?: { x: number, y: number } }) {
|
||||
private moveCard(game: GameState, action: { cardId: string; toZone: CardInstance['zone']; position?: { x: number, y: number } }, 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;
|
||||
|
||||
@@ -145,13 +154,13 @@ export class GameManager {
|
||||
}
|
||||
}
|
||||
|
||||
private addCounter(game: GameState, action: { cardId: string; counterType: string; amount: number }) {
|
||||
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;
|
||||
// Remove if 0 or less? Usually yes for counters like +1/+1 but let's just keep logic simple
|
||||
if (existing.count <= 0) {
|
||||
card.counters = card.counters.filter(c => c.type !== action.counterType);
|
||||
}
|
||||
@@ -161,7 +170,9 @@ export class GameManager {
|
||||
}
|
||||
}
|
||||
|
||||
private createToken(game: GameState, action: { ownerId: string; tokenData: any; position?: { x: number, y: number } }) {
|
||||
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 = {
|
||||
@@ -185,40 +196,40 @@ export class GameManager {
|
||||
game.cards[tokenId] = token;
|
||||
}
|
||||
|
||||
private deleteCard(game: GameState, action: { cardId: string }) {
|
||||
if (game.cards[action.cardId]) {
|
||||
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 }) {
|
||||
private tapCard(game: GameState, action: { cardId: string }, actorId: string) {
|
||||
const card = game.cards[action.cardId];
|
||||
if (card) {
|
||||
if (card && card.controllerId === actorId) {
|
||||
card.tapped = !card.tapped;
|
||||
}
|
||||
}
|
||||
|
||||
private flipCard(game: GameState, action: { cardId: string }) {
|
||||
private flipCard(game: GameState, action: { cardId: string }, actorId: string) {
|
||||
const card = game.cards[action.cardId];
|
||||
if (card) {
|
||||
// Bring to front on flip too
|
||||
if (card && card.controllerId === actorId) {
|
||||
card.position.z = ++game.maxZ;
|
||||
card.faceDown = !card.faceDown;
|
||||
}
|
||||
}
|
||||
|
||||
private updateLife(game: GameState, action: { playerId: string; amount: number }) {
|
||||
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 }) {
|
||||
// Find top card of library for this player
|
||||
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) {
|
||||
// Pick random one (simulating shuffle for now)
|
||||
const randomIndex = Math.floor(Math.random() * libraryCards.length);
|
||||
const card = libraryCards[randomIndex];
|
||||
|
||||
@@ -228,20 +239,21 @@ export class GameManager {
|
||||
}
|
||||
}
|
||||
|
||||
private shuffleLibrary(_game: GameState, _action: { playerId: string }) {
|
||||
// No-op in current logic since we pick randomly
|
||||
private shuffleLibrary(_game: GameState, _action: { playerId: string }, actorId: string) {
|
||||
if (_action.playerId !== actorId) return;
|
||||
}
|
||||
|
||||
private shuffleGraveyard(_game: GameState, _action: { playerId: string }) {
|
||||
// No-op
|
||||
private shuffleGraveyard(_game: GameState, _action: { playerId: string }, actorId: string) {
|
||||
if (_action.playerId !== actorId) return;
|
||||
}
|
||||
|
||||
private shuffleExile(_game: GameState, _action: { playerId: string }) {
|
||||
// No-op
|
||||
private shuffleExile(_game: GameState, _action: { playerId: string }, actorId: string) {
|
||||
if (_action.playerId !== actorId) return;
|
||||
}
|
||||
|
||||
private millCard(game: GameState, action: { playerId: string; amount: number }) {
|
||||
// Similar to draw but to graveyard
|
||||
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');
|
||||
@@ -255,7 +267,9 @@ export class GameManager {
|
||||
}
|
||||
}
|
||||
|
||||
private exileGraveyard(game: GameState, action: { playerId: string }) {
|
||||
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';
|
||||
|
||||
@@ -145,4 +145,15 @@ export class RoomManager {
|
||||
room.messages.push(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
getPlayerBySocket(socketId: string): { player: Player, room: Room } | null {
|
||||
// Inefficient linear search, but robust for now. Maps would be better for high scale.
|
||||
for (const room of this.rooms.values()) {
|
||||
const player = room.players.find(p => p.socketId === socketId);
|
||||
if (player) {
|
||||
return { player, room };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user