feat(engine): add AchievementPlugin with persistent unlock tracking

On GameWonEvent, build an AchievementContext from StatsResource + GameState
+ wall-clock hour, evaluate ALL_ACHIEVEMENTS, flip newly-satisfied records
to unlocked, persist atomically, and emit AchievementUnlockedEvent for
each new unlock. AnimationPlugin's toast resolves the event's ID to the
achievement's display name via achievement_plugin::display_name_for.

Introduces StatsUpdate system set so AchievementPlugin can reliably run
after StatsResource reflects the win. AchievementPlugin::headless() used
in tests to avoid touching ~/.local/share/solitaire_quest/achievements.json.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-24 12:53:31 -07:00
parent 4589c52368
commit 1f6994a084
5 changed files with 256 additions and 6 deletions
+14 -3
View File
@@ -24,6 +24,11 @@ pub struct StatsResource(pub StatsSnapshot);
#[derive(Resource, Debug, Clone)]
pub struct StatsStoragePath(pub Option<PathBuf>);
/// System set for the stats-mutating systems. Downstream plugins that read
/// `StatsResource` after a win/abandon should run `.after(StatsUpdate)`.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct StatsUpdate;
/// Marker component on the stats overlay root node.
#[derive(Component, Debug)]
pub struct StatsScreen;
@@ -63,11 +68,17 @@ impl Plugin for StatsPlugin {
.add_event::<NewGameRequestEvent>()
// record_abandoned must read `move_count` BEFORE handle_new_game
// clobbers it with a fresh game.
.add_systems(Update, update_stats_on_new_game.before(GameMutation))
.add_systems(
Update,
(update_stats_on_win, toggle_stats_screen).after(GameMutation),
);
update_stats_on_new_game
.before(GameMutation)
.in_set(StatsUpdate),
)
.add_systems(
Update,
update_stats_on_win.after(GameMutation).in_set(StatsUpdate),
)
.add_systems(Update, toggle_stats_screen.after(GameMutation));
}
}