Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions crates/draft-core/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,19 @@ fn apply_generate_pairings(
let mut rng =
ChaCha20Rng::seed_from_u64(session.config.rng_seed ^ (round as u64 * 0xDEAD_BEEF));

let new_pairings = match session.config.tournament_format {
let (new_pairings, swiss_bye) = match session.config.tournament_format {
TournamentFormat::Swiss => generate_swiss_pairings(session, round, &mut rng),
TournamentFormat::SingleElimination => generate_se_pairings(session, round),
TournamentFormat::SingleElimination => (generate_se_pairings(session, round), None),
};

for p in &new_pairings {
session.pairings.push(p.clone());
}
// A Swiss bye counts as a match win for the unpaired player; without this credit an
// odd-pod bye scores nothing and Swiss standings (sorted by match_wins) are wrong.
if let Some(bye) = swiss_bye {
ensure_match_record(&mut session.match_records, bye).match_wins += 1;
}
Comment on lines +131 to +143

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Make Swiss bye credit idempotent.

GeneratePairings is permitted after RoundComplete, but this path does not verify that round is new or that pairings for it are absent. Repeating generation for an odd Swiss round can append another pairing set and increment bye standings again, corrupting match totals. Validate round uniqueness/monotonicity or make pairing insertion and bye credit idempotent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/draft-core/src/session.rs` around lines 131 - 143, Make the
GeneratePairings flow around generate_swiss_pairings and the new_pairings
insertion idempotent for repeated calls on the same Swiss round. Detect whether
pairings for round already exist before appending or crediting swiss_bye, and
ensure an existing round cannot duplicate pairings or increment match_wins again
while preserving normal generation for new rounds.

session.status = DraftStatus::MatchInProgress;
session.current_round = round;

Expand All @@ -147,11 +152,14 @@ fn apply_generate_pairings(
])
}

/// Returns the round's pairings plus the bye player, if any. An odd pod leaves one player
/// unpaired; in Swiss that player takes a bye, which counts as a match win — the caller
/// credits it (`Some(pid)`). `None` when every player was paired.
fn generate_swiss_pairings(
session: &DraftSession,
round: u8,
rng: &mut ChaCha20Rng,
) -> Vec<DraftPairing> {
) -> (Vec<DraftPairing>, Option<PlayerId>) {
let seat_indices: Vec<u8> = session
.seats
.iter()
Expand Down Expand Up @@ -222,11 +230,12 @@ fn generate_swiss_pairings(
}
}

// If there's still an unpaired player (odd count), they get a bye (no pairing generated)
// For 8-player pods this shouldn't happen.
// An unpaired player (odd pod) takes a bye — reported to the caller so the bye can be
// credited as a match win. Common only in non-8-player pods.
let bye = carry.map(|(pid, _)| pid);

// Generate DraftPairing structs
paired
let pairings = paired
.iter()
.enumerate()
.map(|(table, (p1, p2))| DraftPairing {
Expand All @@ -237,7 +246,9 @@ fn generate_swiss_pairings(
status: PairingStatus::Pending,
winner: None,
})
.collect()
.collect();

(pairings, bye)
}

fn generate_se_pairings(session: &DraftSession, round: u8) -> Vec<DraftPairing> {
Expand Down Expand Up @@ -1721,4 +1732,23 @@ mod tests {
assert!(flags.get(2)); // new slot, default true
assert!(flags.get(3)); // new slot, default true
}

#[test]
fn swiss_bye_in_odd_pod_is_credited_a_match_win() {
// Odd pod -> exactly one player takes a bye each round.
let (mut session, _) = test_session(3);
session.status = DraftStatus::Deckbuilding; // satisfy the pairing-generation guard
apply_generate_pairings(&mut session, 1).unwrap();

// Three players: one two-player pairing plus one bye.
assert_eq!(
session.pairings.iter().filter(|p| p.round == 1).count(),
1,
"the two paired players get exactly one pairing",
);
// The unpaired (bye) player is credited exactly one match win. Without the credit an
// odd-pod bye scores nothing and Swiss standings (sorted by match_wins) undercount it.
let total_match_wins: u8 = session.match_records.values().map(|r| r.match_wins).sum();
assert_eq!(total_match_wins, 1, "the bye player earns a match win");
}
}
Loading