chore: add pedantic workspace lints (#90)

Add [workspace.lints.rust] and wire each member crate up with
[lints] workspace = true:

  unsafe_code = "deny"        (forbid would break the Android JNI build)
  single_use_lifetimes = "warn"
  trivial_casts = "warn"
  unused_lifetimes = "warn"
  unused_qualifications = "warn"
  variant_size_differences = "warn"
  unexpected_cfgs = "warn"

unsafe_code is "deny" rather than the issue's "forbid" so the three
Android JNI FFI modules (android_keystore, android_clipboard, safe_area)
can opt back in with a scoped #![allow(unsafe_code)] — forbid cannot be
locally overridden. Pure crates carry no unsafe and stay clean.

Clean up the warnings the new lints surface:
- 150ish unused_qualifications removed via `cargo fix` (purely syntactic
  redundant-path-prefix removals).
- table_plugin: the TABLE_COLOUR import was #[cfg(test)]-gated while the
  camera clear-colour used the fully-qualified path; unqualifying it left
  a non-test build with no import. Made the import unconditional instead.
- assets/sources: the `as &[u8]` casts in embed_*_svg! coerce each
  fixed-size &[u8; N] to a uniform slice so the tuples fit the
  &[(&str, &[u8])] arrays — load-bearing, so scoped #[allow(trivial_casts)].

Workspace clippy -D warnings and the full test suite pass. Android build
not compiled here (needs the NDK; built separately per CLAUDE.md §15) —
the deny + scoped-allow keeps the JNI unsafe blocks legal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-12 13:05:28 -07:00
parent 9bbb57134f
commit ceb9c950a1
48 changed files with 191 additions and 130 deletions
+15
View File
@@ -18,6 +18,21 @@ version = "0.1.0"
license = "MIT" license = "MIT"
rust-version = "1.95" rust-version = "1.95"
# Pedantic correctness lints applied across every member crate via
# `[lints] workspace = true`. `unsafe_code` is "deny" rather than "forbid"
# so the three Android JNI FFI modules can opt back in with a scoped
# `#![allow(unsafe_code)]` — `forbid` cannot be locally overridden, which
# would break the Android build. Pure crates (core, sync) carry no `unsafe`
# and so remain effectively forbidden in practice.
[workspace.lints.rust]
unsafe_code = "deny"
single_use_lifetimes = "warn"
trivial_casts = "warn"
unused_lifetimes = "warn"
unused_qualifications = "warn"
variant_size_differences = "warn"
unexpected_cfgs = "warn"
[workspace.dependencies] [workspace.dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
+3
View File
@@ -99,3 +99,6 @@ icon = "@mipmap/ic_launcher"
# in portrait orientation. Remove (or add a landscape layout) before # in portrait orientation. Remove (or add a landscape layout) before
# enabling auto-rotate. # enabling auto-rotate.
orientation = "portrait" orientation = "portrait"
[lints]
workspace = true
+2 -2
View File
@@ -144,7 +144,7 @@ fn build_app_with_settings(
// Android windows always fill the screen; max_width/max_height // Android windows always fill the screen; max_width/max_height
// default to 0.0, which panics Bevy's clamp when min > max. // default to 0.0, which panics Bevy's clamp when min > max.
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
resize_constraints: bevy::window::WindowResizeConstraints { resize_constraints: WindowResizeConstraints {
min_width: 800.0, min_width: 800.0,
min_height: 600.0, min_height: 600.0,
..default() ..default()
@@ -166,7 +166,7 @@ fn build_app_with_settings(
// default makes it walk *out* of the APK's assets root and // default makes it walk *out* of the APK's assets root and
// all loads fail silently — which is what produced the // all loads fail silently — which is what produced the
// solid-red card-back fallback in the v0.22.3 screenshot. // solid-red card-back fallback in the v0.22.3 screenshot.
.set(bevy::asset::AssetPlugin { .set(AssetPlugin {
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
file_path: "../assets".to_string(), file_path: "../assets".to_string(),
..default() ..default()
+3
View File
@@ -30,3 +30,6 @@ path = "src/bin/gen_seeds.rs"
[[bin]] [[bin]]
name = "gen_difficulty_seeds" name = "gen_difficulty_seeds"
path = "src/bin/gen_difficulty_seeds.rs" path = "src/bin/gen_difficulty_seeds.rs"
[lints]
workspace = true
+3
View File
@@ -16,3 +16,6 @@ serde = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
klondike = { workspace = true } klondike = { workspace = true }
card_game = { workspace = true } card_game = { workspace = true }
[lints]
workspace = true
+3
View File
@@ -47,3 +47,6 @@ sqlx = { workspace = true }
jsonwebtoken = { workspace = true } jsonwebtoken = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
[lints]
workspace = true
+5
View File
@@ -1,3 +1,8 @@
// JNI FFI requires `unsafe` to reconstruct `JavaVM` / `JByteArray` handles
// from raw pointers handed over by the Android runtime. Scoped to this
// module so the rest of the workspace stays `deny(unsafe_code)`.
#![allow(unsafe_code)]
/// Android Keystore token storage via JNI. /// Android Keystore token storage via JNI.
/// ///
/// Tokens are serialised to JSON, encrypted with AES-256/GCM/NoPadding using a /// Tokens are serialised to JSON, encrypted with AES-256/GCM/NoPadding using a
+2 -2
View File
@@ -58,7 +58,7 @@ pub trait SyncProvider: Send + Sync {
/// so backends without a server (e.g. `LocalOnlyProvider`) are /// so backends without a server (e.g. `LocalOnlyProvider`) are
/// silently no-op'd by the engine's push-on-win system, matching /// silently no-op'd by the engine's push-on-win system, matching
/// the same pattern `pull` / `push` follow. /// the same pattern `pull` / `push` follow.
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<String, SyncError> { async fn push_replay(&self, _replay: &Replay) -> Result<String, SyncError> {
Err(SyncError::UnsupportedPlatform) Err(SyncError::UnsupportedPlatform)
} }
} }
@@ -94,7 +94,7 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
async fn delete_account(&self) -> Result<(), SyncError> { async fn delete_account(&self) -> Result<(), SyncError> {
(**self).delete_account().await (**self).delete_account().await
} }
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<String, SyncError> { async fn push_replay(&self, replay: &Replay) -> Result<String, SyncError> {
(**self).push_replay(replay).await (**self).push_replay(replay).await
} }
} }
+3
View File
@@ -52,3 +52,6 @@ web-sys = { version = "0.3", features = ["Storage", "Window"] }
async-trait = { workspace = true } async-trait = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
solitaire_core = { workspace = true, features = ["test-support"] } solitaire_core = { workspace = true, features = ["test-support"] }
[lints]
workspace = true
+7 -7
View File
@@ -116,7 +116,7 @@ impl Plugin for AchievementPlugin {
// achievements-scroll system also runs cleanly under // achievements-scroll system also runs cleanly under
// `MinimalPlugins` in tests. // `MinimalPlugins` in tests.
.add_message::<MouseWheel>() .add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>() .add_message::<TouchInput>()
// Run after GameMutation (so GameWonEvent is available), after // Run after GameMutation (so GameWonEvent is available), after
// StatsUpdate (so stats reflect this win), and after ProgressUpdate // StatsUpdate (so stats reflect this win), and after ProgressUpdate
// (so daily_challenge_streak is up to date for daily_devotee). // (so daily_challenge_streak is up to date for daily_devotee).
@@ -671,7 +671,7 @@ mod tests {
.add_plugins(AchievementPlugin::headless()); .add_plugins(AchievementPlugin::headless());
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under // StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
// MinimalPlugins it isn't auto-registered. // MinimalPlugins it isn't auto-registered.
app.init_resource::<bevy::input::ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.update(); app.update();
app app
} }
@@ -819,7 +819,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.set_test_draw_mode(solitaire_core::DrawStockConfig::DrawThree); .set_test_draw_mode(DrawStockConfig::DrawThree);
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 500, score: 500,
@@ -868,7 +868,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.set_test_draw_mode(solitaire_core::DrawStockConfig::DrawThree); .set_test_draw_mode(DrawStockConfig::DrawThree);
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 500, score: 500,
@@ -912,7 +912,7 @@ mod tests {
// Put the active game in Zen mode. evaluate_on_win reads // Put the active game in Zen mode. evaluate_on_win reads
// GameStateResource.mode directly to populate last_win_is_zen. // GameStateResource.mode directly to populate last_win_is_zen.
app.world_mut().resource_mut::<GameStateResource>().0.mode = app.world_mut().resource_mut::<GameStateResource>().0.mode =
solitaire_core::game_state::GameMode::Zen; GameMode::Zen;
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 0, score: 0,
@@ -946,7 +946,7 @@ mod tests {
// Default GameMode is Classic; assert and rely on it. // Default GameMode is Classic; assert and rely on it.
assert_eq!( assert_eq!(
app.world().resource::<GameStateResource>().0.mode, app.world().resource::<GameStateResource>().0.mode,
solitaire_core::game_state::GameMode::Classic GameMode::Classic
); );
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
@@ -1250,7 +1250,7 @@ mod tests {
.add_plugins(crate::progress_plugin::ProgressPlugin::headless()) .add_plugins(crate::progress_plugin::ProgressPlugin::headless())
.add_plugins(crate::settings_plugin::SettingsPlugin::headless()) .add_plugins(crate::settings_plugin::SettingsPlugin::headless())
.add_plugins(AchievementPlugin::headless()); .add_plugins(AchievementPlugin::headless());
app.init_resource::<bevy::input::ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.update(); app.update();
app app
} }
@@ -1,3 +1,8 @@
// JNI FFI requires `unsafe` to reconstruct `JavaVM` / `JObject` handles from
// raw pointers handed over by the Android runtime. Scoped to this module so
// the rest of the workspace stays `deny(unsafe_code)`.
#![allow(unsafe_code)]
/// Android clipboard bridge via JNI. /// Android clipboard bridge via JNI.
/// ///
/// Writes text to the system clipboard by calling into `ClipboardManager` /// Writes text to the system clipboard by calling into `ClipboardManager`
+1 -1
View File
@@ -354,7 +354,7 @@ fn handle_win_cascade(
end: target.truncate(), end: target.truncate(),
elapsed: 0.0, elapsed: 0.0,
duration, duration,
curve: crate::card_animation::MotionCurve::Expressive, curve: MotionCurve::Expressive,
delay: i as f32 * step, delay: i as f32 * step,
start_z: start.z, start_z: start.z,
end_z: target.z, end_z: target.z,
+6
View File
@@ -115,6 +115,10 @@ macro_rules! embed_classic_svg {
} }
/// Every Dark-theme SVG file bundled into the binary. /// Every Dark-theme SVG file bundled into the binary.
// The `as &[u8]` in `embed_dark_svg!` coerces each fixed-size
// `&[u8; N]` (N varies per file) to a uniform `&[u8]` so the tuples fit
// this array type. The cast is load-bearing, not trivial.
#[allow(trivial_casts)]
const DARK_THEME_SVGS: &[(&str, &[u8])] = &[ const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
embed_dark_svg!("back.svg"), embed_dark_svg!("back.svg"),
embed_dark_svg!("clubs_ace.svg"), embed_dark_svg!("clubs_ace.svg"),
@@ -172,6 +176,8 @@ const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
]; ];
/// Every Classic-theme SVG file bundled into the binary. /// Every Classic-theme SVG file bundled into the binary.
// See `DARK_THEME_SVGS`: the `as &[u8]` cast is load-bearing.
#[allow(trivial_casts)]
const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[ const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[
embed_classic_svg!("back.svg"), embed_classic_svg!("back.svg"),
embed_classic_svg!("clubs_ace.svg"), embed_classic_svg!("clubs_ace.svg"),
+2 -2
View File
@@ -192,7 +192,7 @@ fn shared_fontdb() -> Arc<fontdb::Database> {
fn bundled_font_resolver() -> usvg::FontResolver<'static> { fn bundled_font_resolver() -> usvg::FontResolver<'static> {
use usvg::FontResolver; use usvg::FontResolver;
usvg::FontResolver { FontResolver {
select_font: Box::new(|_font, db| db.faces().next().map(|face| face.id)), select_font: Box::new(|_font, db| db.faces().next().map(|face| face.id)),
select_fallback: FontResolver::default_fallback_selector(), select_fallback: FontResolver::default_fallback_selector(),
} }
@@ -282,7 +282,7 @@ mod tests {
/// tightens. /// tightens.
#[test] #[test]
fn settings_satisfies_loader_bounds() { fn settings_satisfies_loader_bounds() {
fn assert_loader_settings<T: Default + serde::Serialize + serde::de::DeserializeOwned>() {} fn assert_loader_settings<T: Default + Serialize + serde::de::DeserializeOwned>() {}
assert_loader_settings::<SvgLoaderSettings>(); assert_loader_settings::<SvgLoaderSettings>();
} }
} }
+1 -1
View File
@@ -177,7 +177,7 @@ mod tests {
.add_plugins(GamePlugin) .add_plugins(GamePlugin)
.add_plugins(TablePlugin) .add_plugins(TablePlugin)
.add_plugins(AutoCompletePlugin); .add_plugins(AutoCompletePlugin);
app.init_resource::<bevy::input::ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.update(); app.update();
app app
} }
+1 -1
View File
@@ -36,7 +36,7 @@ pub struct AvatarFetchEvent {
pub url: String, pub url: String,
} }
impl bevy::prelude::Message for AvatarFetchEvent {} impl Message for AvatarFetchEvent {}
/// In-flight avatar download task. Returns the raw image bytes on success, /// In-flight avatar download task. Returns the raw image bytes on success,
/// or `None` on any network / decode error. /// or `None` on any network / decode error.
@@ -73,7 +73,7 @@ pub struct HoverState {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum BufferedInput { pub enum BufferedInput {
Move { Move {
from: crate::events::MoveRequestEvent, from: MoveRequestEvent,
}, },
Draw, Draw,
Undo, Undo,
+29 -29
View File
@@ -483,7 +483,7 @@ impl Plugin for CardPlugin {
update_stock_empty_indicator.after(GameMutation), update_stock_empty_indicator.after(GameMutation),
update_stock_count_badge update_stock_count_badge
.after(GameMutation) .after(GameMutation)
.run_if(resource_changed::<crate::GameStateResource>), .run_if(resource_changed::<GameStateResource>),
collect_resize_events.after(LayoutSystem::UpdateOnResize), collect_resize_events.after(LayoutSystem::UpdateOnResize),
snap_cards_on_window_resize.after(collect_resize_events), snap_cards_on_window_resize.after(collect_resize_events),
), ),
@@ -2431,7 +2431,7 @@ fn update_tableau_fan_frac(
.into_iter() .into_iter()
.map(|tableau| { .map(|tableau| {
game.0 game.0
.pile(solitaire_core::KlondikePile::Tableau(tableau)) .pile(KlondikePile::Tableau(tableau))
.into_iter() .into_iter()
.filter(|(_, face_up)| *face_up) .filter(|(_, face_up)| *face_up)
.count() .count()
@@ -2566,7 +2566,7 @@ mod tests {
#[test] #[test]
fn card_positions_includes_all_52_cards_at_game_start() { fn card_positions_includes_all_52_cards_at_game_start() {
// At game start waste is empty, so all 52 cards are across stock + tableau. // At game start waste is empty, so all 52 cards are across stock + tableau.
let g = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne); let g = GameState::new(42, DrawStockConfig::DrawOne);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
assert_eq!(positions.len(), 52); assert_eq!(positions.len(), 52);
@@ -2580,7 +2580,7 @@ mod tests {
for _ in 0..3 { for _ in 0..3 {
let _ = g.draw(); let _ = g.draw();
} }
let waste_ids: std::collections::HashSet<Card> = let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect(); g.waste_cards().iter().map(|c| c.0.clone()).collect();
assert_eq!(waste_ids.len(), 3); assert_eq!(waste_ids.len(), 3);
@@ -2624,7 +2624,7 @@ mod tests {
"need at least 3 waste cards for this test" "need at least 3 waste cards for this test"
); );
let waste_ids: std::collections::HashSet<Card> = let waste_ids: HashSet<Card> =
waste_pile.iter().map(|c| c.0.clone()).collect(); waste_pile.iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
@@ -2677,7 +2677,7 @@ mod tests {
let count = waste_pile.len(); let count = waste_pile.len();
assert!(count >= 2, "need at least 2 waste cards"); assert!(count >= 2, "need at least 2 waste cards");
let waste_ids: std::collections::HashSet<Card> = let waste_ids: HashSet<Card> =
waste_pile.iter().map(|c| c.0.clone()).collect(); waste_pile.iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
@@ -2715,7 +2715,7 @@ mod tests {
for _ in 0..3 { for _ in 0..3 {
let _ = g.draw(); let _ = g.draw();
} }
let waste_ids: std::collections::HashSet<Card> = let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect(); g.waste_cards().iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
@@ -2740,7 +2740,7 @@ mod tests {
#[test] #[test]
fn card_positions_tableau_cards_are_fanned_downward() { fn card_positions_tableau_cards_are_fanned_downward() {
let g = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne); let g = GameState::new(42, DrawStockConfig::DrawOne);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
@@ -3083,7 +3083,7 @@ mod tests {
#[test] #[test]
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() { fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
let g = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne); let g = GameState::new(42, DrawStockConfig::DrawOne);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
@@ -3153,7 +3153,7 @@ mod tests {
fn fire_window_resize(app: &mut App, width: f32, height: f32) { fn fire_window_resize(app: &mut App, width: f32, height: f32) {
// Any Entity will do — the snap system reads only width/height. // Any Entity will do — the snap system reads only width/height.
let window = bevy::ecs::entity::Entity::from_raw_u32(0) let window = Entity::from_raw_u32(0)
.expect("Entity::from_raw_u32(0) is a valid placeholder"); .expect("Entity::from_raw_u32(0) is a valid placeholder");
app.world_mut().write_message(WindowResized { app.world_mut().write_message(WindowResized {
window, window,
@@ -3171,9 +3171,9 @@ mod tests {
// entity IDs must remain alive. // entity IDs must remain alive.
let mut app = app(); let mut app = app();
let labels_before: std::collections::HashSet<bevy::prelude::Entity> = app let labels_before: HashSet<Entity> = app
.world_mut() .world_mut()
.query_filtered::<bevy::prelude::Entity, With<CardLabel>>() .query_filtered::<Entity, With<CardLabel>>()
.iter(app.world()) .iter(app.world())
.collect(); .collect();
assert!( assert!(
@@ -3184,9 +3184,9 @@ mod tests {
fire_window_resize(&mut app, 1024.0, 768.0); fire_window_resize(&mut app, 1024.0, 768.0);
advance_past_resize_throttle(&mut app); advance_past_resize_throttle(&mut app);
let labels_after: std::collections::HashSet<bevy::prelude::Entity> = app let labels_after: HashSet<Entity> = app
.world_mut() .world_mut()
.query_filtered::<bevy::prelude::Entity, With<CardLabel>>() .query_filtered::<Entity, With<CardLabel>>()
.iter(app.world()) .iter(app.world())
.collect(); .collect();
@@ -3336,9 +3336,9 @@ mod tests {
// Each shadow's parent must be a CardEntity, so the child relation // Each shadow's parent must be a CardEntity, so the child relation
// is wired correctly. // is wired correctly.
let cards: HashSet<bevy::prelude::Entity> = app let cards: HashSet<Entity> = app
.world_mut() .world_mut()
.query_filtered::<bevy::prelude::Entity, With<CardEntity>>() .query_filtered::<Entity, With<CardEntity>>()
.iter(app.world()) .iter(app.world())
.collect(); .collect();
let mut q = app let mut q = app
@@ -3423,7 +3423,7 @@ mod tests {
let card_entity = { let card_entity = {
let mut q = app let mut q = app
.world_mut() .world_mut()
.query::<(bevy::prelude::Entity, &CardEntity)>(); .query::<(Entity, &CardEntity)>();
q.iter(app.world()) q.iter(app.world())
.find(|(_, c)| c.card == *card) .find(|(_, c)| c.card == *card)
.map(|(e, _)| e) .map(|(e, _)| e)
@@ -3533,7 +3533,7 @@ mod tests {
#[test] #[test]
fn stock_card_count_helper_reads_zero_for_empty_stock() { fn stock_card_count_helper_reads_zero_for_empty_stock() {
let g = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne); let g = GameState::new(42, DrawStockConfig::DrawOne);
let mut g_empty_stock = g.clone(); let mut g_empty_stock = g.clone();
g_empty_stock.set_test_stock_cards(Vec::new()); g_empty_stock.set_test_stock_cards(Vec::new());
assert_eq!(stock_card_count(&g_empty_stock), 0); assert_eq!(stock_card_count(&g_empty_stock), 0);
@@ -3553,9 +3553,9 @@ mod tests {
// Allocate five different strong handles by passing each a // Allocate five different strong handles by passing each a
// distinct dummy `Image`. We never render these; we only // distinct dummy `Image`. We never render these; we only
// compare ids. // compare ids.
let mut images = bevy::asset::Assets::<bevy::image::Image>::default(); let mut images = Assets::<Image>::default();
let backs: [Handle<bevy::image::Image>; 5] = let backs: [Handle<Image>; 5] =
std::array::from_fn(|_| images.add(bevy::image::Image::default())); std::array::from_fn(|_| images.add(Image::default()));
CardImageSet { CardImageSet {
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())), faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
backs, backs,
@@ -3569,8 +3569,8 @@ mod tests {
// card must render with the theme's back regardless of which // card must render with the theme's back regardless of which
// legacy back the player picked in Settings. // legacy back the player picked in Settings.
let mut set = image_set_with_distinct_back_handles(); let mut set = image_set_with_distinct_back_handles();
let mut images = bevy::asset::Assets::<bevy::image::Image>::default(); let mut images = Assets::<Image>::default();
let theme_back: Handle<bevy::image::Image> = images.add(bevy::image::Image::default()); let theme_back: Handle<Image> = images.add(Image::default());
set.theme_back = Some(theme_back.clone()); set.theme_back = Some(theme_back.clone());
let face_down = make_card(Suit::Spades, Rank::Ace); let face_down = make_card(Suit::Spades, Rank::Ace);
@@ -3634,8 +3634,8 @@ mod tests {
use std::collections::HashMap; use std::collections::HashMap;
let mut set = image_set_with_distinct_back_handles(); let mut set = image_set_with_distinct_back_handles();
let mut images = bevy::asset::Assets::<bevy::image::Image>::default(); let mut images = Assets::<Image>::default();
let theme_back: Handle<bevy::image::Image> = images.add(bevy::image::Image::default()); let theme_back: Handle<Image> = images.add(Image::default());
let theme = CardTheme { let theme = CardTheme {
meta: ThemeMeta { meta: ThemeMeta {
@@ -3645,7 +3645,7 @@ mod tests {
version: "0".into(), version: "0".into(),
card_aspect: (2, 3), card_aspect: (2, 3),
}, },
faces: HashMap::<CardKey, Handle<bevy::image::Image>>::new(), faces: HashMap::<CardKey, Handle<Image>>::new(),
back: theme_back.clone(), back: theme_back.clone(),
}; };
@@ -3813,7 +3813,7 @@ mod tests {
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
let waste_ids: std::collections::HashSet<Card> = let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect(); g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions let mut waste_zs: Vec<f32> = positions
@@ -3863,7 +3863,7 @@ mod tests {
let stock_x = layout.pile_positions[&KlondikePile::Stock].x; let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
let waste_ids: std::collections::HashSet<Card> = let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect(); g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_positions: Vec<_> = card_positions(&g, &layout) let mut waste_positions: Vec<_> = card_positions(&g, &layout)
@@ -3893,7 +3893,7 @@ mod tests {
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
let waste_ids: std::collections::HashSet<Card> = let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect(); g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions let mut waste_zs: Vec<f32> = positions
+1 -1
View File
@@ -80,7 +80,7 @@ impl Plugin for CursorPlugin {
Update, Update,
( (
update_cursor_icon, update_cursor_icon,
update_drop_highlights.run_if(resource_changed::<crate::resources::DragState>), update_drop_highlights.run_if(resource_changed::<DragState>),
update_drop_target_overlays, update_drop_target_overlays,
), ),
); );
+1 -1
View File
@@ -639,7 +639,7 @@ fn lerp_color(from: Color, to: Color, t: f32) -> Color {
fn pile_cards( fn pile_cards(
game: &solitaire_core::game_state::GameState, game: &solitaire_core::game_state::GameState,
pile: &KlondikePile, pile: &KlondikePile,
) -> Vec<(solitaire_core::Card, bool)> { ) -> Vec<(Card, bool)> {
match pile { match pile {
KlondikePile::Stock => game.waste_cards(), KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile), _ => game.pile(*pile),
+6 -6
View File
@@ -201,7 +201,7 @@ impl Plugin for GamePlugin {
.add_message::<StateChangedEvent>() .add_message::<StateChangedEvent>()
.add_message::<crate::events::MoveRejectedEvent>() .add_message::<crate::events::MoveRejectedEvent>()
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_message::<crate::events::CardFlippedEvent>() .add_message::<CardFlippedEvent>()
.add_message::<crate::events::AchievementUnlockedEvent>() .add_message::<crate::events::AchievementUnlockedEvent>()
.add_message::<FoundationCompletedEvent>() .add_message::<FoundationCompletedEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
@@ -530,7 +530,7 @@ fn handle_new_game(
// hides that information and reads naturally as "dealt from the // hides that information and reads naturally as "dealt from the
// deck." Skipped when LayoutResource isn't present (headless tests). // deck." Skipped when LayoutResource isn't present (headless tests).
if let Some(layout) = layout.as_ref() if let Some(layout) = layout.as_ref()
&& let Some(stock) = layout.0.pile_positions.get(&solitaire_core::KlondikePile::Stock) && let Some(stock) = layout.0.pile_positions.get(&KlondikePile::Stock)
{ {
for mut tx in &mut card_transforms { for mut tx in &mut card_transforms {
tx.translation.x = stock.x; tx.translation.x = stock.x;
@@ -868,7 +868,7 @@ fn handle_move(
mut game: ResMut<GameStateResource>, mut game: ResMut<GameStateResource>,
mut changed: MessageWriter<StateChangedEvent>, mut changed: MessageWriter<StateChangedEvent>,
mut won: MessageWriter<GameWonEvent>, mut won: MessageWriter<GameWonEvent>,
mut flipped: MessageWriter<crate::events::CardFlippedEvent>, mut flipped: MessageWriter<CardFlippedEvent>,
mut foundation_done: MessageWriter<FoundationCompletedEvent>, mut foundation_done: MessageWriter<FoundationCompletedEvent>,
mut recording: ResMut<RecordingReplay>, mut recording: ResMut<RecordingReplay>,
path: Option<Res<GameStatePath>>, path: Option<Res<GameStatePath>>,
@@ -909,7 +909,7 @@ fn handle_move(
.last() .last()
.is_some_and(|c| c.0 == fcard && c.1) .is_some_and(|c| c.0 == fcard && c.1)
{ {
flipped.write(crate::events::CardFlippedEvent(fcard)); flipped.write(CardFlippedEvent(fcard));
} }
// If this move landed on a foundation pile and that pile is // If this move landed on a foundation pile and that pile is
// now complete (Ace → King, 13 cards), fire the per-suit // now complete (Ace → King, 13 cards), fire the per-suit
@@ -1530,7 +1530,7 @@ mod tests {
// Persistence tests // Persistence tests
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
fn tmp_gs_path(name: &str) -> std::path::PathBuf { fn tmp_gs_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("engine_test_gs_{name}.json")) std::env::temp_dir().join(format!("engine_test_gs_{name}.json"))
} }
@@ -1684,7 +1684,7 @@ mod tests {
let events = app let events = app
.world() .world()
.resource::<Messages<crate::events::CardFlippedEvent>>(); .resource::<Messages<CardFlippedEvent>>();
let mut cursor = events.get_cursor(); let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect(); let fired: Vec<_> = cursor.read(events).collect();
assert!( assert!(
+1 -1
View File
@@ -51,7 +51,7 @@ impl Plugin for HelpPlugin {
// plugin under `DefaultPlugins`; register them explicitly so // plugin under `DefaultPlugins`; register them explicitly so
// scroll systems run cleanly under `MinimalPlugins` in tests. // scroll systems run cleanly under `MinimalPlugins` in tests.
.add_message::<MouseWheel>() .add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>() .add_message::<TouchInput>()
.add_systems( .add_systems(
Update, Update,
( (
+1 -1
View File
@@ -1878,7 +1878,7 @@ mod tests {
let states: Vec<(HomeMode, bool)> = app let states: Vec<(HomeMode, bool)> = app
.world_mut() .world_mut()
.query::<(&HomeModeCard, bevy::ecs::query::Has<Disabled>)>() .query::<(&HomeModeCard, Has<Disabled>)>()
.iter(app.world()) .iter(app.world())
.map(|(c, d)| (c.0, d)) .map(|(c, d)| (c.0, d))
.collect(); .collect();
+2 -2
View File
@@ -2428,8 +2428,8 @@ mod tests {
app.init_resource::<HintSolverConfig>(); app.init_resource::<HintSolverConfig>();
app.init_resource::<crate::pending_hint::PendingHintTask>(); app.init_resource::<crate::pending_hint::PendingHintTask>();
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(crate::layout::LayoutResource( app.insert_resource(LayoutResource(
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true), compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
)); ));
app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne))); app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne)));
app.add_systems(Update, handle_keyboard_hint); app.add_systems(Update, handle_keyboard_hint);
+1 -1
View File
@@ -745,7 +745,7 @@ mod tests {
); );
// The HUD band top clearance (distance from window top to card top) // The HUD band top clearance (distance from window top to card top)
// must match as well — this is the quantity directly visible in Bug 2. // must match as well — this is the quantity directly visible in Bug 2.
let card_top = |layout: &super::Layout| { let card_top = |layout: &Layout| {
layout.pile_positions[&KlondikePile::Stock].y + layout.card_size.y / 2.0 layout.pile_positions[&KlondikePile::Stock].y + layout.card_size.y / 2.0
}; };
assert!( assert!(
+2 -2
View File
@@ -862,7 +862,7 @@ fn handle_display_name_confirm(
.leaderboard_display_name .leaderboard_display_name
.clone() .clone()
.unwrap_or_else(|| { .unwrap_or_else(|| {
if let solitaire_data::settings::SyncBackend::SolitaireServer { if let SyncBackend::SolitaireServer {
ref username, ref username,
.. ..
} = settings.0.sync_backend } = settings.0.sync_backend
@@ -1091,7 +1091,7 @@ mod tests {
.add_plugins(crate::achievement_plugin::AchievementPlugin::headless()) .add_plugins(crate::achievement_plugin::AchievementPlugin::headless())
.add_plugins(SyncPlugin::new(NoOpProvider)) .add_plugins(SyncPlugin::new(NoOpProvider))
.add_plugins(LeaderboardPlugin); .add_plugins(LeaderboardPlugin);
app.init_resource::<bevy::input::ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.update(); app.update();
app app
} }
+4 -4
View File
@@ -2312,8 +2312,8 @@ fn format_suit_glyph_all_suits() {
fn format_foundations_row_empty_board() { fn format_foundations_row_empty_board() {
let game = solitaire_core::game_state::GameState::new_with_mode( let game = solitaire_core::game_state::GameState::new_with_mode(
42, 42,
solitaire_core::DrawStockConfig::DrawOne, DrawStockConfig::DrawOne,
solitaire_core::game_state::GameMode::Classic, GameMode::Classic,
); );
assert_eq!(format_foundations_row(&game), "F: -- -- -- --"); assert_eq!(format_foundations_row(&game), "F: -- -- -- --");
} }
@@ -2324,8 +2324,8 @@ fn format_foundations_row_empty_board() {
fn format_stock_waste_row_initial_state() { fn format_stock_waste_row_initial_state() {
let game = solitaire_core::game_state::GameState::new_with_mode( let game = solitaire_core::game_state::GameState::new_with_mode(
42, 42,
solitaire_core::DrawStockConfig::DrawOne, DrawStockConfig::DrawOne,
solitaire_core::game_state::GameMode::Classic, GameMode::Classic,
); );
let text = format_stock_waste_row(&game); let text = format_stock_waste_row(&game);
assert!( assert!(
+5
View File
@@ -1,5 +1,10 @@
//! Safe-area insets. //! Safe-area insets.
//! //!
// JNI FFI (Android only) requires `unsafe` to reconstruct `JavaVM` /
// `JObject` handles from raw pointers handed over by the runtime. Scoped to
// this module so the rest of the workspace stays `deny(unsafe_code)`.
#![allow(unsafe_code)]
//!
//! Reports the OS-reserved regions around the playable surface (status //! Reports the OS-reserved regions around the playable surface (status
//! bar at the top, gesture / navigation bar at the bottom on Android, //! bar at the top, gesture / navigation bar at the bottom on Android,
//! display cutouts, etc.) so UI anchored to a screen edge can avoid //! display cutouts, etc.) so UI anchored to a screen edge can avoid
+5 -5
View File
@@ -163,7 +163,7 @@ impl Plugin for SelectionPlugin {
update_selection_highlight.after(GameMutation).run_if( update_selection_highlight.after(GameMutation).run_if(
resource_changed::<SelectionState> resource_changed::<SelectionState>
.or(resource_changed::<KeyboardDragState>) .or(resource_changed::<KeyboardDragState>)
.or(resource_changed::<crate::GameStateResource>), .or(resource_changed::<GameStateResource>),
), ),
), ),
); );
@@ -534,7 +534,7 @@ fn handle_selection_keys(
/// destination after a lift. Players who want a different column simply /// destination after a lift. Players who want a different column simply
/// press the right-arrow key once or twice. /// press the right-arrow key once or twice.
pub(crate) fn legal_destinations_for( pub(crate) fn legal_destinations_for(
_bottom: &solitaire_core::Card, _bottom: &Card,
source: &KlondikePile, source: &KlondikePile,
game: &GameState, game: &GameState,
stack_count: usize, stack_count: usize,
@@ -579,7 +579,7 @@ pub(crate) fn legal_destinations_for(
/// Walks backwards from the last element and stops at the first face-down card /// Walks backwards from the last element and stops at the first face-down card
/// (or when the slice is exhausted). Returns at least `1` when the top card is /// (or when the slice is exhausted). Returns at least `1` when the top card is
/// face-up; returns `0` for an empty slice or when the top card is face-down. /// face-up; returns `0` for an empty slice or when the top card is face-down.
fn face_up_run_len(cards: &[(solitaire_core::Card, bool)]) -> usize { fn face_up_run_len(cards: &[(Card, bool)]) -> usize {
let mut count = 0; let mut count = 0;
for (_, face_up) in cards.iter().rev() { for (_, face_up) in cards.iter().rev() {
if *face_up { if *face_up {
@@ -598,8 +598,8 @@ fn face_up_run_len(cards: &[(solitaire_core::Card, bool)]) -> usize {
/// handler can attempt a foundation move first and fall through to a /// handler can attempt a foundation move first and fall through to a
/// multi-card stack move rather than accepting a single-card tableau move. /// multi-card stack move rather than accepting a single-card tableau move.
fn try_foundation_dest( fn try_foundation_dest(
card: &solitaire_core::Card, card: &Card,
game: &solitaire_core::game_state::GameState, game: &GameState,
) -> Option<KlondikePile> { ) -> Option<KlondikePile> {
let source = game.pile_containing_card(card.clone())?; let source = game.pile_containing_card(card.clone())?;
for foundation in [ for foundation in [
+8 -8
View File
@@ -379,8 +379,8 @@ impl Plugin for SettingsPlugin {
.add_message::<DeleteAccountRequestEvent>() .add_message::<DeleteAccountRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>() .add_message::<ToggleSettingsRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<bevy::input::mouse::MouseWheel>() .add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>() .add_message::<TouchInput>()
// `WindowResized` / `WindowMoved` are real Bevy window events // `WindowResized` / `WindowMoved` are real Bevy window events
// and emitted by the windowing backend under `DefaultPlugins`, // and emitted by the windowing backend under `DefaultPlugins`,
// but we register them explicitly here so the geometry watcher // but we register them explicitly here so the geometry watcher
@@ -2662,7 +2662,7 @@ fn handle_scan_themes(
let themes_dir = user_theme_dir(); let themes_dir = user_theme_dir();
let zips: Vec<std::path::PathBuf> = match std::fs::read_dir(&themes_dir) { let zips: Vec<PathBuf> = match std::fs::read_dir(&themes_dir) {
Ok(entries) => entries Ok(entries) => entries
.flatten() .flatten()
.map(|e| e.path()) .map(|e| e.path())
@@ -3019,7 +3019,7 @@ mod tests {
unit: MouseScrollUnit::Line, unit: MouseScrollUnit::Line,
x: 0.0, x: 0.0,
y: -3.0, y: -3.0,
window: bevy::ecs::entity::Entity::PLACEHOLDER, window: Entity::PLACEHOLDER,
}); });
app.update(); app.update();
// ScrollPosition must remain at 0.0 — panel was closed. // ScrollPosition must remain at 0.0 — panel was closed.
@@ -3052,7 +3052,7 @@ mod tests {
unit: MouseScrollUnit::Line, unit: MouseScrollUnit::Line,
x: 0.0, x: 0.0,
y: -2.0, y: -2.0,
window: bevy::ecs::entity::Entity::PLACEHOLDER, window: Entity::PLACEHOLDER,
}); });
app.update(); app.update();
let offset = app let offset = app
@@ -3364,7 +3364,7 @@ mod tests {
fn fire_resize(app: &mut App, width: f32, height: f32) { fn fire_resize(app: &mut App, width: f32, height: f32) {
app.world_mut().write_message(WindowResized { app.world_mut().write_message(WindowResized {
window: bevy::ecs::entity::Entity::PLACEHOLDER, window: Entity::PLACEHOLDER,
width, width,
height, height,
}); });
@@ -3372,7 +3372,7 @@ mod tests {
fn fire_move(app: &mut App, x: i32, y: i32) { fn fire_move(app: &mut App, x: i32, y: i32) {
app.world_mut().write_message(WindowMoved { app.world_mut().write_message(WindowMoved {
window: bevy::ecs::entity::Entity::PLACEHOLDER, window: Entity::PLACEHOLDER,
position: IVec2::new(x, y), position: IVec2::new(x, y),
}); });
} }
@@ -3494,7 +3494,7 @@ mod tests {
unit: MouseScrollUnit::Line, unit: MouseScrollUnit::Line,
x: 0.0, x: 0.0,
y: 5.0, y: 5.0,
window: bevy::ecs::entity::Entity::PLACEHOLDER, window: Entity::PLACEHOLDER,
}); });
app.update(); app.update();
let offset = app let offset = app
+2 -2
View File
@@ -1010,8 +1010,8 @@ mod tests {
use solitaire_data::Settings; use solitaire_data::Settings;
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins) app.add_plugins(MinimalPlugins)
.add_plugins(bevy::asset::AssetPlugin::default()) .add_plugins(AssetPlugin::default())
.init_asset::<bevy::image::Image>() .init_asset::<Image>()
.add_plugins(SplashPlugin); .add_plugins(SplashPlugin);
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<MouseButton>>(); app.init_resource::<ButtonInput<MouseButton>>();
+9 -9
View File
@@ -203,7 +203,7 @@ impl Plugin for StatsPlugin {
// `DefaultPlugins`; register it explicitly so the stats-scroll // `DefaultPlugins`; register it explicitly so the stats-scroll
// system also runs cleanly under `MinimalPlugins` in tests. // system also runs cleanly under `MinimalPlugins` in tests.
.add_message::<MouseWheel>() .add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>() .add_message::<TouchInput>()
// record_abandoned must read `move_count` BEFORE handle_new_game // record_abandoned must read `move_count` BEFORE handle_new_game
// clobbers it with a fresh game. These are NOT in StatsUpdate because // clobbers it with a fresh game. These are NOT in StatsUpdate because
// StatsUpdate (as a set) is ordered after GameMutation by external // StatsUpdate (as a set) is ordered after GameMutation by external
@@ -464,7 +464,7 @@ fn repaint_replay_selector_detail(
/// Pure helper: render the detail line for the selected replay. Returns /// Pure helper: render the detail line for the selected replay. Returns
/// `"{duration} win on {date}"` plus a `" \u{2022} Shareable"` badge /// `"{duration} win on {date}"` plus a `" \u{2022} Shareable"` badge
/// when a share URL is present. Empty when the history slice is empty. /// when a share URL is present. Empty when the history slice is empty.
pub fn replay_selector_detail(replays: &[solitaire_data::Replay], index: usize) -> String { pub fn replay_selector_detail(replays: &[Replay], index: usize) -> String {
let Some(r) = replays.get(index.min(replays.len().saturating_sub(1))) else { let Some(r) = replays.get(index.min(replays.len().saturating_sub(1))) else {
return String::new(); return String::new();
}; };
@@ -1325,7 +1325,7 @@ mod tests {
fn draw_three_win_increments_draw_three_wins_only() { fn draw_three_win_increments_draw_three_wins_only() {
let mut app = headless_app(); let mut app = headless_app();
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.set_test_draw_mode(solitaire_core::DrawStockConfig::DrawThree); .set_test_draw_mode(solitaire_core::DrawStockConfig::DrawThree);
@@ -1371,7 +1371,7 @@ mod tests {
let mut app = headless_app(); let mut app = headless_app();
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.set_test_move_count(3); .set_test_move_count(3);
@@ -1501,7 +1501,7 @@ mod tests {
fn zen_win_event_updates_zen_best_score_only() { fn zen_win_event_updates_zen_best_score_only() {
let mut app = headless_app(); let mut app = headless_app();
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.mode = solitaire_core::game_state::GameMode::Zen; .mode = solitaire_core::game_state::GameMode::Zen;
@@ -1697,7 +1697,7 @@ mod tests {
stats.0.win_streak_current = 3; stats.0.win_streak_current = 3;
} }
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.set_test_move_count(1); .set_test_move_count(1);
@@ -1723,7 +1723,7 @@ mod tests {
stats.0.win_streak_current = 1; stats.0.win_streak_current = 1;
} }
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.set_test_move_count(1); .set_test_move_count(1);
@@ -1948,9 +1948,9 @@ mod tests {
/// ///
/// Uses a fixed seed, DrawOne mode, Classic game, 2026-05-08 date. /// Uses a fixed seed, DrawOne mode, Classic game, 2026-05-08 date.
/// `time_seconds` and `share_url` are the only varying fields across tests. /// `time_seconds` and `share_url` are the only varying fields across tests.
fn make_test_replay(time_seconds: u64, share_url: Option<String>) -> solitaire_data::Replay { fn make_test_replay(time_seconds: u64, share_url: Option<String>) -> Replay {
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date"); let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date");
let mut r = solitaire_data::Replay::new( let mut r = Replay::new(
1, 1,
solitaire_core::DrawStockConfig::DrawOne, solitaire_core::DrawStockConfig::DrawOne,
solitaire_core::game_state::GameMode::Classic, solitaire_core::game_state::GameMode::Classic,
+4 -4
View File
@@ -496,7 +496,7 @@ mod tests {
.add_plugins(crate::achievement_plugin::AchievementPlugin::headless()) .add_plugins(crate::achievement_plugin::AchievementPlugin::headless())
.add_plugins(SyncPlugin::new(provider)); .add_plugins(SyncPlugin::new(provider));
// MinimalPlugins does not register keyboard input. // MinimalPlugins does not register keyboard input.
app.init_resource::<bevy::input::ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.update(); app.update();
app app
} }
@@ -629,8 +629,8 @@ mod tests {
replays: vec![initial], replays: vec![initial],
}; };
save_replay_history_to(&path, &history).expect("seed history on disk"); save_replay_history_to(&path, &history).expect("seed history on disk");
app.insert_resource(crate::stats_plugin::ReplayHistoryResource(history)); app.insert_resource(ReplayHistoryResource(history));
app.insert_resource(crate::stats_plugin::LatestReplayPath(Some(path.clone()))); app.insert_resource(LatestReplayPath(Some(path.clone())));
// Pre-resolved task carrying the URL the production path would // Pre-resolved task carrying the URL the production path would
// get back from the server. // get back from the server.
@@ -659,7 +659,7 @@ mod tests {
// In-memory contract: replays[0].share_url is now Some(url). // In-memory contract: replays[0].share_url is now Some(url).
let live = app let live = app
.world() .world()
.resource::<crate::stats_plugin::ReplayHistoryResource>(); .resource::<ReplayHistoryResource>();
assert_eq!( assert_eq!(
live.0.replays.first().and_then(|r| r.share_url.clone()), live.0.replays.first().and_then(|r| r.share_url.clone()),
Some(url.clone()), Some(url.clone()),
+4 -6
View File
@@ -11,9 +11,7 @@ use solitaire_core::Suit;
use crate::events::{HintVisualEvent, StateChangedEvent}; use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::hud_plugin::HudVisibility; use crate::hud_plugin::HudVisibility;
#[cfg(test)] use crate::layout::{Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, compute_layout};
use crate::layout::TABLE_COLOUR;
use crate::layout::{Layout, LayoutResource, LayoutSystem, compute_layout};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::safe_area::SafeAreaInsets; use crate::safe_area::SafeAreaInsets;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
@@ -177,9 +175,9 @@ fn setup_table(
Camera2d, Camera2d,
Camera { Camera {
clear_color: ClearColorConfig::Custom(Color::srgb( clear_color: ClearColorConfig::Custom(Color::srgb(
crate::layout::TABLE_COLOUR[0], TABLE_COLOUR[0],
crate::layout::TABLE_COLOUR[1], TABLE_COLOUR[1],
crate::layout::TABLE_COLOUR[2], TABLE_COLOUR[2],
)), )),
..default() ..default()
}, },
+6 -6
View File
@@ -236,7 +236,7 @@ pub fn import_theme_into(zip_path: &Path, target_root: &Path) -> Result<ThemeId,
/// Sums every entry's declared uncompressed size and rejects archives /// Sums every entry's declared uncompressed size and rejects archives
/// that overflow [`MAX_ARCHIVE_BYTES`]. Iterates the central /// that overflow [`MAX_ARCHIVE_BYTES`]. Iterates the central
/// directory only — does not actually decompress anything. /// directory only — does not actually decompress anything.
fn enforce_archive_size_limit<R: io::Read + io::Seek>( fn enforce_archive_size_limit<R: Read + io::Seek>(
archive: &mut zip::ZipArchive<R>, archive: &mut zip::ZipArchive<R>,
) -> Result<(), ImportError> { ) -> Result<(), ImportError> {
let mut total: u64 = 0; let mut total: u64 = 0;
@@ -257,7 +257,7 @@ fn enforce_archive_size_limit<R: io::Read + io::Seek>(
/// (after normalisation) escapes its root. Catches `..`, absolute /// (after normalisation) escapes its root. Catches `..`, absolute
/// paths, drive prefixes on Windows, and the awkward case where /// paths, drive prefixes on Windows, and the awkward case where
/// `enclosed_name` returns `None` because the entry is suspicious. /// `enclosed_name` returns `None` because the entry is suspicious.
fn enforce_zip_slip_safe<R: io::Read + io::Seek>( fn enforce_zip_slip_safe<R: Read + io::Seek>(
archive: &mut zip::ZipArchive<R>, archive: &mut zip::ZipArchive<R>,
) -> Result<(), ImportError> { ) -> Result<(), ImportError> {
for i in 0..archive.len() { for i in 0..archive.len() {
@@ -291,7 +291,7 @@ fn is_safe_relative_path(p: &Path) -> bool {
/// `theme.ron` entry at its root. /// `theme.ron` entry at its root.
/// - [`ImportError::ManifestParse`] when the bytes don't form valid /// - [`ImportError::ManifestParse`] when the bytes don't form valid
/// RON for `ThemeManifest`. /// RON for `ThemeManifest`.
fn read_manifest<R: io::Read + io::Seek>( fn read_manifest<R: Read + io::Seek>(
archive: &mut zip::ZipArchive<R>, archive: &mut zip::ZipArchive<R>,
) -> Result<ThemeManifest, ImportError> { ) -> Result<ThemeManifest, ImportError> {
// We can't use `?` directly across `by_name` because a missing // We can't use `?` directly across `by_name` because a missing
@@ -318,7 +318,7 @@ fn read_manifest<R: io::Read + io::Seek>(
/// ///
/// Returns [`ImportError::MissingFile`] when the archive has no entry /// Returns [`ImportError::MissingFile`] when the archive has no entry
/// matching the path. /// matching the path.
fn read_archive_entry<R: io::Read + io::Seek>( fn read_archive_entry<R: Read + io::Seek>(
archive: &mut zip::ZipArchive<R>, archive: &mut zip::ZipArchive<R>,
path: &Path, path: &Path,
) -> Result<Vec<u8>, ImportError> { ) -> Result<Vec<u8>, ImportError> {
@@ -351,7 +351,7 @@ fn archive_key(path: &Path) -> String {
/// parent directories as needed. The destination path is rebuilt from /// parent directories as needed. The destination path is rebuilt from
/// the safe components we already vetted in /// the safe components we already vetted in
/// [`enforce_zip_slip_safe`], not from the raw entry name. /// [`enforce_zip_slip_safe`], not from the raw entry name.
fn write_archive_entry<R: io::Read + io::Seek>( fn write_archive_entry<R: Read + io::Seek>(
archive: &mut zip::ZipArchive<R>, archive: &mut zip::ZipArchive<R>,
name: &str, name: &str,
staging: &Path, staging: &Path,
@@ -384,7 +384,7 @@ fn write_archive_entry<R: io::Read + io::Seek>(
/// Variant of [`write_archive_entry`] keyed by `Path` for the /// Variant of [`write_archive_entry`] keyed by `Path` for the
/// manifest-declared face/back paths. /// manifest-declared face/back paths.
fn write_archive_entry_pathbuf<R: io::Read + io::Seek>( fn write_archive_entry_pathbuf<R: Read + io::Seek>(
archive: &mut zip::ZipArchive<R>, archive: &mut zip::ZipArchive<R>,
path: &Path, path: &Path,
staging: &Path, staging: &Path,
+1 -1
View File
@@ -484,7 +484,7 @@ mod tests {
let mut image_set = empty_card_image_set(); let mut image_set = empty_card_image_set();
// Snapshot the legacy back ids so we can prove they don't // Snapshot the legacy back ids so we can prove they don't
// change when a theme is applied. // change when a theme is applied.
let legacy_ids_before: [bevy::asset::AssetId<bevy::image::Image>; 5] = let legacy_ids_before: [AssetId<Image>; 5] =
std::array::from_fn(|i| image_set.backs[i].id()); std::array::from_fn(|i| image_set.backs[i].id());
let theme = empty_theme(); let theme = empty_theme();
assert!(image_set.theme_back.is_none(), "theme_back starts empty"); assert!(image_set.theme_back.is_none(), "theme_back starts empty");
+1 -1
View File
@@ -521,7 +521,7 @@ mod tests {
// the session timer or the running win count. // the session timer or the running win count.
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
fn tmp_ta_path(name: &str) -> std::path::PathBuf { fn tmp_ta_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("engine_test_ta_{name}.json")) std::env::temp_dir().join(format!("engine_test_ta_{name}.json"))
} }
+1 -1
View File
@@ -1324,7 +1324,7 @@ mod tests {
let row = world.spawn((FocusRow, Node::default())).id(); let row = world.spawn((FocusRow, Node::default())).id();
world.entity_mut(scrim).add_child(row); world.entity_mut(scrim).add_child(row);
let make_swatch = |w: &mut World, marker: fn(&mut bevy::ecs::world::EntityWorldMut)| { let make_swatch = |w: &mut World, marker: fn(&mut EntityWorldMut)| {
let mut e = w.spawn(( let mut e = w.spawn((
Button, Button,
Node::default(), Node::default(),
+3 -3
View File
@@ -982,9 +982,9 @@ mod tests {
outline_width: 0.0, outline_width: 0.0,
outline_offset: 0.0, outline_offset: 0.0,
unrounded_size: card_size, unrounded_size: card_size,
border: bevy::sprite::BorderRect::default(), border: BorderRect::default(),
border_radius: bevy::ui::ResolvedBorderRadius::default(), border_radius: ResolvedBorderRadius::default(),
padding: bevy::sprite::BorderRect::default(), padding: BorderRect::default(),
inverse_scale_factor: 1.0, inverse_scale_factor: 1.0,
}; };
// `is_empty` guard inside Bevy treats zero-size // `is_empty` guard inside Bevy treats zero-size
+7 -7
View File
@@ -249,12 +249,12 @@ pub const BORDER_SUBTLE_HC: Color = Color::srgba(0.627, 0.627, 0.627, 1.0);
pub struct HighContrastBorder { pub struct HighContrastBorder {
/// Border colour to use when high-contrast mode is *off* — the /// Border colour to use when high-contrast mode is *off* — the
/// site's normal idle / active-state colour. /// site's normal idle / active-state colour.
pub default_color: bevy::prelude::Color, pub default_color: Color,
} }
impl HighContrastBorder { impl HighContrastBorder {
/// Convenience constructor — `HighContrastBorder::with_default(BORDER_SUBTLE)`. /// Convenience constructor — `HighContrastBorder::with_default(BORDER_SUBTLE)`.
pub const fn with_default(default_color: bevy::prelude::Color) -> Self { pub const fn with_default(default_color: Color) -> Self {
Self { default_color } Self { default_color }
} }
} }
@@ -282,18 +282,18 @@ impl HighContrastBorder {
pub struct HighContrastBackground { pub struct HighContrastBackground {
/// Background colour to use when high-contrast mode is *off* — /// Background colour to use when high-contrast mode is *off* —
/// the site's normal idle / active-state colour. /// the site's normal idle / active-state colour.
pub default_color: bevy::prelude::Color, pub default_color: Color,
/// Background colour to use when high-contrast mode is *on*. /// Background colour to use when high-contrast mode is *on*.
/// Defaults to [`BORDER_SUBTLE_HC`] via [`with_default`]. /// Defaults to [`BORDER_SUBTLE_HC`] via [`with_default`].
/// ///
/// [`with_default`]: HighContrastBackground::with_default /// [`with_default`]: HighContrastBackground::with_default
pub hc_color: bevy::prelude::Color, pub hc_color: Color,
} }
impl HighContrastBackground { impl HighContrastBackground {
/// Convenience constructor — HC colour defaults to /// Convenience constructor — HC colour defaults to
/// [`BORDER_SUBTLE_HC`]. /// [`BORDER_SUBTLE_HC`].
pub const fn with_default(default_color: bevy::prelude::Color) -> Self { pub const fn with_default(default_color: Color) -> Self {
Self { Self {
default_color, default_color,
hc_color: BORDER_SUBTLE_HC, hc_color: BORDER_SUBTLE_HC,
@@ -305,8 +305,8 @@ impl HighContrastBackground {
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather /// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
/// than to a neutral gray. /// than to a neutral gray.
pub const fn with_hc( pub const fn with_hc(
default_color: bevy::prelude::Color, default_color: Color,
hc_color: bevy::prelude::Color, hc_color: Color,
) -> Self { ) -> Self {
Self { Self {
default_color, default_color,
+1 -1
View File
@@ -551,7 +551,7 @@ fn spawn_win_summary_after_delay(
// speed the duration is zero anyway, suppressing the shake. // speed the duration is zero anyway, suppressing the shake.
let speed = settings let speed = settings
.as_ref() .as_ref()
.map_or(solitaire_data::AnimSpeed::Normal, |s| s.0.animation_speed); .map_or(AnimSpeed::Normal, |s| s.0.animation_speed);
let scaled = scaled_duration(SHAKE_DURATION_SECS, speed); let scaled = scaled_duration(SHAKE_DURATION_SECS, speed);
shake.remaining = scaled; shake.remaining = scaled;
shake.total = scaled; shake.total = scaled;
+3
View File
@@ -32,3 +32,6 @@ dotenvy = { workspace = true }
[dev-dependencies] [dev-dependencies]
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
[lints]
workspace = true
+1 -1
View File
@@ -54,7 +54,7 @@ struct UserIdKeyExtractor {
impl KeyExtractor for UserIdKeyExtractor { impl KeyExtractor for UserIdKeyExtractor {
type Key = String; type Key = String;
fn extract<T>(&self, req: &axum::http::Request<T>) -> Result<Self::Key, GovernorError> { fn extract<T>(&self, req: &Request<T>) -> Result<Self::Key, GovernorError> {
if let Some(user_id) = self.try_extract_user_id(req.headers()) { if let Some(user_id) = self.try_extract_user_id(req.headers()) {
return Ok(user_id); return Ok(user_id);
} }
+3 -3
View File
@@ -565,7 +565,7 @@ async fn register_login_push_pull_full_roundtrip() {
}, },
achievements: vec![], achievements: vec![],
progress: PlayerProgress::default(), progress: PlayerProgress::default(),
last_modified: chrono::Utc::now(), last_modified: Utc::now(),
}; };
let push_resp = post_authed( let push_resp = post_authed(
@@ -1299,7 +1299,7 @@ async fn expired_access_token_returns_401() {
exp: usize, exp: usize,
kind: String, kind: String,
} }
let exp = (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp() as usize; let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
let expired_token = encode( let expired_token = encode(
&Header::default(), &Header::default(),
&ExpiredClaims { &ExpiredClaims {
@@ -1375,7 +1375,7 @@ async fn refresh_with_expired_refresh_token_returns_401() {
exp: usize, exp: usize,
kind: String, kind: String,
} }
let exp = (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp() as usize; let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
let expired_token = encode( let expired_token = encode(
&Header::default(), &Header::default(),
&ExpiredRefreshClaims { &ExpiredRefreshClaims {
+3
View File
@@ -9,3 +9,6 @@ serde = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
[lints]
workspace = true
+7 -7
View File
@@ -755,14 +755,14 @@ mod tests {
fn progress_total_xp_takes_max() { fn progress_total_xp_takes_max() {
let mut local = default_payload(); let mut local = default_payload();
local.progress.total_xp = 1500; local.progress.total_xp = 1500;
local.progress.level = crate::progress::level_for_xp(1500); local.progress.level = level_for_xp(1500);
let mut remote = default_payload(); let mut remote = default_payload();
remote.progress.total_xp = 2500; remote.progress.total_xp = 2500;
remote.progress.level = crate::progress::level_for_xp(2500); remote.progress.level = level_for_xp(2500);
let (merged, _) = merge(&local, &remote); let (merged, _) = merge(&local, &remote);
assert_eq!(merged.progress.total_xp, 2500); assert_eq!(merged.progress.total_xp, 2500);
assert_eq!(merged.progress.level, crate::progress::level_for_xp(2500)); assert_eq!(merged.progress.level, level_for_xp(2500));
} }
#[test] #[test]
@@ -815,7 +815,7 @@ mod tests {
let (merged, _) = merge(&local, &remote); let (merged, _) = merge(&local, &remote);
assert_eq!(merged.progress.total_xp, 5500); assert_eq!(merged.progress.total_xp, 5500);
assert_eq!(merged.progress.level, crate::progress::level_for_xp(5500)); assert_eq!(merged.progress.level, level_for_xp(5500));
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -1051,11 +1051,11 @@ mod tests {
// drop the oldest 50 so the cap is preserved. // drop the oldest 50 so the cap is preserved.
let start = nd(2024, 1, 1); let start = nd(2024, 1, 1);
let local_dates: Vec<NaiveDate> = (0..DAILY_CHALLENGE_HISTORY_CAP as i64) let local_dates: Vec<NaiveDate> = (0..DAILY_CHALLENGE_HISTORY_CAP as i64)
.map(|i| start + chrono::Duration::days(i)) .map(|i| start + Duration::days(i))
.collect(); .collect();
let remote_dates: Vec<NaiveDate> = (DAILY_CHALLENGE_HISTORY_CAP as i64 let remote_dates: Vec<NaiveDate> = (DAILY_CHALLENGE_HISTORY_CAP as i64
..DAILY_CHALLENGE_HISTORY_CAP as i64 + 50) ..DAILY_CHALLENGE_HISTORY_CAP as i64 + 50)
.map(|i| start + chrono::Duration::days(i)) .map(|i| start + Duration::days(i))
.collect(); .collect();
let mut local = default_payload(); let mut local = default_payload();
@@ -1073,7 +1073,7 @@ mod tests {
// is therefore start + 50 days. // is therefore start + 50 days.
assert_eq!( assert_eq!(
merged.progress.daily_challenge_history.first().copied(), merged.progress.daily_challenge_history.first().copied(),
Some(start + chrono::Duration::days(50)) Some(start + Duration::days(50))
); );
// Most recent retained is the last remote date. // Most recent retained is the last remote date.
assert_eq!( assert_eq!(
+3
View File
@@ -28,3 +28,6 @@ web-sys = { version = "0.3", features = ["console"] }
[features] [features]
default = ["console_error_panic_hook"] default = ["console_error_panic_hook"]
[lints]
workspace = true
+3
View File
@@ -21,3 +21,6 @@ console_error_panic_hook = "0.1"
# renderer to WebGL2 compatibility limits, which is wrong for native builds. # renderer to WebGL2 compatibility limits, which is wrong for native builds.
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
bevy = { workspace = true, features = ["webgl2"] } bevy = { workspace = true, features = ["webgl2"] }
[lints]
workspace = true