fix: first suggestion always correct (#11)

* feat: randomize suggestions and store correct answer in BinaryNumbersPuzzle

* feat: remove unused fields and update assertions in BinaryNumbersPuzzle

* feat: update documentation for raw_current_number and clarify signed mode handling

* cleanup

* cleanup

* cleanup
This commit is contained in:
William Raendchen 2025-12-03 10:29:18 +01:00 committed by GitHub
parent 503f9eef0b
commit b1df1b1fbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -723,12 +723,16 @@ impl Bits {
pub struct BinaryNumbersPuzzle {
bits: Bits,
#[allow(dead_code)]
number_mode: NumberMode,
#[allow(dead_code)]
current_number: u32, // scaled value used for suggestions matching
raw_current_number: u32, // raw bit value (unscaled) for display
suggestions: Vec<i32>, // Changed to i32 to support signed values
/// Raw bit pattern (unscaled) for display as binary string.
/// This is u32 (not i32) because it stores the BIT PATTERN, not the numeric value.
/// In signed mode, negative numbers use two's complement representation:
/// - For -1 in 4-bit: raw_current_number = 15 (0b1111), displayed as "1111"
/// - For -8 in 4-bit: raw_current_number = 8 (0b1000), displayed as "1000"
///
/// The same bit pattern has different meanings in signed vs unsigned mode.
raw_current_number: u32,
suggestions: Vec<i32>,
correct_answer: i32,
selected_suggestion: Option<i32>,
time_total: f64,
time_left: f64,
@ -777,11 +781,12 @@ impl BinaryNumbersPuzzle {
},
}
// Shuffle suggestions
suggestions.shuffle(&mut rng);
// Pick a random suggestion as the current number
let correct_index = rng.random_range(0..suggestions.len());
let current_number_signed = suggestions[correct_index];
// Pick first suggestion as the current number
let current_number_signed = suggestions[0];
// Shuffle suggestions so the correct answer is in a random position
suggestions.shuffle(&mut rng);
// Calculate raw_current_number based on mode
let raw_current_number = match number_mode {
@ -791,18 +796,19 @@ impl BinaryNumbersPuzzle {
},
NumberMode::Signed => {
// For signed mode, we need to preserve the two's complement representation
// First, get the unscaled signed value
// Example: -1 in 4-bit two's complement is 0b1111
// We cast i32 to u32 (preserving bit pattern), then mask to n-bits
// Result: -1 becomes 15u32 (0b1111), which displays as "1111"
let unscaled_signed = current_number_signed / (scale as i32);
// Convert to unsigned bits using two's complement masking
// Casting i32 to u32 reinterprets the bits (not a numeric conversion)
// For n-bit number, mask is (2^n - 1)
let mask = (1u32 << num_bits) - 1;
(unscaled_signed as u32) & mask
},
};
let current_number = current_number_signed.unsigned_abs();
// Calculate time based on difficulty
let time_total = 10.0 - (streak.min(8) as f64 * 0.5);
let time_left = time_total;
@ -813,10 +819,9 @@ impl BinaryNumbersPuzzle {
Self {
bits,
number_mode,
current_number,
raw_current_number,
suggestions,
correct_answer: current_number_signed,
time_total,
time_left,
selected_suggestion,
@ -832,7 +837,7 @@ impl BinaryNumbersPuzzle {
}
pub fn is_correct_guess(&self, guess: i32) -> bool {
guess == self.suggestions[0]
guess == self.correct_answer
}
pub fn current_to_binary_string(&self) -> String {
@ -1001,9 +1006,9 @@ mod tests {
for &s in p.suggestions() {
assert_eq!(s.unsigned_abs() % scale, 0);
}
// current number must be one of suggestions and raw_current_number * scale == current_number
assert!(p.suggestions().contains(&(p.current_number as i32)));
assert_eq!(p.raw_current_number * scale, p.current_number);
// correct answer must be one of suggestions and raw_current_number * scale == correct_answer (unsigned)
assert!(p.suggestions().contains(&p.correct_answer));
assert_eq!(p.raw_current_number * scale, p.correct_answer.unsigned_abs());
}
#[test]
@ -1045,7 +1050,7 @@ mod tests {
// the raw_current_number has the sign bit set correctly
for _ in 0..20 {
let p = BinaryNumbersPuzzle::new(Bits::Four, NumberMode::Signed, 0);
let current_signed = p.suggestions[0];
let current_signed = p.correct_answer;
if current_signed < 0 {
// For negative numbers in 4-bit two's complement, the MSB (bit 3) should be 1
@ -1084,12 +1089,50 @@ mod tests {
assert_eq!(p.guess_result, Some(GuessResult::Timeout));
}
#[test]
fn suggestions_are_randomized() {
// Verify that suggestions are properly randomized and the first suggestion
// is not always the correct answer
let mut first_is_correct_count = 0;
let trials = 100;
for _ in 0..trials {
let p = BinaryNumbersPuzzle::new(Bits::Four, NumberMode::Unsigned, 0);
// Check if the first suggestion happens to be the correct answer
if p.suggestions[0] == p.correct_answer {
first_is_correct_count += 1;
}
}
// With 3 suggestions for 4-bit mode, we expect roughly 33% to be correct by chance
// Allow a range of 20-50% (which is generous for 100 trials to account for randomness)
// The key point is that it's NOT 100% (which would indicate no randomization)
assert!(
first_is_correct_count >= 20 && first_is_correct_count <= 50,
"First suggestion was correct {} times out of {}, expected around 33% (20-50 range). \
If this is close to 100%, suggestions are not randomized!",
first_is_correct_count,
trials
);
// Also verify that the correct answer is actually one of the suggestions
for _ in 0..10 {
let p = BinaryNumbersPuzzle::new(Bits::Eight, NumberMode::Signed, 0);
assert!(
p.suggestions.contains(&p.correct_answer),
"correct_answer {} must be in suggestions {:?}",
p.correct_answer,
p.suggestions
);
}
}
#[test]
fn finalize_round_correct_increments_score_streak_and_sets_result_state() {
with_high_score_file(|| {
let mut g = BinaryNumbersGame::new(Bits::Four, NumberMode::Unsigned);
// ensure deterministic: mark puzzle correct
let answer = g.puzzle.current_number as i32;
let answer = g.puzzle.correct_answer;
g.puzzle.guess_result = Some(GuessResult::Correct);
g.finalize_round();
assert_eq!(g.streak, 1);