Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff8c00d2f4 | |||
| 38b81a4004 | |||
| ae7af9adf4 |
@@ -31,13 +31,6 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
# Full debuginfo made the solitaire_engine test-binary link peak past the
|
|
||||||
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
|
|
||||||
# line-tables-only keeps file:line in panic backtraces while cutting the
|
|
||||||
# link's memory footprint enough to fit the runner.
|
|
||||||
env:
|
|
||||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -46,7 +39,7 @@ jobs:
|
|||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: 1.95.0
|
toolchain: 1.95.0
|
||||||
components: clippy, rustfmt
|
components: clippy
|
||||||
|
|
||||||
- name: Cache cargo build
|
- name: Cache cargo build
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
@@ -60,9 +53,6 @@ jobs:
|
|||||||
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
|
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
|
||||||
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev
|
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev
|
||||||
|
|
||||||
- name: Format check
|
|
||||||
run: cargo fmt --check
|
|
||||||
|
|
||||||
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
|
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
|
||||||
# same as the web-e2e workflow's server prebuild.
|
# same as the web-e2e workflow's server prebuild.
|
||||||
- name: Clippy (deny warnings)
|
- name: Clippy (deny warnings)
|
||||||
|
|||||||
+2
-6
@@ -592,15 +592,11 @@ pub struct Session<G> { /* replayable instruction log + derived stats */ }
|
|||||||
|
|
||||||
// From `klondike` (upstream — never edit):
|
// From `klondike` (upstream — never edit):
|
||||||
pub enum KlondikePile {
|
pub enum KlondikePile {
|
||||||
Stock, // NB: no Waste variant — see below
|
Stock,
|
||||||
|
Waste,
|
||||||
Foundation(Foundation), // 4 slots, any suit may claim any slot
|
Foundation(Foundation), // 4 slots, any suit may claim any slot
|
||||||
Tableau(Tableau), // 7 columns
|
Tableau(Tableau), // 7 columns
|
||||||
}
|
}
|
||||||
// Pile-coordinate convention: upstream has no `Waste` variant. In
|
|
||||||
// pile-coordinate space `KlondikePile::Stock` denotes the face-up
|
|
||||||
// *waste* pile (the only stock-side pile cards move out of); use
|
|
||||||
// `GameState::stock_cards()` / `waste_cards()` when the face-down
|
|
||||||
// draw stack must be distinguished. Documented on `GameState::pile`.
|
|
||||||
pub enum DrawStockConfig { DrawOne, DrawThree }
|
pub enum DrawStockConfig { DrawOne, DrawThree }
|
||||||
pub enum KlondikeInstruction { /* RotateStock, DstFoundation, ... — the
|
pub enum KlondikeInstruction { /* RotateStock, DstFoundation, ... — the
|
||||||
serialized move format (schema v4+) */ }
|
serialized move format (schema v4+) */ }
|
||||||
|
|||||||
@@ -439,7 +439,10 @@ impl GameState {
|
|||||||
self.session.history().len()
|
self.session.history().len()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cards_with_face(cards: impl IntoIterator<Item = Card>, face_up: bool) -> Vec<(Card, bool)> {
|
fn cards_with_face(
|
||||||
|
cards: impl IntoIterator<Item = Card>,
|
||||||
|
face_up: bool,
|
||||||
|
) -> Vec<(Card, bool)> {
|
||||||
cards.into_iter().map(|card| (card, face_up)).collect()
|
cards.into_iter().map(|card| (card, face_up)).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,10 +507,8 @@ impl GameState {
|
|||||||
Self::cards_with_face(cards.iter().cloned(), true)
|
Self::cards_with_face(cards.iter().cloned(), true)
|
||||||
}
|
}
|
||||||
KlondikePile::Tableau(tableau) => {
|
KlondikePile::Tableau(tableau) => {
|
||||||
let mut cards = Self::cards_with_face(
|
let mut cards =
|
||||||
state.tableau_face_down_cards(tableau).iter().cloned(),
|
Self::cards_with_face(state.tableau_face_down_cards(tableau).iter().cloned(), false);
|
||||||
false,
|
|
||||||
);
|
|
||||||
cards.extend(Self::cards_with_face(
|
cards.extend(Self::cards_with_face(
|
||||||
state.tableau_face_up_cards(tableau).iter().cloned(),
|
state.tableau_face_up_cards(tableau).iter().cloned(),
|
||||||
true,
|
true,
|
||||||
@@ -822,7 +823,9 @@ impl GameState {
|
|||||||
) -> Option<(KlondikePile, KlondikePile, usize)> {
|
) -> Option<(KlondikePile, KlondikePile, usize)> {
|
||||||
let state = self.session.state().state().state();
|
let state = self.session.state().state().state();
|
||||||
match instruction {
|
match instruction {
|
||||||
KlondikeInstruction::RotateStock => Some((KlondikePile::Stock, KlondikePile::Stock, 1)),
|
KlondikeInstruction::RotateStock => {
|
||||||
|
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
|
||||||
|
}
|
||||||
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
||||||
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
|
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
|
||||||
return None;
|
return None;
|
||||||
@@ -925,7 +928,10 @@ impl GameState {
|
|||||||
///
|
///
|
||||||
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
|
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
|
||||||
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
|
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
|
||||||
pub fn apply_instruction(&mut self, instruction: KlondikeInstruction) -> Result<(), MoveError> {
|
pub fn apply_instruction(
|
||||||
|
&mut self,
|
||||||
|
instruction: KlondikeInstruction,
|
||||||
|
) -> Result<(), MoveError> {
|
||||||
if self.is_won() {
|
if self.is_won() {
|
||||||
return Err(MoveError::GameAlreadyWon);
|
return Err(MoveError::GameAlreadyWon);
|
||||||
}
|
}
|
||||||
@@ -1290,13 +1296,12 @@ mod tests {
|
|||||||
|
|
||||||
game.take_from_foundation = false;
|
game.take_from_foundation = false;
|
||||||
assert!(!game.can_move_cards(&from, &to, 1));
|
assert!(!game.can_move_cards(&from, &to, 1));
|
||||||
assert!(legal_pile_moves(&game).iter().all(|(f, t, _)| !matches!(
|
assert!(
|
||||||
f,
|
legal_pile_moves(&game)
|
||||||
KlondikePile::Foundation(_)
|
.iter()
|
||||||
) || !matches!(
|
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_))
|
||||||
t,
|
|| !matches!(t, KlondikePile::Tableau(_)))
|
||||||
KlondikePile::Tableau(_)
|
);
|
||||||
)));
|
|
||||||
assert!(game.move_cards(from, to, 1).is_err());
|
assert!(game.move_cards(from, to, 1).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1355,18 +1360,9 @@ mod tests {
|
|||||||
fn budget_is_passed_through_not_clamped() {
|
fn budget_is_passed_through_not_clamped() {
|
||||||
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the
|
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the
|
||||||
// budget reaches the solver unchanged.
|
// budget reaches the solver unchanged.
|
||||||
let easy = GameState::solve_fresh_deal(
|
let easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 1_000, 1_000);
|
||||||
0xD1FF_0000_0000_0012,
|
let medium =
|
||||||
DrawStockConfig::DrawOne,
|
GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 5_000, 5_000);
|
||||||
1_000,
|
|
||||||
1_000,
|
|
||||||
);
|
|
||||||
let medium = GameState::solve_fresh_deal(
|
|
||||||
0xD1FF_0000_0000_0012,
|
|
||||||
DrawStockConfig::DrawOne,
|
|
||||||
5_000,
|
|
||||||
5_000,
|
|
||||||
);
|
|
||||||
assert!(easy.is_err());
|
assert!(easy.is_err());
|
||||||
assert!(matches!(medium, Ok(Some(_))));
|
assert!(matches!(medium, Ok(Some(_))));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ pub mod scoring;
|
|||||||
// when decoding instructions to piles in `instruction_to_piles`) and do not
|
// when decoding instructions to piles in `instruction_to_piles`) and do not
|
||||||
// appear in any public method signature.
|
// appear in any public method signature.
|
||||||
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
|
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
|
||||||
pub use klondike::{
|
pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
|
||||||
DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
||||||
// former `solitaire_data::solver` wrapper module.
|
// former `solitaire_data::solver` wrapper module.
|
||||||
|
|||||||
@@ -41,20 +41,13 @@ fn all_cards(game: &GameState) -> Vec<Card> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
for t in &tableaux {
|
for t in &tableaux {
|
||||||
cards.extend(
|
cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone()));
|
||||||
game.pile(KlondikePile::Tableau(*t))
|
|
||||||
.iter()
|
|
||||||
.map(|(c, _)| c.clone()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
cards
|
cards
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
|
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
|
||||||
prop_oneof![
|
prop_oneof![Just(DrawStockConfig::DrawOne), Just(DrawStockConfig::DrawThree)]
|
||||||
Just(DrawStockConfig::DrawOne),
|
|
||||||
Just(DrawStockConfig::DrawThree)
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a sequence of random actions to a game, silently ignoring errors.
|
/// Apply a sequence of random actions to a game, silently ignoring errors.
|
||||||
|
|||||||
@@ -161,10 +161,8 @@ pub struct Settings {
|
|||||||
/// Identifier of the active card-art theme. Matches `meta.id` from
|
/// Identifier of the active card-art theme. Matches `meta.id` from
|
||||||
/// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are
|
/// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are
|
||||||
/// always present; user-supplied themes register under their own ids.
|
/// always present; user-supplied themes register under their own ids.
|
||||||
/// Older `settings.json` files that stored `"default"` (the
|
/// Older `settings.json` files that stored `"default"` or `"classic"`
|
||||||
/// pre-rename id of the dark theme) are migrated to `"dark"` by
|
/// are migrated to `"dark"` by [`Settings::sanitized`].
|
||||||
/// [`Settings::sanitized`]; `"classic"` is a valid player choice
|
|
||||||
/// and is never rewritten.
|
|
||||||
#[serde(default = "default_theme_id")]
|
#[serde(default = "default_theme_id")]
|
||||||
pub selected_theme_id: String,
|
pub selected_theme_id: String,
|
||||||
/// Set to `true` once the achievement-onboarding info-toast has been
|
/// Set to `true` once the achievement-onboarding info-toast has been
|
||||||
|
|||||||
@@ -553,8 +553,8 @@ mod tests {
|
|||||||
"saved file must use schema version 5",
|
"saved file must use schema version 5",
|
||||||
);
|
);
|
||||||
|
|
||||||
let loaded =
|
let loaded = load_game_state_from(&path)
|
||||||
load_game_state_from(&path).expect("a valid in-progress game must load without error");
|
.expect("a valid in-progress game must load without error");
|
||||||
|
|
||||||
// The forward instruction history round-trips, so the reconstructed board
|
// The forward instruction history round-trips, so the reconstructed board
|
||||||
// re-serialises to byte-identical JSON.
|
// re-serialises to byte-identical JSON.
|
||||||
@@ -569,11 +569,7 @@ mod tests {
|
|||||||
|
|
||||||
// Derived board reads match the live game (move count + recycle count are
|
// Derived board reads match the live game (move count + recycle count are
|
||||||
// both rebuilt from the replayed forward history).
|
// both rebuilt from the replayed forward history).
|
||||||
assert_eq!(
|
assert_eq!(loaded.move_count(), gs.move_count(), "move_count round-trips");
|
||||||
loaded.move_count(),
|
|
||||||
gs.move_count(),
|
|
||||||
"move_count round-trips"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
loaded.recycle_count(),
|
loaded.recycle_count(),
|
||||||
gs.recycle_count(),
|
gs.recycle_count(),
|
||||||
|
|||||||
@@ -77,11 +77,6 @@ pub struct SolitaireServerClient {
|
|||||||
username: String,
|
username: String,
|
||||||
/// Shared `reqwest` client (keeps connection pools alive across calls).
|
/// Shared `reqwest` client (keeps connection pools alive across calls).
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
/// Serialises token refreshes. The server rotates refresh tokens and each
|
|
||||||
/// is single-use, so two overlapping 401-retries must not both spend one:
|
|
||||||
/// the loser's (already-consumed) token would be rejected and surface as
|
|
||||||
/// a spurious "session expired" to the player. See [`Self::refresh_token`].
|
|
||||||
refresh_lock: tokio::sync::Mutex<()>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@@ -95,7 +90,6 @@ impl SolitaireServerClient {
|
|||||||
base_url: base_url.into().trim_end_matches('/').to_owned(),
|
base_url: base_url.into().trim_end_matches('/').to_owned(),
|
||||||
username: username.into(),
|
username: username.into(),
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
refresh_lock: tokio::sync::Mutex::new(()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,27 +172,7 @@ impl SolitaireServerClient {
|
|||||||
/// The server rotates refresh tokens on each call: the response includes a
|
/// The server rotates refresh tokens on each call: the response includes a
|
||||||
/// new refresh token that replaces the old one. Both tokens are persisted
|
/// new refresh token that replaces the old one. Both tokens are persisted
|
||||||
/// to the OS keychain on success.
|
/// to the OS keychain on success.
|
||||||
///
|
async fn refresh_token(&self) -> Result<(), SyncError> {
|
||||||
/// `stale_access` is the access token that just earned the caller a 401.
|
|
||||||
/// Refreshes are serialised behind [`Self::refresh_lock`]: with single-use
|
|
||||||
/// rotated refresh tokens, two overlapping 401-retries (e.g. a replay
|
|
||||||
/// upload racing a manual sync) must not both call `/api/auth/refresh` —
|
|
||||||
/// the second call would present an already-consumed token, get rejected,
|
|
||||||
/// and force a re-login for no user-visible reason. Whoever loses the lock
|
|
||||||
/// race checks whether the stored access token has already moved past
|
|
||||||
/// `stale_access`; if so the refresh already happened and there is nothing
|
|
||||||
/// left to do.
|
|
||||||
async fn refresh_token(&self, stale_access: &str) -> Result<(), SyncError> {
|
|
||||||
let _guard = self.refresh_lock.lock().await;
|
|
||||||
|
|
||||||
if let Ok(current) = load_access_token(&self.username)
|
|
||||||
&& current != stale_access
|
|
||||||
{
|
|
||||||
// Another task refreshed while we waited on the lock; retry with
|
|
||||||
// the token it stored rather than spending the new refresh token.
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let old_refresh =
|
let old_refresh =
|
||||||
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?;
|
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?;
|
||||||
|
|
||||||
@@ -257,7 +231,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
// Token expired — refresh and retry once.
|
// Token expired — refresh and retry once.
|
||||||
self.refresh_token(&token).await?;
|
self.refresh_token().await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -290,7 +264,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
// Token expired — refresh and retry once.
|
// Token expired — refresh and retry once.
|
||||||
self.refresh_token(&token).await?;
|
self.refresh_token().await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -357,7 +331,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.refresh_token(&token).await?;
|
self.refresh_token().await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -392,7 +366,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.refresh_token(&token).await?;
|
self.refresh_token().await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -432,7 +406,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.refresh_token(&token).await?;
|
self.refresh_token().await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -472,7 +446,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.refresh_token(&token).await?;
|
self.refresh_token().await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -506,7 +480,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.refresh_token(&token).await?;
|
self.refresh_token().await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -568,7 +542,7 @@ impl SolitaireServerClient {
|
|||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.refresh_token(&token).await?;
|
self.refresh_token().await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
|
|||||||
@@ -911,7 +911,8 @@ 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 = GameMode::Zen;
|
app.world_mut().resource_mut::<GameStateResource>().0.mode =
|
||||||
|
GameMode::Zen;
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 0,
|
score: 0,
|
||||||
|
|||||||
@@ -53,12 +53,12 @@ pub fn set_user_theme_dir(path: PathBuf) -> Result<(), PathBuf> {
|
|||||||
/// Returns the absolute path of the user-themes directory on the
|
/// Returns the absolute path of the user-themes directory on the
|
||||||
/// current platform.
|
/// current platform.
|
||||||
///
|
///
|
||||||
/// When [`solitaire_data::data_dir`] returns `None` (broken `$HOME` /
|
/// # Panics
|
||||||
/// `$XDG_*` on desktop; always on wasm32, which has no filesystem) this
|
///
|
||||||
/// returns an empty path — callers treat that as "no user themes" and
|
/// Panics if [`solitaire_data::data_dir`] returns `None`, which on
|
||||||
/// the bundled default theme still works. A warning naming the
|
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration.
|
||||||
/// [`set_user_theme_dir`] workaround is logged once. Android always
|
/// Android always returns `Some`. The panic message names the
|
||||||
/// resolves.
|
/// supported workaround ([`set_user_theme_dir`]).
|
||||||
pub fn user_theme_dir() -> PathBuf {
|
pub fn user_theme_dir() -> PathBuf {
|
||||||
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
|
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
|
||||||
return p.clone();
|
return p.clone();
|
||||||
@@ -76,32 +76,29 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
|
|||||||
/// Per-target-os resolution of the platform's data dir. Delegates
|
/// Per-target-os resolution of the platform's data dir. Delegates
|
||||||
/// to [`solitaire_data::data_dir`] which encapsulates the
|
/// to [`solitaire_data::data_dir`] which encapsulates the
|
||||||
/// per-target shape (desktop: `dirs::data_dir()`; android: the
|
/// per-target shape (desktop: `dirs::data_dir()`; android: the
|
||||||
/// hardcoded `/data/data/<package>/files` sandbox path).
|
/// hardcoded `/data/data/<package>/files` sandbox path). Panics
|
||||||
///
|
/// only when the underlying resolver returns `None`, which on
|
||||||
/// When the resolver returns `None` — always on wasm32 (no
|
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration —
|
||||||
/// filesystem), or a broken `$HOME` / `$XDG_*` configuration on
|
/// the panic message names the supported workaround.
|
||||||
/// desktop — this degrades to an empty path, which downstream theme
|
|
||||||
/// scanning treats as "no user themes"; the bundled default theme is
|
|
||||||
/// unaffected. CLAUDE.md §2.3 forbids panicking here: losing custom
|
|
||||||
/// themes must not take the whole game down with it.
|
|
||||||
fn detected_platform_data_dir() -> PathBuf {
|
fn detected_platform_data_dir() -> PathBuf {
|
||||||
solitaire_data::data_dir().unwrap_or_else(|| {
|
solitaire_data::data_dir().unwrap_or_else(|| {
|
||||||
|
// On wasm32, data_dir() always returns None — there is no filesystem.
|
||||||
|
// User themes are not supported in the browser build; return an empty
|
||||||
|
// path so callers produce a benign empty dir rather than panicking.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
PathBuf::new()
|
||||||
|
}
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
{
|
||||||
use std::sync::Once;
|
panic!(
|
||||||
static WARN_ONCE: Once = Once::new();
|
"user_theme_dir(): platform data directory is unavailable. \
|
||||||
WARN_ONCE.call_once(|| {
|
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
|
||||||
bevy::log::warn!(
|
the OS reported no Application Support / AppData path. \
|
||||||
"user_theme_dir(): platform data directory is unavailable; \
|
As a workaround call solitaire_engine::assets::user_dir::\
|
||||||
user themes are disabled. On Linux check $XDG_DATA_HOME or \
|
set_user_theme_dir() before App::run()."
|
||||||
$HOME; on macOS / Windows the OS reported no Application \
|
)
|
||||||
Support / AppData path. As a workaround call \
|
|
||||||
solitaire_engine::assets::user_dir::set_user_theme_dir() \
|
|
||||||
before App::run()."
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
PathBuf::new()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,9 +174,9 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::{Deck, Rank, Suit};
|
use solitaire_core::{Deck, Rank, Suit};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -214,11 +214,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
g.set_test_tableau_cards(
|
g.set_test_tableau_cards(
|
||||||
Tableau::Tableau1,
|
Tableau::Tableau1,
|
||||||
vec![solitaire_core::Card::new(
|
vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
|
||||||
Deck::Deck1,
|
|
||||||
Suit::Clubs,
|
|
||||||
Rank::Ace,
|
|
||||||
)],
|
|
||||||
);
|
);
|
||||||
g.set_test_auto_completable(true);
|
g.set_test_auto_completable(true);
|
||||||
let expected = (
|
let expected = (
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ pub struct HoverState {
|
|||||||
/// Describes a user action that arrived while cards were still animating.
|
/// Describes a user action that arrived while cards were still animating.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum BufferedInput {
|
pub enum BufferedInput {
|
||||||
Move { from: MoveRequestEvent },
|
Move {
|
||||||
|
from: MoveRequestEvent,
|
||||||
|
},
|
||||||
Draw,
|
Draw,
|
||||||
Undo,
|
Undo,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ use crate::animation_plugin::EffectiveSlideDuration;
|
|||||||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
|
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
use crate::resources::DragState;
|
use crate::resources::DragState;
|
||||||
use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z};
|
use crate::ui_theme::{
|
||||||
|
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
|
||||||
|
};
|
||||||
|
|
||||||
/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity.
|
/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity.
|
||||||
///
|
///
|
||||||
@@ -195,3 +197,4 @@ pub(super) fn update_card_shadows_on_drag(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Task #28 — Hint highlight tick system
|
// Task #28 — Hint highlight tick system
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
use bevy::color::Color;
|
use bevy::color::Color;
|
||||||
use solitaire_core::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
@@ -271,3 +272,5 @@ pub(super) fn find_top_card_at(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Task #28 — Stock-empty visual indicator
|
// Task #28 — Stock-empty visual indicator
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
use bevy::color::Color;
|
use bevy::color::Color;
|
||||||
use bevy::sprite::Anchor;
|
use bevy::sprite::Anchor;
|
||||||
use solitaire_core::{Card, Rank, Suit};
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
@@ -204,3 +205,4 @@ pub(super) fn add_android_corner_label(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Task #34 — Card-flip animation systems
|
// Task #34 — Card-flip animation systems
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ use crate::font_plugin::FontResource;
|
|||||||
use crate::layout::{Layout, LayoutResource};
|
use crate::layout::{Layout, LayoutResource};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::table_plugin::PileMarker;
|
use crate::table_plugin::PileMarker;
|
||||||
use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG, CARD_SHADOW_PADDING_IDLE};
|
use crate::ui_theme::{
|
||||||
|
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG,
|
||||||
|
CARD_SHADOW_PADDING_IDLE,
|
||||||
|
};
|
||||||
|
|
||||||
/// Coalesces every `WindowResized` event arriving this frame into the latest
|
/// Coalesces every `WindowResized` event arriving this frame into the latest
|
||||||
/// pending size on [`ResizeThrottle`].
|
/// pending size on [`ResizeThrottle`].
|
||||||
@@ -344,3 +347,5 @@ pub(super) fn fill_tableau_fan_on_startup(
|
|||||||
};
|
};
|
||||||
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
|
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use bevy::color::Color;
|
use bevy::color::Color;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::Card;
|
|
||||||
use solitaire_core::{KlondikePile, Tableau};
|
use solitaire_core::{KlondikePile, Tableau};
|
||||||
|
use solitaire_core::Card;
|
||||||
|
|
||||||
use crate::card_animation::CardAnimation;
|
use crate::card_animation::CardAnimation;
|
||||||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
|
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
|
||||||
@@ -577,7 +577,10 @@ impl Plugin for CardPlugin {
|
|||||||
// the chain each pair is a scheduler ambiguity (#143). All
|
// the chain each pair is a scheduler ambiguity (#143). All
|
||||||
// members are cheap and mostly change-gated; sequential
|
// members are cheap and mostly change-gated; sequential
|
||||||
// execution is not a cost that matters here.
|
// execution is not a cost that matters here.
|
||||||
.configure_sets(Update, LayoutSystem::UpdateOnResize.before(BoardVisuals))
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
LayoutSystem::UpdateOnResize.before(BoardVisuals),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -594,7 +597,8 @@ impl Plugin for CardPlugin {
|
|||||||
clear_right_click_highlights_on_pause,
|
clear_right_click_highlights_on_pause,
|
||||||
tick_hint_highlight,
|
tick_hint_highlight,
|
||||||
update_stock_empty_indicator,
|
update_stock_empty_indicator,
|
||||||
update_stock_count_badge.run_if(resource_changed::<GameStateResource>),
|
update_stock_count_badge
|
||||||
|
.run_if(resource_changed::<GameStateResource>),
|
||||||
collect_resize_events,
|
collect_resize_events,
|
||||||
snap_cards_on_window_resize,
|
snap_cards_on_window_resize,
|
||||||
resize_android_corner_labels,
|
resize_android_corner_labels,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
use bevy::color::Color;
|
use bevy::color::Color;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
@@ -11,7 +12,10 @@ use crate::font_plugin::FontResource;
|
|||||||
use crate::layout::{Layout, LayoutResource};
|
use crate::layout::{Layout, LayoutResource};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
|
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
|
||||||
use crate::ui_theme::{STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TYPE_BODY, Z_STOCK_BADGE};
|
use crate::ui_theme::{
|
||||||
|
STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY,
|
||||||
|
TYPE_BODY, Z_STOCK_BADGE,
|
||||||
|
};
|
||||||
|
|
||||||
/// Sprite colour applied to the stock `PileMarker` when the stock pile is empty,
|
/// Sprite colour applied to the stock `PileMarker` when the stock pile is empty,
|
||||||
/// to signal to the player that there are no more cards to draw. Pure white
|
/// to signal to the player that there are no more cards to draw. Pure white
|
||||||
@@ -284,3 +288,4 @@ pub(super) fn update_stock_count_badge(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ use super::*;
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use bevy::color::Color;
|
use bevy::color::Color;
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::{Card, Rank, Suit};
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
|
|
||||||
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
|
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
|
||||||
use crate::card_animation::CardAnimation;
|
use crate::card_animation::CardAnimation;
|
||||||
@@ -700,3 +700,4 @@ pub(super) fn update_card_entity(
|
|||||||
commands.entity(entity).insert(new_children_key);
|
commands.entity(entity).insert(new_children_key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::events::StateChangedEvent;
|
|
||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use crate::layout::LayoutResource;
|
|
||||||
use crate::layout::TABLEAU_FAN_FRAC;
|
use crate::layout::TABLEAU_FAN_FRAC;
|
||||||
use crate::resources::DragState;
|
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
use crate::ui_theme::TEXT_PRIMARY_HC;
|
|
||||||
use bevy::window::WindowResized;
|
|
||||||
use solitaire_core::Deck;
|
use solitaire_core::Deck;
|
||||||
|
use bevy::window::WindowResized;
|
||||||
use solitaire_core::{Card, Rank, Suit};
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use crate::events::StateChangedEvent;
|
||||||
|
use crate::layout::LayoutResource;
|
||||||
|
use crate::resources::DragState;
|
||||||
|
use crate::ui_theme::TEXT_PRIMARY_HC;
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
|
|
||||||
/// Convenience constructor — all unit tests use Deck1.
|
/// Convenience constructor — all unit tests use Deck1.
|
||||||
fn make_card(suit: Suit, rank: Rank) -> Card {
|
fn make_card(suit: Suit, rank: Rank) -> Card {
|
||||||
@@ -118,7 +119,8 @@ fn waste_draw_one_only_renders_top_card() {
|
|||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
let _ = g.draw();
|
let _ = g.draw();
|
||||||
}
|
}
|
||||||
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
let waste_ids: HashSet<Card> =
|
||||||
|
g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||||
assert_eq!(waste_ids.len(), 3);
|
assert_eq!(waste_ids.len(), 3);
|
||||||
|
|
||||||
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);
|
||||||
@@ -161,7 +163,8 @@ fn waste_draw_three_renders_up_to_three_fanned_cards() {
|
|||||||
"need at least 3 waste cards for this test"
|
"need at least 3 waste cards for this test"
|
||||||
);
|
);
|
||||||
|
|
||||||
let waste_ids: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect();
|
let waste_ids: HashSet<Card> =
|
||||||
|
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);
|
||||||
@@ -213,7 +216,8 @@ fn waste_draw_three_fans_correctly_when_pile_smaller_than_visible() {
|
|||||||
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: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect();
|
let waste_ids: HashSet<Card> =
|
||||||
|
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);
|
||||||
|
|
||||||
@@ -250,7 +254,8 @@ fn waste_draw_one_buffer_card_at_same_xy_as_top() {
|
|||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
let _ = g.draw();
|
let _ = g.draw();
|
||||||
}
|
}
|
||||||
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
let waste_ids: HashSet<Card> =
|
||||||
|
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);
|
||||||
let waste_rendered: Vec<_> = positions
|
let waste_rendered: Vec<_> = positions
|
||||||
@@ -740,7 +745,8 @@ fn advance_past_resize_throttle(app: &mut App) {
|
|||||||
|
|
||||||
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 = Entity::from_raw_u32(0).expect("Entity::from_raw_u32(0) is a valid placeholder");
|
let window = Entity::from_raw_u32(0)
|
||||||
|
.expect("Entity::from_raw_u32(0) is a valid placeholder");
|
||||||
app.world_mut().write_message(WindowResized {
|
app.world_mut().write_message(WindowResized {
|
||||||
window,
|
window,
|
||||||
width,
|
width,
|
||||||
@@ -922,7 +928,8 @@ fn resize_in_place_updates_card_label_font_size() {
|
|||||||
// Sanity-check: the new font size matches FONT_SIZE_FRAC × the
|
// Sanity-check: the new font size matches FONT_SIZE_FRAC × the
|
||||||
// post-resize card width, so the in-place path is using the
|
// post-resize card width, so the in-place path is using the
|
||||||
// refreshed Layout.
|
// refreshed Layout.
|
||||||
let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true);
|
let expected_layout =
|
||||||
|
crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true);
|
||||||
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
|
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
|
||||||
assert!(
|
assert!(
|
||||||
(after - expected).abs() < 1e-3,
|
(after - expected).abs() < 1e-3,
|
||||||
@@ -1039,8 +1046,7 @@ fn shadow_offset_increases_during_drag() {
|
|||||||
q.iter(app.world())
|
q.iter(app.world())
|
||||||
.next()
|
.next()
|
||||||
.expect("fixture should spawn at least one CardEntity")
|
.expect("fixture should spawn at least one CardEntity")
|
||||||
.card
|
.card.clone()
|
||||||
.clone()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pick a *different* card to act as the negative control —
|
// Pick a *different* card to act as the negative control —
|
||||||
@@ -1095,7 +1101,9 @@ fn shadow_offset_increases_during_drag() {
|
|||||||
fn shadow_offset_for_card(app: &mut App, card: &Card) -> Vec2 {
|
fn shadow_offset_for_card(app: &mut App, card: &Card) -> Vec2 {
|
||||||
// Map every CardEntity to its (Entity, card).
|
// Map every CardEntity to its (Entity, card).
|
||||||
let card_entity = {
|
let card_entity = {
|
||||||
let mut q = app.world_mut().query::<(Entity, &CardEntity)>();
|
let mut q = app
|
||||||
|
.world_mut()
|
||||||
|
.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)
|
||||||
@@ -1190,7 +1198,8 @@ fn stock_badge_updates_when_stock_count_changes() {
|
|||||||
assert_eq!(stock_badge_text(&mut app), "24");
|
assert_eq!(stock_badge_text(&mut app), "24");
|
||||||
{
|
{
|
||||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
let mut stock: Vec<Card> = game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
|
let mut stock: Vec<Card> =
|
||||||
|
game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
|
||||||
let _ = stock.pop();
|
let _ = stock.pop();
|
||||||
game.0.set_test_stock_cards(stock);
|
game.0.set_test_stock_cards(stock);
|
||||||
}
|
}
|
||||||
@@ -1225,7 +1234,8 @@ fn image_set_with_distinct_back_handles() -> CardImageSet {
|
|||||||
// 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 = Assets::<Image>::default();
|
let mut images = Assets::<Image>::default();
|
||||||
let backs: [Handle<Image>; 5] = std::array::from_fn(|_| images.add(Image::default()));
|
let backs: [Handle<Image>; 5] =
|
||||||
|
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,
|
||||||
@@ -1483,7 +1493,8 @@ fn waste_pile_cards_have_strictly_increasing_z() {
|
|||||||
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: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
let waste_ids: HashSet<Card> =
|
||||||
|
g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||||
|
|
||||||
let mut waste_zs: Vec<f32> = positions
|
let mut waste_zs: Vec<f32> = positions
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1532,7 +1543,8 @@ fn waste_cards_do_not_overlap_stock_column_on_portrait() {
|
|||||||
|
|
||||||
let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
|
let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
|
||||||
|
|
||||||
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
let waste_ids: HashSet<Card> =
|
||||||
|
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)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -1561,7 +1573,8 @@ fn waste_pile_draw_one_cards_have_distinct_z() {
|
|||||||
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: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
let waste_ids: HashSet<Card> =
|
||||||
|
g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||||
|
|
||||||
let mut waste_zs: Vec<f32> = positions
|
let mut waste_zs: Vec<f32> = positions
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -35,8 +35,8 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
||||||
use solitaire_core::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
use crate::card_plugin::RightClickHighlight;
|
use crate::card_plugin::RightClickHighlight;
|
||||||
use crate::layout::{Layout, LayoutResource};
|
use crate::layout::{Layout, LayoutResource};
|
||||||
@@ -437,8 +437,7 @@ fn tableau_or_stack_pos(
|
|||||||
base.x,
|
base.x,
|
||||||
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
||||||
)
|
)
|
||||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree
|
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
|
||||||
{
|
|
||||||
let pile_len = game.waste_cards().len();
|
let pile_len = game.waste_cards().len();
|
||||||
let visible_start = pile_len.saturating_sub(3);
|
let visible_start = pile_len.saturating_sub(3);
|
||||||
let slot = index.saturating_sub(visible_start) as f32;
|
let slot = index.saturating_sub(visible_start) as f32;
|
||||||
@@ -582,10 +581,7 @@ mod tests {
|
|||||||
|
|
||||||
use crate::layout::compute_layout;
|
use crate::layout::compute_layout;
|
||||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
use solitaire_core::{
|
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
||||||
DrawStockConfig,
|
|
||||||
game_state::{GameMode, GameState},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
||||||
/// registered, plus the resources the system needs. Callers
|
/// registered, plus the resources the system needs. Callers
|
||||||
@@ -634,7 +630,11 @@ mod tests {
|
|||||||
// — same colour family, illegal. Tableau(2) must NOT be
|
// — same colour family, illegal. Tableau(2) must NOT be
|
||||||
// highlighted.
|
// highlighted.
|
||||||
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
|
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
|
||||||
set_tableau_top(&mut game, 2, Card::new(Deck::Deck1, Suit::Clubs, Rank::Six));
|
set_tableau_top(
|
||||||
|
&mut game,
|
||||||
|
2,
|
||||||
|
Card::new(Deck::Deck1, Suit::Clubs, Rank::Six),
|
||||||
|
);
|
||||||
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
|
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
|
||||||
|
|
||||||
let mut app = overlay_test_app(game);
|
let mut app = overlay_test_app(game);
|
||||||
|
|||||||
@@ -54,10 +54,7 @@ impl DifficultyIndexResource {
|
|||||||
DifficultyLevel::Hard => &mut self.hard,
|
DifficultyLevel::Hard => &mut self.hard,
|
||||||
DifficultyLevel::Expert => &mut self.expert,
|
DifficultyLevel::Expert => &mut self.expert,
|
||||||
DifficultyLevel::Grandmaster => &mut self.grandmaster,
|
DifficultyLevel::Grandmaster => &mut self.grandmaster,
|
||||||
// Random has no catalog today, so seeds_for() already returned
|
DifficultyLevel::Random => unreachable!("Random has no catalog"),
|
||||||
// None above; if it ever gains one, a time seed is still the
|
|
||||||
// right answer for "Random" — never a reachable panic.
|
|
||||||
DifficultyLevel::Random => return seed_from_system_time(),
|
|
||||||
};
|
};
|
||||||
let seed = catalog[*cursor % catalog.len()];
|
let seed = catalog[*cursor % catalog.len()];
|
||||||
*cursor = cursor.wrapping_add(1);
|
*cursor = cursor.wrapping_add(1);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
use bevy::prelude::Message;
|
use bevy::prelude::Message;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::game_state::GameMode;
|
|
||||||
use solitaire_core::{Card, Suit};
|
use solitaire_core::{Card, Suit};
|
||||||
|
use solitaire_core::game_state::GameMode;
|
||||||
use solitaire_data::AchievementRecord;
|
use solitaire_data::AchievementRecord;
|
||||||
use solitaire_sync::SyncResponse;
|
use solitaire_sync::SyncResponse;
|
||||||
|
|
||||||
|
|||||||
@@ -852,10 +852,7 @@ mod tests {
|
|||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins)
|
app.add_plugins(MinimalPlugins)
|
||||||
.add_plugins(FeedbackAnimPlugin);
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
app.insert_resource(GameStateResource(GameState::new(
|
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
||||||
1,
|
|
||||||
DrawStockConfig::DrawOne,
|
|
||||||
)));
|
|
||||||
app.insert_resource(SettingsResource(Settings {
|
app.insert_resource(SettingsResource(Settings {
|
||||||
reduce_motion_mode: true,
|
reduce_motion_mode: true,
|
||||||
..Settings::default()
|
..Settings::default()
|
||||||
@@ -909,10 +906,7 @@ mod tests {
|
|||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins)
|
app.add_plugins(MinimalPlugins)
|
||||||
.add_plugins(FeedbackAnimPlugin);
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
app.insert_resource(GameStateResource(GameState::new(
|
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
||||||
1,
|
|
||||||
DrawStockConfig::DrawOne,
|
|
||||||
)));
|
|
||||||
app.insert_resource(SettingsResource(Settings {
|
app.insert_resource(SettingsResource(Settings {
|
||||||
reduce_motion_mode: true,
|
reduce_motion_mode: true,
|
||||||
..Settings::default()
|
..Settings::default()
|
||||||
|
|||||||
@@ -14,13 +14,8 @@ use bevy::prelude::*;
|
|||||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||||
use bevy::window::AppLifecycle;
|
use bevy::window::AppLifecycle;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::{
|
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
||||||
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction,
|
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction};
|
||||||
};
|
|
||||||
use solitaire_core::{
|
|
||||||
DrawStockConfig,
|
|
||||||
game_state::{GameMode, GameState},
|
|
||||||
};
|
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
use solitaire_data::latest_replay_path;
|
use solitaire_data::latest_replay_path;
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
@@ -193,9 +188,7 @@ impl Plugin for GamePlugin {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
saved.unwrap_or_else(|| {
|
saved.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)),
|
||||||
GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)
|
|
||||||
}),
|
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@@ -1331,10 +1324,7 @@ fn auto_save_game_state(
|
|||||||
// or there's a pending restore the player hasn't answered — saving
|
// or there's a pending restore the player hasn't answered — saving
|
||||||
// the fresh-deal placeholder we seeded GameStateResource with at
|
// the fresh-deal placeholder we seeded GameStateResource with at
|
||||||
// startup would clobber the real saved game on disk.
|
// startup would clobber the real saved game on disk.
|
||||||
if paused.is_some_and(|p| p.0)
|
if paused.is_some_and(|p| p.0) || game.0.is_won() || game.0.move_count() == 0 || pending.0.is_some()
|
||||||
|| game.0.is_won()
|
|
||||||
|| game.0.move_count() == 0
|
|
||||||
|| pending.0.is_some()
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -372,7 +372,9 @@ fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
|
|||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Messages<CardFlippedEvent>>();
|
let events = app
|
||||||
|
.world()
|
||||||
|
.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!(
|
||||||
@@ -798,10 +800,7 @@ fn replay_recording_skips_undo() {
|
|||||||
1,
|
1,
|
||||||
"only the draw is recorded; the undo does not erase it nor add a new entry",
|
"only the draw is recorded; the undo does not erase it nor add a new entry",
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(recording.moves[0], KlondikeInstruction::RotateStock));
|
||||||
recording.moves[0],
|
|
||||||
KlondikeInstruction::RotateStock
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starting a new game wipes the recording so the next deal begins
|
/// Starting a new game wipes the recording so the next deal begins
|
||||||
@@ -873,8 +872,8 @@ fn replay_recording_freezes_into_replay_on_game_won() {
|
|||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let history =
|
let history = load_replay_history_from(&path)
|
||||||
load_replay_history_from(&path).expect("a winning replay must be persisted to ReplayPath");
|
.expect("a winning replay must be persisted to ReplayPath");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
history.replays.len(),
|
history.replays.len(),
|
||||||
1,
|
1,
|
||||||
@@ -1060,10 +1059,7 @@ fn new_game_with_solver_toggle_off_random_seed_path() {
|
|||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
// Game state was reseeded — move_count is 0 on the new game.
|
// Game state was reseeded — move_count is 0 on the new game.
|
||||||
assert_eq!(
|
assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
|
||||||
app.world().resource::<GameStateResource>().0.move_count(),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1137,10 +1133,7 @@ fn new_game_with_solver_toggle_on_retries_until_winnable() {
|
|||||||
// The chosen seed is non-deterministic (system time),
|
// The chosen seed is non-deterministic (system time),
|
||||||
// but the new game must have been started cleanly:
|
// but the new game must have been started cleanly:
|
||||||
// move_count back to 0, undo stack empty.
|
// move_count back to 0, undo stack empty.
|
||||||
assert_eq!(
|
assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
|
||||||
app.world().resource::<GameStateResource>().0.move_count(),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.world()
|
app.world()
|
||||||
.resource::<GameStateResource>()
|
.resource::<GameStateResource>()
|
||||||
|
|||||||
@@ -432,9 +432,7 @@ fn build_home_context<'a>(
|
|||||||
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
|
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
|
||||||
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
||||||
daily_today,
|
daily_today,
|
||||||
draw_mode: settings
|
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawStockConfig::DrawOne),
|
||||||
.map(|s| s.0.draw_mode)
|
|
||||||
.unwrap_or(DrawStockConfig::DrawOne),
|
|
||||||
font_res,
|
font_res,
|
||||||
difficulty_expanded,
|
difficulty_expanded,
|
||||||
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
|
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// Auto-fade state for the action button bar. The bar fades out when
|
/// Auto-fade state for the action button bar. The bar fades out when
|
||||||
/// the cursor is in the play area (below the HUD band) and back in when
|
/// the cursor is in the play area (below the HUD band) and back in when
|
||||||
/// the cursor approaches the top of the window — same UX as a video
|
/// the cursor approaches the top of the window — same UX as a video
|
||||||
@@ -47,11 +49,7 @@ const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
|
|||||||
/// `target` at a fixed rate so the visual transition is smooth across
|
/// `target` at a fixed rate so the visual transition is smooth across
|
||||||
/// variable framerates.
|
/// variable framerates.
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
pub(super) fn update_action_fade(
|
pub(super) fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut<HudActionFade>) {
|
||||||
windows: Query<&Window>,
|
|
||||||
time: Res<Time>,
|
|
||||||
mut fade: ResMut<HudActionFade>,
|
|
||||||
) {
|
|
||||||
let Ok(window) = windows.single() else {
|
let Ok(window) = windows.single() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -429,3 +427,4 @@ pub(super) fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
|
|||||||
from.alpha + (to.alpha - from.alpha) * t,
|
from.alpha + (to.alpha - from.alpha) * t,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// `Changed<Interaction>` filter ensures we only react on the frame the
|
/// `Changed<Interaction>` filter ensures we only react on the frame the
|
||||||
/// interaction state transitions, avoiding repeat events while the button
|
/// interaction state transitions, avoiding repeat events while the button
|
||||||
/// is held down. Each click handler fires the corresponding request event,
|
/// is held down. Each click handler fires the corresponding request event,
|
||||||
@@ -611,3 +613,4 @@ pub(super) fn toggle_hud_on_tap(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use interaction::*;
|
|||||||
use spawn::*;
|
use spawn::*;
|
||||||
use updates::*;
|
use updates::*;
|
||||||
|
|
||||||
|
|
||||||
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
|
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
|
||||||
// Option<Res<AvatarResource>> parameters below compile without changes.
|
// Option<Res<AvatarResource>> parameters below compile without changes.
|
||||||
// The resource is never inserted on wasm, so every call resolves to None.
|
// The resource is never inserted on wasm, so every call resolves to None.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use crate::avatar_plugin::AvatarResource;
|
use crate::avatar_plugin::AvatarResource;
|
||||||
|
|
||||||
@@ -291,9 +292,7 @@ pub(super) fn spawn_avatar_child(
|
|||||||
const SIZE: f32 = 32.0;
|
const SIZE: f32 = 32.0;
|
||||||
if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
|
if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
|
||||||
// Logged-in with a downloaded avatar: keep the accent disc behind it.
|
// Logged-in with a downloaded avatar: keep the accent disc behind it.
|
||||||
commands
|
commands.entity(parent).insert(BackgroundColor(ACCENT_PRIMARY));
|
||||||
.entity(parent)
|
|
||||||
.insert(BackgroundColor(ACCENT_PRIMARY));
|
|
||||||
// Image fills the circle container; border_radius clips it to a disc.
|
// Image fills the circle container; border_radius clips it to a disc.
|
||||||
commands.entity(parent).with_children(|b| {
|
commands.entity(parent).with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
@@ -550,3 +549,4 @@ pub(super) fn spawn_action_button<M: Component>(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,16 +40,9 @@ fn read_hud_text<M: Component>(app: &mut App) -> String {
|
|||||||
#[test]
|
#[test]
|
||||||
fn score_reflects_game_state() {
|
fn score_reflects_game_state() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
let score = app
|
let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20);
|
||||||
.world_mut()
|
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.0
|
|
||||||
.force_test_score(20);
|
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}"));
|
||||||
read_hud_text::<HudScore>(&mut app),
|
|
||||||
format!("Score: {score}")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -199,9 +192,7 @@ fn challenge_time_color_zero_is_danger() {
|
|||||||
fn challenge_hud_empty_when_no_daily_resource() {
|
fn challenge_hud_empty_when_no_daily_resource() {
|
||||||
// No DailyChallengeResource inserted → HudChallenge must be empty.
|
// No DailyChallengeResource inserted → HudChallenge must be empty.
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.set_changed();
|
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||||
}
|
}
|
||||||
@@ -216,9 +207,7 @@ fn challenge_hud_shows_time_limit_when_resource_present() {
|
|||||||
target_score: None,
|
target_score: None,
|
||||||
max_time_secs: Some(300),
|
max_time_secs: Some(300),
|
||||||
});
|
});
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.set_changed();
|
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
|
||||||
}
|
}
|
||||||
@@ -233,9 +222,7 @@ fn challenge_hud_shows_score_goal_when_resource_present() {
|
|||||||
target_score: Some(4000),
|
target_score: Some(4000),
|
||||||
max_time_secs: None,
|
max_time_secs: None,
|
||||||
});
|
});
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.set_changed();
|
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
|
||||||
}
|
}
|
||||||
@@ -251,10 +238,7 @@ fn challenge_hud_clears_on_win() {
|
|||||||
max_time_secs: Some(300),
|
max_time_secs: Some(300),
|
||||||
});
|
});
|
||||||
// Mark the game as won — HudChallenge should be empty.
|
// Mark the game as won — HudChallenge should be empty.
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true);
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.0
|
|
||||||
.set_test_won(true);
|
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||||
}
|
}
|
||||||
@@ -400,10 +384,7 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
|
|||||||
set_manual_time_step(&mut app, 0.0);
|
set_manual_time_step(&mut app, 0.0);
|
||||||
// Initial state has score=0; bumping by 50 (the threshold)
|
// Initial state has score=0; bumping by 50 (the threshold)
|
||||||
// is the smallest jump that triggers the floater.
|
// is the smallest jump that triggers the floater.
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.0
|
|
||||||
.force_test_score(50);
|
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
// One floater should now exist.
|
// One floater should now exist.
|
||||||
@@ -424,10 +405,7 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn score_floater_despawns_after_full_lifetime() {
|
fn score_floater_despawns_after_full_lifetime() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.0
|
|
||||||
.force_test_score(50);
|
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
|
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
|
||||||
|
|
||||||
@@ -453,10 +431,7 @@ fn score_increase_below_threshold_does_not_spawn_floater() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// +5 mirrors a single tableau-to-foundation move; well below
|
// +5 mirrors a single tableau-to-foundation move; well below
|
||||||
// the 50-point threshold so the floater path stays dormant.
|
// the 50-point threshold so the floater path stays dormant.
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5);
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.0
|
|
||||||
.force_test_score(5);
|
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
count_with::<ScoreFloater>(&mut app),
|
count_with::<ScoreFloater>(&mut app),
|
||||||
@@ -532,10 +507,7 @@ fn score_change_skips_pulse_and_floater_under_reduce_motion() {
|
|||||||
..Settings::default()
|
..Settings::default()
|
||||||
}));
|
}));
|
||||||
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.0
|
|
||||||
.force_test_score(50);
|
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
count_with::<ScorePulse>(&mut app),
|
count_with::<ScorePulse>(&mut app),
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
use bevy::window::WindowResized;
|
use bevy::window::WindowResized;
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::Suit;
|
use solitaire_core::Suit;
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
|
|
||||||
use crate::auto_complete_plugin::AutoCompleteState;
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
|
|
||||||
@@ -609,3 +609,4 @@ pub(super) fn resize_action_bar_labels(
|
|||||||
font.font_size = new_size;
|
font.font_size = new_size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ use bevy::prelude::*;
|
|||||||
use bevy::window::PrimaryWindow;
|
use bevy::window::PrimaryWindow;
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
use bevy::window::{MonitorSelection, WindowMode};
|
use bevy::window::{MonitorSelection, WindowMode};
|
||||||
use solitaire_core::game_state::GameState;
|
|
||||||
use solitaire_core::{Card, Suit};
|
|
||||||
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
|
||||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||||
|
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||||
|
use solitaire_core::{Card, Suit};
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
use crate::auto_complete_plugin::AutoCompleteState;
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
use crate::card_animation::tuning::AnimationTuning;
|
use crate::card_animation::tuning::AnimationTuning;
|
||||||
@@ -394,10 +394,7 @@ pub fn emit_hint_visuals(
|
|||||||
|
|
||||||
// Find the top face-up card in the source pile and highlight it.
|
// Find the top face-up card in the source pile and highlight it.
|
||||||
let source_cards = pile_cards(game, from);
|
let source_cards = pile_cards(game, from);
|
||||||
let top_card = source_cards
|
let top_card = source_cards.last().filter(|(_, face_up)| *face_up).map(|(c, _)| c.clone());
|
||||||
.last()
|
|
||||||
.filter(|(_, face_up)| *face_up)
|
|
||||||
.map(|(c, _)| c.clone());
|
|
||||||
if let Some(card) = top_card {
|
if let Some(card) = top_card {
|
||||||
for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
|
for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
|
||||||
if card_entity.card == card {
|
if card_entity.card == card {
|
||||||
@@ -834,7 +831,9 @@ fn end_drag(
|
|||||||
let origin_cards = pile_cards(&game.0, &origin);
|
let origin_cards = pile_cards(&game.0, &origin);
|
||||||
if !origin_cards.is_empty() {
|
if !origin_cards.is_empty() {
|
||||||
for card in &drag.cards {
|
for card in &drag.cards {
|
||||||
let Some(stack_index) = origin_cards.iter().position(|(c, _)| c == card) else {
|
let Some(stack_index) =
|
||||||
|
origin_cards.iter().position(|(c, _)| c == card)
|
||||||
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
||||||
@@ -1071,7 +1070,8 @@ fn touch_end_drag(
|
|||||||
let origin_cards = pile_cards(&game.0, &origin);
|
let origin_cards = pile_cards(&game.0, &origin);
|
||||||
if !origin_cards.is_empty() {
|
if !origin_cards.is_empty() {
|
||||||
for card in &drag.cards {
|
for card in &drag.cards {
|
||||||
let Some(stack_index) = origin_cards.iter().position(|(c, _)| c == card)
|
let Some(stack_index) =
|
||||||
|
origin_cards.iter().position(|(c, _)| c == card)
|
||||||
else {
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -1174,8 +1174,7 @@ fn card_position(
|
|||||||
y_offset -= layout.card_size.y * step;
|
y_offset -= layout.card_size.y * step;
|
||||||
}
|
}
|
||||||
Vec2::new(base.x, base.y + y_offset)
|
Vec2::new(base.x, base.y + y_offset)
|
||||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree
|
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
|
||||||
{
|
|
||||||
// In Draw-Three mode the top 3 waste cards are fanned in X to match
|
// In Draw-Three mode the top 3 waste cards are fanned in X to match
|
||||||
// card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
|
// card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
|
||||||
// so clicking the visually rightmost (top) card actually registers — a
|
// so clicking the visually rightmost (top) card actually registers — a
|
||||||
@@ -1249,10 +1248,7 @@ fn find_draggable_at(
|
|||||||
}
|
}
|
||||||
(i, i + 1)
|
(i, i + 1)
|
||||||
};
|
};
|
||||||
let cards: Vec<Card> = pile_cards[start..end]
|
let cards: Vec<Card> = pile_cards[start..end].iter().map(|(c, _)| c.clone()).collect();
|
||||||
.iter()
|
|
||||||
.map(|(c, _)| c.clone())
|
|
||||||
.collect();
|
|
||||||
return Some((pile, start, cards));
|
return Some((pile, start, cards));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1554,7 +1550,8 @@ fn handle_double_tap(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some((found_card, found_face_up)) = pile_cards.iter().find(|(c, _)| c == top_card)
|
let Some((found_card, found_face_up)) =
|
||||||
|
pile_cards.iter().find(|(c, _)| c == top_card)
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -1786,6 +1783,8 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const fn tableau_number(tableau: Tableau) -> u8 {
|
const fn tableau_number(tableau: Tableau) -> u8 {
|
||||||
match tableau {
|
match tableau {
|
||||||
Tableau::Tableau1 => 1,
|
Tableau::Tableau1 => 1,
|
||||||
|
|||||||
@@ -89,11 +89,9 @@ fn find_draggable_picks_waste_top_with_multiple_cards() {
|
|||||||
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
clear_test_piles(&mut game);
|
clear_test_piles(&mut game);
|
||||||
let waste = vec![
|
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||||
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)];
|
||||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
|
|
||||||
];
|
|
||||||
game.set_test_waste_cards(waste.clone());
|
game.set_test_waste_cards(waste.clone());
|
||||||
|
|
||||||
let top_index = waste.len() - 1; // 2 = the visible top
|
let top_index = waste.len() - 1; // 2 = the visible top
|
||||||
@@ -101,11 +99,7 @@ fn find_draggable_picks_waste_top_with_multiple_cards() {
|
|||||||
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
|
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
|
||||||
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
|
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
|
||||||
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
|
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
|
||||||
assert_eq!(
|
assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only");
|
||||||
result.2,
|
|
||||||
vec![waste[top_index].clone()],
|
|
||||||
"drags the top card only"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -182,7 +176,8 @@ fn find_draggable_skips_face_down_cards() {
|
|||||||
// face-up card, but the iterator should skip face-down cards and
|
// face-up card, but the iterator should skip face-down cards and
|
||||||
// the cursor sits above the face-up card's AABB, so the result
|
// the cursor sits above the face-up card's AABB, so the result
|
||||||
// is None.
|
// is None.
|
||||||
let face_down_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
let face_down_pos =
|
||||||
|
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
||||||
let result = find_draggable_at(face_down_pos, &game, &layout);
|
let result = find_draggable_at(face_down_pos, &game, &layout);
|
||||||
assert!(result.is_none(), "face-down cards should not be draggable");
|
assert!(result.is_none(), "face-down cards should not be draggable");
|
||||||
}
|
}
|
||||||
@@ -200,7 +195,8 @@ fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() {
|
|||||||
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
||||||
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
||||||
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
|
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
|
||||||
let face_up_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
let face_up_pos =
|
||||||
|
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||||
let result = find_draggable_at(face_up_pos, &game, &layout)
|
let result = find_draggable_at(face_up_pos, &game, &layout)
|
||||||
.expect("clicking the face-up card's visible centre must initiate a drag");
|
.expect("clicking the face-up card's visible centre must initiate a drag");
|
||||||
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
||||||
@@ -217,14 +213,18 @@ fn find_draggable_returns_run_when_picking_mid_stack() {
|
|||||||
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
|
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
|
||||||
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
|
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
|
||||||
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
|
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
|
||||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![king, queen.clone(), jack.clone()]);
|
game.set_test_tableau_cards(
|
||||||
|
Tableau::Tableau1,
|
||||||
|
vec![king, queen.clone(), jack.clone()],
|
||||||
|
);
|
||||||
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
// The Queen's geometric center (index 1) is inside the Jack's bounding box
|
// The Queen's geometric center (index 1) is inside the Jack's bounding box
|
||||||
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
||||||
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
||||||
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
||||||
let queen_center = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
let queen_center =
|
||||||
|
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
||||||
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
||||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||||
@@ -322,11 +322,7 @@ fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
|
|||||||
);
|
);
|
||||||
let (pile, _start, ids) = result.unwrap();
|
let (pile, _start, ids) = result.unwrap();
|
||||||
assert_eq!(pile, KlondikePile::Stock);
|
assert_eq!(pile, KlondikePile::Stock);
|
||||||
assert_eq!(
|
assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste");
|
||||||
ids,
|
|
||||||
vec![four_clubs],
|
|
||||||
"only the top card is draggable from waste"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -562,7 +558,8 @@ fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
|||||||
fn rejected_drag_animation_targets_origin_resting_position() {
|
fn rejected_drag_animation_targets_origin_resting_position() {
|
||||||
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
|
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
|
||||||
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
|
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
|
||||||
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
|
let anim =
|
||||||
|
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
(anim.end - target_pos).length() < 1e-6,
|
(anim.end - target_pos).length() < 1e-6,
|
||||||
@@ -580,7 +577,8 @@ fn rejected_drag_animation_targets_origin_resting_position() {
|
|||||||
fn rejected_drag_animation_starts_from_drag_position() {
|
fn rejected_drag_animation_starts_from_drag_position() {
|
||||||
let drag_pos = Vec2::new(640.0, 200.0);
|
let drag_pos = Vec2::new(640.0, 200.0);
|
||||||
let target_pos = Vec2::new(80.0, -120.0);
|
let target_pos = Vec2::new(80.0, -120.0);
|
||||||
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
|
let anim =
|
||||||
|
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
(anim.start - drag_pos).length() < 1e-6,
|
(anim.start - drag_pos).length() < 1e-6,
|
||||||
@@ -603,8 +601,12 @@ fn rejected_drag_animation_starts_from_drag_position() {
|
|||||||
/// the call site honest.
|
/// the call site honest.
|
||||||
#[test]
|
#[test]
|
||||||
fn rejected_drag_animation_uses_correct_duration() {
|
fn rejected_drag_animation_uses_correct_duration() {
|
||||||
let anim =
|
let anim = build_drag_reject_animation(
|
||||||
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
|
Vec2::new(640.0, 200.0),
|
||||||
|
DRAG_Z,
|
||||||
|
Vec2::new(80.0, -120.0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
|
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
|
||||||
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
|
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
|
||||||
@@ -618,8 +620,12 @@ fn rejected_drag_animation_uses_correct_duration() {
|
|||||||
/// jittery rather than forgiving.
|
/// jittery rather than forgiving.
|
||||||
#[test]
|
#[test]
|
||||||
fn rejected_drag_animation_uses_responsive_curve() {
|
fn rejected_drag_animation_uses_responsive_curve() {
|
||||||
let anim =
|
let anim = build_drag_reject_animation(
|
||||||
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
|
Vec2::new(640.0, 200.0),
|
||||||
|
DRAG_Z,
|
||||||
|
Vec2::new(80.0, -120.0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
anim.curve,
|
anim.curve,
|
||||||
MotionCurve::Responsive,
|
MotionCurve::Responsive,
|
||||||
@@ -677,16 +683,10 @@ fn pressing_h_spawns_pending_hint_task() {
|
|||||||
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(LayoutResource(compute_layout(
|
app.insert_resource(LayoutResource(
|
||||||
Vec2::new(1280.0, 800.0),
|
compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
|
||||||
0.0,
|
));
|
||||||
0.0,
|
app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne)));
|
||||||
true,
|
|
||||||
)));
|
|
||||||
app.insert_resource(GameStateResource(GameState::new(
|
|
||||||
42,
|
|
||||||
DrawStockConfig::DrawOne,
|
|
||||||
)));
|
|
||||||
app.add_systems(Update, handle_keyboard_hint);
|
app.add_systems(Update, handle_keyboard_hint);
|
||||||
|
|
||||||
// Simulate the H key being pressed this frame.
|
// Simulate the H key being pressed this frame.
|
||||||
|
|||||||
@@ -862,8 +862,10 @@ fn handle_display_name_confirm(
|
|||||||
.leaderboard_display_name
|
.leaderboard_display_name
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
if let SyncBackend::SolitaireServer { ref username, .. } =
|
if let SyncBackend::SolitaireServer {
|
||||||
settings.0.sync_backend
|
ref username,
|
||||||
|
..
|
||||||
|
} = settings.0.sync_backend
|
||||||
{
|
{
|
||||||
username.chars().take(32).collect()
|
username.chars().take(32).collect()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ use solitaire_data::{Settings, save_settings_to};
|
|||||||
|
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
use crate::splash_plugin::SplashRoot;
|
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||||
spawn_modal_header,
|
spawn_modal_header,
|
||||||
@@ -37,6 +36,7 @@ use crate::ui_theme::{
|
|||||||
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
|
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
|
||||||
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
};
|
};
|
||||||
|
use crate::splash_plugin::SplashRoot;
|
||||||
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
|
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -969,10 +969,7 @@ mod tests {
|
|||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
app.insert_resource(GameStateResource(GameState::new(
|
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
||||||
1,
|
|
||||||
DrawStockConfig::DrawOne,
|
|
||||||
)));
|
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,9 +178,9 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::events::HintVisualEvent;
|
use crate::events::HintVisualEvent;
|
||||||
use crate::input_plugin::HintSolverConfig;
|
use crate::input_plugin::HintSolverConfig;
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
|
|
||||||
/// Build a minimal Bevy app exercising only the polling system
|
/// Build a minimal Bevy app exercising only the polling system
|
||||||
/// and the resources/messages it touches.
|
/// and the resources/messages it touches.
|
||||||
@@ -249,7 +249,10 @@ mod tests {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.zip(suits.iter())
|
.zip(suits.iter())
|
||||||
{
|
{
|
||||||
game.set_test_tableau_cards(tableau, vec![Card::new(Deck::Deck1, *suit, Rank::King)]);
|
game.set_test_tableau_cards(
|
||||||
|
tableau,
|
||||||
|
vec![Card::new(Deck::Deck1, *suit, Rank::King)],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
game
|
game
|
||||||
}
|
}
|
||||||
@@ -264,11 +267,9 @@ mod tests {
|
|||||||
let mut app = pending_hint_app();
|
let mut app = pending_hint_app();
|
||||||
app.insert_resource(GameStateResource(near_finished_state()));
|
app.insert_resource(GameStateResource(near_finished_state()));
|
||||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
let cfg = *app.world().resource::<HintSolverConfig>();
|
||||||
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
app.world_mut()
|
||||||
near_finished_state(),
|
.resource_mut::<PendingHintTask>()
|
||||||
cfg.moves_budget,
|
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
||||||
cfg.states_budget,
|
|
||||||
);
|
|
||||||
|
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||||
while app.world().resource::<PendingHintTask>().is_pending() {
|
while app.world().resource::<PendingHintTask>().is_pending() {
|
||||||
@@ -305,11 +306,9 @@ mod tests {
|
|||||||
let mut app = pending_hint_app();
|
let mut app = pending_hint_app();
|
||||||
app.insert_resource(GameStateResource(near_finished_state()));
|
app.insert_resource(GameStateResource(near_finished_state()));
|
||||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
let cfg = *app.world().resource::<HintSolverConfig>();
|
||||||
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
app.world_mut()
|
||||||
near_finished_state(),
|
.resource_mut::<PendingHintTask>()
|
||||||
cfg.moves_budget,
|
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
||||||
cfg.states_budget,
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
app.world().resource::<PendingHintTask>().is_pending(),
|
app.world().resource::<PendingHintTask>().is_pending(),
|
||||||
"task is in flight after spawn",
|
"task is in flight after spawn",
|
||||||
@@ -345,22 +344,18 @@ mod tests {
|
|||||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
let cfg = *app.world().resource::<HintSolverConfig>();
|
||||||
|
|
||||||
// First spawn.
|
// First spawn.
|
||||||
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
app.world_mut()
|
||||||
near_finished_state(),
|
.resource_mut::<PendingHintTask>()
|
||||||
cfg.moves_budget,
|
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
||||||
cfg.states_budget,
|
|
||||||
);
|
|
||||||
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
|
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
|
||||||
assert!(first_handle_present);
|
assert!(first_handle_present);
|
||||||
|
|
||||||
// Second spawn. The `spawn` helper drops the prior task
|
// Second spawn. The `spawn` helper drops the prior task
|
||||||
// before assigning the new one — at no point are two tasks
|
// before assigning the new one — at no point are two tasks
|
||||||
// in flight.
|
// in flight.
|
||||||
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
app.world_mut()
|
||||||
near_finished_state(),
|
.resource_mut::<PendingHintTask>()
|
||||||
cfg.moves_budget,
|
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
||||||
cfg.states_budget,
|
|
||||||
);
|
|
||||||
// Resource still pending (the second task), but the first
|
// Resource still pending (the second task), but the first
|
||||||
// is gone. We can't directly observe the first handle once
|
// is gone. We can't directly observe the first handle once
|
||||||
// it's been overwritten — what we *can* assert is that the
|
// it's been overwritten — what we *can* assert is that the
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ use bevy::input::touch::Touches;
|
|||||||
use bevy::math::Vec2;
|
use bevy::math::Vec2;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::PrimaryWindow;
|
use bevy::window::PrimaryWindow;
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||||
use solitaire_core::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
|
|
||||||
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
|
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
|
||||||
use crate::events::{MoveRejectedEvent, MoveRequestEvent};
|
use crate::events::{MoveRejectedEvent, MoveRequestEvent};
|
||||||
@@ -360,6 +360,9 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// Builds the `(destination, anchor)` list for a fresh radial open.
|
/// Builds the `(destination, anchor)` list for a fresh radial open.
|
||||||
///
|
///
|
||||||
/// `half_extents` is the window half-size in world space — icons are clamped
|
/// `half_extents` is the window half-size in world space — icons are clamped
|
||||||
@@ -378,10 +381,8 @@ fn build_radial_destinations(
|
|||||||
.map(|(i, d)| {
|
.map(|(i, d)| {
|
||||||
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
|
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
|
||||||
let clamped = Vec2::new(
|
let clamped = Vec2::new(
|
||||||
raw.x
|
raw.x.clamp(-half_extents.x + margin, half_extents.x - margin),
|
||||||
.clamp(-half_extents.x + margin, half_extents.x - margin),
|
raw.y.clamp(-half_extents.y + margin, half_extents.y - margin),
|
||||||
raw.y
|
|
||||||
.clamp(-half_extents.y + margin, half_extents.y - margin),
|
|
||||||
);
|
);
|
||||||
(d, clamped)
|
(d, clamped)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -246,11 +246,7 @@ pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str {
|
|||||||
/// known card, or `"--"` for an absent top card (empty pile).
|
/// known card, or `"--"` for an absent top card (empty pile).
|
||||||
pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String {
|
pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String {
|
||||||
match card {
|
match card {
|
||||||
Some((c, _)) => format!(
|
Some((c, _)) => format!("{}{}", format_rank_short(c.rank()), format_suit_glyph(c.suit())),
|
||||||
"{}{}",
|
|
||||||
format_rank_short(c.rank()),
|
|
||||||
format_suit_glyph(c.suit())
|
|
||||||
),
|
|
||||||
None => "--".to_string(),
|
None => "--".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use std::sync::Arc;
|
|||||||
use bevy::math::Vec2;
|
use bevy::math::Vec2;
|
||||||
use bevy::prelude::Resource;
|
use bevy::prelude::Resource;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use solitaire_core::Card;
|
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikePile;
|
||||||
|
use solitaire_core::Card;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
|
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ impl Plugin for SafeAreaInsetsPlugin {
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
app.init_resource::<android::SafeAreaPollTries>()
|
app.init_resource::<android::SafeAreaPollTries>()
|
||||||
.add_systems(Update, android::refresh_insets)
|
.add_systems(Update, android::refresh_insets)
|
||||||
.add_systems(Update, android::rearm_on_resumed);
|
.add_systems(Update, android::rearm_on_resumed)
|
||||||
|
.add_systems(Update, android::refresh_surface_size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +226,7 @@ fn on_app_resumed(
|
|||||||
mod android {
|
mod android {
|
||||||
use super::{AppLifecycle, SafeAreaInsets};
|
use super::{AppLifecycle, SafeAreaInsets};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::WindowResized;
|
||||||
|
|
||||||
/// Tracks how many frames `refresh_insets` has polled. Stored as a
|
/// Tracks how many frames `refresh_insets` has polled. Stored as a
|
||||||
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
|
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
|
||||||
@@ -299,11 +301,108 @@ mod android {
|
|||||||
) {
|
) {
|
||||||
for event in lifecycle.read() {
|
for event in lifecycle.read() {
|
||||||
if matches!(event, AppLifecycle::WillResume) {
|
if matches!(event, AppLifecycle::WillResume) {
|
||||||
|
// Evidence line for #130: winit's Android backend has open
|
||||||
|
// TODOs around forwarding resume notifications, so whether
|
||||||
|
// this ever fires on a given device is an open question.
|
||||||
|
info!("safe_area: AppLifecycle::WillResume received; re-arming inset poll");
|
||||||
poll.0 = 0;
|
poll.0 = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Polls the decor view's size via JNI and forces a relayout when it
|
||||||
|
/// disagrees with Bevy's cached `Window` resolution (#130).
|
||||||
|
///
|
||||||
|
/// winit's Android backend does not forward content-rect changes that
|
||||||
|
/// happen while the app is backgrounded (fold/unfold on foldables), so
|
||||||
|
/// after a fold cycle Bevy can keep rendering and laying out for the
|
||||||
|
/// previous screen's dimensions. Unlike `refresh_insets` this poller
|
||||||
|
/// never settles: it cannot rely on `AppLifecycle::WillResume` to re-arm
|
||||||
|
/// it, because that event is itself delivered through the same unreliable
|
||||||
|
/// lifecycle plumbing. A JNI round-trip every `POLL_INTERVAL_FRAMES`
|
||||||
|
/// frames is cheap.
|
||||||
|
///
|
||||||
|
/// On a mismatch it:
|
||||||
|
/// 1. writes the real size into `window.resolution` so the renderer
|
||||||
|
/// reconfigures the surface and systems reading `window.width()` see
|
||||||
|
/// the truth,
|
||||||
|
/// 2. emits a synthetic `WindowResized` (logical pixels) so
|
||||||
|
/// `on_window_resized` in `table_plugin` recomputes the board layout,
|
||||||
|
/// 3. re-arms the inset poller, because a screen change almost always
|
||||||
|
/// moves the system bars too — covering the "re-poll never fires"
|
||||||
|
/// hole left open in #116.
|
||||||
|
pub(super) fn refresh_surface_size(
|
||||||
|
mut frame: Local<u32>,
|
||||||
|
mut windows: Query<(Entity, &mut Window)>,
|
||||||
|
mut resize_events: MessageWriter<WindowResized>,
|
||||||
|
mut poll: ResMut<SafeAreaPollTries>,
|
||||||
|
) {
|
||||||
|
const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps
|
||||||
|
|
||||||
|
*frame += 1;
|
||||||
|
if !frame.is_multiple_of(POLL_INTERVAL_FRAMES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some((entity, mut window)) = windows.iter_mut().next() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (decor_w, decor_h) = match query_decor_size() {
|
||||||
|
Ok(size) => size,
|
||||||
|
Err(e) => {
|
||||||
|
// One-time note; the bridge simply isn't up yet during the
|
||||||
|
// first frames of a launch.
|
||||||
|
if *frame == POLL_INTERVAL_FRAMES {
|
||||||
|
warn!("safe_area: decor size query failed (will retry): {e}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if decor_w == 0 || decor_h == 0 {
|
||||||
|
return; // decor view not laid out yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads go through `Deref` and do not trip change detection; only
|
||||||
|
// mutate `window` once a mismatch is confirmed.
|
||||||
|
let cached_w = window.resolution.physical_width();
|
||||||
|
let cached_h = window.resolution.physical_height();
|
||||||
|
if decor_w == cached_w && decor_h == cached_h {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"safe_area: decor view is {decor_w}x{decor_h} but cached resolution is \
|
||||||
|
{cached_w}x{cached_h}; forcing relayout (fold/unfold missed by winit?)"
|
||||||
|
);
|
||||||
|
window.resolution.set_physical_resolution(decor_w, decor_h);
|
||||||
|
let scale = window.scale_factor();
|
||||||
|
resize_events.write(WindowResized {
|
||||||
|
window: entity,
|
||||||
|
width: decor_w as f32 / scale,
|
||||||
|
height: decor_h as f32 / scale,
|
||||||
|
});
|
||||||
|
poll.0 = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Physical pixel size of the activity's decor view — the ground truth
|
||||||
|
/// for the surface we are actually being displayed on, independent of
|
||||||
|
/// whatever winit last told Bevy.
|
||||||
|
fn query_decor_size() -> Result<(u32, u32), String> {
|
||||||
|
use solitaire_data::android_jni;
|
||||||
|
|
||||||
|
android_jni::with_activity_env(|env, activity| {
|
||||||
|
let window = env
|
||||||
|
.call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
|
||||||
|
.l()?;
|
||||||
|
let decor = env
|
||||||
|
.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
|
||||||
|
.l()?;
|
||||||
|
let w = env.call_method(&decor, "getWidth", "()I", &[])?.i()?;
|
||||||
|
let h = env.call_method(&decor, "getHeight", "()I", &[])?.i()?;
|
||||||
|
Ok((w.max(0) as u32, h.max(0) as u32))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn query_insets() -> Result<SafeAreaInsets, String> {
|
fn query_insets() -> Result<SafeAreaInsets, String> {
|
||||||
use solitaire_data::android_jni;
|
use solitaire_data::android_jni;
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ mod tests {
|
|||||||
.and_then(|prefix| prefix.split_whitespace().last())
|
.and_then(|prefix| prefix.split_whitespace().last())
|
||||||
.and_then(|n| n.parse::<usize>().ok());
|
.and_then(|n| n.parse::<usize>().ok());
|
||||||
parsed.unwrap_or_else(|| {
|
parsed.unwrap_or_else(|| {
|
||||||
panic!("ambiguity panic message no longer parseable (Bevy upgrade?): {msg}")
|
panic!(
|
||||||
|
"ambiguity panic message no longer parseable (Bevy upgrade?): {msg}"
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,9 +37,9 @@
|
|||||||
|
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
|
|
||||||
use crate::card_plugin::CardEntityIndex;
|
use crate::card_plugin::CardEntityIndex;
|
||||||
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
|
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
|
||||||
@@ -487,10 +487,8 @@ fn handle_selection_keys(
|
|||||||
1
|
1
|
||||||
};
|
};
|
||||||
let start = source_cards.len().saturating_sub(count);
|
let start = source_cards.len().saturating_sub(count);
|
||||||
let lifted_cards: Vec<Card> = source_cards[start..]
|
let lifted_cards: Vec<Card> =
|
||||||
.iter()
|
source_cards[start..].iter().map(|(c, _)| c.clone()).collect();
|
||||||
.map(|(c, _)| c.clone())
|
|
||||||
.collect();
|
|
||||||
let Some((bottom, _)) = source_cards.get(start) else {
|
let Some((bottom, _)) = source_cards.get(start) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -599,7 +597,10 @@ fn face_up_run_len(cards: &[(Card, bool)]) -> usize {
|
|||||||
/// This is intentionally separated from [`best_destination`] so the Enter
|
/// This is intentionally separated from [`best_destination`] so the Enter
|
||||||
/// 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(card: &Card, game: &GameState) -> Option<KlondikePile> {
|
fn try_foundation_dest(
|
||||||
|
card: &Card,
|
||||||
|
game: &GameState,
|
||||||
|
) -> Option<KlondikePile> {
|
||||||
let source = game.pile_containing_card(card.clone())?;
|
let source = game.pile_containing_card(card.clone())?;
|
||||||
for foundation in [
|
for foundation in [
|
||||||
Foundation::Foundation1,
|
Foundation::Foundation1,
|
||||||
@@ -696,7 +697,13 @@ fn update_selection_highlight(
|
|||||||
if let Some(ref pile) = source_pile
|
if let Some(ref pile) = source_pile
|
||||||
&& let Some(card) = top_face_up_card(pile, &game.0)
|
&& let Some(card) = top_face_up_card(pile, &game.0)
|
||||||
{
|
{
|
||||||
spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, source_color);
|
spawn_highlight_on_card(
|
||||||
|
&mut commands,
|
||||||
|
&card_index,
|
||||||
|
&card,
|
||||||
|
card_size,
|
||||||
|
source_color,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Destination highlight while lifted.
|
// Destination highlight while lifted.
|
||||||
@@ -707,7 +714,13 @@ fn update_selection_highlight(
|
|||||||
// in destination-pick mode and the focused index is observable
|
// in destination-pick mode and the focused index is observable
|
||||||
// via the resource.
|
// via the resource.
|
||||||
if let Some(card) = top_face_up_card(dest, &game.0) {
|
if let Some(card) = top_face_up_card(dest, &game.0) {
|
||||||
spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, dest_color);
|
spawn_highlight_on_card(
|
||||||
|
&mut commands,
|
||||||
|
&card_index,
|
||||||
|
&card,
|
||||||
|
card_size,
|
||||||
|
dest_color,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1034,7 +1047,10 @@ mod tests {
|
|||||||
press_key(&mut app, KeyCode::Tab);
|
press_key(&mut app, KeyCode::Tab);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let selected = app.world().resource::<SelectionState>().selected_pile;
|
let selected = app
|
||||||
|
.world()
|
||||||
|
.resource::<SelectionState>()
|
||||||
|
.selected_pile;
|
||||||
// The cycle order starts at Waste, but Waste is empty so the next
|
// The cycle order starts at Waste, but Waste is empty so the next
|
||||||
// available pile (Tableau(0)) is selected.
|
// available pile (Tableau(0)) is selected.
|
||||||
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||||
@@ -1200,10 +1216,16 @@ mod tests {
|
|||||||
drag.active_touch_id = None;
|
drag.active_touch_id = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let before = app.world().resource::<SelectionState>().selected_pile;
|
let before = app
|
||||||
|
.world()
|
||||||
|
.resource::<SelectionState>()
|
||||||
|
.selected_pile;
|
||||||
press_key(&mut app, KeyCode::Tab);
|
press_key(&mut app, KeyCode::Tab);
|
||||||
app.update();
|
app.update();
|
||||||
let after = app.world().resource::<SelectionState>().selected_pile;
|
let after = app
|
||||||
|
.world()
|
||||||
|
.resource::<SelectionState>()
|
||||||
|
.selected_pile;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
before, after,
|
before, after,
|
||||||
|
|||||||
@@ -1500,8 +1500,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
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().resource_mut::<GameStateResource>().0.mode =
|
app.world_mut()
|
||||||
solitaire_core::game_state::GameMode::Zen;
|
.resource_mut::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.mode = solitaire_core::game_state::GameMode::Zen;
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 1800,
|
score: 1800,
|
||||||
|
|||||||
@@ -301,9 +301,9 @@ fn push_on_exit(
|
|||||||
exit_events.clear();
|
exit_events.clear();
|
||||||
|
|
||||||
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||||
let result = rt.0.block_on(async {
|
let result = rt
|
||||||
tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await
|
.0
|
||||||
});
|
.block_on(async { tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await });
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(_)) | Ok(Err(SyncError::UnsupportedPlatform)) => {}
|
Ok(Ok(_)) | Ok(Err(SyncError::UnsupportedPlatform)) => {}
|
||||||
Ok(Err(e)) => warn!("sync push on exit failed: {e}"),
|
Ok(Err(e)) => warn!("sync push on exit failed: {e}"),
|
||||||
@@ -667,7 +667,9 @@ 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.world().resource::<ReplayHistoryResource>();
|
let live = app
|
||||||
|
.world()
|
||||||
|
.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()),
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::WindowResized;
|
use bevy::window::WindowResized;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::Suit;
|
|
||||||
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||||
|
use solitaire_core::Suit;
|
||||||
|
|
||||||
use crate::events::{HintVisualEvent, StateChangedEvent};
|
use crate::events::{HintVisualEvent, StateChangedEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
@@ -385,11 +385,7 @@ fn on_window_resized(
|
|||||||
>,
|
>,
|
||||||
mut marker_outlines: Query<
|
mut marker_outlines: Query<
|
||||||
&mut Sprite,
|
&mut Sprite,
|
||||||
(
|
(Without<PileMarker>, Without<TableBackground>, Without<Text2d>),
|
||||||
Without<PileMarker>,
|
|
||||||
Without<TableBackground>,
|
|
||||||
Without<Text2d>,
|
|
||||||
),
|
|
||||||
>,
|
>,
|
||||||
mut marker_labels: Query<&mut TextFont, With<Text2d>>,
|
mut marker_labels: Query<&mut TextFont, With<Text2d>>,
|
||||||
) {
|
) {
|
||||||
@@ -441,7 +437,8 @@ fn on_window_resized(
|
|||||||
// must be re-derived here too or a resize (fold/unfold, rotation)
|
// must be re-derived here too or a resize (fold/unfold, rotation)
|
||||||
// leaves them at the stale size — visible as oversized grey
|
// leaves them at the stale size — visible as oversized grey
|
||||||
// frames on empty piles.
|
// frames on empty piles.
|
||||||
let outline_size = new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
|
let outline_size =
|
||||||
|
new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
|
||||||
let font_size = new_layout.card_size.x * 0.28;
|
let font_size = new_layout.card_size.x * 0.28;
|
||||||
for child in children.into_iter().flatten() {
|
for child in children.into_iter().flatten() {
|
||||||
if let Ok(mut outline) = marker_outlines.get_mut(*child) {
|
if let Ok(mut outline) = marker_outlines.get_mut(*child) {
|
||||||
@@ -596,6 +593,8 @@ fn pile_cards(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -704,10 +703,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert_eq!(outlines, 12, "all 12 markers carry an outline child");
|
assert_eq!(outlines, 12, "all 12 markers carry an outline child");
|
||||||
assert!(
|
assert!(labels >= 11, "tableau + foundation markers carry watermarks");
|
||||||
labels >= 11,
|
|
||||||
"tableau + foundation markers carry watermarks"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -943,7 +939,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn suit_symbol_all_four_are_distinct() {
|
fn suit_symbol_all_four_are_distinct() {
|
||||||
let symbols: Vec<&str> = Suit::SUITS.iter().map(suit_symbol).collect();
|
let symbols: Vec<&str> = Suit::SUITS
|
||||||
|
.iter()
|
||||||
|
.map(suit_symbol)
|
||||||
|
.collect();
|
||||||
let unique: std::collections::HashSet<&&str> = symbols.iter().collect();
|
let unique: std::collections::HashSet<&&str> = symbols.iter().collect();
|
||||||
assert_eq!(unique.len(), 4, "all four suit symbols must be distinct");
|
assert_eq!(unique.len(), 4, "all four suit symbols must be distinct");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,32 +116,24 @@ impl Plugin for ThemePlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the manifest URL for `theme_id`: the bundled themes
|
|
||||||
/// (`"dark"`, `"classic"`) load from the embedded source; any other id
|
|
||||||
/// is a user theme served from the `themes://` directory source.
|
|
||||||
fn theme_manifest_url(theme_id: &str) -> String {
|
|
||||||
bundled_theme_url(theme_id)
|
|
||||||
.map(str::to_string)
|
|
||||||
.unwrap_or_else(|| format!("themes://{theme_id}/theme.ron"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Kicks off the initial theme load — the one named by
|
/// Kicks off the initial theme load — the one named by
|
||||||
/// `Settings::selected_theme_id` if available, falling back to the
|
/// `Settings::selected_theme_id` if available, falling back to the
|
||||||
/// fresh-install default from `solitaire_data::Settings`. The actual
|
/// embedded default. The actual rasterisation runs asynchronously on
|
||||||
/// rasterisation runs asynchronously on the asset task pool; the sync
|
/// the asset task pool; the sync system below picks up the
|
||||||
/// system below picks up the `LoadedWithDependencies` event when every
|
/// `LoadedWithDependencies` event when every face + back is ready.
|
||||||
/// face + back is ready.
|
|
||||||
fn load_initial_theme(
|
fn load_initial_theme(
|
||||||
asset_server: Res<AssetServer>,
|
asset_server: Res<AssetServer>,
|
||||||
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
let default_id = solitaire_data::Settings::default().selected_theme_id;
|
|
||||||
let id = settings
|
let id = settings
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|s| s.0.selected_theme_id.as_str())
|
.map(|s| s.0.selected_theme_id.as_str())
|
||||||
.unwrap_or(&default_id);
|
.unwrap_or("classic");
|
||||||
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(id));
|
let url = bundled_theme_url(id)
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| format!("themes://{id}/theme.ron"));
|
||||||
|
let handle: Handle<CardTheme> = asset_server.load(url);
|
||||||
commands.insert_resource(ActiveTheme(handle));
|
commands.insert_resource(ActiveTheme(handle));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +162,10 @@ fn react_to_settings_theme_change(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(new_id));
|
let url = bundled_theme_url(new_id)
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| format!("themes://{new_id}/theme.ron"));
|
||||||
|
let handle: Handle<CardTheme> = asset_server.load(url);
|
||||||
commands.insert_resource(ActiveTheme(handle));
|
commands.insert_resource(ActiveTheme(handle));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,11 +252,12 @@ fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet
|
|||||||
image_set.theme_back = Some(theme.back.clone());
|
image_set.theme_back = Some(theme.back.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switches the active theme to `theme_id` — the embedded source for
|
|
||||||
/// the bundled `"dark"` / `"classic"` themes, `themes://` for user
|
|
||||||
/// themes. Returns the new `Handle<CardTheme>` so callers can poll
|
/// Switches the active theme to the one served at
|
||||||
/// `Assets<CardTheme>` if they want to wait for the load before
|
/// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>`
|
||||||
/// changing UI state.
|
/// so callers can poll `Assets<CardTheme>` if they want to wait for
|
||||||
|
/// the load before changing UI state.
|
||||||
///
|
///
|
||||||
/// The handle is also written to the [`ActiveTheme`] resource — the
|
/// The handle is also written to the [`ActiveTheme`] resource — the
|
||||||
/// per-frame sync system picks up the `LoadedWithDependencies` event
|
/// per-frame sync system picks up the `LoadedWithDependencies` event
|
||||||
@@ -272,7 +268,8 @@ pub fn set_theme(
|
|||||||
asset_server: &AssetServer,
|
asset_server: &AssetServer,
|
||||||
theme_id: &str,
|
theme_id: &str,
|
||||||
) -> Handle<CardTheme> {
|
) -> Handle<CardTheme> {
|
||||||
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(theme_id));
|
let url = format!("themes://{theme_id}/theme.ron");
|
||||||
|
let handle: Handle<CardTheme> = asset_server.load(url);
|
||||||
commands.insert_resource(ActiveTheme(handle.clone()));
|
commands.insert_resource(ActiveTheme(handle.clone()));
|
||||||
handle
|
handle
|
||||||
}
|
}
|
||||||
@@ -480,43 +477,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn theme_manifest_url_routes_bundled_ids_to_embedded_source() {
|
fn set_theme_url_format_matches_themes_source() {
|
||||||
// The bundled themes live in the binary, not in the user
|
// The format string is the only behavioural surface of
|
||||||
// themes directory — resolving them to `themes://` would
|
// set_theme that doesn't require an App. We assert the URL
|
||||||
// NotFound and silently leave the previous theme active.
|
// shape so a future refactor doesn't accidentally change the
|
||||||
assert_eq!(
|
// path layout.
|
||||||
theme_manifest_url("dark"),
|
let url = format!("themes://{}/theme.ron", "user_uploaded");
|
||||||
crate::assets::DARK_THEME_MANIFEST_URL
|
assert_eq!(url, "themes://user_uploaded/theme.ron");
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
theme_manifest_url("classic"),
|
|
||||||
crate::assets::CLASSIC_THEME_MANIFEST_URL
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn theme_manifest_url_routes_user_ids_to_themes_source() {
|
|
||||||
// We assert the URL shape so a future refactor doesn't
|
|
||||||
// accidentally change the path layout of the user-themes
|
|
||||||
// source.
|
|
||||||
assert_eq!(
|
|
||||||
theme_manifest_url("user_uploaded"),
|
|
||||||
"themes://user_uploaded/theme.ron"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn initial_theme_fallback_matches_settings_default() {
|
|
||||||
// `load_initial_theme` falls back to the data-crate default
|
|
||||||
// when `SettingsResource` is absent. That default must always
|
|
||||||
// name a bundled theme, or a fresh minimal setup would resolve
|
|
||||||
// to a nonexistent user theme (see the v0.33.0 "classic"
|
|
||||||
// fallback drift in CHANGELOG.md).
|
|
||||||
let default_id = solitaire_data::Settings::default().selected_theme_id;
|
|
||||||
assert!(
|
|
||||||
bundled_theme_url(&default_id).is_some(),
|
|
||||||
"default theme id {default_id:?} must be a bundled theme"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test 1: the bundled dark theme always has embedded SVG bytes
|
/// Test 1: the bundled dark theme always has embedded SVG bytes
|
||||||
|
|||||||
@@ -28,8 +28,8 @@
|
|||||||
|
|
||||||
use bevy::ecs::message::MessageReader;
|
use bevy::ecs::message::MessageReader;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::Card;
|
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikePile;
|
||||||
|
use solitaire_core::Card;
|
||||||
|
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::events::StateChangedEvent;
|
use crate::events::StateChangedEvent;
|
||||||
|
|||||||
@@ -304,7 +304,10 @@ impl HighContrastBackground {
|
|||||||
/// [`BORDER_SUBTLE_HC`]. Currently used by the WIN MOVE scrub-bar
|
/// [`BORDER_SUBTLE_HC`]. Currently used by the WIN MOVE scrub-bar
|
||||||
/// 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(default_color: Color, hc_color: Color) -> Self {
|
pub const fn with_hc(
|
||||||
|
default_color: Color,
|
||||||
|
hc_color: Color,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
default_color,
|
default_color,
|
||||||
hc_color,
|
hc_color,
|
||||||
|
|||||||
@@ -1649,62 +1649,62 @@ function __wbg_get_imports() {
|
|||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 61868, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 61918, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7314, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7364, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
|
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
|
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7312, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7362, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
|
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7313, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7363, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+15
-26
@@ -19,14 +19,11 @@
|
|||||||
//! is the contract.
|
//! is the contract.
|
||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use solitaire_core::error::MoveError;
|
|
||||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
|
||||||
use solitaire_core::{
|
|
||||||
DrawStockConfig,
|
|
||||||
game_state::{GameMode, GameState},
|
|
||||||
};
|
|
||||||
use solitaire_core::{KlondikeInstruction, KlondikePile};
|
use solitaire_core::{KlondikeInstruction, KlondikePile};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
|
use solitaire_core::error::MoveError;
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
/// Mirrors `solitaire_data::Replay` v3.
|
/// Mirrors `solitaire_data::Replay` v3.
|
||||||
@@ -148,8 +145,8 @@ impl ReplayPlayer {
|
|||||||
let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
|
let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
|
||||||
self.game.pile(t).iter().map(CardSnapshot::from).collect()
|
self.game.pile(t).iter().map(CardSnapshot::from).collect()
|
||||||
};
|
};
|
||||||
let foundations: [Vec<CardSnapshot>; 4] =
|
let foundations: [Vec<CardSnapshot>; 4] = solitaire_core::FOUNDATIONS
|
||||||
solitaire_core::FOUNDATIONS.map(|f| pile_cards(KlondikePile::Foundation(f)));
|
.map(|f| pile_cards(KlondikePile::Foundation(f)));
|
||||||
let tableaus: [Vec<CardSnapshot>; 7] =
|
let tableaus: [Vec<CardSnapshot>; 7] =
|
||||||
solitaire_core::TABLEAUS.map(|t| pile_cards(KlondikePile::Tableau(t)));
|
solitaire_core::TABLEAUS.map(|t| pile_cards(KlondikePile::Tableau(t)));
|
||||||
StateSnapshot {
|
StateSnapshot {
|
||||||
@@ -345,7 +342,8 @@ fn legal_moves_for_game(game: &GameState) -> Vec<DebugMove> {
|
|||||||
fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport {
|
fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport {
|
||||||
let stock = game.stock_cards();
|
let stock = game.stock_cards();
|
||||||
let waste = game.waste_cards();
|
let waste = game.waste_cards();
|
||||||
let foundations = solitaire_core::FOUNDATIONS.map(|f| game.pile(KlondikePile::Foundation(f)));
|
let foundations =
|
||||||
|
solitaire_core::FOUNDATIONS.map(|f| game.pile(KlondikePile::Foundation(f)));
|
||||||
let tableaus = solitaire_core::TABLEAUS.map(|t| game.pile(KlondikePile::Tableau(t)));
|
let tableaus = solitaire_core::TABLEAUS.map(|t| game.pile(KlondikePile::Tableau(t)));
|
||||||
|
|
||||||
let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new();
|
let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new();
|
||||||
@@ -405,8 +403,7 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
|
|||||||
false
|
false
|
||||||
});
|
});
|
||||||
|
|
||||||
let soft_lock =
|
let soft_lock = !game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty();
|
||||||
!game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty();
|
|
||||||
|
|
||||||
let state_ok = duplicate_cards.is_empty()
|
let state_ok = duplicate_cards.is_empty()
|
||||||
&& missing_cards.is_empty()
|
&& missing_cards.is_empty()
|
||||||
@@ -468,7 +465,8 @@ impl SolitaireGame {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(CardSnapshot::from)
|
.map(CardSnapshot::from)
|
||||||
.collect(),
|
.collect(),
|
||||||
foundations: solitaire_core::FOUNDATIONS.map(|f| cards(KlondikePile::Foundation(f))),
|
foundations: solitaire_core::FOUNDATIONS
|
||||||
|
.map(|f| cards(KlondikePile::Foundation(f))),
|
||||||
tableaus: solitaire_core::TABLEAUS.map(|t| cards(KlondikePile::Tableau(t))),
|
tableaus: solitaire_core::TABLEAUS.map(|t| cards(KlondikePile::Tableau(t))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -890,9 +888,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let idx = pick_move_index(&legal_moves).unwrap_or_default();
|
let idx = pick_move_index(&legal_moves).unwrap_or_default();
|
||||||
if let Err(e) = game.apply_legal_move_native(idx) {
|
if let Err(e) = game.apply_legal_move_native(idx) {
|
||||||
panic!(
|
panic!("failed to advance game before replay export (seed={seed}, step={step}, idx={idx}): {e}");
|
||||||
"failed to advance game before replay export (seed={seed}, step={step}, idx={idx}): {e}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1077,14 +1073,9 @@ mod tests {
|
|||||||
let stock_before = game.game.stock_cards().len();
|
let stock_before = game.game.stock_cards().len();
|
||||||
let waste_before = game.game.waste_cards().len();
|
let waste_before = game.game.waste_cards().len();
|
||||||
|
|
||||||
assert!(
|
assert!(stock_before > 0, "seed {seed}: stock must be non-empty at start");
|
||||||
stock_before > 0,
|
|
||||||
"seed {seed}: stock must be non-empty at start"
|
|
||||||
);
|
|
||||||
|
|
||||||
game.game
|
game.game.draw().expect("draw must succeed when stock is non-empty");
|
||||||
.draw()
|
|
||||||
.expect("draw must succeed when stock is non-empty");
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
game.game.stock_cards().len(),
|
game.game.stock_cards().len(),
|
||||||
@@ -1113,9 +1104,7 @@ mod tests {
|
|||||||
"seed {seed}: stock must have at least 3 cards for this test"
|
"seed {seed}: stock must have at least 3 cards for this test"
|
||||||
);
|
);
|
||||||
|
|
||||||
game.game
|
game.game.draw().expect("draw must succeed when stock has cards");
|
||||||
.draw()
|
|
||||||
.expect("draw must succeed when stock has cards");
|
|
||||||
|
|
||||||
let expected_drawn = stock_before.min(3);
|
let expected_drawn = stock_before.min(3);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ pub fn start() {
|
|||||||
// texture-dimension limit is now taken from the adapter (see
|
// texture-dimension limit is now taken from the adapter (see
|
||||||
// the RenderPlugin below), so this is purely a quality/perf
|
// the RenderPlugin below), so this is purely a quality/perf
|
||||||
// choice, no longer a crash-avoidance hack.
|
// choice, no longer a crash-avoidance hack.
|
||||||
resolution: WindowResolution::default().with_scale_factor_override(1.0),
|
resolution: WindowResolution::default()
|
||||||
|
.with_scale_factor_override(1.0),
|
||||||
..default()
|
..default()
|
||||||
}),
|
}),
|
||||||
..default()
|
..default()
|
||||||
|
|||||||
Reference in New Issue
Block a user