1use std::sync::LazyLock;
4
5use base64::{Engine, engine::general_purpose::STANDARD};
6use comrak::{
7 Options, markdown_to_html_with_plugins, options::Plugins, plugins::syntect::SyntectAdapter,
8};
9use jiff::{Timestamp, Zoned, tz::TimeZone};
10use maud::{DOCTYPE, Markup, PreEscaped, html};
11use serde_json::Value;
12
13use crate::{
14 tools,
15 transcript::{Block, Folio, ImageSource, Known, Panel, PanelKind, Usage},
16};
17
18struct Face {
21 family: &'static str,
22 style: &'static str,
23 weight: &'static str,
24 woff2: &'static [u8],
25}
26
27const FACES: [Face; 4] = [
34 Face {
35 family: "Junicode",
36 style: "normal",
37 weight: "300 700",
38 woff2: include_bytes!("fonts/JunicodeVF-Roman.woff2"),
39 },
40 Face {
41 family: "Junicode",
42 style: "italic",
43 weight: "300 700",
44 woff2: include_bytes!("fonts/JunicodeVF-Italic.woff2"),
45 },
46 Face {
47 family: "UnifrakturCook",
48 style: "normal",
49 weight: "400",
50 woff2: include_bytes!("fonts/UnifrakturCook.woff2"),
51 },
52 Face {
53 family: "Fira Code",
54 style: "normal",
55 weight: "300 700",
56 woff2: include_bytes!("fonts/FiraCode-VF.woff2"),
57 },
58];
59
60const FONT_NOTICES: [(&str, &str); 3] = [
65 ("Junicode", "Copyright 2025 Peter S. Baker"),
66 ("Fira Code", "Copyright 2014 The Fira Code Project Authors"),
67 (
68 "UnifrakturCook",
69 "Copyright 2010 j. 'mach' wust (Reserved Font Name UnifrakturCook), Copyright 2009 Peter Wiegel",
70 ),
71];
72
73fn font_notice() -> String {
75 let mut notice = String::from(
76 "<!-- Embedded fonts, SIL Open Font License 1.1 (https://openfontlicense.org):",
77 );
78 for (family, copyright) in FONT_NOTICES {
79 notice.push_str(&format!("\n {family}: {copyright}"));
80 }
81 notice.push_str("\n-->");
82 notice
83}
84
85static FONT_FACES: LazyLock<String> = LazyLock::new(|| {
89 FACES
90 .iter()
91 .map(|face| {
92 let data = STANDARD.encode(face.woff2);
93 format!(
94 "@font-face{{font-family:\"{}\";font-style:{};font-weight:{};font-display:swap;\
95 src:url(data:font/woff2;base64,{}) format(\"woff2\")}}",
96 face.family, face.style, face.weight, data,
97 )
98 })
99 .collect()
100});
101
102pub struct Colophon {
104 pub generated: Timestamp,
105 pub tool: &'static str,
106 pub version: &'static str,
107 pub home: &'static str,
108}
109
110pub struct Scribe<'a> {
113 options: Options<'a>,
114 plugins: Plugins<'a>,
115 timezone: TimeZone,
116}
117
118impl<'a> Scribe<'a> {
119 pub fn new(highlighter: &'a SyntectAdapter, timezone: TimeZone) -> Self {
120 let mut options = Options::default();
121 options.extension.strikethrough = true;
122 options.extension.table = true;
123 options.extension.tasklist = true;
124 options.extension.autolink = true;
125 options.extension.footnotes = true;
126 options.render.github_pre_lang = true;
127
128 let mut plugins = Plugins::default();
129 plugins.render.codefence_syntax_highlighter = Some(highlighter);
130
131 Self {
132 options,
133 plugins,
134 timezone,
135 }
136 }
137
138 pub(crate) fn markdown(&self, source: &str) -> Markup {
139 PreEscaped(markdown_to_html_with_plugins(
140 source,
141 &self.options,
142 &self.plugins,
143 ))
144 }
145
146 fn zoned(&self, timestamp: Timestamp) -> Zoned {
147 timestamp.to_zoned(self.timezone.clone())
148 }
149
150 pub fn folio(&self, folio: &Folio, colophon: &Colophon) -> Markup {
151 let title = format!("folio {}", folio.session_id());
152 let panels = folio.panels();
153 let left_border = margin_strip(border_seed(folio.session_id(), "left"));
154 let right_border = margin_strip(border_seed(folio.session_id(), "right"));
155 html! {
156 (DOCTYPE)
157 (PreEscaped(font_notice()))
158 html lang="en" {
159 head {
160 meta charset="utf-8";
161 meta name="viewport" content="width=device-width, initial-scale=1";
162 title { (title) }
163 style {
164 (PreEscaped(FONT_FACES.as_str()))
165 (PreEscaped(include_str!("illumination.css")))
166 }
167 script { (PreEscaped(include_str!("illumination.js"))) }
171 }
172 body {
173 div .margin.margin--left style=(format!("background-image:url({left_border})")) aria-hidden="true" {}
178 div .margin.margin--right style=(format!("background-image:url({right_border})")) aria-hidden="true" {}
179 div .plaque {
185 button .plaque__seal type="button" aria-label="folio details" title="folio details" { "❦" }
186 div .plaque__panel {
187 h1 .plaque__title { (title) }
188 dl .plaque__facts {
189 dt { "source" } dd { code { (folio.source.display().to_string()) } }
190 dt { "turns" } dd { (panels.len()) }
191 @if let Some(first) = panels.first() {
192 dt { "opened" } dd { (self.stamp(first.timestamp)) }
193 }
194 @if let (Some(input), Some(output)) = (folio.largest_input(), folio.output()) {
200 dt { "tokens" } dd title=(folio_flux(input, output)) { (tally(input, output)) }
201 }
202 }
203 p .plaque__colophon {
204 "Written by " a href=(colophon.home) { (colophon.tool) } " " (colophon.version)
205 " on " (self.stamp(colophon.generated)) "."
206 }
207 p .plaque__colophon {
208 "Set in Junicode, Fira Code, and UnifrakturCook, under the "
209 a href="https://openfontlicense.org" { "SIL Open Font License" } "."
210 }
211 }
212 }
213 div .search role="search" {
216 div .search__bar {
217 input .search__input type="search" placeholder="search folio" aria-label="search folio";
218 span .search__count aria-live="polite" {}
219 button .search__nav type="button" data-search-nav="prev" aria-label="previous match" { "‹" }
220 button .search__nav type="button" data-search-nav="next" aria-label="next match" { "›" }
221 }
222 div .search__scopes role="group" aria-label="search in" {
225 button .search__scope.search__scope--user type="button" data-scope="user" aria-pressed="true" { "user" }
226 button .search__scope.search__scope--assistant type="button" data-scope="assistant" aria-pressed="true" { "assistant" }
227 button .search__scope type="button" data-scope="tool" aria-pressed="true" { "tool" }
228 button .search__scope type="button" data-scope="thinking" aria-pressed="true" { "thinking" }
229 }
230 }
231 nav .dock aria-label="folio navigation" {
238 div .dock__nav {
239 button .dock__btn .dock__btn--user type="button" data-nav="prev" data-role="user" aria-label="previous user message" title="previous user message" { "▲" }
240 button .dock__btn type="button" data-nav="prev" aria-label="previous message" title="previous message" { "▲" }
241 button .dock__btn .dock__btn--assistant type="button" data-nav="prev" data-role="assistant" aria-label="previous assistant message" title="previous assistant message" { "▲" }
242 button .dock__btn .dock__btn--user type="button" data-nav="next" data-role="user" aria-label="next user message" title="next user message" { "▼" }
243 button .dock__btn type="button" data-nav="next" aria-label="next message" title="next message" { "▼" }
244 button .dock__btn .dock__btn--assistant type="button" data-nav="next" data-role="assistant" aria-label="next assistant message" title="next assistant message" { "▼" }
245 }
246 div .dock__leap {
250 button .dock__btn type="button" data-nav="top" aria-label="jump to top" title="jump to top" { "⤒" }
251 button .dock__btn type="button" data-nav="end" aria-label="jump to end" title="jump to end" { "⤓" }
252 button .dock__btn .dock__btn--tail type="button" data-tail="toggle" aria-pressed="false" aria-label="follow new messages" title="follow new messages, like tail -f" { "⇊" }
253 }
254 div .dock__fold {
255 button .dock__btn .dock__btn--fold type="button" data-fold="expand" aria-label="expand all" title="expand all" { span .dock__chevron { "⌃" } span .dock__chevron { "⌄" } }
256 button .dock__btn .dock__btn--fold type="button" data-fold="collapse" aria-label="collapse all" title="collapse all" { span .dock__chevron { "⌄" } span .dock__chevron { "⌃" } }
257 }
258 }
259 div .controls {
263 div .theme-toggle role="group" aria-label="colour theme" {
264 button type="button" data-theme-choice="light" { "light" }
265 button type="button" data-theme-choice="system" aria-pressed="true" { "system" }
266 button type="button" data-theme-choice="dark" { "dark" }
267 }
268 }
269 main .folio {
270 @for panel in &panels {
271 (self.panel(panel))
272 }
273 }
274 }
275 }
276 }
277 }
278
279 fn stamp(&self, timestamp: Timestamp) -> String {
280 self.zoned(timestamp)
281 .strftime("%Y-%m-%d %H:%M:%S %Z")
282 .to_string()
283 }
284
285 fn panel(&self, panel: &Panel) -> Markup {
286 let kind = panel.kind();
287 let opening = matches!(kind, PanelKind::User | PanelKind::Assistant)
292 .then(|| panel.blocks.iter().position(Block::is_visible_text))
293 .flatten();
294 html! {
295 article id={ "turn-" (panel.turn_number) }
296 class={ "turn turn--" (panel.role.as_str()) } data-kind=(kind.label())
297 data-turn=(panel.turn_number)
298 data-sidechain[panel.is_sidechain] data-meta[panel.is_meta] {
299 header .turn__meta {
300 span .turn__role { (kind.label()) }
301 @if let Some(model) = &panel.model {
302 span .turn__model {
303 (model)
304 @if let Some(effort) = &panel.effort {
305 " " span .turn__effort { "(" (effort) ")" }
306 }
307 }
308 }
309 @if let Some(usage) = &panel.usage {
310 span .turn__usage title=(turn_flux(usage)) {
311 (tally(usage.uncached_input(), usage.output_tokens))
312 }
313 }
314 time .turn__time datetime=(panel.timestamp.to_string()) { (self.stamp(panel.timestamp)) }
315 a .turn__index href={ "#turn-" (panel.turn_number) } { "#" (panel.turn_number) }
316 }
317 @for (index, block) in panel.blocks.iter().enumerate() {
318 (self.block(block, Some(index) == opening))
319 }
320 }
321 }
322 }
323
324 pub(crate) fn block(&self, block: &Block, versal: bool) -> Markup {
325 let known = match block {
326 Block::Known(known) => known,
327 Block::Unknown(value) => return unknown(value),
328 };
329 match known {
330 Known::Text { text } => {
331 html! { div .block.block--text data-versal[versal] { (self.markdown(text)) } }
332 }
333 Known::Thinking { thinking } if thinking.trim().is_empty() => html! {
337 p .block.block--redacted { "reasoning redacted" }
338 },
339 Known::Thinking { thinking } => html! {
340 section .block.block--thinking {
341 (self.markdown(thinking))
342 }
343 },
344 Known::ToolUse { name, input, .. } => self.tool_call(name, input),
345 Known::ToolResult {
346 content,
347 is_error,
348 answers,
349 ..
350 } => html! {
351 details .marginalia.marginalia--result data-error[*is_error] {
352 summary .marginalia__head { @if *is_error { "error" } @else { "result" } }
353 (tools::result(self, answers.as_ref(), content, *is_error))
354 }
355 },
356 Known::Image { source } => image(source),
357 }
358 }
359
360 fn tool_call(&self, name: &str, input: &Value) -> Markup {
365 let setting = tools::call(self, name, input);
366 let head = html! {
367 span .marginalia__tool { (name) }
368 @if let Some(gist) = &setting.gist {
369 @match &setting.href {
370 Some(href) => a .marginalia__gist href=(href) { (gist) },
371 None => span .marginalia__gist { (gist) },
372 }
373 }
374 @for note in &setting.notes {
375 span .marginalia__note { (note) }
376 }
377 };
378 match &setting.body {
379 Some(body) => html! {
380 details .marginalia.marginalia--use {
381 summary .marginalia__head { (head) }
382 (body)
383 }
384 },
385 None => html! {
386 div .marginalia.marginalia--use.marginalia--flat {
387 div .marginalia__head { (head) }
388 }
389 },
390 }
391 }
392
393 pub(crate) fn code_block(&self, lang: &str, code: &str) -> Markup {
397 let code = code.trim_end();
401 let fence = "`".repeat(longest_backtick_run(code).max(2) + 1);
402 self.markdown(&format!("{fence}{lang}\n{code}\n{fence}"))
403 }
404}
405
406const CELL_WIDTH: u32 = 90;
409const CELL_HEIGHT: u32 = 210;
410
411const STRIP_CELLS: usize = 48;
415
416const VINE_CELL: &str = include_str!("drolleries/vine.svg");
418
419const TRAIL: &str = include_str!("drolleries/trail.svg");
424
425const VINE_FADE: &str = "<defs><linearGradient id=\"vinefade\" gradientUnits=\"userSpaceOnUse\" \
430 x1=\"0\" y1=\"0\" x2=\"0\" y2=\"54\">\
431 <stop offset=\"0\" stop-color=\"#c1912f\"/>\
432 <stop offset=\"1\" stop-color=\"#c1912f\" stop-opacity=\"0\"/></linearGradient></defs>";
433
434const DROLLERIES: [(&str, i32, i32); 10] = [
443 (include_str!("drolleries/snail.svg"), 0, -18),
444 (include_str!("drolleries/budgie.svg"), -7, -11),
445 (include_str!("drolleries/cockatiel.svg"), -8, 2),
446 (include_str!("drolleries/cardinal.svg"), -6, -3),
447 (include_str!("drolleries/fish.svg"), -8, 0),
448 (include_str!("drolleries/butterfly.svg"), 0, -23),
449 (include_str!("drolleries/frog.svg"), 0, -32),
450 (include_str!("drolleries/cat.svg"), -5, -23),
451 (include_str!("drolleries/hare.svg"), 0, -14),
452 (include_str!("drolleries/stag.svg"), -2, -21),
453];
454
455struct Rng(u64);
458
459impl Rng {
460 fn next(&mut self) -> u64 {
461 self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
462 let mut z = self.0;
463 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
464 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
465 z ^ (z >> 31)
466 }
467}
468
469fn border_seed(session_id: &str, salt: &str) -> u64 {
474 session_id
475 .bytes()
476 .chain(salt.bytes())
477 .fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
478 (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
479 })
480}
481
482#[derive(Clone, Copy)]
485enum BorderCell {
486 Vine,
487 Drollery {
488 svg: &'static str,
489 dx: i32,
490 dy: i32,
491 flip: bool,
492 },
493}
494
495fn shuffled_bag(rng: &mut Rng) -> Vec<usize> {
499 let mut bag: Vec<usize> = (0..DROLLERIES.len()).collect();
500 for i in (1..bag.len()).rev() {
501 bag.swap(i, rng.next() as usize % (i + 1));
502 }
503 bag
504}
505
506fn border_cells(seed: u64) -> Vec<BorderCell> {
510 let mut rng = Rng(seed);
511 let mut bag = shuffled_bag(&mut rng);
512 let mut cells = Vec::with_capacity(STRIP_CELLS);
513 let mut previous_was_drollery = false;
514 for index in 0..STRIP_CELLS {
515 let seam = index == 0 || index == STRIP_CELLS - 1;
516 let drollery = !seam && !previous_was_drollery && rng.next().is_multiple_of(3);
517 if drollery {
518 if bag.is_empty() {
519 bag = shuffled_bag(&mut rng);
520 }
521 let (svg, dx, dy) = DROLLERIES[bag.pop().expect("bag refilled when empty")];
522 let flip = rng.next().is_multiple_of(2);
523 cells.push(BorderCell::Drollery { svg, dx, dy, flip });
524 } else {
525 cells.push(BorderCell::Vine);
526 }
527 previous_was_drollery = drollery;
528 }
529 cells
530}
531
532fn margin_strip(seed: u64) -> String {
536 let height = STRIP_CELLS as u32 * CELL_HEIGHT;
537 let mut inner = String::new();
538 for (index, cell) in border_cells(seed).iter().enumerate() {
539 let y = index as u32 * CELL_HEIGHT;
540 let content = match cell {
544 BorderCell::Vine => VINE_CELL.to_string(),
545 BorderCell::Drollery { svg, dx, dy, flip } => {
546 let place = if *flip {
549 format!("translate(90,0) scale(-1,1) translate({dx},{dy})")
550 } else {
551 format!("translate({dx},{dy})")
552 };
553 format!(
554 "{TRAIL}<g transform=\"{place}\">{svg}</g>\
555 <g transform=\"translate(0,{CELL_HEIGHT}) scale(1,-1)\">{TRAIL}</g>"
556 )
557 }
558 };
559 inner.push_str(&format!("<g transform=\"translate(0,{y})\">{content}</g>"));
560 }
561 let svg = format!(
562 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{CELL_WIDTH}\" \
563 height=\"{height}\" viewBox=\"0 0 {CELL_WIDTH} {height}\">{VINE_FADE}{inner}</svg>"
564 );
565 format!("data:image/svg+xml;base64,{}", STANDARD.encode(svg))
566}
567
568fn tally(input: u64, output: u64) -> String {
570 format!("↑ {} ↓ {}", compact(input), compact(output))
571}
572
573fn turn_flux(usage: &Usage) -> String {
577 format!(
578 "{} input this turn · {} output this turn",
579 separated(usage.uncached_input()),
580 separated(usage.output_tokens),
581 )
582}
583
584fn folio_flux(largest_input: u64, output: u64) -> String {
587 format!(
588 "{} input at its largest · {} output in all",
589 separated(largest_input),
590 separated(output),
591 )
592}
593
594fn separated(tokens: u64) -> String {
596 let digits = tokens.to_string();
597 let mut grouped = String::new();
598 for (index, digit) in digits.chars().enumerate() {
599 if index > 0 && (digits.len() - index).is_multiple_of(3) {
600 grouped.push(',');
601 }
602 grouped.push(digit);
603 }
604 grouped
605}
606
607fn compact(tokens: u64) -> String {
610 let (scaled, suffix) = match tokens {
614 ..1_000 => return tokens.to_string(),
615 1_000..999_950 => (tokens as f64 / 1_000.0, "k"),
616 _ => (tokens as f64 / 1_000_000.0, "M"),
617 };
618 let rounded = format!("{scaled:.1}");
619 format!("{}{suffix}", rounded.strip_suffix(".0").unwrap_or(&rounded))
620}
621
622fn longest_backtick_run(source: &str) -> usize {
623 let mut longest = 0;
624 let mut run = 0;
625 for byte in source.bytes() {
626 if byte == b'`' {
627 run += 1;
628 longest = longest.max(run);
629 } else {
630 run = 0;
631 }
632 }
633 longest
634}
635
636pub(crate) fn json(value: &Value) -> Markup {
637 let pretty = serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string());
638 html! { pre { code { (pretty) } } }
639}
640
641fn unknown(value: &Value) -> Markup {
642 let label = value
643 .get("type")
644 .and_then(Value::as_str)
645 .unwrap_or("unrecognized");
646 html! {
647 details .block.block--unknown {
648 summary { (label) }
649 (json(value))
650 }
651 }
652}
653
654fn image(source: &ImageSource) -> Markup {
655 let src = format!("data:{};base64,{}", source.media_type, source.data);
656 html! { figure .block.block--image { img src=(src) alt="pasted image"; } }
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662
663 #[test]
664 fn token_counts_shorten_to_one_decimal_place_per_magnitude() {
665 assert_eq!(compact(0), "0");
666 assert_eq!(compact(847), "847");
667 assert_eq!(compact(999), "999");
668 assert_eq!(compact(1_000), "1k");
669 assert_eq!(compact(47_612), "47.6k");
670 assert_eq!(compact(999_400), "999.4k");
671 assert_eq!(compact(7_643_000), "7.6M");
672 }
673
674 #[test]
675 fn a_count_that_rounds_up_a_magnitude_takes_the_next_suffix() {
676 assert_eq!(compact(999_949), "999.9k");
677 assert_eq!(compact(999_950), "1M");
678 assert_eq!(compact(1_000_000), "1M");
679 }
680
681 #[test]
682 fn a_turn_counts_only_the_input_it_added() {
683 let usage = Usage {
686 input_tokens: 3,
687 output_tokens: 214,
688 cache_creation_input_tokens: 32_400,
689 cache_read_input_tokens: 15_200,
690 };
691
692 assert_eq!(
693 tally(usage.uncached_input(), usage.output_tokens),
694 "↑ 32.4k ↓ 214"
695 );
696 assert_eq!(
697 turn_flux(&usage),
698 "32,403 input this turn · 214 output this turn"
699 );
700 }
701
702 #[test]
703 fn a_folio_names_its_input_as_the_largest_rather_than_a_sum() {
704 assert_eq!(
705 folio_flux(60_867, 48_923),
706 "60,867 input at its largest · 48,923 output in all"
707 );
708 }
709
710 #[test]
711 fn exact_counts_group_in_thousands() {
712 assert_eq!(separated(7), "7");
713 assert_eq!(separated(942), "942");
714 assert_eq!(separated(1_206), "1,206");
715 assert_eq!(separated(47_603), "47,603");
716 assert_eq!(separated(7_643_812), "7,643,812");
717 }
718
719 #[test]
720 fn border_strip_is_stable_per_seed() {
721 let seed = border_seed("3f9c-a17b-session", "left");
722 assert_eq!(margin_strip(seed), margin_strip(seed));
723 }
724
725 #[test]
726 fn the_two_borders_of_a_folio_differ() {
727 let session = "3f9c-a17b-session";
728 assert_ne!(
729 margin_strip(border_seed(session, "left")),
730 margin_strip(border_seed(session, "right"))
731 );
732 }
733
734 fn is_drollery(cell: &BorderCell) -> bool {
735 matches!(cell, BorderCell::Drollery { .. })
736 }
737
738 #[test]
739 fn borders_are_mostly_vine_with_non_adjacent_drolleries() {
740 let mut total_drolleries = 0;
743 for salt in [
744 "one", "two", "three", "four", "five", "six", "seven", "eight",
745 ] {
746 let cells = border_cells(border_seed("a-session", salt));
747 assert_eq!(cells.len(), STRIP_CELLS);
748 assert!(matches!(cells[0], BorderCell::Vine));
749 assert!(matches!(cells[STRIP_CELLS - 1], BorderCell::Vine));
750 let drolleries = cells.iter().filter(|cell| is_drollery(cell)).count();
751 assert!(
752 drolleries < STRIP_CELLS / 2,
753 "too many drolleries: {drolleries}"
754 );
755 let adjacent = cells
756 .windows(2)
757 .any(|pair| is_drollery(&pair[0]) && is_drollery(&pair[1]));
758 assert!(!adjacent, "two drolleries sat adjacent for salt {salt:?}");
759 total_drolleries += drolleries;
760 }
761 assert!(total_drolleries > 0, "generator produced no drolleries");
764 }
765
766 #[test]
767 fn drolleries_face_both_ways_across_a_border() {
768 let cells = border_cells(border_seed("flip-variety-session", "left"));
771 let flips: Vec<bool> = cells
772 .iter()
773 .filter_map(|cell| match cell {
774 BorderCell::Drollery { flip, .. } => Some(*flip),
775 BorderCell::Vine => None,
776 })
777 .collect();
778 assert!(flips.iter().any(|&flip| flip), "no creature was flipped");
779 assert!(
780 flips.iter().any(|&flip| !flip),
781 "no creature kept its facing"
782 );
783 }
784
785 #[test]
786 fn a_border_cycles_through_the_whole_bestiary() {
787 let cells = border_cells(border_seed("variety-session", "left"));
791 let used: std::collections::HashSet<&str> = cells
792 .iter()
793 .filter_map(|cell| match cell {
794 BorderCell::Drollery { svg, .. } => Some(*svg),
795 BorderCell::Vine => None,
796 })
797 .collect();
798 let count = cells.iter().filter(|cell| is_drollery(cell)).count();
799 let expected = count.min(DROLLERIES.len());
800 assert_eq!(
801 used.len(),
802 expected,
803 "creatures repeated before the bag drained"
804 );
805 }
806}