a92ac066a6
Build and Deploy / build-and-push (push) Failing after 1m6s
Fixes found while testing the Bevy WASM build in a real browser: 1. chrono wasmbind: add `wasmbind` feature to workspace chrono dep so Local::now()/Utc::now() use js-sys::Date on wasm32 (previously fell through to std::time::SystemTime which panics on wasm32). 2. std::time::SystemTime: replace all remaining direct SystemTime::now() calls (4 sites across game_plugin, difficulty_plugin, time_attack_plugin, solitaire_data/storage) with chrono::Utc::now() which is wasm32-safe. 3. user_dir: return empty PathBuf (instead of panicking) when data_dir() is None on wasm32; there is no filesystem in the browser so user themes are unsupported and a benign empty path is correct. 4. ThemeRegistryPlugin: gate build_registry_on_startup to non-wasm32 (the filesystem scan for user themes has nothing to scan in the browser; only the bundled embedded themes are available). 5. AssetMetaCheck::Never: configure AssetPlugin in solitaire_web to skip `.meta` sidecar fetches — we don't ship .meta files, so the default AssetMetaCheck::Always produced a 404 flood on every card/background asset. Result: `http://localhost:<port>/play` boots in Firefox with zero errors and renders the full Bevy game — home screen, onboarding modal, HUD all visible. Assets load correctly from /assets/. Chromium has a separate wgpu-27/ANGLE/GLES shader translation bug (not in our code); Firefox works. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
235 lines
8.2 KiB
Rust
235 lines
8.2 KiB
Rust
//! Difficulty-tier game-start plugin.
|
|
//!
|
|
//! Handles [`StartDifficultyRequestEvent`] by picking the next seed from the
|
|
//! appropriate pre-verified catalog in `solitaire_data::difficulty_seeds` and
|
|
//! writing a [`NewGameRequestEvent`]. For [`DifficultyLevel::Random`] a
|
|
//! system-time seed is used instead — the deal may or may not be winnable.
|
|
//!
|
|
//! # Catalog cycling
|
|
//!
|
|
//! Each tier maintains an independent cursor in [`DifficultyIndexResource`]
|
|
//! that advances one step each time a game is started at that tier. The cursor
|
|
//! wraps modulo the catalog length so players never run out of variety. The
|
|
//! resource is *not* persisted — it resets to 0 on every launch, which is fine
|
|
//! because the starting position is effectively random (player-chosen timing
|
|
//! determines which seed in the 40-entry catalog they start at).
|
|
|
|
use chrono::Utc;
|
|
|
|
use bevy::prelude::*;
|
|
use solitaire_core::game_state::{DifficultyLevel, GameMode};
|
|
use solitaire_data::difficulty_seeds::seeds_for;
|
|
|
|
use crate::events::{NewGameRequestEvent, StartDifficultyRequestEvent};
|
|
use crate::game_plugin::GameMutation;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Resources
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Per-tier catalog cursors. Each value is the index of the **next** seed to
|
|
/// deal from that tier's catalog. Wraps modulo the catalog length.
|
|
#[derive(Resource, Default)]
|
|
pub struct DifficultyIndexResource {
|
|
easy: usize,
|
|
medium: usize,
|
|
hard: usize,
|
|
expert: usize,
|
|
grandmaster: usize,
|
|
}
|
|
|
|
impl DifficultyIndexResource {
|
|
/// Advance the cursor for `level` and return the seed at the old position.
|
|
/// Falls back to a system-time seed if the catalog is unexpectedly empty.
|
|
pub fn next_seed(&mut self, level: DifficultyLevel) -> u64 {
|
|
let Some(catalog) = seeds_for(level) else {
|
|
return seed_from_system_time();
|
|
};
|
|
if catalog.is_empty() {
|
|
return seed_from_system_time();
|
|
}
|
|
let cursor = match level {
|
|
DifficultyLevel::Easy => &mut self.easy,
|
|
DifficultyLevel::Medium => &mut self.medium,
|
|
DifficultyLevel::Hard => &mut self.hard,
|
|
DifficultyLevel::Expert => &mut self.expert,
|
|
DifficultyLevel::Grandmaster => &mut self.grandmaster,
|
|
DifficultyLevel::Random => unreachable!("Random has no catalog"),
|
|
};
|
|
let seed = catalog[*cursor % catalog.len()];
|
|
*cursor = cursor.wrapping_add(1);
|
|
seed
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Plugin
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Registers all difficulty-mode systems and resources.
|
|
pub struct DifficultyPlugin;
|
|
|
|
impl Plugin for DifficultyPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<DifficultyIndexResource>()
|
|
.add_message::<StartDifficultyRequestEvent>()
|
|
.add_message::<NewGameRequestEvent>()
|
|
.add_systems(Update, handle_difficulty_request.before(GameMutation));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Systems
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Resolves `StartDifficultyRequestEvent` → catalog seed → `NewGameRequestEvent`.
|
|
fn handle_difficulty_request(
|
|
mut requests: MessageReader<StartDifficultyRequestEvent>,
|
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
|
mut index: ResMut<DifficultyIndexResource>,
|
|
) {
|
|
for ev in requests.read() {
|
|
let seed = if ev.level == DifficultyLevel::Random {
|
|
seed_from_system_time()
|
|
} else {
|
|
index.next_seed(ev.level)
|
|
};
|
|
|
|
new_game.write(NewGameRequestEvent {
|
|
seed: Some(seed),
|
|
mode: Some(GameMode::Difficulty(ev.level)),
|
|
confirmed: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn seed_from_system_time() -> u64 {
|
|
// Use chrono so this works on wasm32 (chrono has the `wasmbind` feature;
|
|
// std::time::SystemTime panics on wasm32-unknown-unknown).
|
|
Utc::now().timestamp_nanos_opt().unwrap_or(0) as u64
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::game_plugin::GamePlugin;
|
|
use crate::table_plugin::TablePlugin;
|
|
use solitaire_data::difficulty_seeds::{EASY_SEEDS, MEDIUM_SEEDS};
|
|
|
|
fn headless_app() -> App {
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(GamePlugin)
|
|
.add_plugins(TablePlugin)
|
|
.add_plugins(DifficultyPlugin);
|
|
app.update();
|
|
app
|
|
}
|
|
|
|
fn fire_request(app: &mut App, level: DifficultyLevel) {
|
|
app.world_mut()
|
|
.write_message(StartDifficultyRequestEvent { level });
|
|
app.update();
|
|
}
|
|
|
|
fn drain_new_game_events(app: &mut App) -> Vec<NewGameRequestEvent> {
|
|
let msgs = app.world().resource::<Messages<NewGameRequestEvent>>();
|
|
let mut cursor = msgs.get_cursor();
|
|
cursor.read(msgs).copied().collect()
|
|
}
|
|
|
|
#[test]
|
|
fn easy_request_dispatches_seed_from_easy_catalog() {
|
|
let mut app = headless_app();
|
|
fire_request(&mut app, DifficultyLevel::Easy);
|
|
|
|
let events = drain_new_game_events(&mut app);
|
|
assert_eq!(events.len(), 1);
|
|
let ev = &events[0];
|
|
assert!(ev.seed.is_some());
|
|
assert_eq!(ev.mode, Some(GameMode::Difficulty(DifficultyLevel::Easy)));
|
|
assert!(!ev.confirmed);
|
|
// Seed must come from the Easy catalog (non-empty catalog is the test
|
|
// precondition — the catalog uniqueness test in difficulty_seeds.rs
|
|
// guards integrity).
|
|
if !EASY_SEEDS.is_empty() {
|
|
assert!(
|
|
EASY_SEEDS.contains(&ev.seed.unwrap()),
|
|
"seed {:?} not in EASY_SEEDS",
|
|
ev.seed
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn successive_easy_requests_cycle_through_catalog() {
|
|
let mut app = headless_app();
|
|
fire_request(&mut app, DifficultyLevel::Easy);
|
|
fire_request(&mut app, DifficultyLevel::Easy);
|
|
|
|
let events = drain_new_game_events(&mut app);
|
|
assert_eq!(events.len(), 2);
|
|
// Two successive requests should return different seeds (assuming the
|
|
// catalog has at least 2 entries — it has 40).
|
|
if EASY_SEEDS.len() >= 2 {
|
|
assert_ne!(
|
|
events[0].seed, events[1].seed,
|
|
"successive Easy requests should produce different seeds"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn medium_request_dispatches_seed_from_medium_catalog() {
|
|
let mut app = headless_app();
|
|
fire_request(&mut app, DifficultyLevel::Medium);
|
|
|
|
let events = drain_new_game_events(&mut app);
|
|
assert_eq!(events.len(), 1);
|
|
assert_eq!(
|
|
events[0].mode,
|
|
Some(GameMode::Difficulty(DifficultyLevel::Medium))
|
|
);
|
|
if !MEDIUM_SEEDS.is_empty() {
|
|
assert!(MEDIUM_SEEDS.contains(&events[0].seed.unwrap()));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn random_request_dispatches_some_seed_with_random_mode() {
|
|
let mut app = headless_app();
|
|
fire_request(&mut app, DifficultyLevel::Random);
|
|
|
|
let events = drain_new_game_events(&mut app);
|
|
assert_eq!(events.len(), 1);
|
|
assert!(
|
|
events[0].seed.is_some(),
|
|
"Random should always produce Some(seed)"
|
|
);
|
|
assert_eq!(
|
|
events[0].mode,
|
|
Some(GameMode::Difficulty(DifficultyLevel::Random))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn different_tier_cursors_are_independent() {
|
|
let mut app = headless_app();
|
|
fire_request(&mut app, DifficultyLevel::Easy);
|
|
fire_request(&mut app, DifficultyLevel::Medium);
|
|
|
|
let events = drain_new_game_events(&mut app);
|
|
assert_eq!(events.len(), 2);
|
|
// Seeds from different catalogs should differ (they come from different
|
|
// address ranges by construction of gen_difficulty_seeds).
|
|
assert_ne!(
|
|
events[0].seed, events[1].seed,
|
|
"Easy and Medium should draw from independent catalogs"
|
|
);
|
|
}
|
|
}
|