pinch-points 0.8.0

A fast, kid-friendly crab-routing game: route streams of crabs into your sandcastle before time runs out
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
//! Lifetime stats and achievements, fed by the `SimEvent` stream and the
//! round/puzzle outcomes. Everything persists to `achievements.txt` next to
//! the game (lenient parse, like settings). Unlocks pop a toast and chime.
//!
//! Attribution: stats count the local player's seat, seat 0 in local play
//! and the session seat online. Bot seats never earn anything.

use crate::sim::TideEvent;
use bevy::platform::collections::HashSet;
use bevy::prelude::*;
/// The trophy record, under the XDG data directory.
pub(super) fn save_path() -> std::path::PathBuf {
    crate::app::paths::data_dir().join("achievements.txt")
}

/// Stats changed since they were last written out.
///
/// A bank by the local seat, a crab eaten anywhere: a busy round on a big
/// beach is several a second, and a save is a sync to disk, tens of
/// milliseconds on the frame thread. Writing each one stuttered the game
/// every time a crab came home. Such a change is kept here instead and
/// written when something earns a trophy, when the round ends, when the
/// screen is left, and when the game is closed.
#[derive(Resource, Default, Clone, Copy, PartialEq, Eq, Debug)]
pub struct Unsaved(pub bool);

/// Lifetime counters for the local player.
#[derive(Resource, Default, Clone, PartialEq, Eq, Debug)]
pub struct Stats {
    pub banked: u32,
    pub golden: u32,
    pub lures: u32,
    pub events: u32,
    pub gulls_fed: u32,
    pub raids_taken: u32,
    pub rounds: u32,
    pub wins: u32,
    pub puzzles: u32,
    pub dry_wins: u32,
    /// The day (days since epoch, UTC) `daily_best` belongs to.
    pub daily_day: u32,
    /// Best local score in today's daily challenge.
    pub daily_best: u32,
    /// Giant crabs banked: the ten-pointers worth protecting.
    pub giants: u32,
    /// One bit per tide event ever spun (see [`TideEvent::index`]), so the
    /// roulette trophy needs *variety* rather than volume.
    pub events_seen: u16,
    /// Most crabs banked in a single round.
    pub best_round: u32,
    /// Series taken, of either length.
    pub series_wins: u32,
    /// Rounds won against a live opponent over the wire.
    pub online_wins: u32,
    /// Distinct days the daily challenge was played.
    pub daily_days: u32,
    /// Levels saved out of the editor.
    pub levels_built: u32,
    /// 1 once every built-in Tide Pool stage has been cleared. A count of
    /// solves cannot answer this: it counts re-solves, and says nothing
    /// about which stages they were.
    pub campaign_done: u32,
    /// 1 once every Beach Day stage has been cleared.
    pub beach_done: u32,
    /// Rounds hosted on the local network, counted per round rather than
    /// per match because that is where a round is tallied.
    pub hosted: u32,
    /// The most seats a round the local player was in has ever filled.
    pub crowd: u32,
    /// One bit per seat the local player has won a round from, so the
    /// trophy asks for the whole table rather than four wins from seat 0.
    pub seats_won: u8,
    /// Stages cleared with signposts still in hand.
    pub under_par: u32,
    /// Stages cleared on the first attempt, with no retry in between.
    pub clean_solves: u32,
    /// Stages granting five signposts that have been cleared: the deep end
    /// of the campaign, which is a different thing from a count of solves.
    pub deep_solves: u32,
    /// Best daily score ever, as against `daily_best`, which is today's and
    /// starts over at midnight. A trophy cannot hang on a number that
    /// resets under it.
    pub daily_record: u32,
    /// Levels put on the clipboard as a share code.
    pub codes_shared: u32,
    /// Levels opened from somebody else's share code.
    pub codes_taken: u32,
}

/// What this round has done to the local seat so far: read when the round
/// ends, then thrown away. Its own resource rather than two fields in
/// [`Stats`], so the save file is exactly `Stats`.
#[derive(Resource, Default, Debug)]
pub struct RoundScratch {
    /// Raids on the local castle this round.
    pub raids: u32,
    /// Crabs banked this round; feeds `best_round`.
    pub banked: u32,
}

/// Which stage the player is on and how many times they have reloaded it.
///
/// The first-try trophy is the only thing that needs it, and it cannot be
/// a stat: a retry is not a lifetime total but a fact about the attempt in
/// progress, and it has to be forgotten on the way out of the screen.
#[derive(Resource, Default)]
pub struct PuzzleAttempt {
    /// The stage the count belongs to. Cleared on entering the screen, so
    /// coming back to a stage starts a fresh attempt rather than inheriting
    /// the retries of the last visit.
    stage: String,
    pub retries: u32,
    /// Whether the stage was still unbeaten when this attempt began.
    ///
    /// Two trophies count stages rather than clears: "clear 10 stages with
    /// one to spare" and "clear 10 stages first try". Counting every clear
    /// made ten goes at stage one into ten stages, so they ask for a stage
    /// that had not been beaten yet when the attempt started.
    pub unbeaten: bool,
    /// The signposts standing when the run began: what the player spent,
    /// and what a retry puts back.
    ///
    /// Arrows go down only in setup, and on a Beach Day stage they wear
    /// out while the run plays, so what is left standing at the end is not
    /// what was used. Read there, every Beach Day stage won after its
    /// arrows had expired was a clear "with one to spare", and "R: watch
    /// again" or a retry handed back an empty beach.
    pub layout: Vec<crate::sim::Placement>,
}

impl PuzzleAttempt {
    /// The arrows the last run of `stage` began with, if it ran.
    pub fn layout_of(&self, stage: &str) -> Option<&[crate::sim::Placement]> {
        (self.stage == stage && !self.layout.is_empty()).then_some(self.layout.as_slice())
    }
}

/// Ids of unlocked achievements.
#[derive(Resource, Default)]
pub struct Unlocked(pub HashSet<&'static str>);

/// One achievement: id, stat selector, and the threshold to meet. Names and
/// descriptions live in the i18n tables at the same index.
pub struct Achievement {
    pub id: &'static str,
    stat: fn(&Stats) -> u32,
    threshold: u32,
}

pub const ACHIEVEMENTS: [Achievement; 50] = [
    Achievement {
        id: "first_bank",
        stat: |s| s.banked,
        threshold: 1,
    },
    Achievement {
        id: "bank_100",
        stat: |s| s.banked,
        threshold: 100,
    },
    Achievement {
        id: "bank_1000",
        stat: |s| s.banked,
        threshold: 1000,
    },
    Achievement {
        id: "golden_1",
        stat: |s| s.golden,
        threshold: 1,
    },
    Achievement {
        id: "golden_10",
        stat: |s| s.golden,
        threshold: 5,
    },
    Achievement {
        id: "lure_5",
        stat: |s| s.lures,
        threshold: 5,
    },
    Achievement {
        id: "event_5",
        stat: |s| s.events,
        threshold: 5,
    },
    Achievement {
        id: "win_1",
        stat: |s| s.wins,
        threshold: 1,
    },
    Achievement {
        id: "win_10",
        stat: |s| s.wins,
        threshold: 10,
    },
    Achievement {
        id: "puzzle_5",
        stat: |s| s.puzzles,
        threshold: 5,
    },
    Achievement {
        id: "puzzle_25",
        stat: |s| s.puzzles,
        threshold: 25,
    },
    Achievement {
        // The campaign is far longer than the ladder used to allow for; a
        // player who works through it deserves a rung past the twenty-fifth.
        id: "puzzle_50",
        stat: |s| s.puzzles,
        threshold: 50,
    },
    Achievement {
        id: "dry_castle",
        stat: |s| s.dry_wins,
        threshold: 1,
    },
    Achievement {
        id: "giant_25",
        stat: |s| s.giants,
        threshold: 25,
    },
    Achievement {
        // Variety, not volume: one bit per event, every one lit.
        id: "events_all",
        stat: |s| s.events_seen.count_ones(),
        threshold: TideEvent::ALL.len() as u32,
    },
    Achievement {
        id: "haul_50",
        stat: |s| s.best_round,
        threshold: 50,
    },
    Achievement {
        id: "series_1",
        stat: |s| s.series_wins,
        threshold: 1,
    },
    Achievement {
        id: "online_1",
        stat: |s| s.online_wins,
        threshold: 1,
    },
    Achievement {
        id: "daily_7",
        stat: |s| s.daily_days,
        threshold: 7,
    },
    Achievement {
        id: "built_1",
        stat: |s| s.levels_built,
        threshold: 1,
    },
    Achievement {
        id: "rounds_100",
        stat: |s| s.rounds,
        threshold: 100,
    },
    // The long tail: second rungs on ladders whose first rung a steady
    // player passes in an evening, plus the two that ask for a whole
    // campaign rather than a count of anything.
    Achievement {
        id: "bank_5000",
        stat: |s| s.banked,
        threshold: 5_000,
    },
    Achievement {
        id: "golden_50",
        stat: |s| s.golden,
        threshold: 25,
    },
    Achievement {
        id: "giant_100",
        stat: |s| s.giants,
        threshold: 100,
    },
    Achievement {
        id: "haul_100",
        stat: |s| s.best_round,
        threshold: 100,
    },
    Achievement {
        id: "win_50",
        stat: |s| s.wins,
        threshold: 50,
    },
    Achievement {
        id: "daily_30",
        stat: |s| s.daily_days,
        threshold: 30,
    },
    Achievement {
        id: "built_10",
        stat: |s| s.levels_built,
        threshold: 10,
    },
    Achievement {
        // The one trophy you earn by losing. It was unreachable in practice
        // until gulls started catching the crabs they walked into.
        id: "fed_25",
        stat: |s| s.gulls_fed,
        threshold: 25,
    },
    Achievement {
        id: "campaign_done",
        stat: |s| s.campaign_done,
        threshold: 1,
    },
    Achievement {
        id: "beach_done",
        stat: |s| s.beach_done,
        threshold: 1,
    },
    Achievement {
        id: "series_5",
        stat: |s| s.series_wins,
        threshold: 5,
    },
    // Second rungs on ladders that had only a first, and the two counters
    // the shelf never asked anything of. Nothing here needs new tracking:
    // every one of these numbers was already being kept.
    Achievement {
        id: "raids_25",
        stat: |s| s.raids_taken,
        threshold: 25,
    },
    Achievement {
        id: "lure_50",
        stat: |s| s.lures,
        threshold: 50,
    },
    Achievement {
        id: "event_50",
        stat: |s| s.events,
        threshold: 50,
    },
    Achievement {
        id: "rounds_500",
        stat: |s| s.rounds,
        threshold: 500,
    },
    Achievement {
        id: "puzzle_100",
        stat: |s| s.puzzles,
        threshold: 100,
    },
    Achievement {
        id: "built_25",
        stat: |s| s.levels_built,
        threshold: 25,
    },
    // The table: who you played with, how many of you there were, and
    // whether you have sat everywhere.
    Achievement {
        id: "online_10",
        stat: |s| s.online_wins,
        threshold: 10,
    },
    Achievement {
        id: "hosted_1",
        stat: |s| s.hosted,
        threshold: 1,
    },
    Achievement {
        id: "crowd_4",
        stat: |s| s.crowd,
        threshold: 4,
    },
    Achievement {
        // Variety again, like the roulette: four bits, not four wins. Four
        // of the six, not all of them: the table grew to six and this
        // stayed where it was, so the line under it asks for four
        // different seats rather than every seat there is.
        id: "all_seats",
        stat: |s| s.seats_won.count_ones(),
        threshold: 4,
    },
    // Craft: the campaign asks to be finished, these ask how.
    Achievement {
        id: "under_par_1",
        stat: |s| s.under_par,
        threshold: 1,
    },
    Achievement {
        id: "clean_10",
        stat: |s| s.clean_solves,
        threshold: 10,
    },
    Achievement {
        id: "deep_1",
        stat: |s| s.deep_solves,
        threshold: 1,
    },
    Achievement {
        // Against the all-time record, not today's best: a trophy hung on
        // `daily_best` would come and go with the date.
        id: "daily_40",
        stat: |s| s.daily_record,
        threshold: 40,
    },
    // Both ends of sharing a level are worth a trophy.
    Achievement {
        id: "shared_1",
        stat: |s| s.codes_shared,
        threshold: 1,
    },
    Achievement {
        id: "taken_1",
        stat: |s| s.codes_taken,
        threshold: 1,
    },
    // Second rungs on the two of the new ladders that had somewhere left to
    // go: the widest table the seat count allows, and thrift as a habit
    // rather than a one-off.
    Achievement {
        id: "crowd_6",
        stat: |s| s.crowd,
        threshold: crate::sim::MAX_PLAYERS as u32,
    },
    Achievement {
        id: "under_par_10",
        stat: |s| s.under_par,
        threshold: 10,
    },
];

impl Achievement {
    pub fn met(&self, stats: &Stats) -> bool {
        (self.stat)(stats) >= self.threshold
    }

    /// Progress toward the threshold, for the browser screen.
    pub fn progress(&self, stats: &Stats) -> (u32, u32) {
        ((self.stat)(stats).min(self.threshold), self.threshold)
    }
}

mod save;
mod track;
mod ui;

pub use save::load;
#[cfg(test)]
use save::{parse, to_text};
pub use track::{
    note_posts_spent, record_codes, record_level_built, record_puzzle, record_round,
    reset_puzzle_attempt, reset_round_scratch, save_now, save_on_exit, track_events,
    track_puzzle_attempt,
};
pub use ui::{
    AchievementsUi, achievements_input, enter_achievements, update_shelf_scrollbar, update_toasts,
};

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn stats_round_trip_and_lenient_parse() {
        let stats = Stats {
            banked: 123,
            golden: 4,
            lures: 5,
            events: 6,
            gulls_fed: 7,
            raids_taken: 8,
            rounds: 9,
            wins: 3,
            puzzles: 11,
            dry_wins: 1,
            daily_day: 20_600,
            daily_best: 42,
            giants: 26,
            events_seen: 0b1010_1010,
            best_round: 51,
            series_wins: 2,
            online_wins: 4,
            daily_days: 7,
            levels_built: 3,
            campaign_done: 1,
            beach_done: 0,
            hosted: 2,
            crowd: 4,
            seats_won: 0b0000_1011,
            under_par: 3,
            clean_solves: 12,
            deep_solves: 1,
            daily_record: 41,
            codes_shared: 5,
            codes_taken: 6,
        };
        let mut unlocked = Unlocked::default();
        unlocked.0.insert("first_bank");
        unlocked.0.insert("golden_1");
        let (reparsed, re_unlocked) = parse(&to_text(&stats, &unlocked));
        assert_eq!(reparsed.banked, 123);
        assert_eq!(reparsed.wins, 3);
        assert_eq!(reparsed.daily_day, 20_600);
        assert_eq!(reparsed.daily_best, 42);
        assert_eq!(
            reparsed.campaign_done, 1,
            "a finished campaign stays finished"
        );
        assert_eq!(reparsed.beach_done, 0);
        // Everything a trophy reads has to survive the trip.
        assert_eq!(reparsed.giants, 26);
        assert_eq!(reparsed.events_seen, 0b1010_1010, "the roulette bitmask");
        assert_eq!(reparsed.best_round, 51);
        assert_eq!(reparsed.series_wins, 2);
        assert_eq!(reparsed.online_wins, 4);
        assert_eq!(reparsed.daily_days, 7);
        assert_eq!(reparsed.levels_built, 3);
        assert_eq!(reparsed.hosted, 2);
        assert_eq!(reparsed.crowd, 4);
        assert_eq!(reparsed.seats_won, 0b0000_1011, "the seats-sat-at bitmask");
        assert_eq!(reparsed.under_par, 3);
        assert_eq!(reparsed.clean_solves, 12);
        assert_eq!(reparsed.deep_solves, 1);
        // Today's best (42) is higher than the record the file carried (41),
        // so the parse lifts it: a save written before `daily_record`
        // existed carries no record at all, and its owner has still earned
        // whatever they scored today.
        assert_eq!(reparsed.daily_record, 42, "today's best counts as record");
        let (old_save, _) = parse("daily_best: 55\n");
        assert_eq!(
            old_save.daily_record, 55,
            "a save from before the record existed"
        );
        assert_eq!(reparsed.codes_shared, 5);
        assert_eq!(reparsed.codes_taken, 6);
        assert!(re_unlocked.0.contains("first_bank"));
        assert!(re_unlocked.0.contains("golden_1"));
        // Unknown ids and junk are dropped, junk values zero out.
        let (s, u) = parse("banked: nope\nunlocked: bogus,win_1\nwibble: 3\n");
        assert_eq!(s.banked, 0);
        assert!(u.0.contains("win_1") && u.0.len() == 1);
    }

    /// The trophy list and the string tables are parallel arrays indexed by
    /// position, so a new achievement added at the wrong end silently
    /// mislabels every trophy after it. Every language has to line up.
    #[test]
    fn every_achievement_is_named_in_every_language() {
        for lang in crate::app::i18n::ALL_LANGS {
            let tr = lang.tr();
            assert_eq!(
                tr.ach_names.len(),
                ACHIEVEMENTS.len(),
                "{lang:?} names the wrong number of trophies"
            );
            for (index, achievement) in ACHIEVEMENTS.iter().enumerate() {
                assert!(
                    !tr.ach_names[index].is_empty() && !tr.ach_descs[index].is_empty(),
                    "{lang:?} leaves {} unlabelled",
                    achievement.id
                );
            }
        }
        // Ids are the save-file keys: a duplicate would make one trophy
        // unlockable by another's progress.
        let mut ids: Vec<&str> = ACHIEVEMENTS.iter().map(|a| a.id).collect();
        ids.sort_unstable();
        let count = ids.len();
        ids.dedup();
        assert_eq!(ids.len(), count, "duplicate achievement id");
        // Every trophy must be reachable: a zero threshold would unlock on
        // the first frame, and nothing is earned by doing nothing.
        for achievement in &ACHIEVEMENTS {
            assert!(achievement.threshold > 0, "{} is free", achievement.id);
            assert!(
                !achievement.met(&Stats::default()),
                "{} unlocks on a fresh save",
                achievement.id
            );
        }
    }

    /// The description column is what is left of a row once the mark,
    /// the fixed name column, the gaps and the progress count have taken
    /// theirs, and it clips so a long description never pushes the count
    /// off the end. A description clipped mid-word still reads as a bug,
    /// and only measuring says which ones are.
    #[test]
    fn every_trophy_description_fits_what_is_left_of_its_row() {
        use crate::app::i18n::metrics::text_px;
        // `ui::spawn_trophy`'s own numbers.
        use super::ui::{NAME_PX, ROW_PX};
        const PADDING_PX: f32 = 8.0 * 2.0;
        const MARK_PX: f32 = 14.0;
        const GAPS_PX: f32 = 8.0 * 3.0;
        let fine = crate::app::menu_ui::type_scale::FINE;
        let mut over = Vec::new();
        for lang in crate::app::i18n::ALL_LANGS {
            let tr = lang.tr();
            for (index, achievement) in ACHIEVEMENTS.iter().enumerate() {
                // The count column takes its natural width, so the room the
                // description gets depends on this trophy's own numbers.
                let (_, goal) = achievement.progress(&Stats::default());
                let count = text_px(&format!("{goal}/{goal}"), fine);
                let room = ROW_PX - PADDING_PX - MARK_PX - NAME_PX - GAPS_PX - count;
                let desc = tr.ach_descs[index];
                let width = text_px(desc, fine);
                if width > room {
                    over.push(format!(
                        "{lang:?} {} {desc:?} {width:.0}px into {room:.0}px",
                        achievement.id
                    ));
                }
            }
        }
        assert!(over.is_empty(), "clipped:\n{}", over.join("\n"));
    }

    /// Two trophies with the same name is a shelf you cannot read: the row
    /// says you have done a thing you have not, and the toast that pops on
    /// unlocking names the wrong deed. Caught per language, because a
    /// translation can collapse a distinction English keeps.
    #[test]
    fn no_two_trophies_share_a_name() {
        let mut clashes = Vec::new();
        for lang in crate::app::i18n::ALL_LANGS {
            let tr = lang.tr();
            let mut seen: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
            for (index, achievement) in ACHIEVEMENTS.iter().enumerate() {
                let name = tr.ach_names[index];
                if let Some(first) = seen.insert(name, achievement.id) {
                    clashes.push(format!("{lang:?} {name:?}: {first} and {}", achievement.id));
                }
            }
        }
        assert!(clashes.is_empty(), "shared names:\n{}", clashes.join("\n"));
    }

    /// The name column is a fixed width with `clip_x`, so a name too long
    /// for it loses its tail silently. Measured in pixels rather than
    /// characters: Japanese draws about two-thirds wider per character, so
    /// a character count passes the language most likely to overrun.
    #[test]
    fn every_trophy_name_fits_its_column() {
        // The row's own numbers, from `ui::spawn_trophy`.
        const NAME_COLUMN_PX: f32 = ui::NAME_PX;
        let size = crate::app::menu_ui::type_scale::BODY;
        let mut over = Vec::new();
        for lang in crate::app::i18n::ALL_LANGS {
            let tr = lang.tr();
            for (index, achievement) in ACHIEVEMENTS.iter().enumerate() {
                let name = tr.ach_names[index];
                let width = crate::app::i18n::metrics::text_px(name, size);
                if width > NAME_COLUMN_PX {
                    over.push(format!("{lang:?} {} {name:?} {width:.0}px", achievement.id));
                }
            }
        }
        assert!(
            over.is_empty(),
            "past a {NAME_COLUMN_PX:.0}px column:\n{}",
            over.join("\n")
        );
    }

    #[test]
    fn thresholds_gate_unlocks() {
        let mut stats = Stats::default();
        let bank_100 = &ACHIEVEMENTS[1];
        assert!(!bank_100.met(&stats));
        stats.banked = 99;
        assert!(!bank_100.met(&stats));
        assert_eq!(bank_100.progress(&stats), (99, 100));
        stats.banked = 100;
        assert!(bank_100.met(&stats));
        let dry = ACHIEVEMENTS.iter().find(|a| a.id == "dry_castle").unwrap();
        stats.dry_wins = 1;
        assert!(dry.met(&stats));
    }
}