use super::save::save;
use super::ui::spawn_toast;
use super::{ACHIEVEMENTS, PuzzleAttempt, RoundScratch, Stats, Unlocked, Unsaved};
use crate::app::audio::{Muted, Sounds, play_chime, sfx_gain};
use crate::app::net::Online;
use crate::app::settings::GameSettings;
use crate::app::sim_events::SimEvent;
use crate::app::{Controllers, SeatController};
use crate::sim::CrabKind;
use bevy::prelude::*;
fn local_seat(online: &Online, controllers: &Controllers) -> Option<u8> {
let seat = match &online.0 {
Some(session) => session.session.seat()?,
None => 0,
};
(controllers.0[usize::from(seat)] == SeatController::Local).then_some(seat)
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct Trophies<'w> {
pub stats: ResMut<'w, Stats>,
unlocked: ResMut<'w, Unlocked>,
pub settings: Res<'w, GameSettings>,
sounds: Option<Res<'w, Sounds>>,
muted: Res<'w, Muted>,
unsaved: ResMut<'w, Unsaved>,
}
impl Trophies<'_> {
pub fn credit(&mut self, commands: &mut Commands) {
self.unlock(commands);
self.save();
}
pub fn tally(&mut self, commands: &mut Commands) {
if self.unlock(commands) {
self.save();
} else {
self.unsaved.0 = true;
}
}
fn unlock(&mut self, commands: &mut Commands) -> bool {
unlock_new(
commands,
&self.stats,
&mut self.unlocked,
&self.settings,
&self.muted,
&self.sounds,
)
}
fn save(&mut self) {
save(&self.stats, &self.unlocked);
self.unsaved.0 = false;
}
}
pub fn track_events(
mut commands: Commands,
mut events: MessageReader<SimEvent>,
online: Res<Online>,
controllers: Res<Controllers>,
mut trophies: Trophies,
mut scratch: ResMut<RoundScratch>,
) {
let Some(seat) = local_seat(&online, &controllers) else {
for _ in events.read() {}
return;
};
let mut changed = false;
for event in events.read() {
match event {
SimEvent::CrabBanked { owner, kind, .. } if *owner == seat => {
trophies.stats.banked += 1;
scratch.banked += 1;
match kind {
CrabKind::Golden => trophies.stats.golden += 1,
CrabKind::Giant => trophies.stats.giants += 1,
CrabKind::Common
| CrabKind::Juvenile
| CrabKind::Molting
| CrabKind::Sparkling => {}
}
changed = true;
}
SimEvent::TideEventFired { event, by } => {
trophies.stats.events_seen |= 1 << event.index();
if *by == Some(seat) {
trophies.stats.events += 1;
}
changed = true;
}
SimEvent::LureStarted { owner } if *owner == seat => {
trophies.stats.lures += 1;
changed = true;
}
SimEvent::CrabEaten { .. } => {
trophies.stats.gulls_fed += 1;
changed = true;
}
SimEvent::CastleRaided { owner, .. } if *owner == seat => {
trophies.stats.raids_taken += 1;
scratch.raids += 1;
changed = true;
}
SimEvent::CrabBanked { .. }
| SimEvent::LureStarted { .. }
| SimEvent::CastleRaided { .. }
| SimEvent::CrabSpawned { .. }
| SimEvent::GullArrived
| SimEvent::GullTookOff
| SimEvent::GullLanded { .. }
| SimEvent::GullShooed { .. }
| SimEvent::GullFlewOff { .. }
| SimEvent::GoldenCalled { .. }
| SimEvent::SignpostPlaced { .. }
| SimEvent::SignpostRemoved { .. }
| SimEvent::SignpostEvicted { .. }
| SimEvent::TierUp { .. }
| SimEvent::SurgeStarted
| SimEvent::WheelLocked { .. }
| SimEvent::GoldenOut { .. }
| SimEvent::RoundEnded => {}
}
}
if changed {
trophies.tally(&mut commands);
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(super) struct RoundOutcome {
pub won: bool,
pub online: bool,
pub series: bool,
pub seat: u8,
}
fn credit_daily(stats: &mut Stats, day: u32, score: u32) {
if stats.daily_day != day {
stats.daily_day = day;
stats.daily_best = 0;
stats.daily_days += 1;
}
stats.daily_best = stats.daily_best.max(score);
stats.daily_record = stats.daily_record.max(stats.daily_best);
}
fn credit_round(stats: &mut Stats, scratch: &mut RoundScratch, outcome: RoundOutcome) {
stats.best_round = stats.best_round.max(scratch.banked);
if outcome.won {
stats.wins += 1;
if scratch.raids == 0 {
stats.dry_wins += 1;
}
if outcome.online {
stats.online_wins += 1;
}
stats.seats_won |= 1u8.checked_shl(u32::from(outcome.seat)).unwrap_or(0);
if outcome.series {
stats.series_wins += 1;
}
}
*scratch = RoundScratch::default();
}
pub fn record_round(
mut commands: Commands,
sim: Res<crate::app::Sim>,
seating: crate::app::side_panels::Seating,
daily: Res<crate::app::Daily>,
tournament: Res<crate::app::tournament::Tournament>,
mut trophies: Trophies,
mut scratch: ResMut<RoundScratch>,
) {
let crate::app::side_panels::Seating {
seats,
online,
controllers,
..
} = seating;
let Some(seat) = local_seat(&online, &controllers) else {
return;
};
trophies.stats.rounds += 1;
trophies.stats.crowd = trophies.stats.crowd.max(u32::from(seats.0));
if online
.0
.as_ref()
.is_some_and(crate::app::net::OnlineSession::is_host)
{
trophies.stats.hosted += 1;
}
if daily.active {
credit_daily(
&mut trophies.stats,
daily.day,
sim.0.scores()[seat as usize],
);
}
let mode = crate::app::teams::in_play(&trophies.settings, &online, seats.0);
let winners = crate::app::side_panels::leading_seats(sim.0.scores(), seats.0, mode);
let won = winners[seat as usize];
credit_round(
&mut trophies.stats,
&mut scratch,
RoundOutcome {
won,
online: online.0.is_some(),
series: tournament.is_decided()
&& tournament
.winner(mode, seats.0)
.is_some_and(|champion| champion.claims(seat, mode)),
seat,
},
);
trophies.credit(&mut commands);
}
pub fn save_now(stats: Res<Stats>, unlocked: Res<Unlocked>, mut unsaved: ResMut<Unsaved>) {
save(&stats, &unlocked);
unsaved.0 = false;
}
pub fn save_on_exit(
mut exits: MessageReader<AppExit>,
stats: Res<Stats>,
unlocked: Res<Unlocked>,
mut unsaved: ResMut<Unsaved>,
) {
if exits.read().count() > 0 && unsaved.0 {
save(&stats, &unlocked);
unsaved.0 = false;
}
}
pub fn record_level_built(
mut commands: Commands,
mut saved: MessageReader<crate::app::LevelSaved>,
mut trophies: Trophies,
) {
let built = saved.read().count() as u32;
if built == 0 {
return;
}
trophies.stats.levels_built += built;
trophies.credit(&mut commands);
}
pub fn reset_round_scratch(mut scratch: ResMut<RoundScratch>) {
*scratch = RoundScratch::default();
}
pub fn record_puzzle(
mut commands: Commands,
progress: Res<crate::app::progress::Progress>,
campaign: Res<crate::app::Campaign>,
attempt: Res<PuzzleAttempt>,
mut trophies: Trophies,
) {
trophies.stats.puzzles += 1;
let level = campaign.current();
if attempt.unbeaten {
if attempt.layout.len() < usize::from(level.posts) {
trophies.stats.under_par += 1;
}
if level.posts >= DEEP_POSTS {
trophies.stats.deep_solves += 1;
}
if attempt.retries == 0 {
trophies.stats.clean_solves += 1;
}
}
let builtins = &campaign.levels[..campaign.builtins.min(campaign.levels.len())];
if !builtins.is_empty()
&& builtins
.iter()
.all(|level| progress.is_cleared(campaign.kind, &level.name))
{
match campaign.kind {
crate::app::CampaignKind::TidePool => trophies.stats.campaign_done = 1,
crate::app::CampaignKind::BeachDay => trophies.stats.beach_done = 1,
}
}
trophies.credit(&mut commands);
}
const DEEP_POSTS: u8 = 5;
pub fn track_puzzle_attempt(
mut loads: MessageReader<crate::app::LoadLevel>,
campaign: Res<crate::app::Campaign>,
progress: Res<crate::app::progress::Progress>,
mut attempt: ResMut<PuzzleAttempt>,
) {
let mut loads = loads.read().count() as u32;
if loads == 0 {
return;
}
let name = &campaign.current().name;
if attempt.stage != *name {
attempt.unbeaten = !progress.is_cleared(campaign.kind, name);
attempt.stage = name.clone();
attempt.retries = 0;
attempt.layout.clear();
loads -= 1;
}
attempt.retries += loads;
}
pub fn note_posts_spent(sim: Res<crate::app::Sim>, mut attempt: ResMut<PuzzleAttempt>) {
attempt.layout = sim
.0
.tiles()
.filter_map(|(x, y, _)| {
let post = sim.0.signpost_at(x, y)?;
(post.owner == 0).then_some((x, y, post.dir))
})
.collect();
}
pub fn reset_puzzle_attempt(mut attempt: ResMut<PuzzleAttempt>) {
*attempt = PuzzleAttempt::default();
}
pub fn record_codes(
mut commands: Commands,
mut shared: MessageReader<crate::app::CodeShared>,
mut taken: MessageReader<crate::app::CodeTaken>,
mut trophies: Trophies,
) {
let out = shared.read().count() as u32;
let inn = taken.read().count() as u32;
if out == 0 && inn == 0 {
return;
}
trophies.stats.codes_shared += out;
trophies.stats.codes_taken += inn;
trophies.credit(&mut commands);
}
fn unlock_new(
commands: &mut Commands,
stats: &Stats,
unlocked: &mut Unlocked,
settings: &GameSettings,
muted: &Muted,
sounds: &Option<Res<Sounds>>,
) -> bool {
let tr = settings.tr();
let mut any = false;
for (index, achievement) in ACHIEVEMENTS.iter().enumerate() {
if !achievement.met(stats) || !unlocked.0.insert(achievement.id) {
continue;
}
any = true;
spawn_toast(commands, tr.ach_names[index], tr.ach_descs[index]);
if let Some(sounds) = sounds {
play_chime(commands, sounds, sfx_gain(settings, muted));
}
}
any
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::system::RunSystemOnce;
fn win() -> RoundOutcome {
RoundOutcome {
won: true,
..RoundOutcome::default()
}
}
fn scratch(raids: u32, banked: u32) -> RoundScratch {
RoundScratch { raids, banked }
}
fn tracking(all_unlocked: bool) -> App {
let mut app = App::new();
app.add_message::<SimEvent>();
app.add_message::<AppExit>();
app.init_resource::<Stats>();
app.init_resource::<RoundScratch>();
app.init_resource::<Unsaved>();
app.init_resource::<GameSettings>();
app.init_resource::<Muted>();
app.init_resource::<Online>();
app.init_resource::<Controllers>();
let mut unlocked = Unlocked::default();
if all_unlocked {
unlocked.0.extend(ACHIEVEMENTS.iter().map(|a| a.id));
}
app.insert_resource(unlocked);
app
}
fn eaten(app: &mut App) {
app.world_mut()
.write_message(SimEvent::CrabEaten { pos: Vec2::ZERO });
let _ = app.world_mut().run_system_once(track_events);
}
#[test]
fn a_change_mid_round_is_kept_until_the_game_closes() {
let mut app = tracking(true);
eaten(&mut app);
assert_eq!(app.world().resource::<Stats>().gulls_fed, 1, "counted");
assert!(app.world().resource::<Unsaved>().0, "and not written yet");
app.world_mut().write_message(AppExit::Success);
let _ = app.world_mut().run_system_once(save_on_exit);
assert!(
!app.world().resource::<Unsaved>().0,
"written on the way out"
);
}
#[test]
fn a_change_that_earns_a_trophy_is_written_at_once() {
let mut app = tracking(false);
app.world_mut().write_message(SimEvent::CrabBanked {
id: 0,
owner: 0,
pos: Vec2::ZERO,
keep: Vec2::ZERO,
points: 1,
kind: CrabKind::Common,
handed: crate::sim::Handedness::Right,
});
let _ = app.world_mut().run_system_once(track_events);
assert!(!app.world().resource::<Unlocked>().0.is_empty(), "unlocked");
assert!(!app.world().resource::<Unsaved>().0, "and written");
}
#[test]
fn a_dry_win_needs_an_unraided_castle() {
let mut stats = Stats::default();
let mut round = RoundScratch::default();
credit_round(&mut stats, &mut round, win());
assert_eq!((stats.wins, stats.dry_wins), (1, 1));
let mut round = scratch(2, 0);
credit_round(&mut stats, &mut round, win());
assert_eq!((stats.wins, stats.dry_wins), (2, 1), "raided: no trophy");
assert_eq!(round.raids, 0, "the scratch resets");
let mut round = scratch(5, 0);
credit_round(&mut stats, &mut round, RoundOutcome::default());
assert_eq!((stats.wins, stats.dry_wins), (2, 1));
assert_eq!(round.raids, 0);
}
#[test]
fn the_best_round_is_a_high_water_mark() {
let mut stats = Stats::default();
let mut round = scratch(0, 30);
credit_round(&mut stats, &mut round, RoundOutcome::default());
assert_eq!(stats.best_round, 30, "a losing round still counts");
assert_eq!(round.banked, 0);
credit_round(&mut stats, &mut scratch(0, 12), win());
assert_eq!(stats.best_round, 30, "a worse round does not lower it");
credit_round(&mut stats, &mut scratch(0, 51), win());
assert_eq!(stats.best_round, 51);
}
#[test]
fn a_stage_already_beaten_is_not_another_stage() {
use crate::app::progress::Progress;
use crate::app::{Campaign, CampaignKind, LoadLevel};
let mut levels = crate::sim::campaign_levels();
levels.truncate(2);
let builtins = levels.len();
let first = levels[0].name.clone();
let mut app = App::new();
app.add_message::<LoadLevel>();
app.init_resource::<PuzzleAttempt>();
app.init_resource::<Progress>();
app.insert_resource(Campaign {
kind: CampaignKind::TidePool,
levels,
index: 0,
builtins,
});
app.add_systems(Update, track_puzzle_attempt);
let load = |app: &mut App| {
app.world_mut()
.write_message(LoadLevel { keep_posts: false });
app.update();
};
let unbeaten = |app: &App| app.world().resource::<PuzzleAttempt>().unbeaten;
load(&mut app);
assert!(unbeaten(&app), "nothing cleared yet");
app.world_mut()
.resource_mut::<Progress>()
.mark(CampaignKind::TidePool, &first);
let _ = app.world_mut().run_system_once(reset_puzzle_attempt);
load(&mut app);
assert!(!unbeaten(&app), "a stage already in the book");
app.world_mut().resource_mut::<Campaign>().index = 1;
load(&mut app);
assert!(unbeaten(&app), "a stage never played");
}
#[test]
fn the_first_load_of_a_stage_is_not_a_retry() {
use crate::app::{Campaign, CampaignKind, LoadLevel};
let mut levels = crate::sim::campaign_levels();
levels.truncate(2);
let builtins = levels.len();
let mut app = App::new();
app.add_message::<LoadLevel>();
app.init_resource::<PuzzleAttempt>();
app.init_resource::<crate::app::progress::Progress>();
app.insert_resource(Campaign {
kind: CampaignKind::TidePool,
levels,
index: 0,
builtins,
});
app.add_systems(Update, track_puzzle_attempt);
let load = |app: &mut App| {
app.world_mut()
.write_message(LoadLevel { keep_posts: false });
app.update();
};
let retries = |app: &App| app.world().resource::<PuzzleAttempt>().retries;
load(&mut app);
assert_eq!(retries(&app), 0, "arriving at a stage is not a retry");
load(&mut app);
load(&mut app);
assert_eq!(retries(&app), 2, "two restarts");
app.world_mut().resource_mut::<Campaign>().index = 1;
load(&mut app);
assert_eq!(retries(&app), 0);
load(&mut app);
assert_eq!(retries(&app), 1);
let _ = app.world_mut().run_system_once(reset_puzzle_attempt);
app.world_mut().resource_mut::<Campaign>().index = 0;
load(&mut app);
assert_eq!(retries(&app), 0, "a second visit is a fresh attempt");
}
#[test]
fn the_seat_bitmask_counts_chairs_not_wins() {
let mut stats = Stats::default();
for _ in 0..4 {
credit_round(&mut stats, &mut RoundScratch::default(), win());
}
assert_eq!(stats.wins, 4);
assert_eq!(stats.seats_won, 0b0001, "four wins, one chair");
for seat in 1..4 {
credit_round(
&mut stats,
&mut RoundScratch::default(),
RoundOutcome { seat, ..win() },
);
}
assert_eq!(stats.seats_won.count_ones(), 4, "four of the six chairs");
credit_round(
&mut stats,
&mut RoundScratch::default(),
RoundOutcome {
seat: 5,
..RoundOutcome::default()
},
);
assert_eq!(stats.seats_won.count_ones(), 4);
}
#[test]
fn online_and_series_wins_need_the_win() {
let mut stats = Stats::default();
credit_round(
&mut stats,
&mut RoundScratch::default(),
RoundOutcome {
won: false,
online: true,
series: true,
seat: 0,
},
);
assert_eq!((stats.online_wins, stats.series_wins), (0, 0));
credit_round(
&mut stats,
&mut RoundScratch::default(),
RoundOutcome {
won: true,
online: true,
series: true,
seat: 0,
},
);
assert_eq!((stats.online_wins, stats.series_wins), (1, 1));
credit_round(&mut stats, &mut RoundScratch::default(), win());
assert_eq!((stats.online_wins, stats.series_wins), (1, 1));
assert_eq!(stats.wins, 2);
}
#[test]
fn a_daily_counts_on_the_day_it_was_seeded_for() {
let mut stats = Stats::default();
credit_daily(&mut stats, 100, 12);
assert_eq!(
(stats.daily_day, stats.daily_best, stats.daily_days),
(100, 12, 1)
);
credit_daily(&mut stats, 100, 30);
assert_eq!(
(stats.daily_day, stats.daily_best, stats.daily_days),
(100, 30, 1)
);
credit_daily(&mut stats, 101, 5);
assert_eq!(
(stats.daily_day, stats.daily_best, stats.daily_days),
(101, 5, 2)
);
assert_eq!(stats.daily_record, 30);
}
}