feat: Enhance draft system with 4-player 'pick 2' rules, minimum player count, and fix pack duplication by ensuring unique pack instances.
All checks were successful
Build and Deploy / build (push) Successful in 1m25s

This commit is contained in:
2025-12-16 18:41:43 +01:00
parent 58641b34a5
commit 6163869a17
6 changed files with 90 additions and 5 deletions

View File

@@ -10,3 +10,5 @@
- [Helm Chart Config](./devlog/2025-12-14-214500_helm_config.md): Completed.
- [CSV Import Robustness](./devlog/2025-12-16-152253_csv_import_robustness.md): Completed. Enhanced CSV parser to dynamically map columns from headers, supporting custom user imports.
- [Fix Socket Mixed Content](./devlog/2025-12-16-183000_fix_socket_mixed_content.md): Completed. Resolved mixed content error in production by making socket connection URL environment-aware.
- [Draft Rules & Pick Logic](./devlog/2025-12-16-180000_draft_rules_implementation.md): Completed. Enforced 4-player minimum and "Pick 2" rule for 4-player drafts.
- [Fix Pack Duplication](./devlog/2025-12-16-184500_fix_pack_duplication.md): Completed. Enforced deep cloning and unique IDs for all draft packs to prevent opening identical packs.

View File

@@ -0,0 +1,17 @@
# 2025-12-16 - Draft Rules and Logic Implementation
## Draft Minimum Players
- Added backend check in `index.ts` to prevent drafting with fewer than 4 players.
- Emit `draft_error` to room if condition is not met.
- Added `draft_error` listener in `GameRoom.tsx` to notify users.
## 4-Player Draft Rules (Pick 2)
- Modified `DraftManager.ts`:
- Added `pickedInCurrentStep` to track picks within a single pack pass cycle.
- Implemented logic in `pickCard`:
- If 4 players: Require 2 picks before passing pack.
- Else: Require 1 pick.
- Logic handles pack exhaustion (if pack runs out before picks completed, it passes).
## Robustness
- Updated `rejoin_room` handler in `index.ts` to send the current `draft` state if the room is in `drafting` status. This allows users to refresh and stay in the draft flow (critical for multi-pick scenarios).

View File

@@ -0,0 +1,13 @@
# 2025-12-16 - Fix Pack Duplication in Draft
## Problem
Users reported behavior consistent with "opening the same pack twice". This occurs when the pack objects distributed to players share the same memory reference. If the input source (e.g., from Frontend Generator) contains duplicate references (e.g., created via `Array.fill(pack)`), picking a card from "one" pack would seemingly remove it from "another" pack in a future round, or valid packs would re-appear.
## Solution
- Modified `DraftManager.createDraft` to enforce Strict Isolation of pack instances.
- Implemented **Deep Cloning**: Even if the input array contains shared references, we now map over `allPacks`, spreading the pack object and mapping the cards array to new objects.
- **Unique IDs**: Re-assigned a unique internal ID to every single pack (format: `draft-pack-{index}-{random}`) to guarantee that every pack in the system is distinct, regardless of the quality of the input data.
## Impact
- Ensures that every "pack" opened in the draft is an independent entity.
- Prevents state leakage between rounds or players.

View File

@@ -85,8 +85,18 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
const handleDraftUpdate = (data: any) => {
setDraftState(data);
};
const handleDraftError = (error: { message: string }) => {
alert(error.message); // Simple alert for now
};
socket.on('draft_update', handleDraftUpdate);
return () => { socket.off('draft_update', handleDraftUpdate); };
socket.on('draft_error', handleDraftError);
return () => {
socket.off('draft_update', handleDraftUpdate);
socket.off('draft_error', handleDraftError);
};
}, []);
const sendMessage = (e: React.FormEvent) => {

View File

@@ -87,7 +87,13 @@ io.on('connection', (socket) => {
// Just rejoin the socket channel if validation passes (not fully secure yet)
socket.join(roomId);
const room = roomManager.getRoom(roomId);
if (room) socket.emit('room_update', room);
if (room) {
socket.emit('room_update', room);
if (room.status === 'drafting') {
const draft = draftManager.getDraft(roomId);
if (draft) socket.emit('draft_update', draft);
}
}
});
socket.on('send_message', ({ roomId, sender, text }) => {
@@ -100,6 +106,13 @@ io.on('connection', (socket) => {
socket.on('start_draft', ({ roomId }) => {
const room = roomManager.getRoom(roomId);
if (room && 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;
}
// Create Draft
// All packs in room.packs need to be flat list or handled
// room.packs is currently JSON.

View File

@@ -27,6 +27,7 @@ interface DraftState {
pool: Card[]; // Picked cards
unopenedPacks: Pack[]; // Pack 2 and 3 kept aside
isWaiting: boolean; // True if finished current pack round
pickedInCurrentStep: number; // HOW MANY CARDS PICKED FROM CURRENT ACTIVE PACK
}>;
status: 'drafting' | 'deck_building' | 'complete';
@@ -40,8 +41,16 @@ export class DraftManager extends EventEmitter {
// Distribute 3 packs to each player
// Assume allPacks contains (3 * numPlayers) packs
// Shuffle packs just in case (optional, but good practice)
const shuffledPacks = [...allPacks].sort(() => Math.random() - 0.5);
// DEEP CLONE PACKS to ensure no shared references
// And assign unique internal IDs to avoid collisions
const sanitizedPacks = allPacks.map((p, idx) => ({
...p,
id: `draft-pack-${idx}-${Math.random().toString(36).substr(2, 5)}`,
cards: p.cards.map(c => ({ ...c })) // Shallow clone cards to protect against mutation if needed
}));
// Shuffle packs
const shuffledPacks = sanitizedPacks.sort(() => Math.random() - 0.5);
const draftState: DraftState = {
roomId,
@@ -62,7 +71,8 @@ export class DraftManager extends EventEmitter {
activePack: firstPack || null,
pool: [],
unopenedPacks: playerPacks,
isWaiting: false
isWaiting: false,
pickedInCurrentStep: 0
};
});
@@ -100,8 +110,26 @@ export class DraftManager extends EventEmitter {
// 2. Remove from pack
playerState.activePack.cards = playerState.activePack.cards.filter(c => c !== card);
// Increment pick count for this step
playerState.pickedInCurrentStep = (playerState.pickedInCurrentStep || 0) + 1;
// Determine Picks Required
// Rule: 4 players -> Pick 2. Others -> Pick 1.
const picksRequired = draft.seats.length === 4 ? 2 : 1;
// Check if we should pass the pack
// Pass if: Picked enough cards OR Pack is empty
const shouldPass = playerState.pickedInCurrentStep >= picksRequired || playerState.activePack.cards.length === 0;
if (!shouldPass) {
// Do not pass yet. Returns state so UI updates pool and removes card from view.
return draft;
}
// PASSED
const passedPack = playerState.activePack;
playerState.activePack = null;
playerState.pickedInCurrentStep = 0; // Reset for next pack
// 3. Logic for Passing or Discarding (End of Pack)
if (passedPack.cards.length > 0) {
@@ -137,6 +165,7 @@ export class DraftManager extends EventEmitter {
const p = draft.players[playerId];
if (!p.activePack && p.queue.length > 0) {
p.activePack = p.queue.shift()!;
p.pickedInCurrentStep = 0; // Reset for new pack
}
}
@@ -152,6 +181,7 @@ export class DraftManager extends EventEmitter {
const nextPack = p.unopenedPacks.shift();
if (nextPack) {
p.activePack = nextPack;
p.pickedInCurrentStep = 0; // Reset
}
});
} else {