feat: Implement server-side player context for actions to prevent client tampering.
This commit is contained in:
@@ -17,3 +17,5 @@
|
||||
- [Resizable Draft Interface](./devlog/2025-12-16-200500_resizable_draft_ui.md): Completed. Implemented user-resizable pool panel and card sizes with persistence.
|
||||
- [Draft UI Zoom Zone](./devlog/2025-12-16-203000_zoom_zone.md): Completed. Implemented dedicated zoom zone for card preview.
|
||||
- [Host Disconnect Pause](./devlog/2025-12-16-213500_host_disconnect_pause.md): Completed. Specific logic to pause game when host leaves.
|
||||
- [Anti-Tampering System](./devlog/2025-12-16-215000_anti_tampering.md): Completed. Robust server-side validation using socket session binding and ownership checks.
|
||||
- [Fix Draft UI Layout](./devlog/2025-12-16-215500_fix_draft_ui_layout.md): Completed. Fixed "Waiting for next pack" layout to be consistently full-screen.
|
||||
|
||||
26
docs/development/devlog/2025-12-16-215000_anti_tampering.md
Normal file
26
docs/development/devlog/2025-12-16-215000_anti_tampering.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Anti-Tampering Implementation
|
||||
|
||||
## Objective
|
||||
Implement a robust anti-tampering system to prevent players (including the host) from manipulating the game state via malicious client-side emissions.
|
||||
|
||||
## Changes
|
||||
1. **Server (`src/server/managers/RoomManager.ts`)**:
|
||||
* Added `getPlayerBySocket(socketId)` to securely identify the player associated with a connection, eliminating reliance on client-provided IDs.
|
||||
|
||||
2. **Server (`src/server/index.ts`)**:
|
||||
* Refactored all major socket event listeners (`pick_card`, `game_action`, `start_draft`, `player_ready`) to use `roomManager.getPlayerBySocket(socket.id)`.
|
||||
* The server now ignores `playerId` and `roomId` sent in the payload (where applicable) and uses the trusted session context instead.
|
||||
* This ensures that a user can only perform actions for *themselves* in the room they are *actually connected to*.
|
||||
|
||||
3. **Server (`src/server/managers/GameManager.ts`)**:
|
||||
* Updated `handleAction` to accept an authentic `actorId`.
|
||||
* Added ownership/controller checks to sensitive actions:
|
||||
* `moveCard`: Only the controller can move a card.
|
||||
* `updateLife`: Only the player can update their own life.
|
||||
* `drawCard`, `createToken`, etc.: Validated against `actorId`.
|
||||
|
||||
4. **Frontend (`GameView.tsx`, `DraftView.tsx`, `DeckBuilderView.tsx`)**:
|
||||
* Cleaned up socket emissions to stop sending redundant `roomId` and `playerId` fields, aligning client behavior with the new secure server expectations (though server would safely ignore them anyway).
|
||||
|
||||
## Result
|
||||
The system is now significantly more resistant to session hijacking or spoofing. Users cannot act as other players or manipulate game state objects they do not control, even if they manually emit socket events from the console.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Fix Draft UI Layout Consistency
|
||||
|
||||
## Objective
|
||||
Fix the layout inconsistency where the "Waiting for next pack..." screen and other views in the Draft interface do not fully occupy the screen width, causing the UI to look collapsed or disconnected from the global sidebars.
|
||||
|
||||
## Changes
|
||||
1. **DraftView.tsx**: Added `flex-1` and `w-full` to the root container. This ensures the component expands to fill the available space in the `GameRoom` flex container, maintaining the full-screen layout even when content (like the "waiting" message) is minimal.
|
||||
2. **DeckBuilderView.tsx**: Added `flex-1` and `w-full` to the root container for consistency and to ensure the deck builder also behaves correctly within the main layout.
|
||||
|
||||
## Verification
|
||||
- The `DraftView` should now stretch to fill the area between the left edge (or internal Zoom sidebar) and the right Lobby/Chat sidebar in `GameRoom`.
|
||||
- The "Waiting for next pack..." message will remain centered within this full-height, full-width area, with the background gradient covering the entire zone.
|
||||
@@ -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