feat(engine): grant achievement rewards + gate cosmetic selectors

- Add Reward enum to solitaire_core with CardBack/Background/BonusXp/Badge variants
- Wire rewards into ALL_ACHIEVEMENTS per architecture spec
- evaluate_on_win now applies rewards on first unlock: pushes cosmetic
  indices into PlayerProgress, awards BonusXp (with level-up detection),
  and marks reward_granted = true so rewards are never double-granted
- Add selected_card_back / selected_background fields to Settings
- Settings panel grows Card Back and Background cycle rows, shown only
  when the player has unlocked more than the default (index 0)
- cycle_unlocked() cycles only through earned options

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-27 01:00:18 +00:00
parent b37fe5b49b
commit 6728a4311f
4 changed files with 250 additions and 8 deletions
+30
View File
@@ -34,6 +34,19 @@ pub struct AchievementContext {
pub wall_clock_hour: Option<u32>, pub wall_clock_hour: Option<u32>,
} }
/// Reward granted when an achievement is first unlocked.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reward {
/// Unlocks a card-back design at the given index (0 is always unlocked).
CardBack(usize),
/// Unlocks a background design at the given index (0 is always unlocked).
Background(usize),
/// Awards bonus XP on top of the standard win XP.
BonusXp(u64),
/// A visual badge — no gameplay effect.
Badge,
}
/// A single achievement's static metadata + unlock condition. /// A single achievement's static metadata + unlock condition.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct AchievementDef { pub struct AchievementDef {
@@ -42,6 +55,8 @@ pub struct AchievementDef {
pub description: &'static str, pub description: &'static str,
/// Hidden from the achievements screen until unlocked. /// Hidden from the achievements screen until unlocked.
pub secret: bool, pub secret: bool,
/// Reward granted on first unlock. `None` for cosmetic-only recognition.
pub reward: Option<Reward>,
pub condition: fn(&AchievementContext) -> bool, pub condition: fn(&AchievementContext) -> bool,
} }
@@ -112,6 +127,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
name: "First Win", name: "First Win",
description: "Win your first game", description: "Win your first game",
secret: false, secret: false,
reward: None,
condition: first_win, condition: first_win,
}, },
AchievementDef { AchievementDef {
@@ -119,6 +135,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
name: "On a Roll", name: "On a Roll",
description: "Win 3 games in a row", description: "Win 3 games in a row",
secret: false, secret: false,
reward: Some(Reward::CardBack(1)),
condition: on_a_roll, condition: on_a_roll,
}, },
AchievementDef { AchievementDef {
@@ -126,6 +143,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
name: "Unstoppable", name: "Unstoppable",
description: "Win 10 games in a row", description: "Win 10 games in a row",
secret: false, secret: false,
reward: Some(Reward::Background(1)),
condition: unstoppable, condition: unstoppable,
}, },
AchievementDef { AchievementDef {
@@ -133,6 +151,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
name: "Century", name: "Century",
description: "Play 100 games", description: "Play 100 games",
secret: false, secret: false,
reward: None,
condition: century, condition: century,
}, },
AchievementDef { AchievementDef {
@@ -140,6 +159,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
name: "Veteran", name: "Veteran",
description: "Play 500 games", description: "Play 500 games",
secret: false, secret: false,
reward: Some(Reward::Badge),
condition: veteran, condition: veteran,
}, },
AchievementDef { AchievementDef {
@@ -147,6 +167,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
name: "Speed Demon", name: "Speed Demon",
description: "Win in under 3 minutes", description: "Win in under 3 minutes",
secret: false, secret: false,
reward: None,
condition: speed_demon, condition: speed_demon,
}, },
AchievementDef { AchievementDef {
@@ -154,6 +175,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
name: "Lightning", name: "Lightning",
description: "Win in under 90 seconds", description: "Win in under 90 seconds",
secret: false, secret: false,
reward: Some(Reward::CardBack(2)),
condition: lightning, condition: lightning,
}, },
AchievementDef { AchievementDef {
@@ -161,6 +183,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
name: "High Scorer", name: "High Scorer",
description: "Score at least 5,000 in one game", description: "Score at least 5,000 in one game",
secret: false, secret: false,
reward: None,
condition: high_scorer, condition: high_scorer,
}, },
AchievementDef { AchievementDef {
@@ -168,6 +191,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
name: "Point Machine", name: "Point Machine",
description: "Accumulate 50,000 lifetime points", description: "Accumulate 50,000 lifetime points",
secret: false, secret: false,
reward: Some(Reward::Background(2)),
condition: point_machine, condition: point_machine,
}, },
AchievementDef { AchievementDef {
@@ -175,6 +199,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
name: "No Undo", name: "No Undo",
description: "Win a game without using undo", description: "Win a game without using undo",
secret: false, secret: false,
reward: Some(Reward::BonusXp(25)),
condition: no_undo, condition: no_undo,
}, },
AchievementDef { AchievementDef {
@@ -182,6 +207,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
name: "Draw 3 Master", name: "Draw 3 Master",
description: "Win 10 games in Draw 3 mode", description: "Win 10 games in Draw 3 mode",
secret: false, secret: false,
reward: Some(Reward::CardBack(3)),
condition: draw_three_master, condition: draw_three_master,
}, },
AchievementDef { AchievementDef {
@@ -189,6 +215,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
name: "Night Owl", name: "Night Owl",
description: "Win a game after midnight", description: "Win a game after midnight",
secret: false, secret: false,
reward: None,
condition: night_owl, condition: night_owl,
}, },
AchievementDef { AchievementDef {
@@ -196,6 +223,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
name: "Early Bird", name: "Early Bird",
description: "Win a game before 6am", description: "Win a game before 6am",
secret: false, secret: false,
reward: None,
condition: early_bird, condition: early_bird,
}, },
AchievementDef { AchievementDef {
@@ -203,6 +231,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
name: "???", name: "???",
description: "A secret achievement", description: "A secret achievement",
secret: true, secret: true,
reward: Some(Reward::CardBack(4)),
condition: speed_and_skill, condition: speed_and_skill,
}, },
AchievementDef { AchievementDef {
@@ -210,6 +239,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
name: "Daily Devotee", name: "Daily Devotee",
description: "Complete the daily challenge 7 days in a row", description: "Complete the daily challenge 7 days in a row",
secret: false, secret: false,
reward: Some(Reward::Background(3)),
condition: daily_devotee, condition: daily_devotee,
}, },
]; ];
+12
View File
@@ -84,6 +84,14 @@ pub struct Settings {
/// Which sync backend is active. /// Which sync backend is active.
#[serde(default)] #[serde(default)]
pub sync_backend: SyncBackend, pub sync_backend: SyncBackend,
/// Index of the card-back design currently in use (0 = default).
/// Only indices present in `PlayerProgress::unlocked_card_backs` are valid.
#[serde(default)]
pub selected_card_back: usize,
/// Index of the background design currently in use (0 = default).
/// Only indices present in `PlayerProgress::unlocked_backgrounds` are valid.
#[serde(default)]
pub selected_background: usize,
/// Set to `true` once the player has dismissed the first-run banner. /// Set to `true` once the player has dismissed the first-run banner.
#[serde(default)] #[serde(default)]
pub first_run_complete: bool, pub first_run_complete: bool,
@@ -110,6 +118,8 @@ impl Default for Settings {
animation_speed: AnimSpeed::Normal, animation_speed: AnimSpeed::Normal,
theme: Theme::Green, theme: Theme::Green,
sync_backend: SyncBackend::Local, sync_backend: SyncBackend::Local,
selected_card_back: 0,
selected_background: 0,
first_run_complete: false, first_run_complete: false,
} }
} }
@@ -264,6 +274,8 @@ mod tests {
url: "https://example.com".to_string(), url: "https://example.com".to_string(),
username: "testuser".to_string(), username: "testuser".to_string(),
}, },
selected_card_back: 0,
selected_background: 0,
first_run_complete: true, first_run_complete: true,
}; };
save_settings_to(&path, &s).expect("save"); save_settings_to(&path, &s).expect("save");
+53 -6
View File
@@ -10,15 +10,16 @@ use std::path::PathBuf;
use bevy::prelude::*; use bevy::prelude::*;
use chrono::{Local, Timelike, Utc}; use chrono::{Local, Timelike, Utc};
use solitaire_core::achievement::{ use solitaire_core::achievement::{
achievement_by_id, check_achievements, AchievementContext, ALL_ACHIEVEMENTS, achievement_by_id, check_achievements, AchievementContext, Reward, ALL_ACHIEVEMENTS,
}; };
use solitaire_data::{ use solitaire_data::{
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord, achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord,
save_progress_to,
}; };
use crate::events::{AchievementUnlockedEvent, GameWonEvent}; use crate::events::{AchievementUnlockedEvent, GameWonEvent};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressUpdate}; use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::stats_plugin::{StatsResource, StatsUpdate}; use crate::stats_plugin::{StatsResource, StatsUpdate};
@@ -89,11 +90,13 @@ impl Plugin for AchievementPlugin {
fn evaluate_on_win( fn evaluate_on_win(
mut wins: EventReader<GameWonEvent>, mut wins: EventReader<GameWonEvent>,
mut unlocks: EventWriter<AchievementUnlockedEvent>, mut unlocks: EventWriter<AchievementUnlockedEvent>,
mut levelups: EventWriter<LevelUpEvent>,
game: Res<GameStateResource>, game: Res<GameStateResource>,
stats: Res<StatsResource>, stats: Res<StatsResource>,
progress: Res<ProgressResource>,
path: Res<AchievementsStoragePath>, path: Res<AchievementsStoragePath>,
progress_path: Res<ProgressStoragePath>,
mut achievements: ResMut<AchievementsResource>, mut achievements: ResMut<AchievementsResource>,
mut progress: ResMut<ProgressResource>,
) { ) {
let Some(ev) = wins.read().last() else { let Some(ev) = wins.read().last() else {
return; return;
@@ -119,7 +122,9 @@ fn evaluate_on_win(
} }
let now = Utc::now(); let now = Utc::now();
let mut changed = false; let mut achievements_changed = false;
let mut progress_changed = false;
for def in hits { for def in hits {
let Some(record) = achievements.0.iter_mut().find(|r| r.id == def.id) else { let Some(record) = achievements.0.iter_mut().find(|r| r.id == def.id) else {
continue; continue;
@@ -128,17 +133,59 @@ fn evaluate_on_win(
continue; continue;
} }
record.unlock(now); record.unlock(now);
changed = true; achievements_changed = true;
// Grant the reward on first unlock.
if !record.reward_granted {
if let Some(reward) = def.reward {
match reward {
Reward::CardBack(idx) => {
if !progress.0.unlocked_card_backs.contains(&idx) {
progress.0.unlocked_card_backs.push(idx);
progress_changed = true;
}
}
Reward::Background(idx) => {
if !progress.0.unlocked_backgrounds.contains(&idx) {
progress.0.unlocked_backgrounds.push(idx);
progress_changed = true;
}
}
Reward::BonusXp(amount) => {
let prev_level = progress.0.add_xp(amount);
if progress.0.leveled_up_from(prev_level) {
levelups.send(LevelUpEvent {
previous_level: prev_level,
new_level: progress.0.level,
total_xp: progress.0.total_xp,
});
}
progress_changed = true;
}
Reward::Badge => {}
}
}
record.reward_granted = true;
}
unlocks.send(AchievementUnlockedEvent(record.clone())); unlocks.send(AchievementUnlockedEvent(record.clone()));
} }
if changed { if achievements_changed {
if let Some(target) = &path.0 { if let Some(target) = &path.0 {
if let Err(e) = save_achievements_to(target, &achievements.0) { if let Err(e) = save_achievements_to(target, &achievements.0) {
warn!("failed to save achievements: {e}"); warn!("failed to save achievements: {e}");
} }
} }
} }
if progress_changed {
if let Some(target) = &progress_path.0 {
if let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress after reward: {e}");
}
}
}
} }
/// Convenience: resolve an achievement ID to its human-readable name. /// Convenience: resolve an achievement ID to its human-readable name.
+155 -2
View File
@@ -16,6 +16,7 @@ use solitaire_core::game_state::DrawMode;
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, Settings}; use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, Settings};
use crate::events::ManualSyncRequestEvent; use crate::events::ManualSyncRequestEvent;
use crate::progress_plugin::ProgressResource;
use crate::resources::{SyncStatus, SyncStatusResource}; use crate::resources::{SyncStatus, SyncStatusResource};
/// Volume adjustment step applied by the `[` / `]` hotkeys. /// Volume adjustment step applied by the `[` / `]` hotkeys.
@@ -61,6 +62,14 @@ struct ThemeText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct SyncStatusText; struct SyncStatusText;
/// Marks the `Text` node showing the active card-back index.
#[derive(Component, Debug)]
struct CardBackText;
/// Marks the `Text` node showing the active background index.
#[derive(Component, Debug)]
struct BackgroundText;
/// Tags interactive buttons inside the Settings panel. /// Tags interactive buttons inside the Settings panel.
#[derive(Component, Debug)] #[derive(Component, Debug)]
enum SettingsButton { enum SettingsButton {
@@ -70,6 +79,8 @@ enum SettingsButton {
MusicUp, MusicUp,
ToggleDrawMode, ToggleDrawMode,
ToggleTheme, ToggleTheme,
CycleCardBack,
CycleBackground,
SyncNow, SyncNow,
Done, Done,
} }
@@ -122,6 +133,8 @@ impl Plugin for SettingsPlugin {
sync_settings_panel_visibility, sync_settings_panel_visibility,
handle_settings_buttons, handle_settings_buttons,
update_sync_status_text, update_sync_status_text,
update_card_back_text,
update_background_text,
), ),
); );
} }
@@ -186,6 +199,7 @@ fn sync_settings_panel_visibility(
mut commands: Commands, mut commands: Commands,
settings: Res<SettingsResource>, settings: Res<SettingsResource>,
sync_status: Option<Res<SyncStatusResource>>, sync_status: Option<Res<SyncStatusResource>>,
progress: Option<Res<ProgressResource>>,
) { ) {
if !screen.is_changed() { if !screen.is_changed() {
return; return;
@@ -195,7 +209,21 @@ fn sync_settings_panel_visibility(
let status_label = sync_status let status_label = sync_status
.map(|s| sync_status_label(&s.0)) .map(|s| sync_status_label(&s.0))
.unwrap_or_else(|| "Status: not configured".to_string()); .unwrap_or_else(|| "Status: not configured".to_string());
spawn_settings_panel(&mut commands, &settings.0, &status_label); let unlocked_backs = progress
.as_ref()
.map(|p| p.0.unlocked_card_backs.as_slice())
.unwrap_or(&[0]);
let unlocked_bgs = progress
.as_ref()
.map(|p| p.0.unlocked_backgrounds.as_slice())
.unwrap_or(&[0]);
spawn_settings_panel(
&mut commands,
&settings.0,
&status_label,
unlocked_backs,
unlocked_bgs,
);
} }
} else { } else {
for entity in &panels { for entity in &panels {
@@ -204,6 +232,16 @@ fn sync_settings_panel_visibility(
} }
} }
/// Returns the next unlocked index after `current` in the sorted `unlocked` list.
/// Wraps around. Falls back to `unlocked[0]` if `current` is not found.
fn cycle_unlocked(unlocked: &[usize], current: usize) -> usize {
if unlocked.is_empty() {
return 0;
}
let pos = unlocked.iter().position(|&i| i == current).unwrap_or(0);
unlocked[(pos + 1) % unlocked.len()]
}
/// Keeps the sync-status text node current while the panel is open. /// Keeps the sync-status text node current while the panel is open.
fn update_sync_status_text( fn update_sync_status_text(
sync_status: Option<Res<SyncStatusResource>>, sync_status: Option<Res<SyncStatusResource>>,
@@ -221,6 +259,46 @@ fn update_sync_status_text(
} }
} }
fn update_card_back_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<CardBackText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = card_back_label(settings.0.selected_card_back);
}
}
fn update_background_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<BackgroundText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = background_label(settings.0.selected_background);
}
}
fn card_back_label(idx: usize) -> String {
if idx == 0 {
"Default".to_string()
} else {
format!("Style {idx}")
}
}
fn background_label(idx: usize) -> String {
if idx == 0 {
"Default".to_string()
} else {
format!("Style {idx}")
}
}
fn sync_status_label(status: &SyncStatus) -> String { fn sync_status_label(status: &SyncStatus) -> String {
match status { match status {
SyncStatus::Idle => "Status: idle".to_string(), SyncStatus::Idle => "Status: idle".to_string(),
@@ -249,6 +327,7 @@ fn handle_settings_buttons(
path: Res<SettingsStoragePath>, path: Res<SettingsStoragePath>,
mut changed: EventWriter<SettingsChangedEvent>, mut changed: EventWriter<SettingsChangedEvent>,
mut manual_sync: EventWriter<ManualSyncRequestEvent>, mut manual_sync: EventWriter<ManualSyncRequestEvent>,
progress: Option<Res<ProgressResource>>,
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>)>, mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>)>,
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>)>, mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>)>,
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>)>, mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>)>,
@@ -326,6 +405,26 @@ fn handle_settings_buttons(
**t = theme_label(&settings.0.theme); **t = theme_label(&settings.0.theme);
} }
} }
SettingsButton::CycleCardBack => {
let unlocked = progress
.as_ref()
.map(|p| p.0.unlocked_card_backs.clone())
.unwrap_or_else(|| vec![0]);
settings.0.selected_card_back =
cycle_unlocked(&unlocked, settings.0.selected_card_back);
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
}
SettingsButton::CycleBackground => {
let unlocked = progress
.as_ref()
.map(|p| p.0.unlocked_backgrounds.clone())
.unwrap_or_else(|| vec![0]);
settings.0.selected_background =
cycle_unlocked(&unlocked, settings.0.selected_background);
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
}
SettingsButton::SyncNow => { SettingsButton::SyncNow => {
manual_sync.send(ManualSyncRequestEvent); manual_sync.send(ManualSyncRequestEvent);
} }
@@ -355,7 +454,13 @@ fn theme_label(theme: &Theme) -> String {
// UI construction // UI construction
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
fn spawn_settings_panel(commands: &mut Commands, settings: &Settings, sync_status: &str) { fn spawn_settings_panel(
commands: &mut Commands,
settings: &Settings,
sync_status: &str,
unlocked_card_backs: &[usize],
unlocked_backgrounds: &[usize],
) {
commands commands
.spawn(( .spawn((
SettingsPanel, SettingsPanel,
@@ -458,6 +563,54 @@ fn spawn_settings_panel(commands: &mut Commands, settings: &Settings, sync_statu
icon_button(row, "", SettingsButton::ToggleTheme); icon_button(row, "", SettingsButton::ToggleTheme);
}); });
// Card back row — only shown when the player has unlocked more than one.
if unlocked_card_backs.len() > 1 {
card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
..default()
})
.with_children(|row| {
row.spawn((
Text::new("Card Back"),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::srgb(0.85, 0.85, 0.80)),
));
row.spawn((
CardBackText,
Text::new(card_back_label(settings.selected_card_back)),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::WHITE),
));
icon_button(row, "", SettingsButton::CycleCardBack);
});
}
// Background row — only shown when the player has unlocked more than one.
if unlocked_backgrounds.len() > 1 {
card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
..default()
})
.with_children(|row| {
row.spawn((
Text::new("Background"),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::srgb(0.85, 0.85, 0.80)),
));
row.spawn((
BackgroundText,
Text::new(background_label(settings.selected_background)),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::WHITE),
));
icon_button(row, "", SettingsButton::CycleBackground);
});
}
// --- Sync section --- // --- Sync section ---
section_label(card, "Sync"); section_label(card, "Sync");
card.spawn(Node { card.spawn(Node {