---
format:
html:
toc: true
toc-depth: 2
theme: {dark: darkly, light: cosmo}
number-sections: false
number-depth: 0
code-fold: false
code-tools: true
highlight-style: nord
---
<style></style><style>.printedClojure .sourceCode {
background-color: transparent;
border-style: none;
}
</style><style>.clay-limit-image-width .clay-image {max-width: 100%}
.clay-side-by-side .sourceCode {margin: 0}
.clay-side-by-side {margin: 1em 0}
</style>
<script src="https://code.jquery.com/jquery-3.6.0.min.js" type="text/javascript"></script><script src="https://code.jquery.com/ui/1.13.1/jquery-ui.min.js" type="text/javascript"></script>
```{=html}
<div style="display:none;"><style>/* ---------------------------------------------------------------
Fedoras — one palette, two schemes.
Dark is the default. Quarto puts .quarto-light on the body when the
reader flips the toggle, and everything below is written against
these variables, so a scheme change is nine lines rather than three
hundred.
--------------------------------------------------------------- */
:root {
--paper: #1b1d21; /* background */
--ink: #e6e4df; /* text */
--ink-soft: #a9a49b; /* secondary text */
--rule: rgba(230, 228, 223, 0.22);
--panel: rgba(230, 228, 223, 0.05);
--violet: #9d8dff; /* apparatus, epigraphs */
--gold: #d0a44c; /* beats, commits */
--coral: #e2707c; /* moderation, rewrites */
--green: #6fcf9b; /* the Helps */
--blurple: #8b95f0; /* Discord */
--violet-rgb: 157, 141, 255;
--gold-rgb: 208, 164, 76;
--coral-rgb: 226, 112, 124;
--green-rgb: 111, 207, 155;
--blurple-rgb: 139, 149, 240;
--ink-rgb: 168, 164, 156;
--panel-hard: #101215;
}
body.quarto-light {
--paper: #faf8f4;
--ink: #1c1f24;
--ink-soft: #6b665e;
--rule: rgba(28, 31, 36, 0.22);
--panel: rgba(28, 31, 36, 0.05);
--violet: #6c5ce7;
--gold: #b0863a;
--coral: #e04f5f;
--green: #2ea078;
--blurple: #5865f2;
--violet-rgb: 108, 92, 231;
--gold-rgb: 176, 134, 58;
--coral-rgb: 224, 79, 95;
--green-rgb: 46, 160, 120;
--blurple-rgb: 88, 101, 242;
--ink-rgb: 128, 128, 128;
--panel-hard: #14161a;
}
/* ---------------------------------------------------------------
Fedoras — screenplay typography for the HTML edition.
Loaded by fedoras.screenplay/assets. Edit here, rebuild, done.
PDF styling lives in notebooks/tex/fedoras-preamble.tex instead.
--------------------------------------------------------------- */
.screenplay {
font-family: "Courier Prime", "Courier New", ui-monospace, monospace;
font-size: 1rem;
line-height: 1.35;
max-width: 62ch;
margin: 1.6rem 0 2.2rem 0;
}
.screenplay .slug {
font-weight: 700;
letter-spacing: 0.04em;
margin: 1.6rem 0 0.8rem 0;
}
.screenplay .action {
margin: 0 0 0.85rem 0;
}
.action{
white-space: pre-line;
}
.screenplay .action.beat {
margin-bottom: 0.35rem;
}
.screenplay .dialogue {
margin: 0 0 0.95rem 0;
}
.screenplay .character {
margin: 0 0 0 12ch;
font-weight: 700;
letter-spacing: 0.06em;
}
.screenplay .paren {
margin: 0 0 0 9ch;
font-style: italic;
opacity: 0.85;
}
.screenplay .lines {
margin-left: 6ch;
max-width: 50ch;
}
.screenplay .line {
margin: 0;
}
.screenplay .transition {
margin: 1.1rem 0 1.1rem 0;
text-align: right;
font-weight: 700;
letter-spacing: 0.08em;
}
.screenplay .direction {
font-style: italic;
opacity: 0.85;
margin: 0 0 0.9rem 0;
}
/* In-world text: banners, signs, whiteboards. */
.screenplay .sign {
border: 1px solid currentColor;
padding: 0.7rem 1rem;
margin: 0.9rem 0 1.1rem 4ch;
max-width: 46ch;
text-align: center;
letter-spacing: 0.05em;
}
.screenplay .sign p {
margin: 0.15rem 0;
}
.screenplay .whiteboard {
text-align: left;
}
.screenplay .whiteboard-title {
font-weight: 700;
text-transform: uppercase;
margin-bottom: 0.4rem !important;
}
.screenplay .whiteboard ol {
margin: 0;
padding-left: 2.2ch;
}
/* --- The Fedora Handbook epigraphs ------------------------------- */
.handbook {
max-width: 54ch;
margin: 1.4rem 0 2rem 2ch;
padding-left: 1.1rem;
border-left: 3px solid var(--violet);
font-style: italic;
}
.handbook .rule-number {
font-style: normal;
font-variant: small-caps;
letter-spacing: 0.08em;
opacity: 0.75;
margin-bottom: 0.25rem;
}
.handbook .rule-text {
margin: 0;
}
/* --- Drafting apparatus (hidden in the reading copy) -------------- */
.beats {
border: 1px dashed var(--gold);
background: rgba(var(--gold-rgb), 0.07);
padding: 0.6rem 0.9rem;
margin: 1.1rem 0;
max-width: 62ch;
font-size: 0.88rem;
}
.beats-title {
font-variant: small-caps;
letter-spacing: 0.1em;
font-weight: 700;
margin: 0 0 0.35rem 0;
opacity: 0.8;
}
.beats ul {
margin: 0;
padding-left: 1.4rem;
}
.beats.note {
border-color: var(--blurple);
background: rgba(var(--blurple-rgb), 0.07);
}
.status {
display: inline-block;
font-family: ui-monospace, monospace;
font-size: 0.7rem;
letter-spacing: 0.14em;
padding: 0.15rem 0.5rem;
border-radius: 3px;
margin: 0 0 0.8rem 0;
}
.status-outline { background: var(--ink-soft); color: #fff; }
.status-draft { background: var(--gold); color: #fff; }
.status-locked { background: var(--green); color: #fff; }
/* --- Live (editable) code cells ---------------------------------- */
.live-cell {
border: 1px solid rgba(var(--ink-rgb), 0.4);
border-radius: 5px;
padding: 0.7rem 0.8rem;
margin: 1.2rem 0;
max-width: 68ch;
}
.live-label {
font-size: 0.78rem;
letter-spacing: 0.09em;
text-transform: uppercase;
opacity: 0.7;
margin-bottom: 0.45rem;
}
.live-src {
width: 100%;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.9rem;
line-height: 1.4;
padding: 0.5rem;
border: 1px solid rgba(var(--ink-rgb), 0.35);
border-radius: 4px;
background: rgba(var(--ink-rgb), 0.06);
resize: vertical;
}
.live-bar {
margin-top: 0.5rem;
display: flex;
gap: 0.5rem;
align-items: center;
}
.live-run {
font: inherit;
font-size: 0.85rem;
padding: 0.25rem 0.9rem;
border-radius: 4px;
border: 1px solid rgba(var(--ink-rgb), 0.5);
background: rgba(var(--violet-rgb), 0.15);
cursor: pointer;
}
.live-run:hover { background: rgba(var(--violet-rgb), 0.3); }
.live-out {
margin: 0.5rem 0 0 0;
padding: 0.45rem 0.6rem;
min-height: 1.6rem;
font-size: 0.88rem;
white-space: pre-wrap;
border-left: 3px solid rgba(var(--violet-rgb), 0.6);
background: rgba(var(--ink-rgb), 0.06);
}
/* --- Front matter ------------------------------------------------- */
.title-page {
text-align: center;
margin: 3rem 0 2rem 0;
}
.title-page .title {
font-family: "Courier Prime", "Courier New", monospace;
font-size: 3.2rem;
letter-spacing: 0.22em;
margin: 0;
}
.title-page .subtitle {
font-family: "Courier Prime", "Courier New", monospace;
letter-spacing: 0.1em;
margin-top: 0.8rem;
opacity: 0.85;
}
h1.sidebar-title,
h2.sidebar-title {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.personae {
max-width: 66ch;
}
.personae h3 {
font-variant: small-caps;
letter-spacing: 0.1em;
margin-top: 2rem;
border-bottom: 1px solid rgba(var(--ink-rgb), 0.35);
padding-bottom: 0.2rem;
}
.personae .who {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.06em;
margin: 1rem 0 0.1rem 0;
}
.personae .what {
margin: 0 0 0.2rem 0;
font-style: italic;
opacity: 0.8;
}
.personae .desc {
margin: 0.15rem 0 0 0;
max-width: 60ch;
}
/* --- The reader's protagonist ------------------------------------ */
.pc-name {
font-weight: inherit;
border-bottom: 1px dotted rgba(var(--violet-rgb), 0.55);
}
.screenplay .slug,
.screenplay .transition,
.screenplay .character { text-transform: uppercase; }
.name-field {
border: 1px solid rgba(var(--violet-rgb), 0.55);
border-radius: 5px;
padding: 0.9rem 1rem;
margin: 1.6rem auto;
max-width: 60ch;
}
.name-label { margin: 0 0 0.6rem 0; }
.name-inputs { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.name-inputs input {
font: inherit;
padding: 0.3rem 0.6rem;
border: 1px solid rgba(var(--ink-rgb), 0.45);
border-radius: 4px;
background: rgba(var(--ink-rgb), 0.06);
max-width: 14ch;
}
.name-note {
font-size: 0.82rem;
opacity: 0.7;
margin: 0.6rem 0 0 0;
}
.name-label,
.name-confirm {
font-size: 1.2rem;
}
.name-inputs input,
.name-inputs button {
font: inherit;
font-size: 1.2rem;
}
/* --- Artifacts ---------------------------------------------------- */
.artifact {
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.88rem;
line-height: 1.45;
max-width: 66ch;
margin: 1.4rem 0;
border-radius: 5px;
overflow: hidden;
}
.artifact p { margin: 0.1rem 0; }
/* Discord */
.discord { border: 1px solid rgba(var(--blurple-rgb), 0.5); }
.discord-head {
background: rgba(var(--blurple-rgb), 0.14);
padding: 0.4rem 0.8rem;
display: flex; gap: 0.8rem;
}
.discord-server { font-weight: 700; letter-spacing: 0.06em; }
.discord-channel { opacity: 0.75; }
.discord-body { padding: 0.7rem 0.8rem; }
.discord-msg { margin-bottom: 0.7rem; }
.discord-who { font-weight: 700; }
.discord-role {
font-size: 0.7rem; letter-spacing: 0.08em;
background: rgba(var(--violet-rgb), 0.25);
padding: 0.05rem 0.4rem; border-radius: 3px; margin-left: 0.4rem;
}
.discord-at { opacity: 0.55; margin-left: 0.5rem; font-size: 0.78rem; }
.discord-reacts { margin-top: 0.2rem !important; }
.react {
display: inline-block; font-size: 0.75rem;
border: 1px solid rgba(var(--ink-rgb),0.35); border-radius: 10px;
padding: 0 0.45rem; margin-right: 0.3rem;
}
/* Moderation log */
.modlog {
border-left: 3px solid var(--coral);
background: rgba(var(--coral-rgb), 0.08);
padding: 0.6rem 0.9rem;
font-size: 0.82rem;
letter-spacing: 0.03em;
}
/* Git */
.commit {
border: 1px solid rgba(var(--ink-rgb), 0.4);
padding: 0.7rem 0.9rem;
background: rgba(var(--ink-rgb), 0.05);
}
.commit-hash { color: var(--gold); font-weight: 700; }
.commit-meta { opacity: 0.7; }
.commit-msg { margin-top: 0.5rem; padding-left: 1.5rem; }
/* Terminal */
.terminal {
background: var(--panel-hard);
color: var(--ink);
padding: 0.7rem 0.9rem;
border: 1px solid rgba(var(--ink-rgb),0.35);
}
.term-in { color: var(--green); }
.term-in::before { content: ""; }
.term-out { opacity: 0.9; }
/* AI */
.aichat { border: 1px solid rgba(var(--green-rgb), 0.5); }
.ai-model {
background: rgba(var(--green-rgb), 0.16);
padding: 0.35rem 0.8rem !important;
font-weight: 700; letter-spacing: 0.1em; font-size: 0.75rem;
}
.ai-system { padding: 0.4rem 0.8rem; font-size: 0.8rem; opacity: 0.85; }
.ai-system pre { white-space: pre-wrap; margin: 0.4rem 0 0 0; }
.ai-body { padding: 0.6rem 0.8rem; }
.ai-turn { margin-bottom: 0.6rem; }
.ai-role { font-size: 0.68rem; letter-spacing: 0.12em; opacity: 0.6; }
.ai-assistant .ai-text { border-left: 2px solid rgba(46,160,120,0.6); padding-left: 0.6rem; }
/* Email */
.email { border: 1px solid rgba(var(--ink-rgb),0.45); padding: 0.7rem 0.9rem; }
.email-head td { padding: 0 0.6rem 0.1rem 0; vertical-align: top; font-size: 0.82rem; }
.email-head td:first-child { opacity: 0.6; }
.email-body { margin-top: 0.6rem; }
/* Handouts */
.handout {
border: 1px solid currentColor;
padding: 1rem 1.2rem;
font-family: "Courier Prime", "Courier New", monospace;
}
.handout-title {
font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
border-bottom: 1px solid currentColor; padding-bottom: 0.4rem;
margin-bottom: 0.7rem !important;
}
.handout-table { width: 100%; font-size: 0.85rem; margin: 0.5rem 0; }
.handout-table td.k { opacity: 0.65; width: 34%; padding-right: 1rem; vertical-align: top; }
/* --- Prose between scenes ---------------------------------------- */
.prose {
max-width: 64ch;
margin: 1.5rem 0 2rem 0;
font-family: "Iowan Old Style", "Palatino Linotype", Palatino,
"Book Antiqua", Georgia, serif;
font-size: 1.06rem;
line-height: 1.65;
color: var(--ink);
}
.prose p {
margin: 0 0 1.05rem 0;
}
.prose p:first-child { margin-top: 0; }
.prose p:last-child { margin-bottom: 0; }
blockquote.turn {
max-width: 64ch;
border-left: 3px solid rgba(var(--violet-rgb), 0.6);
padding-left: 1rem;
margin: 1.4rem 0 0.6rem 0;
}
.name-confirm {
margin: 0.7rem 0 0 0;
font-weight: 600;
}
/* --- The d20 ------------------------------------------------------ */
.roll {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 60ch;
font-family: ui-monospace, "Courier New", monospace;
}
.roll-head {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
letter-spacing: 0.12em;
opacity: 0.75;
margin-bottom: 0.5rem;
}
.roll-bar { display: flex; gap: 0.7rem; align-items: center; margin-top: 0.3rem; }
.roll-go, .roll-rewrite {
font: inherit;
font-size: 0.82rem;
padding: 0.25rem 0.9rem;
border-radius: 4px;
border: 1px solid rgba(var(--ink-rgb), 0.5);
background: rgba(var(--violet-rgb), 0.15);
cursor: pointer;
}
.roll-rewrite { background: rgba(var(--coral-rgb), 0.12); }
.roll-go:hover { background: rgba(var(--violet-rgb), 0.3); }
.roll-rewrite:hover { background: rgba(var(--coral-rgb), 0.25); }
.roll-value { font-size: 0.9rem; letter-spacing: 0.05em; }
.roll-out {
margin: 0.6rem 0 0 0 !important;
font-family: "Courier Prime", "Courier New", monospace;
min-height: 1.2rem;
}
.roll-echo { font-size: 0.8rem; opacity: 0.8; }
.roll-note { font-size: 0.8rem; opacity: 0.6; margin: 0.4rem 0 0 0 !important; }
.roll-foot {
font-size: 0.72rem;
letter-spacing: 0.1em;
opacity: 0.55;
margin: 0.5rem 0 0 0 !important;
border-top: 1px solid rgba(var(--ink-rgb),0.25);
padding-top: 0.35rem;
}
.roll-cue {
margin: 0.6rem 0 0 4ch !important;
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.06em;
}
.roll-line {
margin: 0 0 0 6ch !important;
max-width: 40ch;
font-family: "Courier Prime", "Courier New", monospace;
}
.roll-out .roll-line:first-child { margin-left: 0 !important; max-width: none; }
.companion-name {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.14em;
font-size: 1.05rem;
margin: 0.7rem 0 0.3rem 0 !important;
}
.companion-intro { max-width: 58ch; margin: 0 0 0.3rem 0 !important; }
.session { max-width: 60ch; margin: 1.1rem 0; }
.session-who {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700; letter-spacing: 0.12em; margin: 0 0 0.3rem 0 !important;
}
.session-text { margin: 0 !important; }
.ac-name { border-bottom: 1px dotted rgba(var(--violet-rgb), 0.55); }
.companion-naming { margin-top: 0.6rem; }
/* --- Plots -------------------------------------------------------- */
.plot {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
}
.plot-controls {
display: flex; gap: 1rem; flex-wrap: wrap;
font-size: 0.8rem; opacity: 0.85; margin: 0.4rem 0;
}
.plot-controls input {
font: inherit; font-size: 0.8rem; width: 7ch;
padding: 0.15rem 0.35rem;
border: 1px solid rgba(var(--ink-rgb),0.45);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.06);
}
.plot-status { font-size: 0.75rem; letter-spacing: 0.1em; opacity: 0.7; }
.plot canvas {
display: block;
margin-top: 0.6rem;
background: var(--paper);
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
}
.roll-go:disabled, .roll-rewrite:disabled {
opacity: 0.4;
cursor: default;
}
.sketch {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
}
.sketch canvas {
display: block;
margin-top: 0.7rem;
background: var(--paper);
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
max-width: 100%;
}
.sketch-page {
margin-top: 0.7rem;
padding: 0.5rem 0.6rem;
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.04);
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.9rem;
}
.sketch-page button {
font: inherit; font-size: 0.85rem;
border: 1px solid rgba(var(--ink-rgb),0.45);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.08);
cursor: pointer;
min-width: 2ch;
}
/* --- The page itself, when Quarto is not doing it for us ---------- */
.screenplay, .artifact, .prose, .handout, .beats, .roll, .plot, .sketch,
.todo, .puzzle-box, .personae, .name-field, .live-cell, .session {
color: var(--ink);
}
.handout, .puzzle-cell, .sign { border-color: var(--rule); }
.terminal { background: var(--panel-hard); color: var(--ink); }
.title-page .title, .title-page .subtitle { color: var(--ink); }
/* --- Form controls -------------------------------------------------
Inputs, textareas and buttons do not inherit colour from the page,
so every one of them needs telling, or the reader types black on
black and cannot see what they are writing.
------------------------------------------------------------------- */
.name-inputs input,
.companion-input,
.todo-input,
.plot-controls input,
.live-src,
.sketch textarea,
.sketch-page input {
color: var(--ink);
background: var(--panel);
border-color: var(--rule);
caret-color: var(--ink);
}
.name-inputs input::placeholder,
.companion-input::placeholder,
.todo-input::placeholder,
.sketch-page input::placeholder,
.live-src::placeholder {
color: var(--ink-soft);
opacity: 1;
}
.roll-go, .roll-rewrite, .live-run, .todo-del,
.sketch-page button, .puzzle-cell {
color: var(--ink);
}
.sketch-page button {
background: var(--panel);
border-color: var(--rule);
}
/* Quarto's own search box does not follow the book's palette on every
theme, so it is told here as well. */
#quarto-search input,
#quarto-search .aa-Input,
.aa-Form,
.aa-InputWrapper input,
.quarto-search input {
background: var(--panel) !important;
color: var(--ink) !important;
border-color: var(--rule) !important;
}
.aa-Form { border: 1px solid var(--rule) !important; }
#quarto-search input::placeholder,
.aa-Input::placeholder {
color: var(--ink-soft) !important;
opacity: 1;
}
.aa-DetachedSearchButton, .quarto-search-button {
background: var(--panel) !important;
color: var(--ink) !important;
border-color: var(--rule) !important;
}
/* Canvases inherit nothing, so the sketch cells read their ink off the
element's own resolved colour. This is the line that gives them one. */
.sketch canvas, .plot canvas {
color: var(--ink);
background: transparent;
}
.tribute-heading {
margin: 2rem 0 0.5rem;
font-variant: small-caps;
font-weight: 600;
letter-spacing: 0.04em;
}
.tribute p {
margin: 0.6rem 0;
}
.tribute-meta {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--bs-border-color);
text-align: right;
font-size: 0.85rem;
}
.tribute-meta p {
margin: 0.25rem 0;
}
.tribute-date {
font-style: italic;
}
.tribute-attribution {
font-size: 1rem;
}
.tribute-source {
margin-top: 0.75rem !important;
}
.footnote {
margin-top: 1.5rem;
font-size: 0.9rem;
color: var(--bs-secondary);
}
/* ---------------------------------------------------------------
The three endings.
All three are in the HTML. Which one is visible is a class on
<body>, written once by fedoras.reader/paint-standing! from the
reader's standing, so a chapter that forks does it here rather than
in nine lines of DOM work per passage.
A page with no class on it shows the middle one. That is what the
print edition gets, and it is what the table actually got, and the
PDF reads straight through with no gap and no note.
--------------------------------------------------------------- */
.ending { display: none; }
.ending-reconciled { display: block; }
body.standing-closed .ending-reconciled { display: none; }
body.standing-outstanding .ending-reconciled { display: none; }
body.standing-closed .ending-closed { display: block; }
body.standing-outstanding .ending-outstanding { display: block; }
@media print {
.ending { display: none !important; }
.ending-reconciled { display: block !important; }
}
/* ---------------------------------------------------------------
The slips.
--------------------------------------------------------------- */
.slip {
margin: 1.2rem 0 1.2rem 4ch !important;
padding-left: 1rem;
max-width: 46ch;
border-left: 2px solid rgba(var(--coral-rgb), 0.55);
font-family: "Courier Prime", "Courier New", monospace;
font-style: italic;
opacity: 0.8;
white-space: pre-wrap;
}
#quarto-margin-sidebar { display: none; }
.scene-code {
border: 1px solid var(--rule);
border-radius: 3px;
padding: 0.6rem 0.8rem;
margin: 0.9rem 0 1.1rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.88rem;
line-height: 1.45;
background: var(--panel);
color: var(--ink);
overflow-x: auto;
}
.scene-code pre { margin: 0; }
.scene-code code { background: none; padding: 0; }
/* Pressing Enroll flashes the name it confirms, the same name or a new
one, so the press can be seen to have done something. */
.name-confirm .pc-name.pc-flash {
animation: pc-flash 1.4s ease-out;
}
@keyframes pc-flash {
0%, 30% { color: var(--green); }
}</style><script src="scittle.js" type="application/javascript"></script><div style="display:none;"><script type="application/x-scittle">(ns fedoras.carry
"What the reader carries between chapters."
(:require [clojure.string :as str]))
(def ^:private book-key
"The registration on the front of the file. It is not a secret and it
is not pretending to be one — it is the number this is keyed to, and
it is the same number a reader has been looking at since August."
3812)
;; ---------------------------------------------------------------------
;; Bytes
;; ---------------------------------------------------------------------
(defn- to-bytes [s] (.encode (js/TextEncoder.) s))
(defn- from-bytes [b] (.decode (js/TextDecoder. "utf-8") b))
(defn- bytes->str [b]
(loop [i 0 acc ""]
(if (>= i (.-length b))
acc
(recur (inc i) (str acc (js/String.fromCharCode (aget b i)))))))
(defn- str->bytes [s]
(let [out (js/Uint8Array. (count s))]
(dotimes [i (count s)] (aset out i (.charCodeAt s i)))
out))
;; ---------------------------------------------------------------------
;; The keystream
;;
;; The same small xorshift the dice and the floors use, so there is one
;; generator in this project and not four.
;; ---------------------------------------------------------------------
(defn- keystream [n seed]
(let [out (js/Uint8Array. n)]
(loop [i 0 x (bit-or 1 (bit-and seed 0x7fffffff))]
(if (>= i n)
out
(let [x (bit-and (bit-xor x (bit-shift-left x 13)) 0x7fffffff)
x (bit-and (bit-xor x (unsigned-bit-shift-right x 17)) 0x7fffffff)
x (bit-and (bit-xor x (bit-shift-left x 5)) 0x7fffffff)]
(aset out i (bit-and x 0xff))
(recur (inc i) x))))))
(defn- mask [b seed]
(let [k (keystream (.-length b) seed)
out (js/Uint8Array. (.-length b))]
(dotimes [i (.-length b)] (aset out i (bit-xor (aget b i) (aget k i))))
out))
;; ---------------------------------------------------------------------
;; Base64, url-safe
;; ---------------------------------------------------------------------
(defn- b64 [b]
(-> (js/btoa (bytes->str b))
(str/replace "+" "-")
(str/replace "/" "_")
(str/replace "=" "")))
(defn- un-b64 [s]
(let [t (-> s (str/replace "-" "+") (str/replace "_" "/"))
pad (case (mod (count t) 4) 2 "==" 3 "=" "")]
(str->bytes (js/atob (str t pad)))))
;; ---------------------------------------------------------------------
;; Rolls, short
;; ---------------------------------------------------------------------
(defn- pack-rolls
"[roll mod rewrites], shortest form. A roll with no modifier and no
rewrites is one number."
[rolls]
(into {}
(for [[id {:keys [roll mod rewrites]}] rolls
:when roll]
[id (cond
(pos? (or rewrites 0)) [roll (or mod 0) rewrites]
(not (zero? (or mod 0))) [roll mod]
:else [roll])])))
(defn- unpack-rolls
"Every shape this has ever emitted: a bare number, the short vector,
and the verbose map that went out while `pack-rolls` was sitting here
unused. Tokens from before this change still read."
[m]
(into {}
(for [[id v] m]
[(clojure.core/name id)
(cond
(number? v) {:roll v :mod 0 :rewrites 0}
(map? v) {:roll (:roll v)
:mod (or (:mod v) 0)
:rewrites (or (:rewrites v) 0)}
:else {:roll (nth v 0)
:mod (nth v 1 0)
:rewrites (nth v 2 0)})])))
;; ---------------------------------------------------------------------
;; The token
;; ---------------------------------------------------------------------
(defn- checksum [b]
(loop [i 0 s 7]
(if (>= i (.-length b))
(mod s 251)
(recur (inc i) (mod (+ (* s 31) (aget b i)) 251)))))
(defn pack
"A map to a token. The keys are one letter each because this goes in
a query string thirty times a page."
[{:keys [name companion echo rolls deeds epoch todo]}]
(try
(let [payload (js/JSON.stringify
(clj->js
(cond-> {}
name (assoc :n name)
companion (assoc :a [(:n companion) (:given companion)])
(pos? (or echo 0)) (assoc :e echo)
(seq rolls) (assoc :r (pack-rolls rolls))
(seq deeds) (assoc :d (mapv clojure.core/name deeds))
;; when the reader last withdrew the whole record.
;; it travels because the alternative is that only
;; the state travels, and then a link from before
;; the withdrawal quietly reinstates it.
(pos? (or epoch 0)) (assoc :g epoch)
(seq todo) (assoc :t [(:seq todo 0) (:items todo)]))))
body (mask (to-bytes payload) book-key)
out (js/Uint8Array. (inc (.-length body)))]
(aset out 0 (checksum body))
(dotimes [i (.-length body)] (aset out (inc i) (aget body i)))
(b64 out))
(catch :default _ nil)))
(defn unpack
"A token back to a map, or nil if anybody has been at it.
An absent field comes back empty rather than missing, which is fine
and is not a licence: the caller merges this forward and knows that an
empty Echo means the token is old, not that the Echo is nought."
[s]
(try
(when-not (str/blank? s)
(let [raw (un-b64 s)
want (aget raw 0)
body (.slice raw 1)]
(when (= want (checksum body))
(let [m (js->clj (js/JSON.parse (from-bytes (mask body book-key)))
:keywordize-keys true)]
{:name (:n m)
:companion (when-let [a (:a m)] {:n (first a) :given (or (second a) "")})
:echo (or (:e m) 0)
:rolls (unpack-rolls (or (:r m) {}))
:deeds (mapv keyword (or (:d m) []))
:epoch (or (:g m) 0)
:todo (when-let [t (:t m)] {:seq (first t) :items (or (second t) [])})}))))
(catch :default _ nil)))
;; ---------------------------------------------------------------------
;; When it does not reconcile
;; ---------------------------------------------------------------------
(defn complain! []
(js/console.warn
"016.09 — entry does not reconcile and has not been carried forward. Retained."))</script><script type="application/x-scittle">(ns fedoras.reader
"Everything the reader owns.
One atom: containing Name, animal, echo, fortune, score, deeds. A widget
reads it and never touches storage directly, and every change goes through
one of the setters, which writes, re-reads, and lets the watches fire."
(:require [clojure.string :as str]
[fedoras.carry :as carry]))
;; ---------------------------------------------------------------------
;; Storage
;; ---------------------------------------------------------------------
(def prefix "fedoras.")
(def carry-param :f)
(def forget-param
"A page that loads with this on it withdraws its own record and stops.
It is a separate field from the token because it must work on a page
that has never seen a token."
:forget)
(def withdrawn-param
"Survives exactly one reload, to say the thing was done. The state it
would otherwise have been recorded in has just been deleted, which is
the one place a confirmation cannot live."
:withdrawn)
(def legacy-params
"The fields the query string used to carry, one per fact. Stripped off
any link that still has them. There was a second copy of this list
called `carried-params` and the two had drifted."
[:pc :pcl :ac :acn :echo :fortune :score])
(defn- read-json [s]
(js->clj (js/JSON.parse s) :keywordize-keys true))
(defn- write-json [v]
(js/JSON.stringify (clj->js v)))
(defn stored [key]
(try
(when-let [s (.getItem js/localStorage (str prefix (name key)))]
(read-json s))
(catch :default _ nil)))
(defn store! [key val]
(try (.setItem js/localStorage (str prefix (name key)) (write-json val))
(catch :default _ nil))
val)
(defn remove!
"Take something out rather than writing an empty one over it, because
a stored nil and a missing key read back the same and only one of them
is what was meant."
[key]
(try (.removeItem js/localStorage (str prefix (clojure.core/name key)))
(catch :default _ nil)))
(defn storage-works?
"Whether anything at all is going to be persisted.
On a file:// path some browsers give every page its own opaque origin,
which means localStorage throws and the whole book forgets the reader
the moment they click anything. Worth knowing rather than guessing."
[]
(try
(.setItem js/localStorage (str prefix "probe") "1")
(let [ok (= "1" (.getItem js/localStorage (str prefix "probe")))]
(.removeItem js/localStorage (str prefix "probe"))
ok)
(catch :default _ false)))
;; ---------------------------------------------------------------------
;; The query string, which is a transport and nothing else
;; ---------------------------------------------------------------------
(defn- current-url [] (js/URL. (.-href js/location)))
(defn param [key]
(try
(let [v (.get (.-searchParams (current-url)) (name key))]
(when-not (str/blank? v) v))
(catch :default _ nil)))
(defn set-params! [m]
(try
(let [u (current-url) sp (.-searchParams u)]
(doseq [[k v] m]
(if (or (nil? v) (= "" v))
(.delete sp (name k))
(.set sp (name k) (str v))))
(.replaceState js/history nil "" (.toString u)))
(catch :default _ nil)))
(defn- int-or [v d]
(let [n (js/parseInt (str v) 10)] (if (js/isNaN n) d n)))
;; ---------------------------------------------------------------------
;; The atom
;; ---------------------------------------------------------------------
(def default-name ["Owen" "Mercer"])
(def state (atom nil))
(def animal-names
"Number to the animal's grammar: what it is called, what more than one
of them is called, what a ledger would file it under. Filled in by
`fedoras.state/assets` from a later script tag, so it is empty for the
moment between this file being read and the roster arriving, and
nothing here may treat an empty roster as a missing roll."
(atom {}))
(defn- as-grammar
"One roster entry, whatever shape it turned up in.
The page used to be handed {1 \"Pigeon\"} and is now handed
{1 {:animal \"Pigeon\" :plural \"pigeons\" ...}}, and a book whose
`names-form` has not caught up with its roster hands over one of each.
A bare string is an animal with no grammar, which is precisely what it
was before, so it becomes one here.
This is normalised at the boundary rather than at each use because the
alternative was a `merge` three functions away receiving a string,
throwing `conj on a map takes map entries`, and taking down every
render watch on the page — a build that is one function out of step
should read a little worse, not stop."
[v]
(cond
(map? v) v
(string? v) {:animal v}
:else {}))
(defn- read-companion []
(let [s (stored :companion)
n (int-or (first s) nil)]
(when (and n (<= 1 n 20))
(let [given (or (nth s 2 nil) "")
g (as-grammar (get @animal-names n))
;; the animal's name is a convenience and the roster may not
;; have landed yet. the number is the fact.
animal (or (:animal g) (nth s 1 nil) "")
named? (not (str/blank? given))]
(merge {:plural "" :taxonomy "" :collective "" :other-names []}
g
{:n n
:animal animal
:given given
:named? named?
:name (cond named? (str/trim given)
(not (str/blank? animal)) animal
:else (str "d20 " n))})))))
(defn- read-name []
(let [s (stored :pc)]
[(or (first s) (first default-name))
(or (second s) (second default-name))]))
(defn refresh!
"Total on purpose. Every render watch in the book hangs off this atom,
so anything that throws in here blanks the whole page, and what it
reads is JSON that a previous version of this file wrote, which is not
a thing to be optimistic about. A key that is the wrong shape reads as
absent."
[]
(reset! state
{:name (read-name)
:companion (try (read-companion) (catch :default _ nil))
:echo (int-or (stored :echo) 0)
:rolls (let [r (stored :rolls)] (if (map? r) r {}))
:deeds (let [d (stored :deeds)]
(set (map keyword (if (sequential? d) d []))))
:epoch (int-or (stored :epoch) 0)
:todo (let [t (stored :todo)] (if (sequential? t) t []))
:todo-seq (int-or (stored :todo-seq) 0)}))
;; ---------------------------------------------------------------------
;; What a chapter may ask for
;; ---------------------------------------------------------------------
(defn reader-name [] (:name @state))
(defn companion [] (:companion @state))
(defn echo [] (:echo @state))
(defn deeds [] (:deeds @state))
(defn fortune [] (count (:deeds @state)))
(defn epoch
"When this reader last withdrew the whole record, in seconds, or
nought if they never have.
It is not a clock and nothing displays it. It exists so that a
withdrawal can be compared against a link: a token stamped earlier
than this was written by somebody who has since been forgotten, and it
is not allowed to put them back."
[]
(:epoch @state))
(defn standing
"Fortune less echo. Added to every roll for the rest of the book, and
the number the last chapter reads to decide how it ends."
[]
(- (fortune) (echo)))
(defn registration [] "3812")
;; ---------------------------------------------------------------------
;; And what it may change
;; ---------------------------------------------------------------------
(defn set-name! [f l]
(store! :pc [(if (str/blank? f) (first default-name) (str/trim f))
(if (str/blank? l) (second default-name) (str/trim l))])
(refresh!))
(defn set-companion!
"Three cells: the number, the animal's own name as a courtesy, and
whatever the reader called it. The number is the only one that has to
be right, because the other two are recovered from the roster."
[n given]
(store! :companion [n (get-in @animal-names [n :animal] "") (or given "")])
(refresh!))
(defn set-echo! [n] (store! :echo (max 0 n)) (refresh!))
(defn add-echo!
"One more thing taken back."
[]
(set-echo! (inc (echo))))
(defn did!
"Record a deed. A set, so doing the same thing twice is doing it once,
and nothing anywhere says that it happened."
[k]
(store! :deeds (mapv name (conj (deeds) (keyword k))))
(refresh!))
(defn animals!
"The roster's grammar, from the page. Normalised on the way in, so the
shape `fedoras.state/names-form` happens to be emitting this week is
something only this function has an opinion about.
Refreshes, because a companion read before this arrived is holding an
empty animal name."
[m]
(reset! animal-names
(if (map? m)
(into {} (map (fn [[k v]] [k (as-grammar v)])) m)
{}))
(refresh!))
(defn rolls
"Every check the reader has resolved, by id.
Normalised on the way out. Storage keywordizes keys on read, and a
check id is an arbitrary string rather than a name, so the keys come
back as keywords and nothing matches them. And anything that is not a
map at all becomes an empty one, because assoc on a vector with a
string key takes the page down."
[]
(let [r (:rolls @state)]
(if (map? r)
(into {} (map (fn [[k v]] [(name k) v])) r)
{})))
(defn roll-entry [id]
(when-let [e (get (rolls) id)]
{:roll (or (:roll e) (get e "roll"))
:mod (or (:mod e) (get e "mod") 0)
:rewrites (or (:rewrites e) (get e "rewrites") 0)}))
(defn remember-roll! [id e]
(store! :rolls (assoc (rolls) id e))
(refresh!))
(defn score []
(+ (reduce + 0 (keep :roll (vals (rolls))))
(or (:n (companion)) 0)))
;; ---------------------------------------------------------------------
;; Import, once, on arrival
;; ---------------------------------------------------------------------
(def carries-epoch?
"Whether the codec on this page round-trips a withdrawal stamp."
(try
(= 12345 (:epoch (carry/unpack
(carry/pack {:name ["Owen" "Mercer"] :epoch 12345}))))
(catch :default _ false)))
(defn- wipe!
"Everything under the prefix, and then one thing: when that happened."
[at]
(try
(let [ks (vec (for [i (range (.-length js/localStorage))
:let [k (.key js/localStorage i)]
:when (and k (str/starts-with? k prefix))]
k))]
(doseq [k ks] (.removeItem js/localStorage k)))
(catch :default _ nil))
(store! :epoch at))
(defn- merge-token!
[{:keys [name companion echo rolls deeds epoch todo]}]
(let [ours (int-or (stored :epoch) 0)
theirs (or epoch 0)]
(cond
;; A link from before the reader withdrew the record. It is a true
;; account of somebody who does not exist any more, which is not
;; the same thing as an instruction to rebuild them. This is the
;; case that made `forget!` a lie: it emptied one page's storage
;; and every other page, and every entry in the back history, was
;; still holding a complete copy and handed it straight back.
;; A link from before the reader withdrew the record. It is a true
;; account of somebody who does not exist any more, which is not
;; the same thing as an instruction to rebuild them.
;;
;; Only when the codec can say so. A page that cannot read a stamp
;; off a token must not conclude from its absence that every link
;; in the book is stale.
(and carries-epoch? (< theirs ours))
(js/console.info
(str "016.09 — the link this page was opened from was written before the record "
"was withdrawn. Nothing carried forward. Reach this chapter from a page you "
"have opened since, and whatever is current will arrive with you."))
:else
(do
;; They withdrew it somewhere this page has not heard about yet.
;; The erasure travels the way the state travels, which is the
;; only way anything travels in a book read off a disk.
(when (and carries-epoch? (> theirs ours))
(wipe! theirs)
(js/console.info
"016.09 — prior record withdrawn at the reader's request. Nothing carried forward."))
;; the name is the one thing a reader can retype in five seconds,
;; so the token is allowed to have an opinion about it
(when (seq name)
(store! :pc name))
;; The animal:
;;
;; This was write-once — take the token's only if there is
;; nothing here — which is right on one origin and wrong on a
;; folder of files, where every page has its own storage and the
;; token is the only thing that crosses. A reader who opened Act
;; II after rolling and before naming taught that page the horse,
;; permanently, and Act II could never afterwards learn that the
;; horse was called Horace.
;;
;; The Echo is the recency stamp. It only ever goes up, and a
;; reroll raises it, so a token carrying an Echo at least as
;; large as this page's is at least as new as this page and is
;; allowed to speak. One older, and it is a link from before the
;; reroll and is not.
(let [have (read-companion)
here-echo (int-or (stored :echo) 0)
there-echo (or echo 0)]
(when (and (:n companion)
(or (nil? have) (>= there-echo here-echo)))
(store! :companion
[(:n companion) ""
;; a blank name in a token is a reader who had not
;; named it yet, not a reader who unnamed it
(if (str/blank? (:given companion))
(or (:given have) "")
(:given companion))])))
;; upward only, always. after the companion, which reads it.
(store! :echo (max (int-or (stored :echo) 0) (or echo 0)))
;; a check is resolved once, and what is already in this browser
;; was rolled by this reader, so it wins every collision
(when (seq rolls)
(let [named (fn [m] (if (map? m)
(into {} (map (fn [[k v]] [(clojure.core/name k) v])) m)
{}))]
(store! :rolls (merge (named rolls) (named (stored :rolls))))))
;; deeds are a set, and a set does not get smaller by being described
(when (seq deeds)
(let [held (let [d (stored :deeds)] (if (sequential? d) d []))]
(store! :deeds (vec (into (set (map clojure.core/name held))
(map clojure.core/name deeds))))))
(when-let [t todo]
(when (>= (:seq t 0) (int-or (stored :todo-seq) 0))
(store! :todo (:items t))
(store! :todo-seq (:seq t))))))))
(defn- import-token! []
(if-let [t (param carry-param)]
(if-let [m (try (fedoras.carry/unpack t) (catch :default _ nil))]
(merge-token! m)
(try (fedoras.carry/complain!) (catch :default _ nil)))
;; nothing to do, unless somebody has a link from before the token
(do
(when-let [pc (param :pc)]
(store! :pc [pc (or (param :pcl) (second default-name))]))
(when-let [ac (param :ac)]
(let [n (int-or ac nil)]
(when (and n (<= 1 n 20) (nil? (read-companion)))
(store! :companion [n "" (or (param :acn) "")]))))
(when-let [e (param :echo)]
(store! :echo (max (int-or (stored :echo) 0) (int-or e 0)))))))
(defn- withdraw-if-asked!
"A page loaded with a withdrawal stamp on it clears itself and does
nothing else.
This is how one button reaches twelve documents that cannot see each
other. The page the reader clicked opens each of the others in a
hidden frame with the stamp in the query string, and each one runs
this on the way up. Nothing is scripted across the boundary — every
file is its own origin and none of them can touch another's storage —
but a document can always clear its own, and a query string is the one
thing that crosses.
Returns true if this load was a withdrawal, so boot knows to skip the
rest of its work."
[]
(when-let [at (some-> (param forget-param) (int-or nil))]
(when (> at (int-or (stored :epoch) 0))
(wipe! at))
true))
;; ---------------------------------------------------------------------
;; Rendering
;; ---------------------------------------------------------------------
(defn on-render! [k f] (add-watch state k (fn [_ _ _ _] (f))) (f))
(defn el [id] (.getElementById js/document id))
(defn q [node selector] (when node (.querySelector node selector)))
(defn q-all
"Every match, as a vector. One argument searches the document; two
searches inside a node.
Written the long way because a NodeList is not a Clojure collection
and array-seq is not in Scittle's bindings."
([selector] (q-all js/document selector))
([node selector]
(if-not node
[]
(let [nodes (.querySelectorAll node selector)]
(mapv (fn [i] (.item nodes i)) (range (.-length nodes)))))))
(defn text! [node selector s]
(when-let [t (q node selector)] (set! (.-textContent t) s)))
(defn on-click! [node selector f]
(when-let [b (q node selector)] (set! (.-onclick b) f)))
(defn enable! [node selector on? label]
(when-let [b (q node selector)]
(set! (.-disabled b) (not on?))
(when label (set! (.-textContent b) label))))
(defn show! [node selector on?]
(when-let [t (q node selector)]
(set! (.-display (.-style t)) (if on? "flex" "none"))))
(defn widget [k id f]
(on-render! k (fn [] (when-let [node (el id)] (f node)))))
;; ---------------------------------------------------------------------
;; Withdrawing the record, everywhere
;;
;; Twelve documents, each its own origin, none able to read another's
;; storage. Every shared channel is shut: localStorage and IndexedDB are
;; origin-scoped, window.name is cleared on the way across, cookies do
;; not apply to a file, and an iframe gets its own origin so the parent
;; cannot script it.
;;
;; It can still LOAD it. A hidden frame pointed at a sibling chapter,
;; with a stamp in the query string, runs that chapter's own copy of
;; this file, which clears that chapter's own storage. Nothing crosses
;; the boundary except the URL, which is the one thing that was ever
;; going to.
;; ---------------------------------------------------------------------
(defn- sibling-pages
"Every other chapter this page links to.
Read off the sidebar rather than configured, because Quarto already
puts the whole book in it and a hand-kept list of filenames is a list
that goes stale the first time a chapter is renamed."
[]
(let [here (.-pathname js/location)
dir (subs here 0 (inc (.lastIndexOf here "/")))]
(->> (q-all "a[href]")
(keep (fn [a]
(let [h (.getAttribute a "href")]
(when (and h
(not (str/starts-with? h "#"))
(not (re-find #"(?i)^(https?:|mailto:|javascript:)" h)))
(try
(let [u (js/URL. (.-href a))
p (.-pathname u)]
(when (and (str/starts-with? p dir)
(str/ends-with? (str/lower-case p) ".html")
(not= p here))
(str (.-protocol u) "//" (.-host u) p)))
(catch :default _ nil))))))
distinct
vec)))
(defn- sweep!
"Tell every other chapter to withdraw, then call `done` once.
Once, whatever happens: a frame that never fires must not leave the
reader looking at a button that has stopped meaning anything, so there
is a deadline as well as a count."
[at done]
(let [pages (sibling-pages)
fired (atom false)
finish (fn [] (when-not @fired (reset! fired true) (done)))]
(if (empty? pages)
(finish)
(let [box (.createElement js/document "div")
left (atom (count pages))
one (fn [] (when (zero? (swap! left dec)) (finish)))]
(js/console.info "016.09 — withdrawing from" (count pages) "other chapters")
(set! (.-style box)
"position:absolute;width:0;height:0;overflow:hidden;border:0;visibility:hidden")
(.appendChild (.-body js/document) box)
(doseq [p pages]
(let [f (.createElement js/document "iframe")]
(set! (.-onload f) (fn [_] (one)))
(set! (.-onerror f) (fn [_] (one)))
(set! (.-src f) (str p "?" (clojure.core/name forget-param) "=" at))
(.appendChild box f)))
(js/setTimeout finish 5000)))))
(defn forget!
"Forget everything, not the six things somebody remembered to list,
and not only on this page.
Emptying this document's storage was never the whole job: the reader
was also sitting, complete, in the query string of every link on every
other chapter and in every entry of the back history, and the next
navigation put them back. So this clears here, stamps when, and then
visits every other chapter to say so. The stamp is what makes a link
written before the withdrawal refuse to be believed afterwards.
It also says so while it is happening. It used to do all of this in
silence and then reload, which is indistinguishable from a button that
does nothing except reload."
[]
(let [at (quot (.now js/Date) 1000)
btn (q js/document ".pc-forget")]
(when btn
(set! (.-disabled btn) true)
(set! (.-textContent btn) "Withdrawing the record…"))
(wipe! at)
(sweep! at
(fn []
(let [u (current-url)]
(doseq [k (concat [carry-param forget-param] legacy-params)]
(.delete (.-searchParams u) (clojure.core/name k)))
(.set (.-searchParams u) (clojure.core/name withdrawn-param) "1")
(set! (.-href js/location) (.toString u)))))))
(defn- note-withdrawal!
"One line, on the way back up, and then the button goes back to
offering. The confirmation cannot be stored, because storing things is
what has just been undone — so it is read off the query string once,
before anything else in boot is allowed to tidy the query string away."
[withdrew?]
(when withdrew?
(js/console.info
"016.09 — record withdrawn from this chapter and from every chapter linked from it.")
(when-let [b (q js/document ".pc-forget")]
(set! (.-textContent b) "Record withdrawn")
(js/setTimeout (fn [] (set! (.-textContent b) "Forget everything")) 5000))))
;; ---------------------------------------------------------------------
;; Export
;; ---------------------------------------------------------------------
(defn token
"What this reader looks like from outside. Recomputed rather than
stored, so it cannot disagree with the state it is describing.
Guarded, because this runs on every render and inside `decorate-links!`,
and a codec that throws on one unexpected key should cost the reader a
shareable link and nothing else."
[]
(try
(let [{:keys [name companion echo rolls deeds todo todo-seq]} @state]
(fedoras.carry/pack
{:name name
:companion (when companion (select-keys companion [:n :given]))
:echo echo
:rolls rolls
:deeds deeds
:epoch (epoch)
:todo (when (seq todo) {:seq (or todo-seq 0) :items todo})}))
(catch :default e
(js/console.warn "fedoras: could not pack the carry token —" e)
nil)))
(defn decorate-links!
"Every internal link carries one field.
Two things were wrong before this and both of them mattered. It ran
inline, at parse time, so any link below the script tag did not exist
yet — which is every prev-and-next arrow Quarto puts in the footer.
And it copied the query string rather than the state, so a page opened
from storage handed on nothing."
[]
(when-let [t (token)]
(doseq [a (q-all "a[href]")]
(let [h (.getAttribute a "href")]
(when (and h
(not (str/starts-with? h "#"))
(not (re-find #"(?i)^(https?:|mailto:|javascript:)" h)))
(try
(let [lu (js/URL. (.-href a))
sp (.-searchParams lu)]
(doseq [k legacy-params] (.delete sp (clojure.core/name k)))
(.set sp (clojure.core/name carry-param) t)
(set! (.-href a) (.toString lu)))
(catch :default _ nil)))))))
(defn- rewrite-address!
"Put the token in this page's own address bar and take the old fields
off it, so a reader who arrived on a legacy link stops looking at
their own Echo."
[]
(when-let [t (token)]
(set-params! (into {(clojure.core/name carry-param) t}
(map (fn [k] [k nil])
(concat [forget-param withdrawn-param] legacy-params))))))
;; ---------------------------------------------------------------------
;; Tokens
;;
;; It used to be a chain of str/replace calls, which is why {pc-last}
;; worked in a scene and rendered as the literal characters {pc-last}
;; inside a dice passage.
;;
;; Case follows the token: {ac} is `ted`, {Ac} is `Ted`, {AC} is `TED`,
;; so a passage can start a sentence or write a cue line without the
;; author reaching for a second token. Note that this makes {PC} in an
;; already-written scene render as OWEN rather than Owen; the old regex
;; was case-insensitive and threw the information away.
;;
;; An unkown token is passed over: A brace in a passage that is not
;; something this understands comes out exactly as it went in, so this
;; is safe to run over arbitrary prose.
;; ---------------------------------------------------------------------
(def ^:private token-re #"\{([A-Za-z]+)(-[A-Za-z-]+)?\}")
(defn- apply-case [head s]
(cond
(str/blank? s) s
(= head (str/upper-case head)) (str/upper-case s)
(= (subs head 0 1) (str/upper-case (subs head 0 1)))
(str (str/upper-case (subs s 0 1)) (subs s 1))
:else s))
(defn render
"Replace every {token} the resolver knows. `resolve` is called with the
lower-cased head and the lower-cased tail — \"ac\" and \"-plural\" for
{ac-plural} — and returns a string or nil."
[resolve s]
(str/replace (or s "") token-re
(fn [m]
(let [[whole head tail] (if (string? m) [m m nil] m)]
(or (some->> (resolve (str/lower-case head)
(some-> tail str/lower-case))
(apply-case head))
whole)))))
(defn- string-hash
"Stable across loads, which is the point: the same sentence picks the
same alternative name every time the reader opens the page, so nothing
reads as though it were rolled twice."
[s]
(reduce (fn [a i] (mod (+ (* 31 a) (.charCodeAt s i)) 1000003))
7 (range (count s))))
(defn- an [s]
(str (if (re-find #"(?i)^[aeiou]" (str s)) "an " "a ") s))
(defn- animal-tokens
"What a passage may say about the animal. The reader's own name for it
is a name; everything else is the species, because a reader who called
a marmot Ted did not thereby decide that two of them are Teds."
[source tail]
(when-let [{:keys [animal plural taxonomy collective other-names name named?]}
(companion)]
(let [kind (str/lower-case (or animal ""))]
(case (or tail "-name")
"-name" (if named? name (str "the " kind))
"-kind" kind
"-a" (an kind)
"-plural" (if (str/blank? plural) (str kind "s") (str/lower-case plural))
"-many" (if (str/blank? collective)
(str "several " (if (str/blank? plural) (str kind "s") plural))
(str (an collective) " of "
(if (str/blank? plural) (str kind "s") plural)))
"-taxonomy" (str/lower-case (or taxonomy kind))
"-given" (if named? name "")
"-other" (if (seq other-names)
(nth other-names (mod (string-hash source) (count other-names)))
(str "the " kind))
nil))))
(defn- reader-tokens [source]
(let [[f l] (reader-name)]
(fn [head tail]
(case head
"pc" (case (or tail "-first")
"-first" f
"-full" (str f " " l)
"-last" l
nil)
"ac" (or (animal-tokens source tail)
;; no roll yet, and a passage still has to read
(case (or tail "-name")
("-name" "-kind" "-other") "the animal"
"-a" "an animal"
"-plural" "animals"
"-many" "several animals"
"-taxonomy" "animal"
"-given" ""
nil))
nil))))
(defn fill-with
"Put the reader into a passage, plus whatever else the caller knows.
The dice box passes the die in this way."
[extra s]
(let [own (reader-tokens s)]
(render (fn [h t] (or (extra h t) (own h t))) s)))
(defn fill
"Put the reader into a passage. The only place any of these are
resolved."
[s]
(fill-with (fn [_ _] nil) s))
(defn animal-label
"Kept for anything still asking. The display name, as stored, not
shouted — the old one upper-cased and every caller lower-cased it
again somewhere else."
[]
(when-let [c (companion)] (:name c)))
;; ---------------------------------------------------------------------
;; The carry-over
;;
;; What a reader takes to the next book.
;; ---------------------------------------------------------------------
(def on-the-record
"What goes in the token. Everything else is furniture.
A key is on this list because it is small and because losing it would
lose the reader's year. Adding one is a decision about how long every
link on every page is going to be, so it is a list and not a
convention."
#{:pc :companion :echo :rolls :deeds :todo})
(defn kept
"Read something the reader left in a room. No widget that calls this
needs to know anything about tokens."
[k]
(stored k))
(defn keep!
"Leave something in a room. It stays where it was made."
[k v]
(store! k v)
(when (contains? on-the-record k) (refresh!))
v)
(def ^:private alphabet "23456789ABCDEFGHJKLMNPQRSTUVWXYZ")
(defn- b32 [n len]
(loop [n n i 0 out ""]
(if (= i len)
out
(recur (quot n 32) (inc i)
(str (nth alphabet (mod n 32)) out)))))
;; ---------------------------------------------------------------------
;; The name and the animal, on every page
;; ---------------------------------------------------------------------
(defn- paint-name []
(let [[f l] (reader-name)]
(doseq [node (q-all ".pc-name")]
(set! (.-textContent node)
(apply-case (or (.getAttribute node "data-case") "pc")
(case (.getAttribute node "data-pc")
"-full" (str f " " l) "-last" l f))))
(when-let [i (el "pc-first")] (when (str/blank? (.-value i)) (set! (.-value i) f)))
(when-let [i (el "pc-last")] (when (str/blank? (.-value i)) (set! (.-value i) l)))))
(defn- paint-companion
"Each span carries the token that made it, so {ac-plural} written in a
scene at build time still says `marmots` after the reader rolls a
marmot. It used to carry nothing and every span got the same word."
[]
(doseq [node (q-all ".ac-name")]
(let [tail (or (.getAttribute node "data-ac") "-name")
head (or (.getAttribute node "data-case") "ac")]
(set! (.-textContent node)
(or (some->> ((reader-tokens (or (.-textContent node) "")) "ac" tail)
(apply-case head))
"the animal")))))
(defn- paint-tokens []
(doseq [node (q-all ".tok")]
(set! (.-textContent node) (fill (.getAttribute node "data-tok")))))
;; ---------------------------------------------------------------------
;; Ending / Standing
;; ---------------------------------------------------------------------
(def deed-roll
[:nerve ;; a natural twenty with the Echo at nought
:named-it ;; the animal was given a name and kept it
:kept-the-programmes ;; both sheets went in the bag and stayed there
:no-name-for-the-box ;; nothing was spoken into the undertaker's box
:read-it-back ;; the ledger was read against the room out loud
:opened-the-cage
:held-the-door
:went-back-for-it
:signed-nothing
:walked-out
:told-a-new-one ;; Ariathne was paid in something that was theirs
:said-why]) ;; the chancellor asked, and got a true answer
(def ^:private deed-bits 15)
(defn- deed-mask []
(let [held (deeds)]
(reduce (fn [acc [index deed]]
(if (contains? held deed)
(+ acc (bit-shift-left 1 index))
acc))
0
(map-indexed vector deed-roll))))
(defn- deeds-from-mask [mask]
(set (keep-indexed (fn [index deed]
(when (pos? (bit-and mask (bit-shift-left 1 index)))
deed))
deed-roll)))
(def band-code {:closed 0 :reconciled 1 :outstanding 2})
(def code-band {0 :closed 1 :reconciled 2 :outstanding})
(defn ending-band []
(let [reader-standing (standing)]
(cond
(>= reader-standing 4) :outstanding
(>= reader-standing 0) :reconciled
:else :closed)))
(defn- paint-standing!
"One class on the body, so a chapter that forks three ways does it in
the stylesheet instead of in nine lines of DOM work per passage. A page
with no class on it shows :reconciled, which is what the print edition
gets and what the table actually got.
This existed and was never wired to anything, which meant every
`sp/by-standing` on every page was rendering all three endings at
once, or none."
[]
(when-let [body (.-body js/document)]
(let [classes (.-classList body)]
(doseq [old ["standing-closed" "standing-reconciled" "standing-outstanding"]]
(.remove classes old))
(.add classes (str "standing-" (clojure.core/name (ending-band)))))))
(def code-version 3)
(defn- check-char [body]
(nth alphabet
(mod (reduce + (map (fn [c] (.indexOf alphabet c)) body)) 32)))
(defn carry-code
"What this reader looks like to the next book, as an accession line."
[]
(let [n (+ (* code-version 4294967296)
(* (get band-code (ending-band) 1) 1073741824)
(* (min 31 (or (:n (companion)) 0)) 33554432)
(* (min 31 (echo)) 1048576)
(* (deed-mask) 32)
(min 31 (quot (score) 10)))
body (b32 n 7)]
(str "016.14 · " (registration) " · "
(subs body 0 4) "-" (subs body 4) (check-char body))))
(defn read-carry
"The other side of it, for the book after this one."
[s]
(let [cleaned (-> (str s) str/upper-case (str/replace #"[^2-9A-Z]" ""))
tail (subs cleaned (max 0 (- (count cleaned) 8)))]
(when (= 8 (count tail))
(let [body (subs tail 0 7)
n (reduce (fn [acc c] (+ (* 32 acc) (.indexOf alphabet c))) 0 body)]
(when (= (nth tail 7) (check-char body))
{:version (quot n 4294967296)
:band (get code-band (mod (quot n 1073741824) 4))
:companion (mod (quot n 33554432) 32)
:echo (mod (quot n 1048576) 32)
:deeds (deeds-from-mask (mod (quot n 32) (bit-shift-left 1 deed-bits)))
:score-floor (* 10 (mod n 32))})))))
;; ---------------------------------------------------------------------
;; Is any of this working
;;
;; Type fedoras.reader.diagnose_BANG_() in the console. Faster than
;; opening the storage inspector and reading JSON, and it says whether
;; the token agrees with the storage, which is the failure that cost an
;; animal.
;; ---------------------------------------------------------------------
(defn diagnose! []
(let [t (param carry-param)]
(js/console.log "storage works: " (storage-works?))
(js/console.log "origin: " (.-origin js/location)
(if (= "file:" (.-protocol js/location))
"— every page may have its own storage"
"— one bucket for the whole book"))
(js/console.log "name: " (pr-str (reader-name)))
(js/console.log "companion: " (pr-str (companion)))
(js/console.log " raw storage: " (pr-str (stored :companion)))
(js/console.log "roster loaded: " (count @animal-names) "animals")
(js/console.log "echo / fortune: " (echo) "/" (fortune) " → standing" (standing))
(js/console.log "withdrawn at: " (if (pos? (epoch)) (epoch) "never")
(if carries-epoch?
""
"— CODEC DROPS THE STAMP, carry.cljs is out of date"))
(js/console.log "rolls: " (pr-str (rolls)))
(js/console.log "token on url: " (or t "none"))
(when t
(js/console.log " unpacks to: "
(pr-str (try (fedoras.carry/unpack t)
(catch :default e (str "THREW: " e))))))
(js/console.log "token we'd emit:" (or (token) "none — pack failed"))
:ok))
;; A handle on `window`, because Scittle keeps its namespaces inside the
;; SCI context and the browser console cannot see into it. There is no
;; `fedoras.reader.diagnose_BANG_()` to call — that is the shape a
;; compiled build would have, and this is an interpreted one.
;;
;; fedoras.diagnose()
;; fedoras.forget()
;;
;; and for anything else, scittle.core.eval_string("(fedoras.reader/...)")
(set! (.-fedoras js/window)
(js-obj "diagnose" (fn [] (diagnose!))
"state" (fn [] (clj->js @state))
"token" (fn [] (token))
"forget" (fn [] (forget!))))
;; ---------------------------------------------------------------------
;; Boot
;; ---------------------------------------------------------------------
(if (withdraw-if-asked!)
;; a hidden frame, doing one job. no token, no links, no painting.
(refresh!)
;; read before anything registers a watch: `on-render!` calls its
;; function once on the spot, and the links watch tidies the query
;; string, so the flag is gone by the time the DOM is ready
(let [withdrew? (boolean (param withdrawn-param))]
(when-not carries-epoch?
(js/console.warn
(str "fedoras: carry.cljs does not round-trip the withdrawal stamp. "
"`forget!` will clear this chapter and every chapter linked from it, "
"but a stale link will not be refused afterwards. "
"Update resources/fedoras/carry.cljs — `pack` needs :epoch in its "
"destructuring and :g in its payload, and `unpack` needs :epoch back out.")))
(import-token!)
(refresh!)
(on-render! ::name paint-name)
(on-render! ::companion paint-companion)
(on-render! ::tokens paint-tokens)
(on-render! ::standing paint-standing!)
(on-render! ::links (fn [] (decorate-links!) (rewrite-address!)))
(if (= "loading" (.-readyState js/document))
(.addEventListener js/document "DOMContentLoaded"
(fn [_]
(note-withdrawal! withdrew?)
(decorate-links!)
(paint-standing!)))
(do (note-withdrawal! withdrew?)
(decorate-links!)))))</script><script type="application/x-scittle">(fedoras.reader/animals! {1 {:animal "Pigeon", :plural "pigeons", :taxonomy "bird", :collective "kit", :other-names ["dusty wings" "coooo"]}, 2 {:animal "Giant Centipede", :plural "centipedes", :taxonomy "arthropod", :collective "colony", :other-names ["clicker" "legs gooo"]}, 3 {:animal "Marmot", :plural "marmots", :taxonomy "rodent", :collective "madness", :other-names ["the alarm" "the fat rat"]}, 4 {:animal "Cat", :plural "cats", :taxonomy "felid", :collective "clowder", :other-names ["the cat" "cat"]}, 5 {:animal "Turkey", :plural "turkeys", :taxonomy "fowl", :collective "rafter", :other-names ["thanksgiving dinner for non vegans" "gobbler"]}, 6 {:animal "Giant Badger", :plural "badgers", :taxonomy "mustelid", :collective "cete", :other-names ["angry grandpa" "stripes"]}, 7 {:animal "Giant Weasel", :plural "weasels", :taxonomy "mustelid", :collective "sneak", :other-names ["the scout" "slenderman" "the ermine"]}, 8 {:animal "Fox", :plural "foxes", :taxonomy "canid", :collective "skulk", :other-names ["foxxxy" "spry foot"]}, 9 {:animal "Wolverine", :plural "wolverines", :taxonomy "mustelid", :collective "gang", :other-names ["the small trouble"]}, 10 {:animal "Dog", :plural "dogs", :taxonomy "canid", :collective "pack", :other-names ["the dog" "the ordinary dog"]}, 11 {:animal "Python", :plural "pythons", :taxonomy "serpent", :collective "knot", :other-names ["ssSSssSsss" "coilssss"]}, 12 {:animal "Mule", :plural "mules", :taxonomy "equid", :collective "barren", :other-names ["dapple" "HeeHaw :("]}, 13 {:animal "Lynx", :plural "lynxes", :taxonomy "felid", :collective "clowder", :other-names ["tufts" "yelper"]}, 14 {:animal "Ape", :plural "apes", :taxonomy "primate", :collective "shrewdness", :other-names ["treecrawler"]}, 15 {:animal "Horse", :plural "horses", :taxonomy "equid", :collective "harras", :other-names ["rocinante" "the one nobody rides"]}, 16 {:animal "Moose", :plural "moose", :taxonomy "cervid", :collective "herd", :other-names ["its a giant moose"]}, 17 {:animal "Wyrmling", :plural "wyrmlings", :taxonomy "draconic", :collective "clutch", :other-names ["the wyrmling" "scales"]}, 18 {:animal "Falcon", :plural "falcons", :taxonomy "raptor", :collective "cast", :other-names ["the raptor" "true sight"]}, 19 {:animal "Wolf", :plural "wolves", :taxonomy "canid", :collective "pack", :other-names ["the wolf" "the packleader"]}, 20 {:animal "Bear", :plural "bears", :taxonomy "ursid", :collective "sloth", :other-names ["the bear" "the citadel"]}})</script></div></div>
```
# Scene 3 — Breakout
```{=html}
<span style="display:none;"></span>
```
```{=html}
<blockquote class="handbook"><p class="rule-number">The Fedora Handbook, Rule #23</p><p class="rule-text">“There is no such thing as a private opinion. There is only a public opinion you have not said yet.”</p></blockquote>
```
```{=html}
<div class="scene"><div class="screenplay"><p class="action">Since the assessment there have been four. He has counted them the way you count anything you have decided not to tell anybody about: badly, and only afterwards.</p><p class="action">They come the same way every time and the order does not change. The smell first, which is a coin held too long. Then a stretch he is not there for, which was seconds in September and is not seconds now. Then the nose, which is the part other people see, and which is therefore the part that has to be managed.</p><p class="action">Two of the four were in the basement. He has not put those two next to the other two, because to put them next to each other he would have to write them down.</p></div><div class="screenplay"><p class="slug">INT. STERLING CENTER — AN INNER OFFICE — FRIDAY 6 FEBRUARY, 3:00 P.M.</p></div><div class="screenplay"><p class="action">One window, one desk, a chair on each side of it, and a screen turned away.</p><p class="action">VICE DEAN PARR. He gives his name at the start and it is the only thing about him that is not a form.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">The schedule came back on the sixteenth.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Came back where?</p></div></div><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">Here.</p></div></div></div><div class="screenplay"><p class="action">He does not turn the screen round.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">Question three-a. A hundred and forty hours below</p><p class="line">ground level.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">It's a service assignment. They sent me.</p></div></div><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">Yes. It's ended.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Ended how?</p></div></div><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">The whole line's been contracted out. It isn't</p><p class="line">about you and I'd rather you didn't take it as</p><p class="line">though it were.</p></div></div></div><div class="screenplay"><p class="action beat">He scrolls.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">Question four. You said no.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">I did.</p></div></div><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">Yes.</p></div></div></div><div class="screenplay"><p class="action beat">He goes on scrolling.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">That's the whole of question four.</p></div></div></div><div class="screenplay"><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> does not say anything.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">Your standing is Provisional. Provisional gets</p><p class="line">looked at in March, ahead of the rest of it,</p><p class="line">because it's the one that costs money.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Looked at by who?</p></div></div><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">It's looked at.</p></div></div></div><div class="screenplay"><p class="action">He turns a card over on the desk and does not read it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">Meridian takes four from here a year. It isn't a</p><p class="line">component of review. It replaces review.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">What is it?</p></div></div><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">A placement.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Doing what?</p></div></div></div><div class="screenplay"><p class="action beat">He looks at the card properly for the first time.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">It doesn't say.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Where is it?</p></div></div><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">It doesn't say that either.</p></div></div></div><div class="screenplay"><p class="action beat">There is nothing in his face that suggests he is holding anything back. He has read the whole card.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">And if it isn't for you there's an array. There's</p><p class="line">always an array. There's a seat on one the week</p><p class="line">after March and a submanager who'd know your name</p><p class="line">by the Friday.</p></div></div></div><div class="screenplay"><p class="action">The smell arrives while he is saying it.</p><p class="action beat">It is a coin held too long.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">I'd not have you think of that as a failure. It's</p><p class="line">honest, it's well paid, and people are proud of</p><p class="line">it.</p></div></div></div><div class="screenplay"><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> is looking at the edge of the desk.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">Are you with me?</p></div></div></div><div class="screenplay"><p class="action beat">Two seconds.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Yes.</p></div></div><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">Say the last thing I said.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">The array. The week after March.</p></div></div></div><div class="screenplay"><p class="action beat">It is the right answer and it is not the last thing he said.</p><p class="action">Parr looks at him for a moment, and then does not pursue it, and afterwards <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> will not be able to decide which of those two things was the kindness.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">Right.</p></div></div></div><div class="screenplay"><p class="action">He scrolls once more and stops.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">You've nominated nobody.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">No.</p></div></div><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">That isn't a criticism. It's a field on a page and</p><p class="line">it's blank and I'm obliged to ask whether that's</p><p class="line">current.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">It's current.</p></div></div><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">So there's nobody who'd need to be told.</p></div></div></div><div class="screenplay"><p class="action beat">He says it the way you would note that a room has no window.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">You've until the end of term. Say yes and March</p><p class="line">doesn't happen to you.</p></div></div></div><div class="screenplay"><p class="action">He puts the card in a drawer.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">One more thing and it isn't a condition. There's</p><p class="line">no assignment for you below ground any more and</p><p class="line">there's no reason for you to be down there. That's</p><p class="line">advice.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Is advice recorded?</p></div></div><div class="dialogue"><p class="character">PARR</p><div class="lines"><p class="line">Everything's recorded.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="action">It stops on the stairs. He asks his Help about the array on the way down, because everyone asks their Help, and because it is the only part of the conversation that had a name in it.</p></div><div class="artifact aichat"><p class="ai-model">HELP · personal · v9</p><div class="ai-body"><div class="ai-turn ai-user"><p class="ai-role">USER</p><div class="ai-text"><p><span>whats an array placement actually like</span></p></div></div><div class="ai-turn ai-assistant"><p class="ai-role">ASSISTANT</p><div class="ai-text"><p><span>You are given a desk, a seat and a feed line. Aftercall is</span></p><p><span>the interval between one contact and the next and is the</span></p><p><span>figure your submanager will discuss with you.</span></p><p><span>Sessions are four hours. Most placements are two sessions.</span></p><p><span>Hours accrue against your name and may be balloted.</span></p></div></div><div class="ai-turn ai-user"><p class="ai-role">USER</p><div class="ai-text"><p><span>for how long</span></p></div></div><div class="ai-turn ai-assistant"><p class="ai-role">ASSISTANT</p><div class="ai-text"><p><span>Placements are open-ended. The median placement on the</span></p><p><span>arrays is thirty-one years.</span></p></div></div><div class="ai-turn ai-user"><p class="ai-role">USER</p><div class="ai-text"><p><span>thirty one years</span></p></div></div><div class="ai-turn ai-assistant"><p class="ai-role">ASSISTANT</p><div class="ai-text"><p><span>Yes.</span></p></div></div><div class="ai-turn ai-user"><p class="ai-role">USER</p><div class="ai-text"><p><span>is that a long time for a job</span></p></div></div><div class="ai-turn ai-assistant"><p class="ai-role">ASSISTANT</p><div class="ai-text"><p><span>It is the median. Longer placements are common and are</span></p><p><span>recognised.</span></p></div></div><div class="ai-turn ai-user"><p class="ai-role">USER</p><div class="ai-text"><p><span>recognised where</span></p></div></div><div class="ai-turn ai-assistant"><p class="ai-role">ASSISTANT</p><div class="ai-text"><p><span>At the desk, by the array, and in the programme.</span></p></div></div><div class="ai-turn ai-user"><p class="ai-role">USER</p><div class="ai-text"><p><span>what programme</span></p></div></div><div class="ai-turn ai-assistant"><p class="ai-role">ASSISTANT</p><div class="ai-text"><p><span>THE FLOOR. Fourteen seasons. Approved.</span></p><p><span>Would you like me to add you to a list?</span></p></div></div></div></div><div class="screenplay"><p class="action">Thirty-one years is the median, which means half of them are longer, and it says so pleasantly, and the last thing it says is an offer.</p></div><div class="screenplay"><p class="slug">INT. PLANT SERVICES — 5:40 P.M.</p></div><div class="screenplay"><p class="action">The waders are off the wall and folded on the bench, which is the first thing <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> notices and he could not say why.</p><p class="action">HOLLIS is sitting on a stack of buckets with a mug. He has been drinking since about four and he is not loud with it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">They've contracted it.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Contracted what?</p></div></div><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">It.</p></div></div></div><div class="screenplay"><p class="action">He puts the mug down on the bench, misses the edge by an inch, and catches it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">Eighty a year I asked for. I had forty, once, and</p><p class="line">it's in the book, and you've read the book.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">I have.</p></div></div></div><div class="screenplay"><p class="action beat">Hollis looks up.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">Have you.</p></div></div></div><div class="screenplay"><p class="action">He does not follow it up. Then he looks a moment longer than he has looked at him all term.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">You're getting the nose.</p></div></div></div><div class="screenplay"><p class="action beat"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> does not answer, which is an answer.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">Fenner had them.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Had them how often?</p></div></div><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">Not to start with.</p></div></div></div><div class="screenplay"><p class="action">He drinks.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">Don't dig.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">You said that in September.</p></div></div><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">I did.</p></div></div><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">It's not advice. It's what I was told to say.</p><p class="line">First morning, stood where you are.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Told by who?</p></div></div><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">Fenner.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Where's Fenner?</p></div></div><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">Dead.</p></div></div></div><div class="screenplay"><p class="action beat">Neither of them puts those two answers next to each other out loud.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> looks at the map on the wall, at the ink on the south sheet that stops two thirds of the way down in the middle of nothing.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">Forty-one years. I have never been past the second</p><p class="line">junction box.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Why not?</p></div></div></div><div class="screenplay"><p class="action beat">Hollis thinks about it, which is the part that is hard to watch.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">Nobody stopped me.</p></div></div></div><div class="screenplay"><p class="action">There is one ring on the hooks by the door and there has always been one ring on the hooks by the door.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> takes it. Hollis watches him do it and does not put the mug down.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">The long one's the stair. The one with the tape on</p><p class="line">it is the grate at the bottom, and it wants</p><p class="line">lifting, not turning.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Right.</p></div></div></div><div class="screenplay"><p class="action beat">He is at the door before Hollis says the last of it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">It got worse for him at the end.</p></div></div></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="screenplay"><p class="action">A bat, a ball, and a wall between them.</p></div><div><pre><code class="sourceCode language-clojure printed-clojure">#'act3.s3-breakout/wall
</code></pre></div><div><pre><code class="sourceCode language-clojure printed-clojure">#'act3.s3-breakout/brick-at
</code></pre></div><div><pre><code class="sourceCode language-clojure printed-clojure">#'act3.s3-breakout/step
</code></pre></div><div><pre><code class="sourceCode language-clojure printed-clojure">#'act3.s3-breakout/start
</code></pre></div>30<div class="screenplay"><p class="action">Thirty bricks, and a bat that does not move, and four thousand moments.</p></div>{:broken 30, :left 0, :lost 15}<div class="screenplay"><p class="action">It takes the whole wall down without anybody touching it, and drops the ball fifteen times doing it.</p></div><div><pre><code class="sourceCode language-clojure printed-clojure">#'act3.s3-breakout/when-each-fell
</code></pre></div>{:first-twenty-six 1504, :all-thirty 1520}<div class="screenplay"><p class="action">Twenty-six of them take one thousand five hundred and four moments. The last four take sixteen.</p><p class="action">Somewhere around the fifteen hundredth the ball goes up through a gap it made itself and gets above the wall, and there is nothing up there for it to lose to, and it comes down through the bricks and goes back up through them and there is nothing left to do.</p><p class="action">He watches that happen with his hands off the keyboard and does not think about it again for four months.</p></div><div><script type="application/x-scittle">(ns fedoras.sketch
"Editable ClojureScript that draws.
The live cells elsewhere in the book evaluate an expression and print
what it returned, which is fine for a list and useless for a picture.
A sketch cell gives the code a canvas and five things to draw with,
and then gets out of the way: what the reader edits is the same
algorithm that ran on the JVM at build time, in the same language,
and it runs in front of them.
What the page provides, and the scene says so out loud because Reyes
said it in class:
(clear!) wipe it
(ink! \"#1c1f24\") colour
(dot x y) one mark, in world coordinates
(poly [[x y] ...]) a closed outline
(frames! total per f) call (f i) total times, per of them a frame
Nothing else is injected. Everything above the fold in the cell is
the reader's.
WHERE THE CODE IS. All of it is here, in one file read twice. The
build reads the :clj half, which writes the markup and, for each cell,
a header binding the short names to that cell's own canvas or piece of
page. The page reads the :cljs half, because the build ships this file
to it as it stands: the drawing kit the headers call, and the runner
behind every Run button."
#?(:clj (:require [clojure.string :as str]
[fedoras.state :as state]
[scicloj.kindly.v4.kind :as kind])))
;; ---------------------------------------------------------------------
;; The build: headers and markup
;; ---------------------------------------------------------------------
#?(:clj
(do
(defn canvas-header
"The short names a sketch cell may use, bound to its own canvas.
Built as Clojure forms rather than as a string, so it cannot be
unbalanced and can be read back and printed."
[id world-w world-h]
[(list 'def 'C (list 'fedoras.sketch/mount id world-w world-h))
'(def cv (:cv C))
'(def ctx (:ctx C))
'(def CW (:cw C))
'(def CH (:ch C))
'(def SX (:sx C))
'(def SY (:sy C))
'(defn clear! [] (fedoras.sketch/clear! C))
'(defn ink! [c] (fedoras.sketch/ink! C c))
'(defn dot ([x y] (fedoras.sketch/dot C x y)) ([x y s] (fedoras.sketch/dot C x y s)))
'(defn poly [pts] (fedoras.sketch/poly C pts))
'(defn frames! [total per f] (fedoras.sketch/frames! C total per f))
'(defn on-pointer! [f] (fedoras.sketch/on-pointer! C f))
'(def page-ink (fedoras.sketch/page-ink C))
'(clear!)
'(ink! page-ink)
'(fedoras.sketch/line-width! C 0.8)])
(defn page-header
"The names a page cell may use. No canvas: a piece of the page."
[id]
[(list 'def 'root (list '.getElementById 'js/document (str id "-out")))
'(defn html! [s] (set! (.-innerHTML root) s))
'(defn on! [id f]
(let [e (.getElementById js/document id)]
(when e (set! (.-onclick e) (fn [_] (f))))))
'(defn fetch [k]
(try
(let [v (.getItem js/localStorage (str "fedoras." k))]
(when v (read-string v)))
(catch :default _ nil)))
'(defn store! [k v]
(try (.setItem js/localStorage (str "fedoras." k) (pr-str v))
(catch :default _ nil))
v)])
(defn- header-text [forms]
(str/join "\n" (map pr-str (cons '(in-ns 'fedoras.sketch-runner) forms))))
(defn primitives
"What the page hands a cell, printed, so that nothing in the book is
doing something the reader cannot look at."
([] (primitives 100 100))
([world-w world-h]
(kind/code (header-text (canvas-header "the-page" world-w world-h)))))
(defn cell
"An editable ClojureScript sketch with a canvas under it.
(sketch/cell {:id \"chaos\" :label \"CHAOS.CLJS\" :world [100 86.6]}
\"(dot 50 40)\")"
[{:keys [id label world w h rows]
:or {id "sketch" world [100 100] w 420 h 380 rows 16}}
code]
(let [[ww wh] world]
(kind/hiccup
[:div
(state/source "fedoras/sketch.cljc")
[:div.sketch {:id id}
[:div.roll-head
[:span.roll-label (or label "SKETCH")]
[:span.roll-dc (str ww " × " wh)]]
[:textarea {:id (str id "-src") :class "live-src" :rows rows :spellcheck "false"}
(str/trim code)]
[:div.live-bar
[:button.live-run "Run"]]
[:canvas {:id (str id "-canvas") :width w :height h}]
[:pre {:id (str id "-out") :class "live-out"}]]
(state/call 'fedoras.sketch/cell! id (header-text (canvas-header id ww wh)))])))
(defn page
"An editable ClojureScript cell with a piece of page under it instead
of a canvas. The header gives the code five things:
root the div it owns
(html! s) put markup in it
(on! id f) wire a click
(fetch k) read something the reader saved
(store! k v) save something, and it is still there tomorrow
Everything else in the cell belongs to the reader."
[{:keys [id label rows] :or {id "page" rows 20}} code]
(kind/hiccup
[:div
(state/source "fedoras/sketch.cljc")
[:div.sketch {:id id}
[:div.roll-head
[:span.roll-label (or label "PAGE")]
[:span.roll-dc "cljs"]]
[:textarea {:id (str id "-src") :class "live-src" :rows rows :spellcheck "false"}
(str/trim code)]
[:div.live-bar
[:button.live-run "Run"]]
[:div {:id (str id "-out") :class "sketch-page"}]
[:pre {:id (str id "-res") :class "live-out"}]]
(state/call 'fedoras.sketch/cell! id (header-text (page-header id)))]))))
;; ---------------------------------------------------------------------
;; The page: the drawing kit and the runner
;; ---------------------------------------------------------------------
#?(:cljs
(do
(defonce counters
(atom {}))
(defn- counters-for
"The frame counters of one canvas: made the first time it is mounted
and kept for every Run after, so that each press of Run takes the
next number and the loop the last press started sees it and stops."
[id]
(or (get @counters id)
(let [made {:run (atom 0) :i (atom 0)}]
(swap! counters assoc id made)
made)))
(defn mount
"Everything one cell needs, from the id of its canvas and the size of
the world it draws in. Every drawing function below takes the map
this returns."
[id world-w world-h]
(let [cv (.getElementById js/document (str id "-canvas"))
ctx (.getContext cv "2d")
cw (.-width cv)
ch (.-height cv)]
(merge {:cv cv :ctx ctx :cw cw :ch ch
:sx (/ cw world-w)
:sy (/ ch world-h)}
(counters-for id))))
(defn ink!
"Both fill and stroke, because a cell that sets one and not the other
is a cell that draws in two colours by accident."
[{:keys [ctx]} colour]
(set! (.-fillStyle ctx) colour)
(set! (.-strokeStyle ctx) colour))
(defn page-ink
"Whatever colour the surrounding prose is, so a drawing belongs to the
page it is on and follows it into dark mode."
[{:keys [cv]}]
(.-color (js/getComputedStyle cv)))
(defn clear! [{:keys [ctx cw ch]}]
(.clearRect ctx 0 0 cw ch))
(defn dot
([c x y] (dot c x y 1.2))
([{:keys [ctx sx sy ch]} x y size]
(.fillRect ctx (* x sx) (- ch (* y sy)) size size)))
(defn poly
"A closed outline through the points, in world coordinates."
[{:keys [ctx sx sy ch]} pts]
(when (seq pts)
(let [[fx fy] (first pts)]
(.beginPath ctx)
(.moveTo ctx (* fx sx) (- ch (* fy sy)))
(doseq [[x y] (rest pts)]
(.lineTo ctx (* x sx) (- ch (* y sy))))
(.closePath ctx)
(.stroke ctx))))
(defn line-width! [{:keys [ctx]} w]
(set! (.-lineWidth ctx) w))
(defn- tick! [{:keys [run i] :as c} generation total per f]
(when (= generation @run)
(dotimes [_ per]
(when (< @i total) (f @i) (swap! i inc)))
(when (< @i total)
(js/requestAnimationFrame (fn [] (tick! c generation total per f)))))
nil)
(defn frames!
"Call (f i) total times, per of them per frame.
Pressing Run again retires the loop that is already going. Without
that, a cell run twice advances twice per frame and looks broken in a
way nobody can debug from the outside."
[{:keys [run i] :as c} total per f]
(let [generation (swap! run inc)]
(reset! i 0)
(tick! c generation total per f))
nil)
(defn on-pointer!
"Pointer position in world coordinates, which is what a cell wants and
never what the event gives it."
[{:keys [cv sx sy]} f]
(set! (.-onmousemove cv)
(fn [e]
(f (/ (.-offsetX e) sx)
(- (/ (.-height cv) sy) (/ (.-offsetY e) sy))))))
(defonce headers
(atom {}))
(defn- element
[id]
(.getElementById js/document id))
(defn- line-count
[text]
(count (re-seq #"\n" text)))
(defn- error-text
"An error as the reader should see it: the message, and the line of
their own code it came from, when there is one."
[error header-lines]
(let [line (:line (ex-data error))]
(str (or (ex-message error) (str error))
(when (and line (> line header-lines))
(str " (line " (- line header-lines) ")")))))
(defn run-cell!
"Cell `id`'s header, then the reader's code, with what came back
printed under the cell, or the error, counted in the reader's lines."
[id]
(let [header (get @headers id)
out (or (element (str id "-res")) (element (str id "-out")))
source (.-value (element (str id "-src")))]
(set! (.-textContent out)
(try
;; load-string is Scittle's own; plain ClojureScript has none
(let [value #_{:clj-kondo/ignore [:unresolved-symbol]} (load-string (str header "\n" source))]
(if (nil? value) "" (str value)))
(catch :default error
(error-text error (inc (line-count header))))))))
(defn- prepare-canvas!
"Give a sketch cell's canvas its drawing context while the page
loads, so that the first Run draws on a canvas the browser has
already shown once. A context made at the moment of the first
drawing loses that drawing in Firefox: the first frames of the
first Run never appear."
[id]
(when-let [cv (element (str id "-canvas"))]
(.clearRect (.getContext cv "2d") 0 0 (.-width cv) (.-height cv))))
(defn cell!
"Keep cell `id`'s header and wire its Run button, and the first time
the cell is seen, prepare its canvas, if it has one. Only the first
time, because preparing clears, and a canvas that is already drawing
must not be cleared under it."
[id header]
(let [first-time? (not (contains? @headers id))]
(swap! headers assoc id header)
(when-let [button (some-> (element id) (.querySelector ".live-run"))]
(set! (.-onclick button) (fn [_] (run-cell! id))))
(when first-time?
(prepare-canvas! id))))))</script><div class="sketch" id="breakout"><div class="roll-head"><span class="roll-label">BREAKOUT.CLJS · as handed in</span><span class="roll-dc">100 × 70</span></div><textarea class="live-src" id="breakout-src" rows="30" spellcheck="false">(defn full-wall [] (set (for [r (range 3) c (range 10)] [r c])))
(def wall (atom (full-wall)))
(def state (atom {:ball [50 20] :vel [0.8 0.9] :bat 50 :broken 0 :lost 0}))
(set! (.-onmousemove cv) (fn [e] (swap! state assoc :bat (/ (.-offsetX e) SX))))
(set! (.-onclick cv)
(fn [_] (reset! wall (full-wall))
(reset! state {:ball [50 20] :vel [0.8 0.9] :bat (:bat @state) :broken 0 :lost 0})))
(defn brick-at [x y]
(when (and (>= x 0) (< x 100) (>= y 52) (< y 70))
(let [col (int (/ x 10)) row (int (/ (- y 52) 6))]
(when (@wall [row col]) [row col]))))
(defn step [{:keys [ball vel bat broken lost] :as s}]
(let [[x y] ball [dx dy] vel
nx (+ x dx) ny (+ y dy)
hit (brick-at nx ny)]
(cond
hit (do (swap! wall disj hit)
(assoc s :vel [dx (- dy)] :ball [nx (- y dy)] :broken (inc broken)))
(or (< nx 0) (> nx 100)) (assoc s :ball [(max 0 (min 100 nx)) ny] :vel [(- dx) dy])
(> ny 70) (assoc s :ball [nx 70] :vel [dx (- dy)])
(and (< ny 3) (< (Math/abs (- nx bat)) 10)) (assoc s :ball [nx 3] :vel [dx (- dy)])
(< ny 0) (assoc s :ball [50 20] :vel [0.8 0.9] :lost (inc lost))
:else (assoc s :ball [nx ny]))))
(defn box [x1 y1 x2 y2] (poly [[x1 y1] [x2 y1] [x2 y2] [x1 y2]]))
(defn draw [{:keys [ball bat broken lost]}]
(clear!)
(doseq [[r c] @wall]
(box (+ (* c 10) 0.5) (+ 52 (* r 6) 0.5)
(+ (* c 10) 9.5) (+ 52 (* r 6) 5.5)))
(let [[x y] ball] (box (- x 1) (- y 1) (+ x 1) (+ y 1)))
(box (- bat 10) 0.5 (+ bat 10) 2.5)
(.fillText ctx (str broken " / 30 lost " lost
(when (empty? @wall) " cleared — click to build it again"))
8 14))
(frames! 100000 1 (fn [_] (swap! state step) (draw @state)))</textarea><div class="live-bar"><button class="live-run">Run</button></div><canvas height="380" id="breakout-canvas" width="420"></canvas><pre class="live-out" id="breakout-out"></pre></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.sketch/cell!))] (mount "breakout" "(in-ns (quote fedoras.sketch-runner))\n(def C (fedoras.sketch/mount \"breakout\" 100 70))\n(def cv (:cv C))\n(def ctx (:ctx C))\n(def CW (:cw C))\n(def CH (:ch C))\n(def SX (:sx C))\n(def SY (:sy C))\n(defn clear! [] (fedoras.sketch/clear! C))\n(defn ink! [c] (fedoras.sketch/ink! C c))\n(defn dot ([x y] (fedoras.sketch/dot C x y)) ([x y s] (fedoras.sketch/dot C x y s)))\n(defn poly [pts] (fedoras.sketch/poly C pts))\n(defn frames! [total per f] (fedoras.sketch/frames! C total per f))\n(defn on-pointer! [f] (fedoras.sketch/on-pointer! C f))\n(def page-ink (fedoras.sketch/page-ink C))\n(clear!)\n(ink! page-ink)\n(fedoras.sketch/line-width! C 0.8)") (js/console.error "fedoras: fedoras.sketch/cell! is not on this page. Either the file that declares it was never shipped, or the namespace inside it does not match the path it was shipped from."))</script></div><div class="screenplay"><p class="slug">INT. HALLORAN UNION — THE PRINT SHOP — THE FOLLOWING WEEK</p></div><div class="screenplay"><p class="action">He has it open on the counter because Lena asked what he had been doing and he did not have a better answer.</p><p class="action">ELI plays it for four minutes and loses eleven times.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">I can't steer it.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">You move the bat.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">I move the bat and it comes off the bat the same</p><p class="line">way every time. That's not a bat, that's a wall.</p></div></div></div><div class="screenplay"><p class="action beat">That is the best note anybody gives him all year and it takes eleven minutes to do.</p><p class="action">Where it lands on the bat now decides where it goes, and the game becomes a game, and Eli plays it for forty minutes and clears it and is insufferable.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. HALLORAN UNION — ROOM 3C — WEDNESDAY, BEFORE</p></div><div class="screenplay"><p class="action">Four of them have played it now, on the machine, before Victor starts.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">It's still too hard.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">You cleared it.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Once. Make the bat bigger and I'd clear it every</p><p class="line">time and I'd enjoy myself, which is what it's for.</p></div></div><div class="dialogue"><p class="character">DEREK</p><div class="lines"><p class="line">What if there were two balls?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Then you'd lose both of them.</p></div></div><div class="dialogue"><p class="character">DEREK</p><div class="lines"><p class="line">No, that's the point. Two balls is more of the</p><p class="line">good thing.</p></div></div><div class="dialogue"><p class="character">MILES</p><div class="lines"><p class="line">I'd want to be able to hold it and aim. Not react.</p><p class="line">Hold it, look at what's left, and put it where I</p><p class="line">want it.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">It ends.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">It's meant to end.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">Everything on that sheet ends. This is the one you</p><p class="line">did because you wanted to. It shouldn't be able to</p><p class="line">end.</p></div></div></div><div class="screenplay"><p class="action beat">Nobody argues with him and afterwards nobody can say why not.</p><p class="action">MAYA plays it once, on the Thursday, at the bench, between readings.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">How many did I get?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">It says on the—</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">It says how many are out of thirty. I want how</p><p class="line">many I got, and how many I lost getting them, and</p><p class="line">I want it after, not while.</p></div></div></div><div class="screenplay"><p class="action beat">That is the only request anybody makes that is not a feature.</p><p class="action">SIMON plays it on the Wednesday for about ten minutes and does not ask for anything.</p></div><div class="screenplay"><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">That's good, that.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">What would you change?</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Nothing.</p></div></div></div><div class="screenplay"><p class="action beat">It is the only note he cannot act on and it is the only one he thinks about afterwards.</p></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="screenplay"><p class="action">He does all of them. He does them over about a fortnight, in the order they were asked for, and he does not take any of them out, because taking one out would be taking a thing off somebody who asked for it.</p><p class="action">Here it is with all of them in it. The list is at the top and it is the only part of the program that is not the program.</p><p class="action">Take things out of it and find out which one it was.</p></div><div><style>/* ---------------------------------------------------------------
The widget shell. Every cs_class widget sits in one of these, so
anything here is a decision about all of them at once.
--------------------------------------------------------------- */
.widget {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
color: var(--ink);
}
.widget:focus { outline: 1px solid rgba(var(--violet-rgb), 0.7); }
.widget:focus-visible { outline: 1px solid rgba(var(--violet-rgb), 0.9); }
/* the plate on the right of the header. every widget writes to it and
nothing else does, so it is the one line a reader learns to check. */
.widget-state {
font-variant-numeric: tabular-nums;
text-align: right;
}
.widget-source {
margin: 0.9rem 0 0.3rem 0;
border: 1px solid rgba(var(--ink-rgb), 0.35);
border-radius: 4px;
overflow: hidden;
}
.widget-source-head {
display: flex;
justify-content: space-between;
padding: 0.3rem 0.6rem;
background: rgba(var(--ink-rgb), 0.10);
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
opacity: 0.75;
}
.widget-settings {
display: block;
width: 100%;
box-sizing: border-box;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.82rem;
line-height: 1.5;
padding: 0.6rem 0.7rem;
border: none;
border-top: 1px solid rgba(var(--ink-rgb), 0.25);
background: rgba(var(--ink-rgb), 0.05);
color: var(--ink);
caret-color: var(--ink);
resize: vertical;
}
.widget-source .live-bar { padding: 0 0.7rem 0.6rem 0.7rem; }
.widget-settings-said {
margin: 0 0.7rem 0.5rem 0.7rem !important;
font-size: 0.76rem;
min-height: 1rem;
opacity: 0.7;
}
.widget-settings-broke {
opacity: 1;
color: var(--coral);
}
.widget-body { display: none; }
.widget-body-shown { display: block; }
.widget-source { margin: 0.7rem 0 0.6rem 0; }</style><style>/* fedoras/legend/legend.css
*
* One key, one palette, every board.
*
* THE ALIGNMENT. Three columns per item — glyph, label, note — with the
* first two a fixed width, so every note in the key begins at the same
* x whether it is in the first column of the key or the third. The old
* one was flex, which sizes each item to its own text and lets them
* pair up wherever they fit, and that is why the right-hand column
* wandered about.
*
* --legend-cols is set per board. --legend-label widens the name column
* for a program with long names in it. */
.legend {
display: grid;
grid-template-columns: repeat(var(--legend-cols, 2), minmax(0, 1fr));
gap: 0.12rem 1.4rem;
align-items: start;
font-size: 0.72rem;
line-height: 1.7;
margin: 0.5rem 0 0.2rem;
}
.legend-item {
display: grid;
grid-template-columns: 1.15rem var(--legend-label, 5.6rem) minmax(0, 1fr);
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.legend-glyph {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.15rem;
height: 1.15rem;
border: 1px solid currentColor;
background: rgba(128, 128, 128, 0.14);
flex: none;
}
.legend-glyph svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
/* the two that are filled rather than drawn */
.legend-glyph.tone-you svg circle,
.legend-glyph.tone-entity svg path,
.legend-glyph.tone-entity svg circle { fill: currentColor; }
.legend-glyph.tone-you svg path { fill: none; }
.legend-letter {
font-size: 0.62rem;
font-weight: 600;
line-height: 1;
color: currentColor;
}
.legend-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.legend-note {
opacity: 0.5;
font-style: italic;
min-width: 0;
}
/* a bad one is red and its box is dashed, because somebody is reading
this on paper and somebody else cannot see the red at all */
.legend-item:has(.tone-bad) .legend-note { opacity: 0.72; }
.legend-glyph.tone-bad { border-style: dashed; }
@media (max-width: 34rem) {
.legend { grid-template-columns: 1fr; }
}
/* ---------------------------------------------------------------------
* THE PALETTE
*
* Set on any ancestor. A board that wants its own look overrides one
* property rather than touching a renderer.
* ------------------------------------------------------------------ */
.legend,
.floor,
.brk,
.widget-rogue,
.walk {
--tone-ink: var(--ink);
--tone-bad: #c0453a;
--tone-good: #6f9f5e;
--tone-aim: #d8a657;
--tone-box: #a5794a;
--tone-shield: #5a86b8;
--tone-you: #f2e8c4;
--tone-entity: #b8503f;
}
.tone-ink { color: var(--tone-ink); }
.tone-bad { color: var(--tone-bad); }
.tone-good { color: var(--tone-good); }
.tone-aim { color: var(--tone-aim); }
.tone-box { color: var(--tone-box); }
.tone-shield { color: var(--tone-shield); }
.tone-you { color: var(--tone-you); }
.tone-entity { color: var(--tone-entity); }</style><script type="application/x-scittle">(ns fedoras.ui.widget-ui
"What every widget's page side does the same way.
Called fully qualified from the widgets, the way `fedoras.reader` is,
because the frame emits this in an earlier Scittle tag and Scittle
evaluates tags in document order. Nothing here depends on a require
crossing that boundary."
(:require [clojure.string :as str]))
(defonce mounted
(atom #{}))
(defonce running
(atom #{}))
(defonce shipped
(atom {}))
(defn running? [id] (contains? @running id))
(defn reveal!
"Show the widget's body. Called on the first successful run, and never
undone: a reader who breaks the setting afterwards keeps the thing that
was already working."
[node id]
(when-let [body (fedoras.reader/q node ".widget-body")]
(.add (.-classList body) "widget-body-shown"))
(swap! running conj id))
(defn state!
"Write the plate in the header."
[node text]
(fedoras.reader/text! node ".widget-state" text))
(defn kept
"What this widget left here last time, or nil."
[k]
(fedoras.reader/kept k))
(defn keep!
"Leave it here. Whether it also travels is the reader's business and
not this widget's."
[k v]
(fedoras.reader/keep! k v))
(defn did!
"Award the reader a point of fortune, once, silently.
A widget calls this when somebody has actually done the thing —
finished the conversation, raked the garden, written on the card. It
is a set, so it cannot be given twice, and nothing anywhere tells the
reader it happened.
Keep the keys in fedoras.endings.standing/deeds so there is one place
to see how much fortune a reader can actually get. Six to ten is the
ceiling: past that the modifier swamps the die."
[k]
(fedoras.reader/did! k))
(defn q!
"An element inside a widget, or a complaint in the header.
`fedoras.reader/q` returns nil for a selector that matches nothing,
which then fails as a null dereference three functions away from the
typo that caused it. This says which selector was wrong, in the one
place a reader is already looking."
[node selector]
(or (fedoras.reader/q node selector)
(do (state! node (str "missing: " selector)) nil)))
(defn wire!
"One control, wired in isolation.
Widgets used to set up every handler in one function, so the first
thing that threw took every handler after it with it, silently. Each
call here stands alone and says so if it fails."
[node what f]
(try
(f node)
(catch :default e
(js/console.error (str "widget: " what " did not wire") e)
(state! node (str what " did not wire")))))
(defn evaluate
"Evaluate ClojureScript source, in the page, and hand back what it
returned. This is Scittle evaluating the same way it evaluates the
book's own script tags, so what a reader types is not a lesser kind of
code than what shipped."
[source]
(if-let [core (some-> (.-scittle js/window) (.-core))]
(.eval_string core source)
(throw (js/Error. "the ClojureScript runtime is not on this page"))))
(defn- say!
[node message ok?]
(when-let [line (fedoras.reader/q node ".widget-settings-said")]
(set! (.-textContent line) message)
(.toggle (.-classList line) "widget-settings-broke" (not ok?))))
(defn reveal!
"Show the widget's body. Called on the first successful run, and never
undone: a reader who breaks the settings afterwards keeps the thing
that was already working."
[node id]
(when-let [body (fedoras.reader/q node ".widget-body")]
(.add (.-classList body) "widget-body-shown"))
(swap! running conj id))
(defn run-settings!
"Evaluate the panel and hand the result to `f`. A reader who breaks it
gets the message the runtime gave, under the box, and the widget goes
on running on the last settings that worked."
[node id f]
(when-let [box (fedoras.reader/q node ".widget-settings")]
(try
(let [value (evaluate (.-value box))]
(if (map? value)
(do (reveal! node id)
(f value)
(say! node (str "Running on " (count value) " settings.") true)
(fedoras.reader/enable! node ".widget-settings-run" true "Run again"))
(say! node "That did not come out as a map, so nothing was changed." false)))
(catch :default e
(say! node (str "It says: " (.-message e)) false)))))
(defn settings!
"Wire the source panel. Nothing runs until the reader presses Run,
which is the whole point of putting the file above the program."
[node id f]
(when-let [box (fedoras.reader/q node ".widget-settings")]
(swap! shipped assoc id (.-value box))
(fedoras.reader/on-click! node ".widget-settings-run"
(fn [_] (run-settings! node id f)))
(fedoras.reader/on-click! node ".widget-settings-reset"
(fn [_]
(set! (.-value box) (get @shipped id))
(run-settings! node id f)
(say! node "Put back as it shipped, and run again." true)))))
(defn mount!
"Register a widget. `setup!` runs once for a given element, `paint!`
runs on every render once the widget is running.
The node is checked because `getElementById` returns the first match in
the document, so a stale cell earlier in a chapter carrying the same id
silently hands a widget somebody else's element. Every selector inside
it then misses, nothing throws, and the widget reads as a Run button
that does nothing."
[k id setup! paint!]
(fedoras.reader/widget
k id
(fn [node]
(if-not (.contains (.-classList node) "widget")
(js/console.error "widget:" id "is not a widget element. Another"
"element on this page claims that id." node)
(do
(when-not (contains? @mounted [k id])
(swap! mounted conj [k id])
(when-not (fedoras.reader/q node ".widget-settings")
(reveal! node id))
(setup! node))
(when (running? id) (paint! node)))))))
(defn download!
"Hand the reader a file. Theirs, on their disc, out of the book."
[text filename]
(let [blob (js/Blob. #js [text] #js {:type "text/plain;charset=utf-8"})
url (js/URL.createObjectURL blob)
anchor (.createElement js/document "a")]
(set! (.-href anchor) url)
(set! (.-download anchor) filename)
(.click anchor)
(.revokeObjectURL js/URL url)))
(defn html!
"Replace the contents of one element inside a widget. Silent if it is
not there, because a renderer runs on every chapter and most chapters
do not have most widgets."
[node selector markup]
(when-let [target (fedoras.reader/q node selector)]
(set! (.-innerHTML target) markup)))
(defn grid!
"Repaint a grid: the column count as a custom property, the cells as
markup. Two lines that every grid widget in the book was writing out."
[node selector cols markup]
(when-let [target (fedoras.reader/q node selector)]
(.setProperty (.-style target) "--cols" (str cols))
(set! (.-innerHTML target) markup)))
(defn data
"Integer data attributes off the nearest element matching `selector`,
or nil if the event did not land on one.
(data event \".floor-room\" \"r\" \"c\") => [3 7]
This is how a grid gets its clicks: one handler on the container that
reads the coordinates back off the element, rather than a handler per
cell that has to be rewired every time the grid is repainted."
[event selector & names]
(when-let [element (.closest (.-target event) selector)]
(mapv (fn [n] (js/parseInt (.getAttribute element (str "data-" n)) 10))
names)))
(defn delegate!
"One click handler on a container, resolving to whichever child was
hit. `f` is called with the event and the vector `data` returned."
[node container-selector child-selector names f]
(when-let [container (fedoras.reader/q node container-selector)]
(set! (.-onclick container)
(fn [event]
(when-let [found (apply data event child-selector names)]
(f event found))))))
(defn keys!
"Wire a keymap onto the widget itself. The shell carries the tabindex,
so the keys work when the widget has focus and nowhere else, and the
page still scrolls when it does not."
[node keymap f]
(set! (.-onkeydown node)
(fn [event]
(when-let [what (get keymap (.-key event))]
(.preventDefault event)
(f event what)))))</script><script type="application/x-scittle">(ns fedoras.ui.legend
"The key, built in the page.
Both boards build their own key at runtime rather than taking a
rendered one from the JVM, because Breakout's changes the moment
somebody catches a bad drop. So the renderer lives here and both of
them call it with data.
An entry is {:svg or :letter, :label, :note, :tone}. Nothing here
knows what any of the tones look like — that is the stylesheet's
business and it is the reason a board never contains a hex code."
(:require [clojure.string :as str]))
(defn- glyph [{:keys [svg letter tone] :or {tone :ink}}]
(str "<span class='legend-glyph tone-" (name tone) "'>"
(if svg
(str "<svg viewBox='0 0 24 24'>" svg "</svg>")
(str "<span class='legend-letter'>" letter "</span>"))
"</span>"))
(defn html
"The whole key as one string, ready for innerHTML."
[entries]
(str/join
""
(for [{:keys [label note] :as e} entries]
(str "<div class='legend-item'>"
(glyph e)
"<span class='legend-label'>" label "</span>"
"<span class='legend-note'>" (or note "") "</span>"
"</div>"))))
(defn paint!
"Put a key into an element. `cols` is how many across.
(legend/paint! (reader/q node \".floor-legend\") entries 2)"
([el entries] (paint! el entries 2))
([el entries cols]
(when el
(set! (.-className el) "legend")
(.setProperty (.-style el) "--legend-cols" (str cols))
(set! (.-innerHTML el) (html entries)))))
(defn tone-class
"For the board itself, so a box on the grid is the same brown as the
box in the key."
[tone]
(str "tone-" (name (or tone :ink))))</script><style>/* ---------------------------------------------------------------
BRICKS.CLJS
Three colours. The ink most of it is drawn in, the grey a brick
turns when it cannot be broken, and the red a drop turns when it is
worse than nothing.
The canvas reads all three off its own resolved custom properties at
draw time, so the scheme toggle repaints instead of leaving the wall
in the other scheme's colours, and nothing in the ClojureScript
contains a colour.
--------------------------------------------------------------- */
.widget-breakout {
max-width: 34rem;
--brk-ink: var(--ink);
--brk-fixed: #7d7a74;
--brk-bad: #c0453a;
}
body.quarto-light .widget-breakout {
--brk-fixed: #9d9992;
--brk-bad: #c0392b;
}
.brk-canvas {
display: block;
width: 100%;
aspect-ratio: 100 / 70;
margin: 0.6rem 0 0.5rem 0;
background: rgba(var(--ink-rgb), 0.05);
border: 1px solid var(--rule);
border-radius: 3px;
cursor: none;
touch-action: none;
}
.brk-counts {
margin: 0.1rem 0 !important;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
opacity: 0.8;
min-height: 1.2em;
}
/* ---------------------------------------------------------------
The key. Not a palette — there is nothing to choose. Which one you
get is not his decision and that is the whole of why they are in the
wall in the first place.
--------------------------------------------------------------- */
.brk-key {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.1rem 1.1rem;
margin: 0.35rem 0 0.3rem 0;
}
.brk-drop {
display: grid;
grid-template-columns: 1.15rem 3.4rem minmax(0, 1fr);
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
line-height: 1.6;
color: var(--brk-ink);
}
.brk-drop .legend-glyph {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.15rem;
height: 1.15rem;
border: 1px solid currentColor;
flex: none;
}
.brk-letter { font-size: 0.62rem; font-weight: 600; line-height: 1; }
.brk-drop .legend-label { white-space: nowrap; }
.brk-drop .legend-note { opacity: 0.5; font-style: italic; }
/* worse than nothing: red, and dashed, because somebody is reading
this on paper and somebody else cannot see the red at all */
.brk-bad { color: var(--brk-bad); }
.brk-bad .legend-glyph { border-style: dashed; }
.brk-bad .legend-note { opacity: 0.75; }
@media (max-width: 34rem) {
.brk-key { grid-template-columns: 1fr; }
}</style><script type="application/x-scittle">(ns fedoras.cs-class.breakout.breakout-ui
"The wall, running.
THIRTY BRICKS, three rows of ten, and the arithmetic that finds which
one a ball is in is four lines and is the whole of the wall.
THE FIXED ONES ACCUMULATE. Each wall keeps every brick that was fixed
in the last one and adds another. Wall five has five. Wall twenty has
twenty. Wall thirty has thirty and there is nothing on it to hit.
A wall counts as cleared when nothing breakable is left, because
otherwise it never would be, and the readout says how many are
permanent so a reader can watch the number climb.
THE BALL CANNOT GET STUCK. A held ball releases on a click, on a
mousedown, on the space bar, and by itself after two seconds. Four
ways out of one state, because a state a player cannot leave is not a
feature and was the only bug anybody ever reported about this."
(:require [clojure.string :as str]))
;; ---------------------------------------------------------------------
;; The board
;; ---------------------------------------------------------------------
(def W 100)
(def H 70)
(def ROWS 3)
(def COLS 10)
(def BW 10)
(def BH 6)
(def WALL-BOTTOM 52)
(defonce st (atom nil))
(defonce known (atom #{}))
(def settings
(atom {:drop-chance 0.22 :effect 600 :serve 120
:fixed-first 1 :fixed-each 1
:speed 1.0 :bat 10 :mark :always}))
;; ---------------------------------------------------------------------
;; What is in the wall
;;
;; Kind, letter, whether it is worse than nothing, and what it does.
;; Who asked for which is in the scene, which is where it belongs.
;; ---------------------------------------------------------------------
(def drops
[{:kind :wide :letter "W" :bad? false :is "a bigger bat, for a while"}
{:kind :multi :letter "M" :bad? false :is "three of it"}
{:kind :pierce :letter "P" :bad? false :is "three bricks that do not turn it"}
{:kind :catch :letter "C" :bad? false :is "the bat holds it so you can aim"}
{:kind :slow :letter "S" :bad? false :is "a slower ball, for a while"}
{:kind :narrow :letter "N" :bad? true :is "a smaller bat, for a while"}
{:kind :hurry :letter "H" :bad? true :is "a faster ball, for a while"}])
(def by-kind (into {} (map (fn [d] [(:kind d) d])) drops))
;; ---------------------------------------------------------------------
;; What the reader has found out the hard way
;; ---------------------------------------------------------------------
(defn- learn! [kind]
(let [ks (conj @known kind)]
(reset! known ks)
(fedoras.reader/keep! :brk-known (mapv name ks))))
(defn- bad-now? [kind]
(boolean
(and (:bad? (by-kind kind))
(case (:mark @settings)
:always true
:never false
(contains? @known kind)))))
;; ---------------------------------------------------------------------
;; The wall
;; ---------------------------------------------------------------------
(defn- lay-wall
"Thirty bricks, and the ones that were permanent last time are still
permanent, and one more has joined them."
[fixed]
(into {} (for [r (range ROWS) c (range COLS)]
[[r c] (if (contains? fixed [r c]) :fixed :plain)])))
(defn- add-fixed
"Pick another brick to make permanent, out of the ones that are not."
[fixed n]
(let [free (vec (for [r (range ROWS) c (range COLS)
:when (not (contains? fixed [r c]))]
[r c]))]
(reduce (fn [acc _]
(let [left (vec (remove acc free))]
(if (seq left)
(conj acc (nth left (int (* (js/Math.random) (count left)))))
acc)))
fixed (range n))))
(defn- fresh []
(let [fixed (add-fixed #{} (:fixed-first @settings))]
{:wall (lay-wall fixed)
:fixed fixed
:bat 50
:balls [{:p [50 6] :v [0.8 0.9] :held (:serve @settings) :pierce 0}]
:drops []
:fx {}
:tally {:broken 0 :lost 0 :walls 1}
:frame 0}))
(defn- next-wall [s]
(let [fixed (add-fixed (:fixed s) (:fixed-each @settings))]
(-> s
(assoc :fixed fixed :wall (lay-wall fixed))
(update-in [:tally :walls] inc))))
(defn- breakable [s]
(count (filter (fn [[_ k]] (= k :plain)) (:wall s))))
;; ---------------------------------------------------------------------
;; Effects
;; ---------------------------------------------------------------------
(defn- on? [s k] (pos? (get-in s [:fx k] 0)))
(defn- bat-half
"Ten, six more for a wide one, four fewer for the other kind, and
never less than five, because a bat you cannot hit anything with is
not a bat."
[s]
(max 5 (+ (:bat @settings) (if (on? s :wide) 6 0) (if (on? s :narrow) -4 0))))
(defn- speed [s]
(* (:speed @settings)
(cond (on? s :slow) 0.72 (on? s :hurry) 1.35 :else 1.0)))
(defn- tick-fx [s]
(update s :fx (fn [m] (into {} (keep (fn [[k v]] (when (> v 1) [k (dec v)])) m)))))
;; ---------------------------------------------------------------------
;; Bricks
;; ---------------------------------------------------------------------
(defn- brick-at [s x y]
(when (and (>= x 0) (< x W) (>= y WALL-BOTTOM) (< y (+ WALL-BOTTOM (* ROWS BH))))
(let [c (int (/ x BW)) r (int (/ (- y WALL-BOTTOM) BH))]
(when (get (:wall s) [r c]) [r c]))))
(defn- centre [[r c]]
[(+ (* c BW) (/ BW 2)) (+ WALL-BOTTOM (* r BH) (/ BH 2))])
(defn- break-brick
"A permanent brick turns the ball and stays where it is. Nothing else
happens and nothing is recorded, which is the point of it."
[s brick]
(if (= :fixed (get (:wall s) brick))
s
(cond-> (-> s (update :wall dissoc brick) (update-in [:tally :broken] inc))
(< (js/Math.random) (:drop-chance @settings))
(update :drops conj
{:p (centre brick)
:kind (:kind (nth drops (int (* (js/Math.random) (count drops)))))}))))
;; ---------------------------------------------------------------------
;; One ball
;;
;; The horizontal and the vertical are tested separately, so a ball that
;; comes in at the side of a brick turns sideways and one that comes in
;; underneath turns over, which is the difference between a game and a
;; thing that occasionally does what you expected.
;; ---------------------------------------------------------------------
(defn- move-ball [s {[x y] :p [dx dy] :v held :held pierce :pierce :as b}]
(if (and held (pos? held))
[s (assoc b :p [(:bat s) 6] :held (dec held))]
(let [k (speed s)
nx (+ x (* dx k)) ny (+ y (* dy k))
half (bat-half s)
hx (brick-at s nx y) hy (brick-at s x ny) hd (brick-at s nx ny)]
(cond
(or hx hy hd)
(let [brick (or hy hx hd)
hard (= :fixed (get (:wall s) brick))
s' (break-brick s brick)]
(if (and (pos? (or pierce 0)) (not hard))
[s' (assoc b :p [nx ny] :pierce (dec pierce))]
[s' (assoc b :p [nx ny]
:v (cond hy [dx (- dy)] hx [(- dx) dy] :else [dx (- dy)]))]))
(or (< nx 0) (> nx W))
[s (assoc b :p [(max 0 (min W nx)) ny] :v [(- dx) dy])]
(> ny H) [s (assoc b :p [nx H] :v [dx (- dy)])]
;; the bat. where it lands decides where it goes, which was the
;; first thing anybody asked for and the only one that helped.
(and (< ny 4) (neg? dy) (< (js/Math.abs (- nx (:bat s))) half))
(let [off (/ (- nx (:bat s)) half)
ndx (max -1.4 (min 1.4 (+ dx (* 0.9 off))))]
[s (if (on? s :catch)
(assoc b :p [nx 6] :v [ndx (js/Math.abs dy)] :held 240)
(assoc b :p [nx 4] :v [ndx (js/Math.abs dy)]))])
(< ny 0) [s (assoc b :dead true)]
:else [s (assoc b :p [nx ny])]))))
(defn- move-balls [s]
(let [[s' bs] (reduce (fn [[acc out] b]
(let [[acc' b'] (move-ball acc b)]
[acc' (conj out b')]))
[s []] (:balls s))
alive (vec (remove :dead bs))]
(if (seq alive)
(assoc s' :balls alive)
(-> s'
(update-in [:tally :lost] inc)
(assoc :balls [{:p [(:bat s') 6] :v [0.8 0.9]
:held (:serve @settings) :pierce 0}])
(assoc :drops [])))))
;; ---------------------------------------------------------------------
;; The drops
;; ---------------------------------------------------------------------
(defn- apply-drop [s kind]
(when (:bad? (by-kind kind)) (learn! kind))
(case kind
:multi (update s :balls
(fn [bs]
(if (>= (count bs) 5)
bs
(vec (mapcat (fn [{[dx dy] :v :as b}]
[b (assoc b :v [(- dx) dy] :held nil)
(assoc b :v [dy dx] :held nil)])
bs)))))
:pierce (update s :balls (fn [bs] (mapv (fn [b] (assoc b :pierce 3)) bs)))
(assoc-in s [:fx kind] (:effect @settings))))
(defn- move-drops [s]
(let [half (bat-half s)]
(reduce
(fn [acc {[x y] :p kind :kind :as d}]
(let [ny (- y 0.55)]
(cond
;; the bat is the only thing on this board that catches
;; anything and it cannot be selective
(and (< ny 4) (< (js/Math.abs (- x (:bat acc))) half))
(apply-drop acc kind)
(< ny 0) acc
:else (update acc :drops conj (assoc d :p [x ny])))))
(assoc s :drops [])
(:drops s))))
(defn tick [s]
(let [s (-> s (update :frame inc) tick-fx move-balls move-drops)]
(if (zero? (breakable s)) (next-wall s) s)))
(defn serve! []
(swap! st update :balls (fn [bs] (mapv (fn [b] (assoc b :held nil)) bs))))
;; ---------------------------------------------------------------------
;; Drawing
;;
;; The ink comes off the canvas rather than out of this file, so the
;; palette is a stylesheet decision and the scheme toggle repaints.
;; ---------------------------------------------------------------------
(defn- css-var [node nm fallback]
(or (try (let [v (.getPropertyValue (js/getComputedStyle node) nm)]
(when-not (str/blank? v) (str/trim v)))
(catch :default _ nil))
fallback))
(defn draw! [node cv ctx]
(let [s @st
w (.-width cv) h (.-height cv)
sx (/ w W) sy (/ h H)
ink (css-var node "--brk-ink" "#e6e4df")
hard (css-var node "--brk-fixed" "#7d7a74")
warn (css-var node "--brk-bad" "#c0453a")
px (fn [x] (* x sx))
py (fn [y] (* (- H y) sy))]
(.clearRect ctx 0 0 w h)
(set! (.-lineWidth ctx) 1)
(doseq [[[r c] kind] (:wall s)]
(let [x1 (px (+ (* c BW) 0.4))
y1 (py (+ WALL-BOTTOM (* r BH) BH -0.4))
bw (* (- BW 0.8) sx) bh (* (- BH 0.8) sy)]
(if (= kind :fixed)
;; permanent bricks are filled, because a thing you cannot
;; take out should not look like a thing you can
(do (set! (.-fillStyle ctx) hard)
(.fillRect ctx x1 y1 bw bh))
(do (set! (.-strokeStyle ctx) ink)
(.strokeRect ctx x1 y1 bw bh)))))
(set! (.-strokeStyle ctx) ink)
(set! (.-fillStyle ctx) ink)
(doseq [{[x y] :p pierce :pierce} (:balls s)]
(if (pos? (or pierce 0))
(.strokeRect ctx (px (- x 1.3)) (py (+ y 1.3)) (* 2.6 sx) (* 2.6 sy))
(.fillRect ctx (px (- x 1)) (py (+ y 1)) (* 2 sx) (* 2 sy))))
(set! (.-font ctx) "bold 9px sans-serif")
(set! (.-textAlign ctx) "center")
(doseq [{[x y] :p kind :kind} (:drops s)]
(let [bad (bad-now? kind) col (if bad warn ink)]
(set! (.-strokeStyle ctx) col)
(set! (.-fillStyle ctx) col)
(.setLineDash ctx (if bad #js [3 2] #js []))
(.strokeRect ctx (px (- x 2.2)) (py (+ y 2)) (* 4.4 sx) (* 4 sy))
(.setLineDash ctx #js [])
(.fillText ctx (:letter (by-kind kind)) (px x) (+ (py y) 3))))
;; put it back, or the bat comes out red
(set! (.-strokeStyle ctx) ink)
(set! (.-fillStyle ctx) ink)
(let [half (bat-half s)]
(.fillRect ctx (px (- (:bat s) half)) (py 2.6) (* (* 2 half) sx) (* 2.2 sy)))))
;; ---------------------------------------------------------------------
;; The key
;; ---------------------------------------------------------------------
(defn- key-html []
(str/join ""
(for [{:keys [kind letter is]} drops]
(str "<div class='legend-item brk-drop" (when (bad-now? kind) " brk-bad") "'>"
"<span class='legend-glyph'><span class='brk-letter'>" letter "</span></span>"
"<span class='legend-label'>" (name kind) "</span>"
"<span class='legend-note'>" is "</span></div>"))))
(defn paint! [node]
(let [s @st
{:keys [broken lost walls]} (:tally s)
fx (->> (:fx s) keys (map (fn [k] (if (bad-now? k) (str (name k) "!") (name k))))
sort (str/join ", "))]
(fedoras.ui.widget-ui/html! node ".brk-key" (key-html))
(fedoras.ui.widget-ui/state!
node
(str broken " broken · " lost " lost · " (count (:balls s)) " in play"
(when (seq fx) (str " · " fx))))
(fedoras.reader/text!
node ".brk-counts"
(str "wall " walls " · " (breakable s) " to break · "
(count (:fixed s)) " that cannot be"
(when (zero? (breakable s)) " · there is nothing left to hit")))))
;; ---------------------------------------------------------------------
;; Hands
;; ---------------------------------------------------------------------
(defn- wire-canvas! [node]
(when-let [cv (fedoras.ui.widget-ui/q! node ".brk-canvas")]
(set! (.-onmousemove cv)
(fn [e] (swap! st assoc :bat (* W (/ (.-offsetX e) (.-clientWidth cv))))))
;; four ways out of one state
(set! (.-onmousedown cv) (fn [e] (.preventDefault e) (serve!)))
(set! (.-onclick cv) (fn [_] (serve!)))))
(defn- wire-keys! [node]
(fedoras.ui.widget-ui/keys!
node {" " :serve "r" :again "ArrowLeft" :left "ArrowRight" :right}
(fn [_ what]
(case what
:serve (serve!)
:again (reset! st (fresh))
:left (swap! st update :bat (fn [b] (max 0 (- b 4))))
:right (swap! st update :bat (fn [b] (min W (+ b 4))))))))
(defn- wire-controls! [node]
(fedoras.reader/on-click! node ".brk-again" (fn [_] (reset! st (fresh)) (paint! node))))
(defn- wire-settings! [node id]
(fedoras.ui.widget-ui/settings!
node id
(fn [setting]
(swap! settings
(fn [current]
(merge current
(select-keys setting
[:drop-chance :effect :serve :fixed-first
:fixed-each :speed :bat :mark]))))
(paint! node))))
(defn setup! [node]
(let [id (.-id node)
cv (fedoras.reader/q node ".brk-canvas")
ctx (.getContext cv "2d")]
(reset! known (set (map keyword (or (fedoras.ui.widget-ui/kept :brk-known) []))))
(reset! st (fresh))
(fedoras.ui.widget-ui/wire! node "canvas" wire-canvas!)
(fedoras.ui.widget-ui/wire! node "keys" wire-keys!)
(fedoras.ui.widget-ui/wire! node "controls" wire-controls!)
(fedoras.ui.widget-ui/wire! node "settings" (fn [n] (wire-settings! n id)))
(letfn [(frame []
(swap! st tick)
(draw! node cv ctx)
(when (zero? (mod (:frame @st) 15)) (paint! node))
(js/requestAnimationFrame frame))]
(frame))))
(defn mount! [{:keys [id]}]
(fedoras.ui.widget-ui/mount! ::brk (or id "brk") setup! paint!))</script><div class="widget widget-breakout" id="brk" tabindex="0"><div class="roll-head"><span class="roll-label">BRICKS.CLJS · move to aim · click or space to serve</span><span class="widget-state">press Run</span></div><div class="widget-source"><div class="widget-source-head"><span class="widget-source-name">breakout_settings.cljs</span><span class="widget-source-lang">cljs</span></div><textarea class="widget-settings" rows="20" spellcheck="false">;; ---------------------------------------------------------------------
;; BREAKOUT_SETTINGS.CLJS — what the wall is set to.
;;
;; Change a number and press Run. Break it and the line underneath will
;; tell you what it thought you meant. Nothing you do here can lose the
;; original: `Put it back` is the file as it shipped.
;;
;; WHAT EACH DROP DOES IS NOT IN HERE. That is a table at the top of
;; breakout_ui.cljs, one row each, because what a thing does is a fact
;; about the thing and not a setting. How often they turn up is a
;; setting, and it is the first line below.
;; ---------------------------------------------------------------------
{;; How likely a brick is to have had something in it. 0 and the wall is
;; just a wall. 1 and every brick you break gives you something, and
;; two things in seven are worse than nothing, so it gets harder rather
;; than easier, which is worth finding out on purpose.
:drop-chance 0.22
;; How long an effect lasts, in frames. Sixty is a second.
:effect 600
;; How long the bat holds a ball before it lets go by itself. There are
;; three other ways out of that state and this is the one that does not
;; need anybody.
:serve 120
;; THE PERMANENT ONES.
;;
;; :fixed-first is how many bricks in the first wall cannot be broken.
;; :fixed-each is how many more join them every time the wall comes
;; back — and the ones from last time are still there.
;;
;; At 1 and 1 the thirtieth wall is solid and there is nothing left to
;; hit. At 0 and 0 it is the game as he handed it in. At 1 and 3 you
;; will see the end of it in about ten minutes.
;;
;; Nothing anywhere calls this losing.
:fixed-first 1
:fixed-each 1
;; The ball, and the bat, before anything in the wall has had an
;; opinion about either.
:speed 1.0
:bat 10
;; :always, :learned or :never.
;;
;; :learned is the one he wrote. A drop is unmarked until you have
;; caught one, and red for ever after — he did not mark them, you did.
;; It is not the default because the key is the one place a player is
;; entitled to be told things.
:mark :always}</textarea><div class="live-bar"><button class="roll-go widget-settings-run">Run</button><button class="roll-rewrite widget-settings-reset">Put it back</button></div><p class="widget-settings-said">Press Run.</p></div><div class="widget-body"><canvas class="brk-canvas" height="280" width="400"></canvas><p class="brk-counts"></p><div class="brk-key"></div><div class="live-bar"><button class="roll-rewrite brk-again">Start over (r)</button></div></div><p class="roll-foot">No brick in here knows about any other brick.</p></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.cs-class.breakout.breakout-ui/mount!))] (mount {:id "brk"}) (js/console.error "fedoras: fedoras.cs-class.breakout.breakout-ui/mount! is not on this page. Either the file that declares it was never shipped, or the namespace inside it does not match the path it was shipped from."))</script></div><div class="screenplay"><p class="action">It has a wide bat and three balls and you can hold it and aim it and the wall comes back and there is a running count of everything, and every one of those was asked for by somebody who plays it.</p><p class="action">It is not a game.</p><p class="action">He cannot say which one did it. He has gone back through them and taken them out one at a time and put each one back, and there is no single one of them that is the problem, and every one of them was reasonable, and the list has never once got shorter.</p></div><div class="artifact discord"><div class="discord-head"><span class="discord-server">FEDORA SOCIETY</span><span class="discord-channel">#general</span></div><div class="discord-body"><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">6 Feb, 7:04 PM</span></p><div class="discord-text"><p><span>film in 3c. its the rooms one again</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">6 Feb, 7:22 PM</span></p><div class="discord-text"><p><span>wheres the ranger</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">6 Feb, 7:40 PM</span></p><div class="discord-text"><p><span>no idea</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">6 Feb, 9:15 PM</span></p><div class="discord-text"><p><span>hes fine. hes in his room</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">6 Feb, 9:16 PM</span></p><div class="discord-text"><p><span>how do you know</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">6 Feb, 9:31 PM</span></p><div class="discord-text"><p><span>because i went and looked</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">6 Feb, 11:58 PM</span></p><div class="discord-text"><p><span>Attendance Friday four of five.</span></p><p><span>Absence is given before the day, in words. That is Article</span></p><p><span>Seven and it has not been amended.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">6 Feb, 11:59 PM</span></p><div class="discord-text"><p><span>Nothing further.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">7 Feb, 1:14 AM</span></p><div class="discord-text"><p><span>random question does anyone else get nosebleeds</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">7 Feb, 2:40 AM</span></p><div class="discord-text"><p><span>ignore that</span></p></div></div></div></div><div class="screenplay"><p class="action">Nobody answers Simon, because it is a quarter past one in the morning, and because it is a question about a nosebleed.</p></div><div class="screenplay"><p class="slug">INT. PRENTISS SCIENCE CENTER — THE BENCH ROOM — 11:00 P.M.</p></div><div class="screenplay"><p class="action">MAYA has a run on and a column of the same number going back to six o'clock.</p><p class="action">She looks up when he comes in and then looks at him for about a second longer than looking up takes.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">How many is that now?</p></div></div></div><div class="screenplay"><p class="action beat">He had not decided whether he was going to tell her and now does not have to.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Five.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Since when?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Since the assessment.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Since January.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Yes.</p></div></div></div><div class="screenplay"><p class="action">She writes the flask reading down, and the time next to it, and then turns to a clean page at the back of the notebook and writes something that is not a flask reading.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Tonight's one. Time and length.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Thirteen minutes.</p></div></div></div><div class="screenplay"><p class="action beat">Her hand stops.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Thirteen.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">It was seconds in September.</p></div></div></div><div class="screenplay"><p class="action">She writes it down. She does not say any of the four things that a person says here, and afterwards he will think that was the single kindest thing anybody did to him all year.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">What else.</p></div></div></div><div class="screenplay"><p class="action">He tells her all of it, in about four minutes, badly, and she does not interrupt once.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Twelve cards in one drawer say a building that</p><p class="line">isn't on the map, and every one of them says held.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Yes.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">And two of your five were in that basement, and</p><p class="line">you've worked that out and you've been sitting on</p><p class="line">it.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Yes.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Then going down there tonight is the stupidest</p><p class="line">available thing and I want that in the book before</p><p class="line">I get my coat.</p></div></div></div><div class="screenplay"><p class="action">She writes that down too, and dates it, and gets her coat.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. HALLORAN UNION — THE PRINT SHOP — 11:20 P.M.</p></div><div class="screenplay"><p class="action">LENA is trimming a run with a steel rule and a scalpel, because the machine that does it lives behind a door that shuts at ten.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Is there a way back up that isn't the way down?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">I don't know.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">No. All right.</p></div></div></div><div class="screenplay"><p class="action">She puts the scalpel in her pocket, and the rule, and turns the light off with her elbow.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Three institutions. I've never been under one.</p></div></div></div><div class="screenplay"><p class="action beat">That is the only reason she gives and it is the true one.</p><p class="action">At the door she stops.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">When it happens down there. What do you want done?</p></div></div></div><div class="screenplay"><p class="action beat">Nobody has asked him that.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Nothing. Wait for it.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Right.</p></div></div></div><div class="screenplay"><p class="action">She does not ask him anything else about it, that night or at any point afterwards, and it is the reason he tells her things first for the rest of the year.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">EXT. THE QUAD — 11:30 P.M.</p></div><div class="screenplay"><p class="action">ELI is coming back from the Guild with a tube of posters under his arm and hears about forty seconds of it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Is it on the list?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">No.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Then yes.</p></div></div></div><div class="screenplay"><p class="action beat">He puts the tube down against a bin and leaves it there.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">I'd like to see a thing first and judge it</p><p class="line">afterwards.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">That's a thing people say.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">It is. I've been waiting two terms to say it to</p><p class="line">somebody.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">EXT. MERIDIAN HALL — 11:44 P.M.</p></div><div class="screenplay"><p class="action">Four of them at a door with one torch and a ring of keys that is not his.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> opens the nomination field, which he has not looked at since the gymnasium in August.</p><p class="action beat">It takes one name.</p><p class="action">He looks at it for a moment and closes it, and unlocks the door, and holds it for the other three.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div></div>
```