use super::save::save;
use super::ui::spawn_toast;
use super::{ACHIEVEMENTS, PuzzleAttempt, RoundScratch, Stats, Unlocked};
use crate::app::Bots;
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::sim::CrabKind;
use bevy::prelude::*;
fn local_seat(online: &Online, bots: &Bots) -> Option<u8> {
let seat = match &online.0 {
Some(session) => session.session.seat()?,
None => 0,
};
if bots.0[seat as usize].is_some() {
None
} else {
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>,
}
impl Trophies<'_> {
pub fn credit(&mut self, commands: &mut Commands) {
unlock_new(
commands,
&self.stats,
&mut self.unlocked,
&self.settings,
&self.muted,
&self.sounds,
);
save(&self.stats, &self.unlocked);
}
}
pub fn track_events(
mut commands: Commands,
mut events: MessageReader<SimEvent>,
online: Res<Online>,
bots: Res<Bots>,
mut trophies: Trophies,
mut scratch: ResMut<RoundScratch>,
) {
let Some(seat) = local_seat(&online, &bots) 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::Molting => trophies.stats.lures += 1,
CrabKind::Sparkling => trophies.stats.events += 1,
CrabKind::Giant => trophies.stats.giants += 1,
CrabKind::Common | CrabKind::Juvenile => {}
}
changed = true;
}
SimEvent::TideEventFired { event } => {
trophies.stats.events_seen |= 1 << event.index();
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::CastleRaided { .. }
| SimEvent::CrabSpawned { .. }
| SimEvent::GullArrived
| SimEvent::GullTookOff
| SimEvent::GullLanded { .. }
| SimEvent::GullShooed { .. }
| SimEvent::SignpostPlaced { .. }
| SimEvent::SignpostRemoved { .. }
| SimEvent::SignpostEvicted { .. }
| SimEvent::TierUp { .. }
| SimEvent::SurgeStarted
| SimEvent::RoundEnded => {}
}
}
if changed {
trophies.credit(&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_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();
}
#[allow(clippy::too_many_arguments)]
pub fn record_round(
mut commands: Commands,
sim: Res<crate::app::Sim>,
seats: Res<crate::app::Seats>,
online: Res<Online>,
bots: Res<Bots>,
daily: Res<crate::app::Daily>,
tournament: Res<crate::app::tournament::Tournament>,
mut trophies: Trophies,
mut scratch: ResMut<RoundScratch>,
) {
let Some(seat) = local_seat(&online, &bots) 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 {
let today = crate::app::Daily::today();
if trophies.stats.daily_day != today {
trophies.stats.daily_day = today;
trophies.stats.daily_best = 0;
trophies.stats.daily_days += 1;
}
trophies.stats.daily_best = trophies.stats.daily_best.max(sim.0.scores()[seat as usize]);
trophies.stats.daily_record = trophies.stats.daily_record.max(trophies.stats.daily_best);
}
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>) {
save(&stats, &unlocked);
}
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>,
sim: Res<crate::app::Sim>,
attempt: Res<PuzzleAttempt>,
mut trophies: Trophies,
) {
trophies.stats.puzzles += 1;
let level = campaign.current();
if attempt.unbeaten {
if sim.0.signpost_count(0) < 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;
loads -= 1;
}
attempt.retries += loads;
}
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>>,
) {
let tr = settings.tr();
for (index, achievement) in ACHIEVEMENTS.iter().enumerate() {
if !achievement.met(stats) || !unlocked.0.insert(achievement.id) {
continue;
}
spawn_toast(commands, tr.ach_names[index], tr.ach_descs[index]);
if let Some(sounds) = sounds {
play_chime(commands, sounds, sfx_gain(settings, muted));
}
}
}
#[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 }
}
#[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);
}
}