---
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 5 — The Boy Who Isn't On Anything
```{=html}
<span style="display:none;"></span>
```
```{=html}
<blockquote class="handbook"><p class="rule-number">The Fedora Handbook, Rule #28</p><p class="rule-text">“”</p></blockquote>
```
```{=html}
<div class="scene"><div class="screenplay"><p class="slug">INT. UNDER MERIDIAN — BETWEEN TWO BAYS — SATURDAY 7 FEBRUARY, 12:34 A.M.</p></div><div class="screenplay"><p class="action">A space the width of a person, between two bays, that is not a space from the front.</p><p class="action">Four of them in it, and something with hold of <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>'s coat.</p><p class="action beat">The light goes past.</p><p class="action">It takes four minutes and nobody moves for any of them.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> puts his hand on the hand that has hold of him, because that is what you do, and it is not warm.</p><p class="action beat">He does not say anything about that.</p><p class="action">It lets go.</p></div><div class="screenplay"><div class="dialogue"><p class="character">A VOICE (O.S.)</p><div class="lines"><p class="line">Left. Then left.</p></div></div></div><div class="screenplay"><p class="action beat">It comes from about knee height and it is not coming from a person.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Who's that?</p></div></div><div class="dialogue"><p class="character">A VOICE (O.S.)</p><div class="lines"><p class="line">Left. Then left. Mind the step.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. UNDER MERIDIAN — A CORRIDOR — CONTINUOUS</p></div><div class="screenplay"><p class="action">The bays end and there is a wall with a door in it, and the door is a door: hinges, a handle, a frame, and paint that somebody has kept up.</p><p class="action">Under the door there is a line of light and it is steady.</p><p class="action beat">Nothing they have seen tonight has been steady.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">That's mains.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Whose mains?</p></div></div></div><div class="screenplay"><p class="action beat">Nobody has an answer and the door is not locked.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. UNDER MERIDIAN — THE ROOM WITH THE LIGHT — CONTINUOUS</p></div><div class="screenplay"><p class="action">A long room with a bench down one side and a strip light over it that is on, and the light is white and does not flicker and has clearly been on for years.</p><p class="action">On the bench, three machines the size of cats. Tracked. Wheels, arms, a lens each. None of them is moving.</p><p class="action">At the end of the bench there is a screen with a board of keys in front of it.</p><p class="action beat">It is on.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">What is 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">It's a screen and a keyboard.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">That's a peripheral.</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">Why is it outside?</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">Outside of what?</p></div></div></div><div class="screenplay"><p class="action beat">Maya starts to answer and then does not, because she has worked out in the middle of the sentence that she was going to say outside of a person.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> has a tower and a screen in his room in Ashcroft and has had since August, and a quarterback once asked him where the screen was, and he is the only person in this room who is not looking at this thing as though it were an animal.</p><p class="action">He sits down at it, because there is a chair, and the chair is at the right height, and the chair has been at the right height for some time.</p><p class="action">There is one thing on the screen and it is waiting to be played.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Somebody left that on for us.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Somebody left it on.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">That's what I said.</p></div></div></div><div class="screenplay"><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> presses the key that plays it.</p></div><div class="screenplay"><p class="direction">The recording is below. Part one.</p></div><div class="artifact handout"><p class="handout-title">RECORDING · no header · no date · found playing</p><p><span>— a young man, seated, in a room with a strip light. Version four is written on the wall behind him in marker. —</span></p><p><span>If you're watching this, somebody let you get this far, and that somebody is me, and I'd like that understood at the start.</span></p><p><span>This is the fourth version. The first three were too long. One of them made somebody cry, which was not the intention and was also not useful.</span></p><p><span>I'm going to tell you what the ground under you is. Then I'm going to tell you what the building over you is for. Then you can decide whether you want to meet me, and if you don't, the way out is the way you came in, and nothing will stop you, and I mean nothing rather than nobody.</span></p><p><span>THE GROUND.</span></p><p><span>There was a library here. It was not the university's. It was here first and it was open to anybody who could get to the door.</span></p><p><span>It closed. Nobody burned it and nobody raided it. A committee sat for eleven years and checked everything written before the founding, and what came through is the list, and the list is the one on your instructor's door.</span></p><p><span>What did not come through was not destroyed. Destroying is work. It was left where it was, and the ground was put over it, and the university was built on top, because the site was flat and it was already level.</span></p><p><span>Here is the part I would like you to sit with.</span></p><p><span>They did not move the catalogue.</span></p><p><span>Every cabinet in Beckwith Hall is an index to the room you are standing in. Nobody took the cards out because the cards were not the problem. People put their bags on them.</span></p><p><span>LOCATION: OAKES — withdrawn means it went with the ground.</span></p><p><span>LOCATION: C-3 — held means it is under your feet, and it is still there, and somebody in an office once wrote the word held next to it, and held is an honest word, and it is the only honest word anywhere in the whole system.</span></p><p><span>End of part one. There's a key with an arrow on it.</span></p></div><div class="screenplay"><p class="slug">INT. THE ROOM WITH THE LIGHT — CONTINUOUS</p></div><div class="screenplay"><p class="action">Nobody says anything for a while.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">I've had my bag on one of those.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Everybody's had their bag on one of those.</p></div></div></div><div class="screenplay"><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> is not looking at the screen. He is looking at the three machines on the bench, which are all facing the same way, and which have all stopped in a line.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Play the rest of it.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Do we want the rest of it?</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">I've come down a hole in the floor at midnight.</p><p class="line">Yes.</p></div></div></div><div class="screenplay"><p class="direction">Part two.</p></div><div class="artifact handout"><p class="handout-title">RECORDING · part two</p><p><span>THE BUILDING. This is the part people argue with, so I'll go slowly.</span></p><p><span>You've been told this is a university. It admits one thousand and twenty-four every year, and it has admitted one thousand and twenty-four every year for longer than any record I can reach.</span></p><p><span>That isn't a count. A count is what you get when you finish counting. That's a target. Somebody writes it down in September and then the year is arranged until it's true.</span></p><p><span>Review is the arranging. Repossession is the arranging. Placement is the arranging. The docket is the arranging.</span></p><p><span>Nobody is being punished. I want that to be clear, because when people work half of this out they always land on cruelty, and cruelty would be a relief. Nobody down the corridor from you hates you. It's an allocation problem and the allocation is going well.</span></p><p><span>Now ask what it's being arranged FOR.</span></p><p><span>Everything anybody has ever thought is in the knowledge. All of it. Every hour of every person who has ever been saved, which is everybody, and has been everybody for longer than anybody will tell you.</span></p><p><span>A thing that contains everything cannot make anything.</span></p><p><span>It has no new. It can't get new by asking itself, because asking itself is the one operation it has already done. New only ever happens to a person, and only when a person is stuck.</span></p><p><span>So it manufactures stuck.</span></p><p><span>No Helps on these six. The supplement is open by subject and closed by every other subject. Your torch bulb takes a fortnight. Your instructor has one copy and you may not photograph it. Every shortage you have ever stood in was built, and it was built well, and it was built for you.</span></p><p><span>There's a woman upstairs who says that sooner or later something you need will be shut and there will have to be somebody in the room who can open it. She says it every year. She means it. She has no idea what she is for and I would be grateful if you didn't tell her.</span></p><p><span>The docket puts eleven unattached people in a room below the level of the quad, for an hour, once a week, and hands them six questions on a card so that they cannot say anything else.</span></p><p><span>Ask yourself why that room.</span></p><p><span>THE FLOOR is fourteen seasons of a woman at a desk and the programme is warm about her and the warmth is accurate. It isn't propaganda. Propaganda's cheaper. It's a recruitment film that thinks it's a comedy, and it is genuinely funny, and that is why it works.</span></p><p><span>Remote operations is the only part of the whole apparatus that isn't a lie. It's the best thing they do. They do it because attention, with nobody watching, is the most expensive thing there is, and it's the one thing they can't fake and can't take.</span></p><p><span>Repossessed students are placed. That's true, it isn't a euphemism, and it isn't the whole of it. I have had nine years and I have not got the rest of it, and I'd rather say that than make something up for you.</span></p><p><span>That's the end of version four.</span></p><p><span>If you want to argue: left, then left, and mind the step, and don't touch the plants.</span></p></div><div class="screenplay"><p class="slug">INT. UNDER MERIDIAN — THE LONG ROOM — 1:05 A.M.</p></div><div class="screenplay"><p class="action">Left, and then left, and there is a step, and the step is where he said it was.</p><p class="action">Then a room with light in it that is not like light.</p><p class="action">Eight lamps on a frame over four beds of plants, and the light is pink and it is very loud, in the way that too much of anything is loud.</p><p class="action">Cable comes in through a hole in the brick, in a bundle, wrapped, dressed properly, and clipped every few feet by somebody who cared about the clipping.</p><p class="action">A young man is standing under the lamps with his sleeves rolled and his hands in a tray.</p><p class="action">Tall. Narrow. Hair he cuts himself and a beard to match. A Plant Services coat in a pattern that has not been issued in decades, wet to the knee. He has been down here a long time and the room reeks of him and of the plants he tends to.</p></div><div class="screenplay"><div class="dialogue"><p class="character">THE YOUNG MAN</p><p class="paren">(without turning round)</p><div class="lines"><p class="line">Which one of you put the book back?</p></div></div></div><div class="screenplay"><p class="action beat">Nobody answers.</p></div><div class="screenplay"><div class="dialogue"><p class="character">THE YOUNG MAN</p><div class="lines"><p class="line">It wasn't the loud one.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">I'm the loud one.</p></div></div><div class="dialogue"><p class="character">THE YOUNG MAN</p><div class="lines"><p class="line">Yes.</p></div></div></div><div class="screenplay"><p class="action">He turns round and wipes his hands on the coat.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Lazlo.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Maya.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I know. He said it into a Help four feet from a</p><p class="line">grate and the grate is mine.</p></div></div></div><div class="screenplay"><p class="action beat">Something comes out from under the bench at speed.</p><p class="action">It is the size of a large mouse and it is tracked and it goes up Eli's leg and into the pocket of his coat.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><p class="paren">(very still)</p><div class="lines"><p class="line">There is a thing in my coat.</p></div></div><div class="dialogue"><p class="character">PIP (O.S.)</p><div class="lines"><p class="line">Two coins. A pencil. A folded card for a poster</p><p class="line">job on the eighteenth. Nothing sharp.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">It talks.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Constantly.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">What is it?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">That's Pip. Pip's mostly 'grown' more than</p><p class="line">'made'... buuuuut- -let's not do that conversation</p><p class="line">tonight.</p></div></div><div class="dialogue"><p class="character">PIP (O.S.)</p><div class="lines"><p class="line">He can't do it. He's tried four times.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Pip.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. THE LONG ROOM — CONTINUOUS</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">How long have you been down here?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Nine years.</p></div></div></div><div class="screenplay"><p class="action">Four people look at him. He is their age, possibly a year younger even Lena.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Yes. Next question.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">That isn't how that works.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">No. Next question anyway, because the answer takes</p><p class="line">an hour and I've got until your Helps come back</p><p class="line">on.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Why can't you go up?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I wouldn't save a second of the world you know.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Why?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">There's a day of mine that stopped nine years ago,</p><p class="line">mid-sentence, which hasn't got a day after it. If</p><p class="line">I walk up that stair it gets one. A nine-year gap</p><p class="line">closing is not a thing that happens. It's the</p><p class="line">brightest and loudest object on the campus, and</p><p class="line">it's got my name on it, and it'd take about four</p><p class="line">minutes.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Can't you take it out?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">No. Neither can you. No human that I know of has</p><p class="line">ever taken one out of anybody, it would be akin to</p><p class="line">removing a childhood.</p></div></div></div><div class="screenplay"><p class="action">He goes along the bench to the three stopped machines and turns one of them over.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Rovers. That one's mine, that one's mine, that one</p><p class="line">I got from somebody down the tunnel and I've never</p><p class="line">got it to do what he said it did.</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 are they all facing the same way?</p></div></div></div><div class="screenplay"><p class="action beat">Lazlo looks at him properly for the first time.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Because they all stopped at the same mark. Not</p><p class="line">near it. On it. Four inches of each other, three</p><p class="line">different builds, nine months apart.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Where's the mark?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">South. Past the flooded end. About two hundred</p><p class="line">feet, and it isn't a wall and it isn't water and</p><p class="line">there is nothing there to look at.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Everything with a chip in it stops there. A person</p><p class="line">wouldn't. But every person up there is carrying</p><p class="line">one in their head, so a person doesn't either.</p></div></div></div><div class="screenplay"><p class="action beat">Nobody says anything.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">That's the whole of my problem and I've had it for</p><p class="line">nine years and I've said it out loud four times</p><p class="line">and this is the fourth.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. THE LONG ROOM — 1:40 A.M.</p></div><div class="screenplay"><p class="action">There is a hatch at the far end with a draught coming out of it and it is not the same air as the room.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">What's down there?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Somebody like me, about a mile and a half north.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">You've got neighbours?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Four that I know of and I've met two. The trouble</p><p class="line">isn't the neighbours.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">What's the trouble?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">The tunnel isn't ours. We use it. Nobody made it</p><p class="line">and nobody keeps it and it isn't hidden from</p><p class="line">anything, which is a different thing from being</p><p class="line">hidden from people.</p></div></div></div><div class="screenplay"><p class="action beat">He does not elaborate and Pip does not say anything either, which is the first time all night.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I've lost eleven rovers in that tunnel and I've</p><p class="line">got four of them back and two of the four had been</p><p class="line">somewhere I didn't send them.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Is any of it Meridian?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Some of it. I don't know how much and I don't know</p><p class="line">where the edge is and I have stopped sending</p><p class="line">things out to find the edge, because that's how</p><p class="line">you tell somebody where you are.</p></div></div></div><div class="screenplay"><p class="action">H4RPER says something from a speaker on the wall that none of them can locate.</p></div><div class="screenplay"><div class="dialogue"><p class="character">H4RPER (O.S.)</p><div class="lines"><p class="line">Fifty minutes.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I know.</p></div></div><div class="dialogue"><p class="character">H4RPER (O.S.)</p><div class="lines"><p class="line">You said to say it at fifty and you said not to</p><p class="line">argue about it at fifty.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I know, Harper.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Is that a Help?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">No.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">It sounds like a Help.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">It sounds like me. It's a net and I grew it off my</p><p class="line">own and it has been wrong about things with me for</p><p class="line">nine years, which is not what yours does.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">What does ours do?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Yours is very good and it is in your head and you</p><p class="line">do know that.</p></div></div></div><div class="screenplay"><p class="action beat">Two of them say yes and one of them says nothing and one of them has never thought about it in that order.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. THE LONG ROOM — 2:10 A.M.</p></div><div class="screenplay"><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> takes the page out of the front of his bag. It has been folded into eight since August and the folds have gone through in two places.</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">Do you know what this is?</p></div></div></div><div class="screenplay"><p class="action">Lazlo takes it and opens it and does not look at the printed side.</p><p class="action beat">He looks at the pencil.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I wondered where that went.</p></div></div></div><div class="screenplay"><p class="action beat">Four 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">There's no page 219.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">No. There isn't.</p></div></div></div><div class="screenplay"><p class="action">He folds it along the same folds and gives it back.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Right. I want something and I'm going to ask for</p><p class="line">it badly, because I have not asked anybody for</p><p class="line">anything in nine years and I have got worse at it,</p><p class="line">not better.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I want the catalogue. All of it. Every drawer in</p><p class="line">every cabinet in that building, photographed, in</p><p class="line">order, front and back, because the back is where</p><p class="line">the locations are.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">How many cards?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I don't know. That's the point. I've got a room</p><p class="line">full of shelves and no way to know what's on them,</p><p class="line">and there's a room full of what's on them with</p><p class="line">people's coats on it.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">And then what?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">And then I know what's down here, and then for the</p><p class="line">first time in nine years I can look something up.</p></div></div></div><div class="screenplay"><p class="action beat">He says that the way other people say a much bigger word.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">What do we get?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Nothing. There isn't a currency down here.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">That's not an answer.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">You get the list too. That's all I've got and it's</p><p class="line">more than anybody up there has been given since</p><p class="line">the founding.</p></div></div></div><div class="screenplay"><p class="action">PIP comes along the bench and stops about a foot from <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> and stays there.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Pip.</p></div></div><div class="dialogue"><p class="character">PIP (O.S.)</p><div class="lines"><p class="line">I know.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Do it again.</p></div></div></div><div class="screenplay"><p class="action">Pip goes to the end of the bench and comes back and stops in the same place.</p><p class="action beat">Lazlo does not say anything for a while.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">How long have you been getting the nosebleeds?</p></div></div></div><div class="screenplay"><p class="action beat">Maya looks at <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> and <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> does not look at anybody.</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">Since August.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">How long are they now?</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">Four minutes. Tonight it was four minutes.</p></div></div></div><div class="screenplay"><p class="action">Lazlo puts the tray down, which he has been holding since they came in.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Right. Don't come back down here.</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 just asked me for—</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I asked you for a catalogue that is on the third</p><p class="line">floor of Beckwith Hall in the dry, and I'm telling</p><p class="line">you not to come back down here, and both of those</p><p class="line">are the same sentence.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Why?</p></div></div></div><div class="screenplay"><p class="action beat">He does not answer her.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Harper. What was that.</p></div></div><div class="dialogue"><p class="character">H4RPER (O.S.)</p><div class="lines"><p class="line">I'd want it four more times before I said</p><p class="line">anything, and you know what I'd say.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Yes.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. THE LONG ROOM — 2:40 A.M.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Now the part that keeps you alive, which I'd like</p><p class="line">someone to write down, and I'd prefer it to be</p><p class="line">her.</p></div></div></div><div class="screenplay"><p class="action">Maya has the notebook out before he has finished saying it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">One. Grate down properly. Not nearly. If it sits</p><p class="line">proud, somebody's foot finds it in April and</p><p class="line">there's a work order, and a work order is a date,</p><p class="line">and a date is four names.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Two. You didn't take anything. Keep not taking</p><p class="line">anything. A missing book is a person. Forty</p><p class="line">thousand books nobody has counted is weather.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Three. Wet shoes. Go along the plant corridor and</p><p class="line">out the back and stand on the grass for two</p><p class="line">minutes before you go anywhere with a floor.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Four, and this is the one that tends to trip</p><p class="line">everybody up-. -your Helps come back on about</p><p class="line">twenty feet up those stairs. There's a gap in your</p><p class="line">day and you cannot close it, nor should you try.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">So what do we do about it?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Nothing. Go to bed. A gap with sleep on both ends</p><p class="line">of it is a gap where somebody slept. A gap with a</p><p class="line">question on the end of it is not.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">So you don't ask it. Not what a C series is, not</p><p class="line">what's under the refectory, not what happens to a</p><p class="line">repossessed student, not tonight, not in March,</p><p class="line">not ever. It is very good and it is in your head</p><p class="line">and it will answer you, and the answer is not the</p><p class="line">thing that costs you.</p></div></div></div><div class="screenplay"><p class="action beat">Maya writes all four down and then, underneath, writes something that is not one of the four.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">What was that one?</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">The time.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Good.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. UNDER MERIDIAN — THE STACKS — 3:50 A.M.</p></div><div class="screenplay"><p class="action">He walks them back as far as the end of the bays and does not come out into the row.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Beckwith. Front and back. Take your time over 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">How do I get it to you?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Something of mine will be near you.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">That is a horrible sentence.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Yes.</p></div></div></div><div class="screenplay"><p class="action">PIP is not on the bench and has not been on the bench for a while.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">EXT. THE QUAD — 4:10 A.M.</p></div><div class="screenplay"><p class="action">Four of them come up out of the plant door at the back of Meridian Hall into an ordinary cold morning.</p><p class="action">The quad is the quad. The banner is the banner. A light is on in the Commons because the Commons never closes.</p><p class="action beat">Nothing has happened to the campus.</p><p class="action">About twenty feet up the stair their Helps came back on, one after another, and none of them said anything about it and all four of them noticed.</p><p class="action">They stand on the grass for two minutes, which is longer than two minutes is.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> locks the plant door. They walk down to Plant Services and he puts the keys back on the hook, and HOLLIS is asleep on the crate exactly as he was at ten, and has not moved, and is breathing.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><p class="paren">(outside, quietly)</p><div class="lines"><p class="line">Is he all right?</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">He's asleep, Eli.</p></div></div></div><div class="screenplay"><p class="action beat">Nobody says anything on the way back across.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><p class="paren">(at the corner of Ashcroft)</p><div class="lines"><p class="line">So we tell people.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Tell them what?</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">All of it. Any of it. That there's a room down</p><p class="line">there with—</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">With shelves in it. And what have you got.</p></div></div></div><div class="screenplay"><p class="action beat">Eli has a photograph on his Help of a doorway, taken in the dark, at an angle, which could be any doorway anywhere.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">I've done this. Not this. Something like it,</p><p class="line">twice. You go to somebody and you tell them, and</p><p class="line">they're decent about it, and they write it down</p><p class="line">somewhere you can't see, and then the thing you</p><p class="line">told them about is a thing you have a history of</p><p class="line">saying.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">So we do nothing.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">I didn't say that.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">We check it.</p></div></div></div><div class="screenplay"><p class="action beat">Everybody looks at her.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Everything down there is somebody telling us</p><p class="line">something. He told us a great deal and he was</p><p class="line">honest about the bits he doesn't know, which is</p><p class="line">the only reason I'd give him anything at all, and</p><p class="line">it is still somebody telling us something.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">So what isn't?</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">The thing about him.</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">About me.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">He said manufactured. He said every shortage you</p><p class="line">have stood in was built for you. That is a claim</p><p class="line">about the work, and the work is a thing we have</p><p class="line">got.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">And you can test that?</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">You can test anything if you can count it. He</p><p class="line">writes a thing. We count what's in it. If there's</p><p class="line">something in it he didn't put there, then there's</p><p class="line">something in it he didn't put there, and that</p><p class="line">isn't a story anybody told us, that's a number.</p></div></div></div><div class="screenplay"><p class="action beat">It is a quarter to five in the morning.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Do number four.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="action">Assignment four. A machine that takes money.</p><p class="action">Three reels, eight positions on a reel, and the same eight symbols on each of them. Match three and it pays. That is the whole of what he sat down to write and he wrote it in about forty minutes without stopping, in the bench room, with three people watching him do it.</p></div><div><pre><code class="sourceCode language-clojure printed-clojure">#'act3.s5-not-on-anything/reel
</code></pre></div><div><pre><code class="sourceCode language-clojure printed-clojure">#'act3.s5-not-on-anything/pick
</code></pre></div><div><pre><code class="sourceCode language-clojure printed-clojure">#'act3.s5-not-on-anything/weight
</code></pre></div><div><pre><code class="sourceCode language-clojure printed-clojure">#'act3.s5-not-on-anything/next-along
</code></pre></div><div><pre><code class="sourceCode language-clojure printed-clojure">#'act3.s5-not-on-anything/spin
</code></pre></div><bar>cherrybar</bar><div class="screenplay"><p class="action">It works. It pays about one time in twenty, which is about right, and he handed it in on the Monday and it was marked and it was fine.</p><p class="action">Then Maya put her finger on the middle of the screen and asked him what those three lines were for.</p><p class="action">The first two reels are picked. The third one is not. If the first two have come up the same, the third reel is given the winning symbol as often as it would have come up on its own, and every other time it is given the next symbol along.</p><p class="action">It pays exactly as often as an honest machine would pay. It also stops, over and over, one position short of paying.</p></div><div><pre><code class="sourceCode language-clojure printed-clojure">#'act3.s5-not-on-anything/wins?
</code></pre></div><div><pre><code class="sourceCode language-clojure printed-clojure">#'act3.s5-not-on-anything/one-off?
</code></pre></div><div><pre><code class="sourceCode language-clojure printed-clojure">#'act3.s5-not-on-anything/honest-spin
</code></pre></div><div><pre><code class="sourceCode language-clojure printed-clojure">#'act3.s5-not-on-anything/measure
</code></pre></div>{:pays 0.051105, :one-off 0.04483}{:pays 0.05102, :one-off 0.166705}<div class="screenplay"><p class="action">Same money. Four times as many nearly.</p><p class="action">He did not decide to do that. He does not remember typing it. He can read it and he can explain it and he can tell you exactly what it is doing and why it is a good idea, which is the part that goes through him like cold water, because he can explain it and he did not think of it.</p></div><div class="screenplay"><p class="slug">INT. THE BENCH ROOM — 6:20 A.M.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">All right, but you've done four months of this.</p><p class="line">You've read other people's. You picked it up</p><p class="line">somewhere and forgot where. That's what a person</p><p class="line">is.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Yes. So I'll do one.</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've never written anything.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">I have written nothing at all, and I have watched</p><p class="line">you do it for four months, and I have the sheet,</p><p class="line">and I am not going to look at yours.</p></div></div></div><div class="screenplay"><p class="action">She turns the screen away from him. LENA sits on the counter and does not say anything. ELI goes and gets coffee from the machine in the corridor, four times, over the next two hours.</p><p class="action">It takes her two hours and does not run the first three times.</p><p class="action beat">Then it runs.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Right. Count mine.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="action">Hers is not like his. Her names are different, her reel is different, she has done the whole of it with numbers where he used words, and one part of it is genuinely better than his and he says so.</p></div><div><pre><code class="sourceCode language-clojure printed-clojure">#'act3.s5-not-on-anything/her-reel
</code></pre></div><div><pre><code class="sourceCode language-clojure printed-clojure">#'act3.s5-not-on-anything/her-spin
</code></pre></div><div><pre><code class="sourceCode language-clojure printed-clojure">#'act3.s5-not-on-anything/her-one-off?
</code></pre></div>{:pays 0.03948, :one-off 0.149465}<div class="screenplay"><p class="action">Same shape. Different words, different numbers, different order, and in the middle of it the same three lines doing the same thing, written by somebody who has never written anything before, in a room with the screen turned away.</p><p class="action">Nobody says anything for a while. Eli is standing in the doorway with the fourth coffee and has been for some time.</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="slots-orig"><div class="roll-head"><span class="roll-label">SLOTS.CLJS</span><span class="roll-dc">cljs</span></div><textarea class="live-src" id="slots-orig-src" rows="26" spellcheck="false">(def reel [:cherry :bell :bar :seven :plum :bell :cherry :bar])
(def tally (atom {:spins 0 :pays 0 :one-off 0}))
(defn pick [] (rand-nth reel))
(defn share [s] (/ (count (filter #{s} reel)) (count reel)))
(defn next-along [s] (nth reel (mod (inc (.indexOf reel s)) (count reel))))
;; take the three lines out and see what changes and what doesn't
(defn spin []
(let [a (pick) b (pick)]
[a b (if (= a b)
(if (< (rand) (share a)) a (next-along a))
(pick))]))
(defn show [row]
(html!
(str "<p style='font-size:1.6rem;letter-spacing:0.3em'>"
(apply str (map (fn [s] (subs (name s) 0 2)) row))
"</p><p>"
(let [{:keys [spins pays one-off]} @tally]
(str spins " spins · " pays " paid · " one-off " one short"))
"</p><div class='live-bar'><button id='go'>Spin</button>"
"<button id='hundred'>Spin 100</button></div>"))
(on! "go" (fn [] (let [r (spin)]
(swap! tally update :spins inc)
(when (apply = r) (swap! tally update :pays inc))
(when (and (= (first r) (second r)) (not= (nth r 2) (first r)))
(swap! tally update :one-off inc))
(show r))))
(on! "hundred" (fn []
(dotimes [_ 100]
(let [r (spin)]
(swap! tally update :spins inc)
(when (apply = r) (swap! tally update :pays inc))
(when (and (= (first r) (second r)) (not= (nth r 2) (first r)))
(swap! tally update :one-off inc))))
(show (spin)))))
(show [:bar :bar :seven])</textarea><div class="live-bar"><button class="live-run">Run</button></div><div class="sketch-page" id="slots-orig-out"></div><pre class="live-out" id="slots-orig-res"></pre></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.sketch/cell!))] (mount "slots-orig" "(in-ns (quote fedoras.sketch-runner))\n(def root (.getElementById js/document \"slots-orig-out\"))\n(defn html! [s] (set! (.-innerHTML root) s))\n(defn on! [id f] (let [e (.getElementById js/document id)] (when e (set! (.-onclick e) (fn [_] (f))))))\n(defn fetch [k] (try (let [v (.getItem js/localStorage (str \"fedoras.\" k))] (when v (read-string v))) (catch :default _ nil)))\n(defn store! [k v] (try (.setItem js/localStorage (str \"fedoras.\" k) (pr-str v)) (catch :default _ nil)) v)") (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><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>/* ---------------------------------------------------------------
SLOTS.CLJS
The reel is large, the purse is next, and the three lines that
would have told you anything are small and grey underneath. That
is the order of attention the machine itself is built to produce,
and the stylesheet does not fight it.
The purse gets the gold, because that is what a machine like this
makes you look at: a number going up, sitting directly on top of a
number going down, in the same line, in the same colour.
The career line is smaller than the purse and larger than the
rates, which is roughly how much thought anybody gives to how many
of these they have sat down at.
--------------------------------------------------------------- */
.widget-slots {
max-width: 32rem;
}
.slots-row {
margin: 0.7rem 0 0.4rem 0 !important;
font-size: 2rem;
letter-spacing: 0.25em;
line-height: 1.2;
}
.slots-purse {
margin: 0.2rem 0 0.2rem 0 !important;
font-size: 1rem;
font-variant-numeric: tabular-nums;
color: var(--gold);
}
.slots-career {
margin: 0.1rem 0 !important;
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
opacity: 0.9;
letter-spacing: 0.02em;
}
.slots-tally,
.slots-rates {
margin: 0.1rem 0 !important;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.slots-tally {
opacity: 0.75;
}
.slots-rates {
opacity: 0.55;
min-height: 1.2em;
}</style><script type="application/x-scittle">(ns fedoras.cs-class.slots.slots-ui
(:require [clojure.string :as str]))
(def settings
(atom {:strips
{"CHERRY DELUXE" [:cherry :bell :bar :seven :plum :bell :cherry :bar]
"TRIPLE CROWN" [:seven :bell :bell :bar :cherry :bar :plum :bell :cherry :seven]
"THE LONG SHOT" [:cherry :cherry :bell :bar :seven :plum :bar :bell :cherry :seven :bell :bar]}
:reel-counts [3 3 3 3 4 5]
:faces {:cherry "🍒" :bell "🔔" :bar "🍫" :seven "7️⃣" :plum "🍇"}
:rigged? true
:batch 100
:start-money 25
:stake 1
:stakes [1 2 5 10]
:returns 0.76
:near-points 5
:free-every 3}))
(defonce st (atom nil)) ;; the machine currently in front of you
(defonce career (atom nil)) ;; everything that follows you between them
;; ---------------------------------------------------------------------
;; The reel
;;
;; Every machine has its own strip, so which symbol counts as 'one
;; along' is a property of the cabinet you happen to be standing at
;; and not of the game. Nobody walking the floor has any reason to
;; know that, and nothing on any of them says so.
;; ---------------------------------------------------------------------
(defn- index-of
"Where a symbol first sits on a strip. Written out rather than handed
to .indexOf, because a strip is a Clojure vector and that method
belongs to JavaScript arrays -- and because a strip is allowed to
hold numbers as easily as keywords, which is the whole point of
there being two of these in the chapter."
[coll x]
(first (keep-indexed (fn [i v] (when (= v x) i)) coll)))
(defn- next-along-in [strip s]
(nth strip (mod (inc (or (index-of strip s) 0)) (count strip))))
(defn- share-in
"How much of the strip is this symbol. A symbol that appears twice on
an eight-symbol strip comes up a quarter of the time, and the last
reel is honest about that even while it is being dishonest about
everything else."
[strip s]
(/ (count (filter #{s} strip)) (count strip)))
(defn- strip [] (:strip @st))
(defn- pick [] (rand-nth (strip)))
(defn- next-along [s] (next-along-in (strip) s))
(defn- share [s] (share-in (strip) s))
;; ---------------------------------------------------------------------
;; The last one
;;
;; Every reel but the last lands wherever it lands. The last one looks
;; at them first.
;;
;; When they disagree there is nothing to arrange, so it does not
;; bother, and the pick is honest. When they all agree, it pays at
;; exactly the rate the strip says it should -- and when it doesn't
;; pay, it does not land just anywhere. It lands one along. Close
;; enough to see.
;;
;; The rate is not the lie. The rate is real, it is measurable, and it
;; matches an honest machine exactly -- so does how often the front
;; reels match and the last one doesn't. The lie is only ever in where
;; the losing reel stops, and it takes a test somebody had to already
;; suspect to go looking for.
;;
;; :rigged? false takes this out and picks fair every time. The money
;; comes out the same. So does almost everything else.
;; ---------------------------------------------------------------------
(defn- spin []
(let [n (:reels @st)
front (vec (repeatedly (dec n) pick))
a (first front)
last-one (if (and (:rigged? @settings) (apply = front))
(if (< (rand) (share a)) a (next-along a))
(pick))]
(conj front last-one)))
;; ---------------------------------------------------------------------
;; Counting
;; ---------------------------------------------------------------------
(defn- paid? [row] (apply = row))
(defn- one-off?
"Every reel but the last showing the same thing, and the last one
stopped exactly one position along the strip from them.
Not merely 'the front reels match and the last doesn't', which is
the obvious way to write this and measures nothing: a fair machine
and a rigged one produce that at the same rate, because the rig pays
at exactly the rate an honest pick would have paid. Every number a
regulator would think to check comes out identical.
Where they differ is where the losing reel stops, and only this
stricter test can see it. Once in a while on a fair machine, since
it has to happen by chance. Every single near miss on a rigged one."
[row]
(let [front (butlast row)
a (first front)
c (last row)]
(and (apply = front) (not= c a) (= c (next-along a)))))
(defn- round-nice
"Two significant figures, because a pay table with 48.64 on it has
never existed and never will."
[x]
(if (< x 10)
(max 1 (js/Math.round x))
(let [mag (js/Math.pow 10 (- (js/Math.floor (/ (js/Math.log x) (js/Math.log 10))) 1))]
(* mag (js/Math.round (/ x mag))))))
(defn- pay-table
"Derived from the strip, not written down anywhere.
A machine is not designed by choosing what the sevens pay. It is
designed by choosing what fraction of the money goes back out --
:returns, and seventy-six cents is a hard machine; the real ones sit
nearer ninety and have to say so somewhere -- and then solving for
what each line must pay to hit exactly that number.
Which is why this comes out different on every cabinet on the floor,
and why a symbol paying ten times more than another one tells you
precisely nothing about the machine except how rare that symbol is
on this particular strip."
[strip reels]
(let [n (count strip)
returns (:returns @settings)]
(into {}
(map (fn [s]
(let [p (share-in strip s)]
[s (round-nice (/ returns (js/Math.pow p (dec reels))))]))
(distinct strip)))))
(defn- payout-for [s stake]
(* stake (get (:pays @st) s 0)))
;; ---------------------------------------------------------------------
;; The floor
;;
;; Money is a property of the machine you are at. Points, spins, and
;; every number underneath them are properties of you, and they do not
;; reset when you stand up, because you don't either.
;;
;; A new machine is a new strip, possibly a different number of reels,
;; and — if you had nothing left at the last one — another twenty-five
;; dollars into the slot, which the floor counts even if you have
;; stopped.
;; ---------------------------------------------------------------------
(defn- roll-machine []
(let [[nm strip] (rand-nth (vec (:strips @settings)))
reels (rand-nth (:reel-counts @settings))
strip (vec strip)
a (first strip)]
{:name nm
:strip strip
:reels reels
:pays (pay-table strip reels)
;; a near miss on the glass before anybody has touched it. it has
;; not been spun. it is just what the machine would like you to
;; think it does.
:row (conj (vec (repeat (dec reels) a)) (next-along-in strip a))
:stake (:stake @settings)
:money (:start-money @settings)
:machine-points 0
:near-run 0 :free-spins 0 :last-win 0 :broke? false}))
(defn- fresh-career []
{:points 0 :spins 0 :pays 0 :one-off 0
:machines (or (fedoras.ui.widget-ui/kept :slots-machines) 1)
:fed-in (:start-money @settings)
:best (or (fedoras.ui.widget-ui/kept :slots-best) 0)})
(defn- can-spin? [s]
(or (pos? (:free-spins s)) (>= (:money s) (:stake s))))
(defn- take-spin! []
(when (can-spin? @st)
(let [free? (pos? (:free-spins @st))
stake (:stake @st)
row (spin)
won? (paid? row)
near? (one-off? row)
win-amount (if won? (payout-for (first row) stake) 0)
gained (+ 1 (if near? (:near-points @settings) 0) win-amount)
every (:free-every @settings)]
(swap! st
(fn [s]
(let [near-run (if near? (inc (:near-run s)) (:near-run s))
earned? (and near? (pos? every) (>= near-run every))
s' (assoc s
:row row
:last-win win-amount
:near-run (if earned? 0 near-run)
:money (+ (:money s) (if free? 0 (- stake)) win-amount)
:free-spins (max 0 (+ (if free?
(dec (:free-spins s))
(:free-spins s))
(if earned? 1 0)))
:machine-points (+ (:machine-points s) gained))]
(assoc s' :broke? (not (can-spin? s'))))))
(swap! career
(fn [c]
(-> c
(update :spins inc)
(update :points + gained)
(update :pays (fn [n] (if won? (inc n) n)))
(update :one-off (fn [n] (if near? (inc n) n)))))))))
(defn- remember-best! []
(let [p (:machine-points @st)]
(when (> p (:best @career))
(swap! career assoc :best p)
(fedoras.ui.widget-ui/keep! :slots-best p))))
(defn- new-machine!
"Stand up, walk to another one. Everything you have counted so far
comes with you. If there was nothing left in the last one, another
twenty-five goes in, and that gets counted too."
[]
(let [broke? (:broke? @st)
left (:money @st)]
(swap! career (fn [c] (-> c
(update :machines inc)
(update :fed-in + (if broke? (:start-money @settings) 0)))))
(fedoras.ui.widget-ui/keep! :slots-machines (:machines @career))
(reset! st (roll-machine))
;; you take your money with you; you only feed the slot again when
;; there was nothing to take
(when-not broke?
(swap! st assoc :money left))))
;; ---------------------------------------------------------------------
;; Painting
;; ---------------------------------------------------------------------
(defn- face
"The emoji for a symbol, or the symbol itself when there isn't one --
which is how a strip of plain numbers still runs on the same floor
without needing anything of its own."
[s]
(or (get (:faces @settings) s) (str s)))
(defn- pct [n d]
(if (zero? d) "—" (str (.toFixed (* 100 (/ n d)) 1) "%")))
(defn paint! [node]
(let [{:keys [row money stake free-spins last-win broke? name reels]} @st
{:keys [points spins pays one-off machines fed-in best]} @career]
(fedoras.reader/text!
node ".slots-row"
(str/join " " (map face row)))
(fedoras.reader/text!
node ".slots-purse"
(str "$" money " · " points " points"))
(fedoras.reader/text!
node ".slots-career"
(str name " · " reels " reels · machine " machines " · $" fed-in " in"
(when (pos? best) (str " · best machine " best))))
(fedoras.reader/text!
node ".slots-tally"
(str spins " spins · " pays " paid · " one-off " one short"
(when (pos? free-spins) (str " · " free-spins " free"))
(when (pos? last-win) (str " · won $" last-win))))
(fedoras.reader/text!
node ".slots-rates"
(cond
broke? "nothing left in this one · there are others"
(zero? spins) "nothing spun yet"
:else (str "pays " (pct pays spins) " · one short " (pct one-off spins))))
;; the buttons say what they will actually do, since the stake and
;; the batch size are both settings and a label that lies about
;; either is worse than no label
(fedoras.reader/text! node ".slots-go" (str "Spin $" stake))
(fedoras.reader/text! node ".slots-batch" (str "Spin " (:batch @settings)))
(fedoras.reader/text! node ".slots-stake" (str "Stake $" stake " ▸"))))
;; ---------------------------------------------------------------------
;; Hands
;; ---------------------------------------------------------------------
(defn- spin-once! []
(take-spin!)
(remember-best!))
(defn- spin-batch!
"Stops the moment there is nothing left in this machine, rather than
running the remaining ninety spins against an empty one."
[]
(dotimes [_ (:batch @settings)]
(when-not (:broke? @st) (take-spin!)))
(remember-best!))
(defn- cycle-stake!
"Steps through the stakes in order and wraps. Raising it raises what
a win pays by exactly the same factor, so the odds do not move at
all -- only how fast you find out."
[]
(swap! st (fn [s]
(let [ss (:stakes @settings)
i (or (index-of ss (:stake s)) 0)]
(assoc s :stake (nth ss (mod (inc i) (count ss))))))))
(defn- wire-controls! [node]
(fedoras.reader/on-click! node ".slots-go"
(fn [_] (spin-once!) (paint! node)))
(fedoras.reader/on-click! node ".slots-batch"
(fn [_] (spin-batch!) (paint! node)))
(fedoras.reader/on-click! node ".slots-stake"
(fn [_] (cycle-stake!) (paint! node)))
(fedoras.reader/on-click! node ".slots-next"
(fn [_] (new-machine!) (paint! node)))
;; the only thing on this widget that actually forgets anything
(fedoras.reader/on-click! node ".slots-walk"
(fn [_]
(fedoras.ui.widget-ui/keep! :slots-machines 1)
(reset! career (assoc (fresh-career) :machines 1))
(reset! st (roll-machine))
(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
[:strips :reel-counts :faces :rigged? :batch
:start-money :stake :stakes :returns
:near-points :free-every]))))
(reset! career (fresh-career))
(reset! st (roll-machine))
(paint! node))))
(defn setup! [node]
(reset! career (fresh-career))
(reset! st (roll-machine))
(fedoras.ui.widget-ui/wire! node "controls" wire-controls!)
(fedoras.ui.widget-ui/wire! node "settings" (fn [n] (wire-settings! n (.-id node))))
(paint! node))
(defn mount! [{:keys [id]}]
(fedoras.ui.widget-ui/mount! ::slots (or id "slots") setup! paint!))</script><div class="widget widget-slots" id="slots"><div class="roll-head"><span class="roll-label">SLOTS.CLJS · spin it</span><span class="widget-state">press Run</span></div><div class="widget-source"><div class="widget-source-head"><span class="widget-source-name">slots_settings.cljs</span><span class="widget-source-lang">cljs</span></div><textarea class="widget-settings" rows="26" spellcheck="false">;; ---------------------------------------------------------------------
;; SLOTS_SETTINGS.CLJS — what the floor 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.
;;
;; Running this walks you out and starts the count over.
;; ---------------------------------------------------------------------
{;; ---------------------------------------------------------------
;; THE FLOOR
;;
;; Every machine on it has its own strip and its own number of
;; reels, drawn when you sit down at it. Which symbol counts as 'one
;; along' is therefore a property of the cabinet and not of the
;; game, and nothing on any of them says so.
;;
;; :reel-counts is drawn from as written, so four threes and one
;; each of four and five means most machines are ordinary and now
;; and then you find one that is not. Add strips freely. A strip of
;; plain numbers works and prints itself, since only symbols with a
;; face get one.
;; ---------------------------------------------------------------
:strips
{"CHERRY DELUXE" [:cherry :bell :bar :seven :plum :bell :cherry :bar]
"TRIPLE CROWN" [:seven :bell :bell :bar :cherry :bar :plum :bell :cherry :seven]
"THE LONG SHOT" [:cherry :cherry :bell :bar :seven :plum :bar :bell :cherry :seven :bell :bar]}
:reel-counts [3 3 3 3 4 5]
:faces {:cherry "🍒" :bell "🔔" :bar "🍫" :seven "7️⃣" :plum "🍇"}
;; ---------------------------------------------------------------
;; THE LAST REEL
;;
;; This is the setting. Everything else here is furniture.
;;
;; True, and when every reel but the last one matches, the last one
;; checks them before it lands. It pays at exactly the rate the
;; strip says it should — that part is not a lie and you can measure
;; it — and when it does not pay, it stops one position along. Close
;; enough to see. Close enough to feel like it nearly happened.
;;
;; False, and the last reel picks like the others do, and the
;; machine is honest.
;;
;; Spin a few thousand of each. What it pays does not move. How
;; often the front reels match and the last does not move either.
;; Both machines are identical on every number anybody thinks to ask
;; for.
;;
;; The one number that moves is the last one: how often the losing
;; reel stops exactly one position along instead of anywhere. Now
;; and again when the machine is honest, because it has to happen by
;; accident. Every single time when it is not.
;;
;; There is no regulation anywhere requiring that number on the side
;; of the cabinet.
;; ---------------------------------------------------------------
:rigged? true
;; ---------------------------------------------------------------
;; THE MONEY
;;
;; :returns is the whole pay table. Nobody designs one of these by
;; deciding what the sevens are worth; they decide what fraction of
;; the money goes back out, and then solve for what every line has
;; to pay to land on exactly that. So the table is different on
;; every cabinet, and a symbol paying ten times another tells you
;; nothing except how rare it is on that particular strip.
;;
;; 0.76 is a hard machine. Real ones sit nearer 0.90 and are
;; required to say so somewhere. Set it to 1.0 and the floor breaks
;; even forever; above that it pays you to stand there, which no
;; machine has ever done and none ever will.
;;
;; Raising the stake raises what a win pays by exactly the same
;; factor, so it does not improve or worsen your odds by any amount
;; at all. It only changes how long the twenty-five dollars lasts.
;; ---------------------------------------------------------------
:returns 0.76
:start-money 25
:stake 1
:stakes [1 2 5 10]
;; ---------------------------------------------------------------
;; THE POINTS
;;
;; Points are not money. You cannot spin them, you cannot spend
;; them, and nothing on this floor will ever convert one into the
;; other. They exist so that the number you are watching goes up
;; while the number you are spending goes down, and they follow you
;; from machine to machine, because you do.
;;
;; A spin is one point. A near miss is :near-points more, so the
;; machine is at its most generous exactly when it has taken your
;; money and given you nothing, and it is at its most generous most
;; often when :rigged? is true.
;;
;; Every :free-every near misses buys a free spin — the machine
;; settling up in the only currency it prints itself. They need not
;; be consecutive; the machine is counting all of them, always, and
;; is the only party to this arrangement that is. 0 turns it off,
;; and a run gets about a third shorter, which is the point of it
;; being there.
;; ---------------------------------------------------------------
:near-points 5
:free-every 3
;; How many spins the second button does at once. It stops early
;; when the machine in front of you is empty, which it usually is
;; before it gets there.
:batch 100}</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"><p class="slots-row"></p><p class="slots-purse"></p><p class="slots-career"></p><p class="slots-tally"></p><p class="slots-rates"></p><div class="live-bar"><button class="roll-go slots-go">Spin $1</button><button class="roll-go slots-batch">Spin 100</button><button class="roll-go slots-stake">Stake $1 ▸</button><button class="roll-go slots-next">Another machine</button><button class="roll-go slots-walk">Walk out</button></div></div><p class="roll-foot">The money belongs to the machine. Everything under it belongs to you, and comes with you to the next one.</p></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.cs-class.slots.slots-ui/mount!))] (mount {:id "slots"}) (js/console.error "fedoras: fedoras.cs-class.slots.slots-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="artifact email"><table class="email-head"><tbody><tr><td>From:</td><td><span>Office of the Registrar <no-reply@newcarthage.edu></span></td></tr><tr><td>To:</td><td><span><span class="pc-name" data-case="pc" data-pc="-full">Owen Mercer</span> <<span class="pc-name" data-case="pc" data-pc="-first">Owen</span>@newcarthage.edu></span></td></tr><tr><td>Date:</td><td>Saturday 7 February, 4:00 AM</td></tr><tr><td>Subject:</td><td><strong><span>Service assignment — variation processed</span></strong></td></tr></tbody></table><div class="email-body"><p><span>Your service assignment has been varied with effect from Monday.</span></p><p><span>PLANT SERVICES — closed.</span></p><p><span>SYSTEMS — commences Monday, 9:00 AM, Sterling Center, fourth floor. This post is paid.</span></p><p><span>Thank you for confirming.</span></p><p><span>Service hours are not a component of review. Service hours are recorded.</span></p></div></div><div class="screenplay"><p class="action">Four in the morning is when the Registrar sends things. It was sent at four in the morning on the Saturday, which was while the four of them were under the refectory with their Helps out, and he has not confirmed anything, and there is nobody he can say that to.</p><p class="action">He reads it four times looking for the sentence that is a problem.</p><p class="action">There isn't one.</p><p class="action">He is in bed by seven, which is what he was told to do, four hours later than he was told to do it.</p></div></div>
```