a7b781cd36
Adds a full-screen overlay listing all achievements with unlock status. Unlocked achievements show in gold with a check mark; locked ones are greyed out. Secret achievements that are still locked are hidden. Header shows unlocked/total count. Press A again to dismiss. Two new unit tests: spawns on first A press, dismisses on second. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
418 lines
14 KiB
Rust
418 lines
14 KiB
Rust
//! Evaluates achievements on `GameWonEvent`, persists unlocks, and fires
|
|
//! `AchievementUnlockedEvent` for each newly unlocked achievement.
|
|
//!
|
|
//! The persistence path is configurable via `AchievementPlugin::storage_path`.
|
|
//! `AchievementPlugin::default()` uses the platform data dir;
|
|
//! `AchievementPlugin::headless()` disables I/O entirely (for tests).
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use bevy::prelude::*;
|
|
use chrono::{Local, Timelike, Utc};
|
|
use solitaire_core::achievement::{
|
|
achievement_by_id, check_achievements, AchievementContext, ALL_ACHIEVEMENTS,
|
|
};
|
|
use solitaire_data::{
|
|
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord,
|
|
};
|
|
|
|
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
|
use crate::game_plugin::GameMutation;
|
|
use crate::progress_plugin::{ProgressResource, ProgressUpdate};
|
|
use crate::resources::GameStateResource;
|
|
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
|
|
|
/// Marker on the achievements overlay root node.
|
|
#[derive(Component, Debug)]
|
|
pub struct AchievementsScreen;
|
|
|
|
/// All per-player achievement records (one per known achievement).
|
|
#[derive(Resource, Debug, Clone)]
|
|
pub struct AchievementsResource(pub Vec<AchievementRecord>);
|
|
|
|
/// Persistence path for `AchievementsResource`. `None` disables I/O.
|
|
#[derive(Resource, Debug, Clone)]
|
|
pub struct AchievementsStoragePath(pub Option<PathBuf>);
|
|
|
|
pub struct AchievementPlugin {
|
|
pub storage_path: Option<PathBuf>,
|
|
}
|
|
|
|
impl Default for AchievementPlugin {
|
|
fn default() -> Self {
|
|
Self {
|
|
storage_path: achievements_file_path(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AchievementPlugin {
|
|
/// Plugin configured with no persistence.
|
|
pub fn headless() -> Self {
|
|
Self { storage_path: None }
|
|
}
|
|
}
|
|
|
|
impl Plugin for AchievementPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
let mut records = match &self.storage_path {
|
|
Some(path) => load_achievements_from(path),
|
|
None => Vec::new(),
|
|
};
|
|
// Ensure every known achievement has a record. Keeps file forward-compatible
|
|
// when new achievements are added in future releases.
|
|
for def in ALL_ACHIEVEMENTS {
|
|
if !records.iter().any(|r| r.id == def.id) {
|
|
records.push(AchievementRecord::locked(def.id));
|
|
}
|
|
}
|
|
|
|
app.insert_resource(AchievementsResource(records))
|
|
.insert_resource(AchievementsStoragePath(self.storage_path.clone()))
|
|
.add_event::<AchievementUnlockedEvent>()
|
|
.add_event::<GameWonEvent>()
|
|
// Run after GameMutation (so GameWonEvent is available), after
|
|
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
|
// (so daily_challenge_streak is up to date for daily_devotee).
|
|
.add_systems(
|
|
Update,
|
|
evaluate_on_win
|
|
.after(GameMutation)
|
|
.after(StatsUpdate)
|
|
.after(ProgressUpdate),
|
|
)
|
|
.add_systems(Update, toggle_achievements_screen);
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn evaluate_on_win(
|
|
mut wins: EventReader<GameWonEvent>,
|
|
mut unlocks: EventWriter<AchievementUnlockedEvent>,
|
|
game: Res<GameStateResource>,
|
|
stats: Res<StatsResource>,
|
|
progress: Res<ProgressResource>,
|
|
path: Res<AchievementsStoragePath>,
|
|
mut achievements: ResMut<AchievementsResource>,
|
|
) {
|
|
let Some(ev) = wins.read().last() else {
|
|
return;
|
|
};
|
|
|
|
let ctx = AchievementContext {
|
|
games_played: stats.0.games_played,
|
|
games_won: stats.0.games_won,
|
|
win_streak_current: stats.0.win_streak_current,
|
|
best_single_score: stats.0.best_single_score,
|
|
lifetime_score: stats.0.lifetime_score,
|
|
draw_three_wins: stats.0.draw_three_wins,
|
|
daily_challenge_streak: progress.0.daily_challenge_streak,
|
|
last_win_score: ev.score,
|
|
last_win_time_seconds: ev.time_seconds,
|
|
last_win_used_undo: game.0.undo_count > 0,
|
|
wall_clock_hour: Some(Local::now().hour()),
|
|
};
|
|
|
|
let hits = check_achievements(&ctx);
|
|
if hits.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let now = Utc::now();
|
|
let mut changed = false;
|
|
for def in hits {
|
|
let Some(record) = achievements.0.iter_mut().find(|r| r.id == def.id) else {
|
|
continue;
|
|
};
|
|
if record.unlocked {
|
|
continue;
|
|
}
|
|
record.unlock(now);
|
|
changed = true;
|
|
unlocks.send(AchievementUnlockedEvent(record.clone()));
|
|
}
|
|
|
|
if changed {
|
|
if let Some(target) = &path.0 {
|
|
if let Err(e) = save_achievements_to(target, &achievements.0) {
|
|
warn!("failed to save achievements: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Convenience: resolve an achievement ID to its human-readable name.
|
|
/// Used by the toast renderer in `animation_plugin`.
|
|
pub fn display_name_for(id: &str) -> String {
|
|
achievement_by_id(id)
|
|
.map(|d| d.name.to_string())
|
|
.unwrap_or_else(|| id.to_string())
|
|
}
|
|
|
|
/// Toggle the achievements overlay with the `A` key.
|
|
fn toggle_achievements_screen(
|
|
mut commands: Commands,
|
|
keys: Res<ButtonInput<KeyCode>>,
|
|
achievements: Res<AchievementsResource>,
|
|
screens: Query<Entity, With<AchievementsScreen>>,
|
|
) {
|
|
if !keys.just_pressed(KeyCode::KeyA) {
|
|
return;
|
|
}
|
|
if let Ok(entity) = screens.get_single() {
|
|
commands.entity(entity).despawn_recursive();
|
|
} else {
|
|
spawn_achievements_screen(&mut commands, &achievements.0);
|
|
}
|
|
}
|
|
|
|
fn spawn_achievements_screen(commands: &mut Commands, records: &[AchievementRecord]) {
|
|
let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect();
|
|
let total = ALL_ACHIEVEMENTS.len();
|
|
|
|
commands
|
|
.spawn((
|
|
AchievementsScreen,
|
|
Node {
|
|
position_type: PositionType::Absolute,
|
|
left: Val::Percent(0.0),
|
|
top: Val::Percent(0.0),
|
|
width: Val::Percent(100.0),
|
|
height: Val::Percent(100.0),
|
|
flex_direction: FlexDirection::Column,
|
|
justify_content: JustifyContent::Center,
|
|
align_items: AlignItems::Center,
|
|
..default()
|
|
},
|
|
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.82)),
|
|
ZIndex(210),
|
|
))
|
|
.with_children(|root| {
|
|
root.spawn((
|
|
Node {
|
|
flex_direction: FlexDirection::Column,
|
|
padding: UiRect::all(Val::Px(28.0)),
|
|
row_gap: Val::Px(8.0),
|
|
min_width: Val::Px(380.0),
|
|
max_height: Val::Percent(80.0),
|
|
overflow: Overflow::clip_y(),
|
|
..default()
|
|
},
|
|
BackgroundColor(Color::srgb(0.09, 0.09, 0.12)),
|
|
BorderRadius::all(Val::Px(8.0)),
|
|
))
|
|
.with_children(|card| {
|
|
// Header
|
|
card.spawn((
|
|
Text::new(format!(
|
|
"Achievements ({}/{})",
|
|
unlocked.len(),
|
|
total
|
|
)),
|
|
TextFont { font_size: 26.0, ..default() },
|
|
TextColor(Color::WHITE),
|
|
));
|
|
card.spawn((
|
|
Text::new("Press A to close"),
|
|
TextFont { font_size: 14.0, ..default() },
|
|
TextColor(Color::srgb(0.55, 0.55, 0.60)),
|
|
));
|
|
|
|
// Separator
|
|
card.spawn((
|
|
Node {
|
|
height: Val::Px(1.0),
|
|
margin: UiRect::vertical(Val::Px(6.0)),
|
|
..default()
|
|
},
|
|
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
|
|
));
|
|
|
|
// Achievement rows — unlocked first, then locked
|
|
let mut sorted: Vec<_> = records.iter().collect();
|
|
sorted.sort_by_key(|r| (!r.unlocked, r.id.clone()));
|
|
|
|
for record in &sorted {
|
|
let def = achievement_by_id(&record.id);
|
|
let (name, description) = def
|
|
.map(|d| (d.name, d.description))
|
|
.unwrap_or((&record.id, ""));
|
|
|
|
// Hide secret locked achievements
|
|
let is_secret = def.map(|d| d.secret).unwrap_or(false);
|
|
if is_secret && !record.unlocked {
|
|
continue;
|
|
}
|
|
|
|
let (name_color, desc_color, prefix) = if record.unlocked {
|
|
(
|
|
Color::srgb(1.0, 0.87, 0.0),
|
|
Color::srgb(0.75, 0.75, 0.70),
|
|
"✓ ",
|
|
)
|
|
} else {
|
|
(
|
|
Color::srgb(0.45, 0.45, 0.50),
|
|
Color::srgb(0.35, 0.35, 0.40),
|
|
"◯ ",
|
|
)
|
|
};
|
|
|
|
card.spawn(Node {
|
|
flex_direction: FlexDirection::Column,
|
|
row_gap: Val::Px(1.0),
|
|
margin: UiRect::bottom(Val::Px(4.0)),
|
|
..default()
|
|
})
|
|
.with_children(|row| {
|
|
row.spawn((
|
|
Text::new(format!("{prefix}{name}")),
|
|
TextFont { font_size: 16.0, ..default() },
|
|
TextColor(name_color),
|
|
));
|
|
if !description.is_empty() {
|
|
row.spawn((
|
|
Text::new(format!(" {description}")),
|
|
TextFont { font_size: 13.0, ..default() },
|
|
TextColor(desc_color),
|
|
));
|
|
}
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::game_plugin::GamePlugin;
|
|
use crate::stats_plugin::StatsPlugin;
|
|
use crate::table_plugin::TablePlugin;
|
|
|
|
fn headless_app() -> App {
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(GamePlugin)
|
|
.add_plugins(TablePlugin)
|
|
.add_plugins(StatsPlugin::headless())
|
|
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
|
.add_plugins(AchievementPlugin::headless());
|
|
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
|
|
// MinimalPlugins it isn't auto-registered.
|
|
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
|
app.update();
|
|
app
|
|
}
|
|
|
|
#[test]
|
|
fn resource_is_populated_with_all_known_ids() {
|
|
let app = headless_app();
|
|
let records = &app.world().resource::<AchievementsResource>().0;
|
|
assert_eq!(records.len(), ALL_ACHIEVEMENTS.len());
|
|
for def in ALL_ACHIEVEMENTS {
|
|
assert!(records.iter().any(|r| r.id == def.id && !r.unlocked));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn win_unlocks_first_win_and_fires_event() {
|
|
let mut app = headless_app();
|
|
|
|
// StatsPlugin runs update_stats_on_win first (after GameMutation); that
|
|
// bumps games_won to 1 before evaluate_on_win reads StatsResource.
|
|
app.world_mut().send_event(GameWonEvent {
|
|
score: 1000,
|
|
time_seconds: 300,
|
|
});
|
|
app.update();
|
|
|
|
let unlocked_first_win = app
|
|
.world()
|
|
.resource::<AchievementsResource>()
|
|
.0
|
|
.iter()
|
|
.find(|r| r.id == "first_win")
|
|
.map(|r| r.unlocked)
|
|
.unwrap_or(false);
|
|
assert!(unlocked_first_win);
|
|
|
|
// Verify the event was emitted.
|
|
let events = app.world().resource::<Events<AchievementUnlockedEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
|
assert!(fired.contains(&"first_win".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn repeated_win_does_not_refire_already_unlocked_achievement() {
|
|
let mut app = headless_app();
|
|
|
|
app.world_mut().send_event(GameWonEvent {
|
|
score: 1000,
|
|
time_seconds: 300,
|
|
});
|
|
app.update();
|
|
|
|
// Clear events from first win.
|
|
app.world_mut()
|
|
.resource_mut::<Events<AchievementUnlockedEvent>>()
|
|
.clear();
|
|
|
|
app.world_mut().send_event(GameWonEvent {
|
|
score: 1000,
|
|
time_seconds: 300,
|
|
});
|
|
app.update();
|
|
|
|
let events = app.world().resource::<Events<AchievementUnlockedEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
|
assert!(
|
|
!fired.contains(&"first_win".to_string()),
|
|
"first_win must not re-fire on subsequent wins"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn display_name_resolves_known_and_unknown_ids() {
|
|
assert_eq!(display_name_for("first_win"), "First Win");
|
|
assert_eq!(display_name_for("bogus"), "bogus");
|
|
}
|
|
|
|
fn press(app: &mut App, key: KeyCode) {
|
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
input.release(key);
|
|
input.clear();
|
|
input.press(key);
|
|
}
|
|
|
|
#[test]
|
|
fn pressing_a_spawns_achievements_screen() {
|
|
let mut app = headless_app();
|
|
press(&mut app, KeyCode::KeyA);
|
|
app.update();
|
|
let count = app
|
|
.world_mut()
|
|
.query::<&AchievementsScreen>()
|
|
.iter(app.world())
|
|
.count();
|
|
assert_eq!(count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn pressing_a_twice_dismisses_screen() {
|
|
let mut app = headless_app();
|
|
press(&mut app, KeyCode::KeyA);
|
|
app.update();
|
|
press(&mut app, KeyCode::KeyA);
|
|
app.update();
|
|
let count = app
|
|
.world_mut()
|
|
.query::<&AchievementsScreen>()
|
|
.iter(app.world())
|
|
.count();
|
|
assert_eq!(count, 0);
|
|
}
|
|
}
|