feat(engine): emit SyncCompleteEvent on pull resolve

ARCHITECTURE.md §5 lists SyncCompleteEvent(Result<SyncResponse, String>) as
a cross-system event, but it was never declared or fired. Add the message
to events.rs, register it in SyncPlugin, and emit it from poll_pull_result
on both the success path (carrying the merged payload + conflicts as
SyncResponse) and the failure path (carrying the user-facing error
message). UI/persistence systems can now react to sync completion without
polling SyncStatusResource.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-29 21:14:21 +00:00
parent fbe984cf64
commit abda354562
3 changed files with 30 additions and 9 deletions
+11
View File
@@ -4,6 +4,7 @@ use bevy::prelude::Message;
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::GameMode;
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use solitaire_data::AchievementRecord; use solitaire_data::AchievementRecord;
use solitaire_sync::SyncResponse;
/// Request to move `count` cards from `from` to `to`. Fired by input systems, /// Request to move `count` cards from `from` to `to`. Fired by input systems,
/// consumed by `GamePlugin`. /// consumed by `GamePlugin`.
@@ -78,6 +79,16 @@ pub struct AchievementUnlockedEvent(pub AchievementRecord);
#[derive(Message, Debug, Clone, Copy, Default)] #[derive(Message, Debug, Clone, Copy, Default)]
pub struct ManualSyncRequestEvent; pub struct ManualSyncRequestEvent;
/// Fired by `SyncPlugin` after a pull task resolves and the merged result has
/// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus
/// any `ConflictReport`s the merge produced. `Err(String)` carries a
/// human-readable failure message (network, auth, serialization, etc.).
///
/// UI systems listen for this to refresh views without polling
/// `SyncStatusResource`. See [ARCHITECTURE.md §4](../../ARCHITECTURE.md).
#[derive(Message, Debug, Clone)]
pub struct SyncCompleteEvent(pub Result<SyncResponse, String>);
/// Fired by `InputPlugin` when N is pressed while a game is in progress /// Fired by `InputPlugin` when N is pressed while a game is in progress
/// but confirmation has not yet been received. The animation plugin shows /// but confirmation has not yet been received. The animation plugin shows
/// a "Press N again to confirm" toast. A second N press within the /// a "Press N again to confirm" toast. A second N press within the
+1 -1
View File
@@ -69,7 +69,7 @@ pub use events::{
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
ForfeitEvent, GameWonEvent, HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, ForfeitEvent, GameWonEvent, HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent,
MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent,
StateChangedEvent, UndoRequestEvent, XpAwardedEvent, StateChangedEvent, SyncCompleteEvent, UndoRequestEvent, XpAwardedEvent,
}; };
pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath}; pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath};
pub use help_plugin::{HelpPlugin, HelpScreen}; pub use help_plugin::{HelpPlugin, HelpScreen};
+18 -8
View File
@@ -22,10 +22,10 @@ use solitaire_data::{
save_achievements_to, save_progress_to, save_stats_to, AchievementRecord, PlayerProgress, save_achievements_to, save_progress_to, save_stats_to, AchievementRecord, PlayerProgress,
StatsSnapshot, SyncError, SyncProvider, StatsSnapshot, SyncError, SyncProvider,
}; };
use solitaire_sync::{merge, SyncPayload}; use solitaire_sync::{merge, SyncPayload, SyncResponse};
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath}; use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
use crate::events::ManualSyncRequestEvent; use crate::events::{ManualSyncRequestEvent, SyncCompleteEvent};
use crate::progress_plugin::{ProgressResource, ProgressStoragePath}; use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
use crate::resources::{SyncStatus, SyncStatusResource}; use crate::resources::{SyncStatus, SyncStatusResource};
use crate::stats_plugin::{StatsResource, StatsStoragePath}; use crate::stats_plugin::{StatsResource, StatsStoragePath};
@@ -94,6 +94,7 @@ impl Plugin for SyncPlugin {
.init_resource::<PullTaskResult>() .init_resource::<PullTaskResult>()
.init_resource::<PullTask>() .init_resource::<PullTask>()
.add_message::<ManualSyncRequestEvent>() .add_message::<ManualSyncRequestEvent>()
.add_message::<SyncCompleteEvent>()
.add_systems(Startup, start_pull) .add_systems(Startup, start_pull)
.add_systems(Update, (poll_pull_result, handle_manual_sync_request)) .add_systems(Update, (poll_pull_result, handle_manual_sync_request))
.add_systems(Last, push_on_exit); .add_systems(Last, push_on_exit);
@@ -161,6 +162,7 @@ fn poll_pull_result(
achievements_path: Res<AchievementsStoragePath>, achievements_path: Res<AchievementsStoragePath>,
mut progress: ResMut<ProgressResource>, mut progress: ResMut<ProgressResource>,
progress_path: Res<ProgressStoragePath>, progress_path: Res<ProgressStoragePath>,
mut complete_writer: MessageWriter<SyncCompleteEvent>,
) { ) {
let Some(task) = task_res.0.as_mut() else { let Some(task) = task_res.0.as_mut() else {
return; return;
@@ -173,7 +175,7 @@ fn poll_pull_result(
match result { match result {
Ok(remote) => { Ok(remote) => {
let local = build_payload(&stats.0, &achievements.0, &progress.0); let local = build_payload(&stats.0, &achievements.0, &progress.0);
let (merged, _conflicts) = merge(&local, &remote); let (merged, conflicts) = merge(&local, &remote);
// Persist merged state atomically. // Persist merged state atomically.
if let Some(p) = &stats_path.0 if let Some(p) = &stats_path.0
@@ -190,10 +192,17 @@ fn poll_pull_result(
} }
// Update in-world resources. // Update in-world resources.
stats.0 = merged.stats; let now = Utc::now();
achievements.0 = merged.achievements; stats.0 = merged.stats.clone();
progress.0 = merged.progress; achievements.0 = merged.achievements.clone();
status.0 = SyncStatus::LastSynced(Utc::now()); progress.0 = merged.progress.clone();
status.0 = SyncStatus::LastSynced(now);
complete_writer.write(SyncCompleteEvent(Ok(SyncResponse {
merged,
server_time: now,
conflicts,
})));
} }
Err(SyncError::UnsupportedPlatform) => { Err(SyncError::UnsupportedPlatform) => {
// No backend configured — not an error, just leave status as Idle. // No backend configured — not an error, just leave status as Idle.
@@ -207,7 +216,8 @@ fn poll_pull_result(
SyncError::Serialization(_) => format!("Unexpected server response: {e}"), SyncError::Serialization(_) => format!("Unexpected server response: {e}"),
SyncError::UnsupportedPlatform => unreachable!("handled above"), SyncError::UnsupportedPlatform => unreachable!("handled above"),
}; };
status.0 = SyncStatus::Error(msg); status.0 = SyncStatus::Error(msg.clone());
complete_writer.write(SyncCompleteEvent(Err(msg)));
} }
} }
} }