fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
Build and Deploy / build-and-push (push) Successful in 3m54s
Build and Deploy / build-and-push (push) Successful in 3m54s
- #66: Clamp safe-area insets to 25% of window height with warn!() on excess - #68: Move fire_flush outside per-event loop in analytics (batch flush once) - #56: Persist progress before marking reward_granted to prevent XP loss on crash - #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh - #62: Add validate_header() in replay upload with mode/draw_mode allowlists - #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original queries already in .sqlx cache; EXISTS variant would require sqlx prepare Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -26,11 +26,11 @@ use crate::settings_plugin::SettingsResource;
|
||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||
use crate::ui_modal::ModalScrim;
|
||||
use crate::ui_theme::{
|
||||
scaled_duration, ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
|
||||
ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
|
||||
MOTION_SCORE_BREAKDOWN_STAGGER_SECS, MOTION_WIN_SHAKE_AMPLITUDE, MOTION_WIN_SHAKE_SECS,
|
||||
RADIUS_LG, RADIUS_MD, SCRIM, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY,
|
||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_DISPLAY, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
|
||||
Z_WIN_CASCADE,
|
||||
Z_WIN_CASCADE, scaled_duration,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -227,9 +227,7 @@ impl Plugin for WinSummaryPlugin {
|
||||
// the player's old personal-best values before `StatsPlugin` overwrites them.
|
||||
.add_systems(
|
||||
Update,
|
||||
cache_win_data
|
||||
.after(GameMutation)
|
||||
.before(StatsUpdate),
|
||||
cache_win_data.after(GameMutation).before(StatsUpdate),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -351,10 +349,17 @@ impl ScoreBreakdown {
|
||||
} else {
|
||||
scaled.clamp(i32::MIN as f32, i32::MAX as f32) as i32
|
||||
};
|
||||
let no_undo_bonus = if undo_count == 0 { SCORE_NO_UNDO_BONUS } else { 0 };
|
||||
let no_undo_bonus = if undo_count == 0 {
|
||||
SCORE_NO_UNDO_BONUS
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let multiplier = match mode {
|
||||
GameMode::Zen => 0.0,
|
||||
GameMode::Classic | GameMode::Challenge | GameMode::TimeAttack | GameMode::Difficulty(_) => 1.0,
|
||||
GameMode::Classic
|
||||
| GameMode::Challenge
|
||||
| GameMode::TimeAttack
|
||||
| GameMode::Difficulty(_) => 1.0,
|
||||
};
|
||||
Self {
|
||||
base,
|
||||
@@ -549,10 +554,9 @@ fn spawn_win_summary_after_delay(
|
||||
// intensity is left at its design-token value because amplitude
|
||||
// does not benefit from "fast" / "instant" scaling — at Instant
|
||||
// speed the duration is zero anyway, suppressing the shake.
|
||||
let speed = settings.as_ref().map_or(
|
||||
solitaire_data::AnimSpeed::Normal,
|
||||
|s| s.0.animation_speed,
|
||||
);
|
||||
let speed = settings
|
||||
.as_ref()
|
||||
.map_or(solitaire_data::AnimSpeed::Normal, |s| s.0.animation_speed);
|
||||
let scaled = scaled_duration(SHAKE_DURATION_SECS, speed);
|
||||
shake.remaining = scaled;
|
||||
shake.total = scaled;
|
||||
@@ -632,25 +636,16 @@ fn handle_win_summary_buttons(
|
||||
new_game.write(NewGameRequestEvent::default());
|
||||
}
|
||||
WinSummaryButton::WatchReplay => {
|
||||
let latest = history
|
||||
.as_ref()
|
||||
.and_then(|h| h.0.replays.last())
|
||||
.cloned();
|
||||
let latest = history.as_ref().and_then(|h| h.0.replays.last()).cloned();
|
||||
match (latest, playback.as_mut()) {
|
||||
(Some(replay), Some(pb)) => {
|
||||
for entity in &overlays {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
crate::replay_playback::start_replay_playback(
|
||||
&mut commands,
|
||||
pb,
|
||||
replay,
|
||||
);
|
||||
crate::replay_playback::start_replay_playback(&mut commands, pb, replay);
|
||||
}
|
||||
(Some(_), None) => {
|
||||
toast.write(InfoToastEvent(
|
||||
"Replay playback not available".to_string(),
|
||||
));
|
||||
toast.write(InfoToastEvent("Replay playback not available".to_string()));
|
||||
}
|
||||
(None, _) => {
|
||||
toast.write(InfoToastEvent("No replay saved yet".to_string()));
|
||||
@@ -710,7 +705,11 @@ fn apply_screen_shake(
|
||||
// Decay factor: 1.0 at start, 0.0 at end. Falls back to the design-token
|
||||
// duration if `total` is zero (older armings or test setups that bypass
|
||||
// `spawn_win_summary_after_delay`) so we never divide by zero.
|
||||
let total = if shake.total > 0.0 { shake.total } else { SHAKE_DURATION_SECS };
|
||||
let total = if shake.total > 0.0 {
|
||||
shake.total
|
||||
} else {
|
||||
SHAKE_DURATION_SECS
|
||||
};
|
||||
let decay = shake.remaining / total;
|
||||
let elapsed = time.elapsed_secs();
|
||||
let offset_x = (elapsed * 47.0).sin() * shake.intensity * decay;
|
||||
@@ -792,7 +791,10 @@ fn spawn_overlay(
|
||||
// Heading
|
||||
card.spawn((
|
||||
Text::new("You Won!"),
|
||||
TextFont { font_size: TYPE_DISPLAY, ..default() },
|
||||
TextFont {
|
||||
font_size: TYPE_DISPLAY,
|
||||
..default()
|
||||
},
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
|
||||
@@ -800,7 +802,10 @@ fn spawn_overlay(
|
||||
if let Some(level) = challenge_level {
|
||||
card.spawn((
|
||||
Text::new(format!("Challenge {level} complete!")),
|
||||
TextFont { font_size: TYPE_HEADLINE, ..default() },
|
||||
TextFont {
|
||||
font_size: TYPE_HEADLINE,
|
||||
..default()
|
||||
},
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
}
|
||||
@@ -810,7 +815,10 @@ fn spawn_overlay(
|
||||
if pending.new_record {
|
||||
card.spawn((
|
||||
Text::new("New Record!"),
|
||||
TextFont { font_size: TYPE_HEADLINE, ..default() },
|
||||
TextFont {
|
||||
font_size: TYPE_HEADLINE,
|
||||
..default()
|
||||
},
|
||||
TextColor(STATE_WARNING),
|
||||
));
|
||||
}
|
||||
@@ -822,14 +830,20 @@ fn spawn_overlay(
|
||||
// Time
|
||||
card.spawn((
|
||||
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
|
||||
TextFont { font_size: TYPE_HEADLINE, ..default() },
|
||||
TextFont {
|
||||
font_size: TYPE_HEADLINE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
|
||||
// XP total
|
||||
card.spawn((
|
||||
Text::new(format!("XP earned: +{}", pending.xp)),
|
||||
TextFont { font_size: TYPE_BODY_LG, ..default() },
|
||||
TextFont {
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
},
|
||||
TextColor(STATE_SUCCESS),
|
||||
));
|
||||
|
||||
@@ -837,7 +851,10 @@ fn spawn_overlay(
|
||||
if !pending.xp_detail.is_empty() {
|
||||
card.spawn((
|
||||
Text::new(pending.xp_detail.clone()),
|
||||
TextFont { font_size: 15.0, ..default() },
|
||||
TextFont {
|
||||
font_size: 15.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
@@ -874,7 +891,10 @@ fn spawn_overlay(
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new("Watch Replay"),
|
||||
TextFont { font_size: TYPE_BODY_LG, ..default() },
|
||||
TextFont {
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
},
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
});
|
||||
@@ -894,7 +914,10 @@ fn spawn_overlay(
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new("Play Again \u{21B5}"),
|
||||
TextFont { font_size: TYPE_BODY_LG, ..default() },
|
||||
TextFont {
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
},
|
||||
TextColor(BG_BASE),
|
||||
));
|
||||
});
|
||||
@@ -915,7 +938,10 @@ const MAX_ACHIEVEMENTS_SHOWN: usize = 3;
|
||||
fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String]) {
|
||||
card.spawn((
|
||||
Text::new("Achievements Unlocked"),
|
||||
TextFont { font_size: TYPE_BODY_LG, ..default() },
|
||||
TextFont {
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
},
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
|
||||
@@ -923,7 +949,10 @@ fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String])
|
||||
for name in &names[..shown] {
|
||||
card.spawn((
|
||||
Text::new(format!(" {name}")),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextFont {
|
||||
font_size: 16.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
}
|
||||
@@ -932,7 +961,10 @@ fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String])
|
||||
if overflow > 0 {
|
||||
card.spawn((
|
||||
Text::new(format!(" ...and {overflow} more")),
|
||||
TextFont { font_size: 15.0, ..default() },
|
||||
TextFont {
|
||||
font_size: 15.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
@@ -1091,13 +1123,19 @@ fn spawn_breakdown_row(
|
||||
// Label — left-aligned.
|
||||
row.spawn((
|
||||
Text::new(label.to_string()),
|
||||
TextFont { font_size: TYPE_BODY, ..default() },
|
||||
TextFont {
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
},
|
||||
TextColor(label_color_with_alpha),
|
||||
));
|
||||
// Value — right-aligned via the parent's JustifyContent::SpaceBetween.
|
||||
row.spawn((
|
||||
Text::new(value),
|
||||
TextFont { font_size: TYPE_BODY, ..default() },
|
||||
TextFont {
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
},
|
||||
TextColor(value_color_with_alpha),
|
||||
));
|
||||
});
|
||||
@@ -1170,7 +1208,10 @@ mod tests {
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.insert_resource(StatsResource(StatsSnapshot::default()))
|
||||
.insert_resource(GameStateResource(GameState::new(0, solitaire_core::game_state::DrawMode::DrawOne)))
|
||||
.insert_resource(GameStateResource(GameState::new(
|
||||
0,
|
||||
solitaire_core::game_state::DrawMode::DrawOne,
|
||||
)))
|
||||
.insert_resource(ProgressResource(PlayerProgress::default()));
|
||||
app.update();
|
||||
app
|
||||
@@ -1282,17 +1323,18 @@ mod tests {
|
||||
app.update();
|
||||
|
||||
// Confirm it was recorded.
|
||||
assert_eq!(
|
||||
app.world().resource::<SessionAchievements>().names.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(app.world().resource::<SessionAchievements>().names.len(), 1);
|
||||
|
||||
// Fire NewGameRequestEvent — should clear the list.
|
||||
app.world_mut().write_message(NewGameRequestEvent::default());
|
||||
app.world_mut()
|
||||
.write_message(NewGameRequestEvent::default());
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().resource::<SessionAchievements>().names.is_empty(),
|
||||
app.world()
|
||||
.resource::<SessionAchievements>()
|
||||
.names
|
||||
.is_empty(),
|
||||
"session achievements must be cleared on NewGameRequestEvent"
|
||||
);
|
||||
}
|
||||
@@ -1332,7 +1374,10 @@ mod tests {
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().resource::<SessionAchievements>().names.is_empty(),
|
||||
app.world()
|
||||
.resource::<SessionAchievements>()
|
||||
.names
|
||||
.is_empty(),
|
||||
"session achievements must be cleared when a mode-switch NewGameRequestEvent fires"
|
||||
);
|
||||
}
|
||||
@@ -1341,15 +1386,20 @@ mod tests {
|
||||
fn cache_win_data_sets_score_and_time() {
|
||||
let mut app = make_app();
|
||||
|
||||
app.world_mut()
|
||||
.write_message(GameWonEvent { score: 1234, time_seconds: 90 });
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 1234,
|
||||
time_seconds: 90,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert_eq!(pending.score, 1234);
|
||||
assert_eq!(pending.time_seconds, 90);
|
||||
// 90s < 120s → speed bonus present; default game has undo_count=0 → no-undo bonus present.
|
||||
assert!(!pending.xp_detail.is_empty(), "xp_detail must be populated on GameWonEvent");
|
||||
assert!(
|
||||
!pending.xp_detail.is_empty(),
|
||||
"xp_detail must be populated on GameWonEvent"
|
||||
);
|
||||
assert!(pending.xp_detail.contains("+50 base"));
|
||||
}
|
||||
|
||||
@@ -1357,7 +1407,10 @@ mod tests {
|
||||
fn cache_win_data_sets_xp_from_xp_awarded_event() {
|
||||
let mut app = make_app();
|
||||
|
||||
app.world_mut().write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 0,
|
||||
time_seconds: 0,
|
||||
});
|
||||
app.world_mut().write_message(XpAwardedEvent { amount: 75 });
|
||||
app.update();
|
||||
|
||||
@@ -1369,12 +1422,17 @@ mod tests {
|
||||
fn game_won_event_arms_screen_shake() {
|
||||
let mut app = make_app();
|
||||
|
||||
app.world_mut()
|
||||
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 0,
|
||||
time_seconds: 0,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let shake = app.world().resource::<ScreenShakeResource>();
|
||||
assert!(shake.remaining > 0.0, "shake must be armed after GameWonEvent");
|
||||
assert!(
|
||||
shake.remaining > 0.0,
|
||||
"shake must be armed after GameWonEvent"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1387,8 +1445,10 @@ mod tests {
|
||||
// Any positive-score win should be flagged as a new record.
|
||||
let mut app = make_app();
|
||||
|
||||
app.world_mut()
|
||||
.write_message(GameWonEvent { score: 500, time_seconds: 120 });
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 120,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
@@ -1405,12 +1465,17 @@ mod tests {
|
||||
}
|
||||
|
||||
// Score 500 beats previous best of 400.
|
||||
app.world_mut()
|
||||
.write_message(GameWonEvent { score: 500, time_seconds: 300 });
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 300,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert!(pending.new_record, "beating best score should set new_record");
|
||||
assert!(
|
||||
pending.new_record,
|
||||
"beating best score should set new_record"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1423,12 +1488,17 @@ mod tests {
|
||||
}
|
||||
|
||||
// Score 500 does not beat 800, but time 100 < 200.
|
||||
app.world_mut()
|
||||
.write_message(GameWonEvent { score: 500, time_seconds: 100 });
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 100,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert!(pending.new_record, "beating fastest time should set new_record");
|
||||
assert!(
|
||||
pending.new_record,
|
||||
"beating fastest time should set new_record"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1441,8 +1511,10 @@ mod tests {
|
||||
}
|
||||
|
||||
// Score 500 < 800 and time 120 > 60 — neither record broken.
|
||||
app.world_mut()
|
||||
.write_message(GameWonEvent { score: 500, time_seconds: 120 });
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 120,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
@@ -1472,8 +1544,10 @@ mod tests {
|
||||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
||||
}
|
||||
|
||||
app.world_mut()
|
||||
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 0,
|
||||
time_seconds: 0,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
@@ -1488,8 +1562,10 @@ mod tests {
|
||||
fn classic_win_leaves_challenge_level_none() {
|
||||
let mut app = make_app();
|
||||
// Default game mode is Classic — challenge_level should stay None.
|
||||
app.world_mut()
|
||||
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 0,
|
||||
time_seconds: 0,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
@@ -1519,8 +1595,10 @@ mod tests {
|
||||
game.0.undo_count = 2;
|
||||
}
|
||||
|
||||
app.world_mut()
|
||||
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 0,
|
||||
time_seconds: 0,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
@@ -1552,7 +1630,10 @@ mod tests {
|
||||
fn score_breakdown_zen_mode_zeros_total() {
|
||||
let bd = ScoreBreakdown::compute(500, 60, 0, GameMode::Zen, 1.0);
|
||||
assert!((bd.multiplier - 0.0).abs() < f32::EPSILON);
|
||||
assert!(bd.shows_multiplier_row(), "Zen ×0 must display the multiplier row");
|
||||
assert!(
|
||||
bd.shows_multiplier_row(),
|
||||
"Zen ×0 must display the multiplier row"
|
||||
);
|
||||
assert_eq!(bd.total(), 0);
|
||||
}
|
||||
|
||||
@@ -1616,7 +1697,10 @@ mod tests {
|
||||
#[test]
|
||||
fn score_breakdown_applies_time_bonus_multiplier() {
|
||||
let raw = compute_time_bonus(120);
|
||||
assert_eq!(raw, 5833, "sanity-check raw bonus before testing the multiplier");
|
||||
assert_eq!(
|
||||
raw, 5833,
|
||||
"sanity-check raw bonus before testing the multiplier"
|
||||
);
|
||||
|
||||
let bd = ScoreBreakdown::compute(0, 120, 0, GameMode::Classic, 0.5);
|
||||
let expected = ((raw as f32) * 0.5).round() as i32;
|
||||
@@ -1712,9 +1796,27 @@ mod tests {
|
||||
// Frame 1: `time.delta` is 0 (first frame), so only row0
|
||||
// (delay = 0) should reveal.
|
||||
app.update();
|
||||
assert!(app.world().entity(row0).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||
assert!(!app.world().entity(row1).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||
assert!(!app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||
assert!(
|
||||
app.world()
|
||||
.entity(row0)
|
||||
.get::<ScoreBreakdownRow>()
|
||||
.unwrap()
|
||||
.revealed
|
||||
);
|
||||
assert!(
|
||||
!app.world()
|
||||
.entity(row1)
|
||||
.get::<ScoreBreakdownRow>()
|
||||
.unwrap()
|
||||
.revealed
|
||||
);
|
||||
assert!(
|
||||
!app.world()
|
||||
.entity(row2)
|
||||
.get::<ScoreBreakdownRow>()
|
||||
.unwrap()
|
||||
.revealed
|
||||
);
|
||||
|
||||
// Advance time by one stagger interval — row1 should reveal.
|
||||
{
|
||||
@@ -1722,8 +1824,20 @@ mod tests {
|
||||
time.advance_by(std::time::Duration::from_secs_f32(stagger + 0.001));
|
||||
}
|
||||
app.update();
|
||||
assert!(app.world().entity(row1).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||
assert!(!app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||
assert!(
|
||||
app.world()
|
||||
.entity(row1)
|
||||
.get::<ScoreBreakdownRow>()
|
||||
.unwrap()
|
||||
.revealed
|
||||
);
|
||||
assert!(
|
||||
!app.world()
|
||||
.entity(row2)
|
||||
.get::<ScoreBreakdownRow>()
|
||||
.unwrap()
|
||||
.revealed
|
||||
);
|
||||
|
||||
// Advance again — row2 should reveal.
|
||||
{
|
||||
@@ -1731,7 +1845,13 @@ mod tests {
|
||||
time.advance_by(std::time::Duration::from_secs_f32(stagger + 0.001));
|
||||
}
|
||||
app.update();
|
||||
assert!(app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||
assert!(
|
||||
app.world()
|
||||
.entity(row2)
|
||||
.get::<ScoreBreakdownRow>()
|
||||
.unwrap()
|
||||
.revealed
|
||||
);
|
||||
}
|
||||
|
||||
/// Under `AnimSpeed::Instant`, breakdown rows must spawn already
|
||||
|
||||
Reference in New Issue
Block a user