fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
Build and Deploy / build-and-push (push) Successful in 3m54s
Build and Deploy / build-and-push (push) Successful in 3m54s
- #66: Clamp safe-area insets to 25% of window height with warn!() on excess - #68: Move fire_flush outside per-event loop in analytics (batch flush once) - #56: Persist progress before marking reward_granted to prevent XP loss on crash - #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh - #62: Add validate_header() in replay upload with mode/draw_mode allowlists - #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original queries already in .sqlx cache; EXISTS variant would require sqlx prepare Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -156,13 +156,11 @@ impl Plugin for SelectionPlugin {
|
||||
.in_set(SelectionKeySet)
|
||||
.before(GameMutation),
|
||||
clear_selection_on_state_change.after(GameMutation),
|
||||
update_selection_highlight
|
||||
.after(GameMutation)
|
||||
.run_if(
|
||||
resource_changed::<SelectionState>
|
||||
.or(resource_changed::<KeyboardDragState>)
|
||||
.or(resource_changed::<crate::GameStateResource>),
|
||||
),
|
||||
update_selection_highlight.after(GameMutation).run_if(
|
||||
resource_changed::<SelectionState>
|
||||
.or(resource_changed::<KeyboardDragState>)
|
||||
.or(resource_changed::<crate::GameStateResource>),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -191,10 +189,7 @@ fn cycled_piles() -> Vec<PileType> {
|
||||
///
|
||||
/// If `current` is `None` the first available pile is returned.
|
||||
/// If `available` is empty, `None` is returned.
|
||||
pub fn cycle_next_pile(
|
||||
available: &[PileType],
|
||||
current: Option<&PileType>,
|
||||
) -> Option<PileType> {
|
||||
pub fn cycle_next_pile(available: &[PileType], current: Option<&PileType>) -> Option<PileType> {
|
||||
if available.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -227,11 +222,7 @@ pub fn cycle_next_pile(
|
||||
///
|
||||
/// Both `current` and `next` must be `Some`; if either is `None` this returns
|
||||
/// `false`.
|
||||
fn did_wrap(
|
||||
available: &[PileType],
|
||||
current: Option<&PileType>,
|
||||
next: Option<&PileType>,
|
||||
) -> bool {
|
||||
fn did_wrap(available: &[PileType], current: Option<&PileType>, next: Option<&PileType>) -> bool {
|
||||
let (Some(cur), Some(nxt)) = (current, next) else {
|
||||
return false;
|
||||
};
|
||||
@@ -306,8 +297,7 @@ fn handle_selection_keys(
|
||||
destination_index,
|
||||
} = &mut *kbd_drag
|
||||
{
|
||||
let shift_held =
|
||||
keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
||||
let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
||||
|
||||
// Cycle destinations forward / backward.
|
||||
let advance = keys.just_pressed(KeyCode::ArrowRight)
|
||||
@@ -436,9 +426,7 @@ fn handle_selection_keys(
|
||||
return;
|
||||
}
|
||||
// Priority 2: tableau stack move.
|
||||
let run_len = face_up_run_len(
|
||||
game.0.piles.get(pile).map_or(&[], |p| p.cards.as_slice()),
|
||||
);
|
||||
let run_len = face_up_run_len(game.0.piles.get(pile).map_or(&[], |p| p.cards.as_slice()));
|
||||
let bottom_card = game.0.piles.get(pile).and_then(|p| {
|
||||
let start = p.cards.len().saturating_sub(run_len);
|
||||
p.cards.get(start)
|
||||
@@ -486,16 +474,13 @@ fn handle_selection_keys(
|
||||
return;
|
||||
}
|
||||
let start = pile_cards.cards.len().saturating_sub(count);
|
||||
let lifted_cards: Vec<u32> =
|
||||
pile_cards.cards[start..].iter().map(|c| c.id).collect();
|
||||
let lifted_cards: Vec<u32> = pile_cards.cards[start..].iter().map(|c| c.id).collect();
|
||||
let Some(bottom) = pile_cards.cards.get(start) else {
|
||||
return;
|
||||
};
|
||||
let legal = legal_destinations_for(bottom, source, &game.0, count);
|
||||
if legal.is_empty() {
|
||||
info_toast.write(InfoToastEvent(
|
||||
"No legal moves for this card".to_string(),
|
||||
));
|
||||
info_toast.write(InfoToastEvent("No legal moves for this card".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -603,9 +588,10 @@ fn try_foundation_dest(
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, pile) {
|
||||
return Some(dest);
|
||||
}
|
||||
&& can_place_on_foundation(card, pile)
|
||||
{
|
||||
return Some(dest);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -831,22 +817,34 @@ mod tests {
|
||||
// Press 1: no current selection → first pile, no wrap.
|
||||
let sel1 = cycle_next_pile(&available, None);
|
||||
assert_eq!(sel1, Some(PileType::Waste));
|
||||
assert!(!did_wrap(&available, None, sel1.as_ref()), "first Tab should not wrap");
|
||||
assert!(
|
||||
!did_wrap(&available, None, sel1.as_ref()),
|
||||
"first Tab should not wrap"
|
||||
);
|
||||
|
||||
// Press 2: Waste → Tableau(0), no wrap.
|
||||
let sel2 = cycle_next_pile(&available, sel1.as_ref());
|
||||
assert_eq!(sel2, Some(PileType::Tableau(0)));
|
||||
assert!(!did_wrap(&available, sel1.as_ref(), sel2.as_ref()), "second Tab should not wrap");
|
||||
assert!(
|
||||
!did_wrap(&available, sel1.as_ref(), sel2.as_ref()),
|
||||
"second Tab should not wrap"
|
||||
);
|
||||
|
||||
// Press 3: Tableau(0) → Tableau(1), still no wrap.
|
||||
let sel3 = cycle_next_pile(&available, sel2.as_ref());
|
||||
assert_eq!(sel3, Some(PileType::Tableau(1)));
|
||||
assert!(!did_wrap(&available, sel2.as_ref(), sel3.as_ref()), "third Tab (T0→T1) should not wrap");
|
||||
assert!(
|
||||
!did_wrap(&available, sel2.as_ref(), sel3.as_ref()),
|
||||
"third Tab (T0→T1) should not wrap"
|
||||
);
|
||||
|
||||
// Press 4: Tableau(1) → Waste, this IS the wrap.
|
||||
let sel4 = cycle_next_pile(&available, sel3.as_ref());
|
||||
assert_eq!(sel4, Some(PileType::Waste));
|
||||
assert!(did_wrap(&available, sel3.as_ref(), sel4.as_ref()), "fourth Tab should wrap back to Waste");
|
||||
assert!(
|
||||
did_wrap(&available, sel3.as_ref(), sel4.as_ref()),
|
||||
"fourth Tab should wrap back to Waste"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -869,9 +867,24 @@ mod tests {
|
||||
fn face_up_run_len_all_face_up() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card { id: 0, suit: Suit::Clubs, rank: Rank::King, face_up: true },
|
||||
Card { id: 1, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
|
||||
Card { id: 2, suit: Suit::Spades, rank: Rank::Jack, face_up: true },
|
||||
Card {
|
||||
id: 0,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
},
|
||||
Card {
|
||||
id: 1,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Queen,
|
||||
face_up: true,
|
||||
},
|
||||
Card {
|
||||
id: 2,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Jack,
|
||||
face_up: true,
|
||||
},
|
||||
];
|
||||
assert_eq!(face_up_run_len(&cards), 3);
|
||||
}
|
||||
@@ -880,10 +893,30 @@ mod tests {
|
||||
fn face_up_run_len_mixed_stops_at_face_down() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card { id: 0, suit: Suit::Clubs, rank: Rank::King, face_up: false },
|
||||
Card { id: 1, suit: Suit::Hearts, rank: Rank::Queen, face_up: false },
|
||||
Card { id: 2, suit: Suit::Spades, rank: Rank::Jack, face_up: true },
|
||||
Card { id: 3, suit: Suit::Diamonds, rank: Rank::Ten, face_up: true },
|
||||
Card {
|
||||
id: 0,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::King,
|
||||
face_up: false,
|
||||
},
|
||||
Card {
|
||||
id: 1,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Queen,
|
||||
face_up: false,
|
||||
},
|
||||
Card {
|
||||
id: 2,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Jack,
|
||||
face_up: true,
|
||||
},
|
||||
Card {
|
||||
id: 3,
|
||||
suit: Suit::Diamonds,
|
||||
rank: Rank::Ten,
|
||||
face_up: true,
|
||||
},
|
||||
];
|
||||
// Only the top two cards are face-up.
|
||||
assert_eq!(face_up_run_len(&cards), 2);
|
||||
@@ -893,8 +926,18 @@ mod tests {
|
||||
fn face_up_run_len_top_card_face_down_is_zero() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card { id: 0, suit: Suit::Clubs, rank: Rank::King, face_up: true },
|
||||
Card { id: 1, suit: Suit::Hearts, rank: Rank::Queen, face_up: false },
|
||||
Card {
|
||||
id: 0,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
},
|
||||
Card {
|
||||
id: 1,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Queen,
|
||||
face_up: false,
|
||||
},
|
||||
];
|
||||
assert_eq!(face_up_run_len(&cards), 0);
|
||||
}
|
||||
@@ -902,9 +945,12 @@ mod tests {
|
||||
#[test]
|
||||
fn face_up_run_len_single_face_up_card() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true },
|
||||
];
|
||||
let cards = vec![Card {
|
||||
id: 0,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
}];
|
||||
assert_eq!(face_up_run_len(&cards), 1);
|
||||
}
|
||||
|
||||
@@ -956,27 +1002,43 @@ mod tests {
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
// Place test cards.
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 100,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Five,
|
||||
face_up: true,
|
||||
});
|
||||
g.piles.get_mut(&PileType::Tableau(1)).unwrap().cards.push(Card {
|
||||
id: 101,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Six,
|
||||
face_up: true,
|
||||
});
|
||||
g.piles.get_mut(&PileType::Tableau(2)).unwrap().cards.push(Card {
|
||||
id: 102,
|
||||
suit: Suit::Diamonds,
|
||||
rank: Rank::Six,
|
||||
face_up: true,
|
||||
});
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 100,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Five,
|
||||
face_up: true,
|
||||
});
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(1))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 101,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Six,
|
||||
face_up: true,
|
||||
});
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(2))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 102,
|
||||
suit: Suit::Diamonds,
|
||||
rank: Rank::Six,
|
||||
face_up: true,
|
||||
});
|
||||
g
|
||||
}
|
||||
|
||||
@@ -1014,17 +1076,32 @@ mod tests {
|
||||
app.update();
|
||||
|
||||
// Initial state: nothing selected, KeyboardDragState::Idle.
|
||||
assert!(app.world().resource::<SelectionState>().selected_pile.is_none());
|
||||
assert_eq!(*app.world().resource::<KeyboardDragState>(), KeyboardDragState::Idle);
|
||||
assert!(
|
||||
app.world()
|
||||
.resource::<SelectionState>()
|
||||
.selected_pile
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
*app.world().resource::<KeyboardDragState>(),
|
||||
KeyboardDragState::Idle
|
||||
);
|
||||
|
||||
press_key(&mut app, KeyCode::Tab);
|
||||
app.update();
|
||||
|
||||
let selected = app.world().resource::<SelectionState>().selected_pile.clone();
|
||||
let selected = app
|
||||
.world()
|
||||
.resource::<SelectionState>()
|
||||
.selected_pile
|
||||
.clone();
|
||||
// The cycle order starts at Waste, but Waste is empty so the next
|
||||
// available pile (Tableau(0)) is selected.
|
||||
assert_eq!(selected, Some(PileType::Tableau(0)));
|
||||
assert_eq!(*app.world().resource::<KeyboardDragState>(), KeyboardDragState::Idle);
|
||||
assert_eq!(
|
||||
*app.world().resource::<KeyboardDragState>(),
|
||||
KeyboardDragState::Idle
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 2 — Enter while a source is selected lifts the stack.
|
||||
@@ -1038,8 +1115,9 @@ mod tests {
|
||||
app.update();
|
||||
|
||||
// Manually focus Tableau(0) so we don't depend on Tab.
|
||||
app.world_mut().resource_mut::<SelectionState>().selected_pile =
|
||||
Some(PileType::Tableau(0));
|
||||
app.world_mut()
|
||||
.resource_mut::<SelectionState>()
|
||||
.selected_pile = Some(PileType::Tableau(0));
|
||||
|
||||
press_key(&mut app, KeyCode::Enter);
|
||||
app.update();
|
||||
@@ -1081,8 +1159,9 @@ mod tests {
|
||||
let mut app = drag_test_app();
|
||||
install_state(&mut app, deterministic_state());
|
||||
app.update();
|
||||
app.world_mut().resource_mut::<SelectionState>().selected_pile =
|
||||
Some(PileType::Tableau(0));
|
||||
app.world_mut()
|
||||
.resource_mut::<SelectionState>()
|
||||
.selected_pile = Some(PileType::Tableau(0));
|
||||
press_key(&mut app, KeyCode::Enter);
|
||||
app.update();
|
||||
|
||||
@@ -1091,7 +1170,9 @@ mod tests {
|
||||
// higher. Verify that the destinations are exactly those tableaus
|
||||
// (in cycle order T1 then T2).
|
||||
let initial_dests: Vec<PileType> = match app.world().resource::<KeyboardDragState>() {
|
||||
KeyboardDragState::Lifted { legal_destinations, .. } => legal_destinations.clone(),
|
||||
KeyboardDragState::Lifted {
|
||||
legal_destinations, ..
|
||||
} => legal_destinations.clone(),
|
||||
_ => panic!("expected Lifted"),
|
||||
};
|
||||
assert_eq!(
|
||||
@@ -1109,7 +1190,14 @@ mod tests {
|
||||
rank: Rank::Five,
|
||||
face_up: true,
|
||||
};
|
||||
let pile = app.world().resource::<GameStateResource>().0.piles.get(dest).unwrap().clone();
|
||||
let pile = app
|
||||
.world()
|
||||
.resource::<GameStateResource>()
|
||||
.0
|
||||
.piles
|
||||
.get(dest)
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert!(
|
||||
can_place_on_tableau(&bottom_card, &pile),
|
||||
"destination {dest:?} must be legal for the lifted stack",
|
||||
@@ -1118,7 +1206,9 @@ mod tests {
|
||||
|
||||
// Initial focused destination = first entry.
|
||||
assert_eq!(
|
||||
app.world().resource::<KeyboardDragState>().focused_destination(),
|
||||
app.world()
|
||||
.resource::<KeyboardDragState>()
|
||||
.focused_destination(),
|
||||
Some(&PileType::Tableau(1)),
|
||||
);
|
||||
|
||||
@@ -1127,7 +1217,9 @@ mod tests {
|
||||
press_key(&mut app, KeyCode::ArrowRight);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world().resource::<KeyboardDragState>().focused_destination(),
|
||||
app.world()
|
||||
.resource::<KeyboardDragState>()
|
||||
.focused_destination(),
|
||||
Some(&PileType::Tableau(2)),
|
||||
);
|
||||
|
||||
@@ -1136,7 +1228,9 @@ mod tests {
|
||||
press_key(&mut app, KeyCode::ArrowRight);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world().resource::<KeyboardDragState>().focused_destination(),
|
||||
app.world()
|
||||
.resource::<KeyboardDragState>()
|
||||
.focused_destination(),
|
||||
Some(&PileType::Tableau(1)),
|
||||
"destination index must wrap back to 0 after exhausting the list",
|
||||
);
|
||||
@@ -1150,8 +1244,9 @@ mod tests {
|
||||
let mut app = drag_test_app();
|
||||
install_state(&mut app, deterministic_state());
|
||||
app.update();
|
||||
app.world_mut().resource_mut::<SelectionState>().selected_pile =
|
||||
Some(PileType::Tableau(0));
|
||||
app.world_mut()
|
||||
.resource_mut::<SelectionState>()
|
||||
.selected_pile = Some(PileType::Tableau(0));
|
||||
press_key(&mut app, KeyCode::Enter);
|
||||
app.update();
|
||||
|
||||
@@ -1194,8 +1289,9 @@ mod tests {
|
||||
let mut app = drag_test_app();
|
||||
install_state(&mut app, deterministic_state());
|
||||
app.update();
|
||||
app.world_mut().resource_mut::<SelectionState>().selected_pile =
|
||||
Some(PileType::Tableau(0));
|
||||
app.world_mut()
|
||||
.resource_mut::<SelectionState>()
|
||||
.selected_pile = Some(PileType::Tableau(0));
|
||||
press_key(&mut app, KeyCode::Enter);
|
||||
app.update();
|
||||
assert!(app.world().resource::<KeyboardDragState>().is_lifted());
|
||||
@@ -1240,10 +1336,18 @@ mod tests {
|
||||
drag.active_touch_id = None;
|
||||
}
|
||||
|
||||
let before = app.world().resource::<SelectionState>().selected_pile.clone();
|
||||
let before = app
|
||||
.world()
|
||||
.resource::<SelectionState>()
|
||||
.selected_pile
|
||||
.clone();
|
||||
press_key(&mut app, KeyCode::Tab);
|
||||
app.update();
|
||||
let after = app.world().resource::<SelectionState>().selected_pile.clone();
|
||||
let after = app
|
||||
.world()
|
||||
.resource::<SelectionState>()
|
||||
.selected_pile
|
||||
.clone();
|
||||
|
||||
assert_eq!(
|
||||
before, after,
|
||||
@@ -1258,8 +1362,9 @@ mod tests {
|
||||
let mut app = drag_test_app();
|
||||
install_state(&mut app, deterministic_state());
|
||||
app.update();
|
||||
app.world_mut().resource_mut::<SelectionState>().selected_pile =
|
||||
Some(PileType::Tableau(0));
|
||||
app.world_mut()
|
||||
.resource_mut::<SelectionState>()
|
||||
.selected_pile = Some(PileType::Tableau(0));
|
||||
press_key(&mut app, KeyCode::Enter);
|
||||
app.update();
|
||||
|
||||
@@ -1276,7 +1381,10 @@ mod tests {
|
||||
press_key(&mut app, KeyCode::Escape);
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<SelectionState>().selected_pile.is_none(),
|
||||
app.world()
|
||||
.resource::<SelectionState>()
|
||||
.selected_pile
|
||||
.is_none(),
|
||||
"second Esc clears the source selection",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user