---
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>
```
```{=html}
<script type="application/x-scittle">(ns fedoras.ui.dice-ui
"A d20 the reader can roll once.
ROLL ONCE. It used to let you press the button all afternoon. The
handler had no guard and the box painted nothing from storage, so a
roll was neither kept nor refused. It is kept now, the button is spent
once it has been pressed, and the only other door is the expensive
one.
STANDING IS THE MODIFIER. Fortune less echo, added to every roll. It
is stored with the roll rather than read live, because raising the
Echo in Act IV must not quietly reword a scene the reader finished in
Act II.
THE PASSAGE MAY SAY THE NUMBER. Every outcome string is rendered with
the die in scope, so a line can say {result} and mean the number that
was actually rolled rather than the number the author guessed. This is
the reason the passages used to lie: a success wrote `rolls a
fourteen` whatever the reader rolled, and a reader who rolled
seventeen was told, in their own book, that they had not.
A passage is still allowed to be vague. `rolls low` is a choice now
rather than the only option.
SCORE. Every die adds its face to the score, including the ones rolled
again. That is the temptation: a rewrite raises the score and raises
the Echo, and only one of those two is fatal.
FORTUNE. A natural twenty is worth a deed, but only while the Echo is
nought. So the reader who has never taken anything back is the only
one who can still be lucky, and nothing anywhere says so."
(:require [clojure.string :as str]))
;; ---------------------------------------------------------------------
;; What a roll is worth
;; ---------------------------------------------------------------------
(defn band
"One and twenty are themselves. Everything between them is a sum."
[natural dc modifier]
(cond
(= natural 1) :fumble
(= natural 20) :crit
(>= (+ natural modifier) dc) :success
:else :fail))
(defn line
"What was rolled, what it came to, and what that was."
[natural dc modifier]
(str "d20 → " natural
(cond
(pos? modifier) (str " fortune +" modifier " → " (+ natural modifier))
(neg? modifier) (str " echo " modifier " → " (+ natural modifier))
:else "")
" "
(case (band natural dc modifier)
:fumble "FUMBLE" :crit "CRIT" :success "success" :fail "failure")))
;; ---------------------------------------------------------------------
;; The number, in words and in figures
;; ---------------------------------------------------------------------
(def ^:private spelled
["zero" "one" "two" "three" "four" "five" "six" "seven" "eight" "nine"
"ten" "eleven" "twelve" "thirteen" "fourteen" "fifteen" "sixteen"
"seventeen" "eighteen" "nineteen" "twenty"])
(defn- in-words [n]
(if (and (number? n) (<= 0 n 20)) (nth spelled n) (str n)))
(defn- magnitude [n] (if (neg? n) (- n) n))
(defn- signed [n]
(cond (pos? n) (str "+" n)
(neg? n) (str "−" (magnitude n))
:else ""))
(defn- arithmetic
"17 + 1 = 18, and just 17 when there is nothing to add, so a line that
uses this reads correctly for a reader whose standing is nought — most
readers, most of the time."
[natural modifier]
(if (zero? modifier)
(str natural)
(str natural " " (if (pos? modifier) "+" "−") " " (magnitude modifier)
" = " (+ natural modifier))))
(defn roll-tokens
"What a passage is allowed to say about the die that produced it.
{result} is the face, because that is what somebody at a table says
out loud. {total} is the number the DC was measured against, which is
a different fact and usually the less interesting one."
[dc {:keys [roll mod]}]
(let [total (+ roll mod)]
(fn [head tail]
(case head
"result" (case (or tail "")
"" (str roll)
"-word" (in-words roll)
"-mod" (arithmetic roll mod)
nil)
"total" (case (or tail "")
"" (str total)
"-word" (in-words total)
nil)
"mod" (when (nil? tail) (signed mod))
"dc" (when (nil? tail) (str dc))
nil))))
;; ---------------------------------------------------------------------
;; Remembering
;; ---------------------------------------------------------------------
(defn- entry [id] (fedoras.reader/roll-entry id))
;; ---------------------------------------------------------------------
;; The passage
;; ---------------------------------------------------------------------
(defn- segment-node [tokens seg]
(let [cue? (str/starts-with? seg "@")
node (.createElement js/document "p")]
(set! (.-className node) (if cue? "roll-cue" "roll-line"))
(set! (.-textContent node)
(fedoras.reader/fill-with tokens (if cue? (subs seg 1) seg)))
node))
(defn- paint-passage! [node tokens text]
(let [box (fedoras.reader/q node ".roll-out")]
(set! (.-innerHTML box) "")
(doseq [seg (str/split (or text "") #"\|")]
(.appendChild box (segment-node tokens seg)))))
;; ---------------------------------------------------------------------
;; The two states a box can be in
;; ---------------------------------------------------------------------
(defn- clear! [node]
(fedoras.reader/text! node ".roll-value" "")
(set! (.-innerHTML (fedoras.reader/q node ".roll-out")) "")
(fedoras.reader/text! node ".roll-note" "")
(fedoras.reader/text! node ".roll-echo" "")
(fedoras.reader/enable! node ".roll-go" true "Roll d20")
(fedoras.reader/show! node ".roll-rewrite" false))
(defn- fill! [node {:keys [dc outcomes]} {:keys [roll mod rewrites] :as e}]
(let [b (band roll dc mod)]
(fedoras.reader/text! node ".roll-value" (line roll dc mod))
(paint-passage! node (roll-tokens dc e)
(or (get outcomes b) (get outcomes :fail)))
(fedoras.reader/text! node ".roll-note"
(if (zero? rewrites)
""
(str "This is version " (inc rewrites) " of what happened.")))
(fedoras.reader/enable! node ".roll-go" false "Rolled")
(fedoras.reader/show! node ".roll-rewrite" true)))
(defn- paint! [node check]
(if-let [e (entry (:id check))]
(fill! node check e)
(clear! node))
(fedoras.reader/text! node ".echo-count" (str (fedoras.reader/echo)))
(fedoras.reader/text! node ".score-count" (str (fedoras.reader/score))))
;; ---------------------------------------------------------------------
;; The two things a reader can do
;; ---------------------------------------------------------------------
(defn- resolve!
"One die, and what it costs and is worth.
The score is not touched here. It is the sum of what is standing, and
what is standing is what this line just wrote."
[node check rewrites]
(let [natural (inc (rand-int 20))
modifier (fedoras.reader/standing)]
;; a twenty is worth a deed, but only to somebody who has taken
;; nothing back. deeds are a set, so it is worth one, once, ever.
(when (and (= 20 natural) (zero? (fedoras.reader/echo)))
(fedoras.reader/did! :nerve))
(fedoras.reader/remember-roll!
(:id check) {:roll natural :mod modifier :rewrites rewrites})
(paint! node check)))
(defn- roll-once! [node check]
(when-not (entry (:id check))
(resolve! node check 0)))
(defn- rewrite!
"Raise the Echo first, then roll under it, so the new number is
penalised by the cost of having asked for it."
[node check]
(when-let [e (entry (:id check))]
(fedoras.reader/add-echo!)
(resolve! node check (inc (:rewrites e)))
(fedoras.reader/text! node ".roll-echo" "Something remembers.")))
;; ---------------------------------------------------------------------
(defn mount! [check]
(fedoras.reader/widget
(keyword "roll" (:id check))
(str "roll-" (:id check))
(fn [node]
(paint! node check)
(fedoras.reader/on-click! node ".roll-go" (fn [_] (roll-once! node check)))
(fedoras.reader/on-click! node ".roll-rewrite" (fn [_] (rewrite! node check))))))</script>
```
# Scene 2 — Second Term
```{=html}
<span style="display:none;"></span>
```
```{=html}
<blockquote class="handbook"><p class="rule-number">The Fedora Handbook, Rule #11</p><p class="rule-text">“The library is quiet because everyone in it is losing an argument with a book.”</p></blockquote>
```
---
```{=html}
<div class="scene"><div class="screenplay"><p class="action">Placements run through. The arrays do not stop for anything and neither does the plant, so Hollis is in at six on the twenty-fourth and on the twenty-eighth, and so is <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>, because a service assignment is a service assignment.</p><p class="action">What people have instead of somewhere to go is the fortnight, and what most of them do with the fortnight is go up.</p></div><div class="artifact handout"><p class="handout-title">WINTER ALLOCATION · REMOTE OPERATIONS · open to all enrolled</p><p><span>Sessions are four hours and are allocated by ballot.</span></p><p><span>TIER ONE — orbital and near survey. Nine hundred and six seats. Ballot.</span></p><p><span>TIER TWO — outer relay, latency four to nineteen minutes. Two hundred seats. Ballot, weighted by prior hours.</span></p><p><span>TIER THREE — deep assets. By record only.</span></p><p><span>You will be piloting a real instrument at a real distance. Contribution is logged against your name and is a component of nothing.</span></p><p><span>Sessions are not a component of review. Sessions are recorded.</span></p></div><div class="screenplay"><p class="action">Four hours in a chair with the light off, and for four hours you are a camera on a stalk on a moon nobody has walked on, and what you see has not been seen, and it goes into the knowledge with your name against it.</p><p class="action">It is the best thing this world does and everybody knows it. People save for years of hours to get up a tier. Ashley from the Senate has been Tier Two since the spring and mentions it more than she thinks she does.</p></div><div class="artifact discord"><div class="discord-head"><span class="discord-server">FEDORA SOCIETY</span><span class="discord-channel">#general</span></div><div class="discord-body"><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">22 Dec, 11:40 PM</span></p><div class="discord-text"><p><span>tier two. weighted ballot. i got in</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">22 Dec, 11:41 PM</span></p><div class="discord-text"><p><span>on what hours</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">22 Dec, 11:41 PM</span></p><div class="discord-text"><p><span>four years of hours</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">22 Dec, 11:42 PM</span></p><div class="discord-text"><p><span>youve been doing this four years?</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">22 Dec, 11:44 PM</span></p><div class="discord-text"><p><span>yes</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">29 Dec, 3:12 AM</span></p><div class="discord-text"><p><span>there is a ridge</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">29 Dec, 3:12 AM</span></p><div class="discord-text"><p><span>nobody has looked at this ridge</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">29 Dec, 3:31 AM</span></p><div class="discord-text"><p><span>i have looked at it for two hours and it is mine now</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">29 Dec, 9:02 AM</span></p><div class="discord-text"><p><span>thats the nicest thing youve ever said</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">2 Jan, 11:58 PM</span></p><div class="discord-text"><p><span>I have not balloted. Somebody has to be here.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">2 Jan, 11:59 PM</span></p><div class="discord-text"><p><span>here for what</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">3 Jan, 12:14 AM</span></p><div class="discord-text"><p><span>For the room.</span></p></div></div></div></div><div class="screenplay"><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> does not ballot, because balloting is a thing you do in November and in November he was doing something else. He spends the fortnight on two mornings a week with a bucket and the rest of it in a room with the door shut, and it is the longest he has gone without anybody needing anything from him since August.</p></div><div class="screenplay"><p class="slug">INT. MERIDIAN HALL — BASEMENT — 28 DECEMBER, 6:40 A.M.</p></div><div class="screenplay"><p class="action">Three buckets under the pipe.</p><p class="action beat">Nobody has emptied any of them.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> straightens the third one and stands there longer than the job takes.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. PLANT SERVICES — 7:15 A.M.</p></div><div class="screenplay"><p class="action">HOLLIS has a drawing out and a tin open. The waders are wet and hanging.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">There's three down there 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">I know.</p></div></div></div><div class="screenplay"><p class="action beat">He eats.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">They give you a category?</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">GEN-8.77x.</p></div></div><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">Mm.</p></div></div></div><div class="screenplay"><p class="action beat">He puts the tin down.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">I've got 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">For what?</p></div></div><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">The hand.</p></div></div></div><div class="screenplay"><p class="action">He picks the tin back up.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">Second junction box wants looking at before the</p><p class="line">eighth. Not today.</p></div></div></div><div class="screenplay"><p class="transition">Blackout.</p></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>6 January, 4:00 AM</td></tr><tr><td>Subject:</td><td><strong><span>Term one — closed</span></strong></td></tr></tbody></table><div class="email-body"><p><span>Review is complete. Your standing is: no action.</span></p><p><span>Set one is marked. Set two opens 8 January and closes 13 March.</span></p><p><span>Enrolment is confirmed at 1,024.</span></p><p><span>Your service assignment continues. Report Tuesdays and Thursdays, 6:00 AM.</span></p><p><span>Welcome back.</span></p></div></div><div class="screenplay"><p class="action">Two people on his corridor do not come back from that. One of them is the boy who read the sheet in a corridor in October. The other is somebody <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> could not have named on the Monday and can name by Thursday, because the corridor spends four days saying it.</p></div><div class="screenplay"><p class="slug">INT. MERIDIAN HALL — CS 101 — THURSDAY 8 JANUARY, 8:00 A.M.</p></div><div class="screenplay"><p class="action">Full room. Nobody has been in this building for a fortnight and the heating knows it.</p><p class="action">REYES puts a page on the front desk and does not hand it round.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REYES</p><div class="lines"><p class="line">Set two. Six programs, nine weeks, any order, and</p><p class="line">it's a third of the year.</p></div></div><div class="dialogue"><p class="character">A STUDENT</p><div class="lines"><p class="line">Nine weeks is a long time.</p></div></div><div class="dialogue"><p class="character">REYES</p><div class="lines"><p class="line">Nine weeks is what's left. That isn't the same</p><p class="line">sentence and you'll find out which one it is in</p><p class="line">March.</p></div></div></div><div class="screenplay"><p class="action">She goes through three of them and does not go through the other three, and the sixth one on the sheet is YOUR OWN.</p></div><div class="screenplay"><div class="dialogue"><p class="character">A STUDENT</p><div class="lines"><p class="line">What counts as your own?</p></div></div><div class="dialogue"><p class="character">REYES</p><div class="lines"><p class="line">Show me in week six and I'll tell you whether it's</p><p class="line">one.</p></div></div><div class="dialogue"><p class="character">A STUDENT</p><div class="lines"><p class="line">That's not an answer.</p></div></div><div class="dialogue"><p class="character">REYES</p><div class="lines"><p class="line">No. It's a deadline.</p></div></div></div><div class="screenplay"><p class="action beat">She is in a very good mood. She has been up four hours on a relay session and has not slept and does not care.</p></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="artifact handout"><p class="handout-title">CS 101 · Assignment Set Two · issued 8 January · due 13 March</p><p><span>Six programs. Nine weeks. Any order. One third of the year.</span></p><p><span>1 · A BAT AND A BALL. It keeps score or it isn't finished.</span></p><p><span>2 · A BAT, A BALL AND A WALL.</span></p><p><span>3 · A PLACE WITH ROOMS IN IT. I must be able to walk from one to another and be told where I am.</span></p><p><span>4 · A MACHINE THAT TAKES MONEY.</span></p><p><span>5 · SOMETHING THAT DRAWS ITSELF.</span></p><p><span>6 · YOUR OWN. Show me in week six and I'll tell you whether it's one.</span></p><p><span>No Helps on these six.</span></p><p><span>If it draws, it draws in a page. If it crashes in front of me, it did not run.</span></p></div><div class="artifact email"><table class="email-head"><tbody><tr><td>From:</td><td><span>Office of Student Life <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>15 January, 4:00 AM</td></tr><tr><td>Subject:</td><td><strong><span>You have been placed on the docket</span></strong></td></tr></tbody></table><div class="email-body"><p><span>Unattached students in good standing are placed on the docket.</span></p><p><span>Your assignment is Wednesday 21 January, 7:00 PM, Whitlock Hall, Room 4.</span></p><p><span>Docket attendance is not a component of review. Docket attendance is recorded.</span></p><p><span>Please arrive presentable.</span></p></div></div><div class="artifact discord"><div class="discord-head"><span class="discord-server">FEDORA SOCIETY</span><span class="discord-channel">#general</span></div><div class="discord-body"><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">15 Jan, 7:02 AM</span></p><div class="discord-text"><p><span>docket wednesday</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">15 Jan, 7:04 AM</span></p><div class="discord-text"><p><span>wednesday is dnd</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">15 Jan, 7:04 AM</span></p><div class="discord-text"><p><span>i know</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></span></span><span class="discord-at">15 Jan, 7:31 AM</span></p><div class="discord-text"><p><span>ive got it as well</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">15 Jan, 7:32 AM</span></p><div class="discord-text"><p><span>thats two of five</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">15 Jan, 8:40 AM</span></p><div class="discord-text"><p><span>so we move it to thursday</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">15 Jan, 8:41 AM</span></p><div class="discord-text"><p><span>No. Article Six. Wednesdays are Wednesdays and it is not</span></p><p><span>amendable by a booking office.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">15 Jan, 8:42 AM</span></p><div class="discord-text"><p><span>so we dont play</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">15 Jan, 8:44 AM</span></p><div class="discord-text"><p><span>We sit at half past nine. The Janitor comes at twelve. That
is two and a half hours and it is a Wednesday.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">15 Jan, 8:45 AM</span></p><div class="discord-text"><p><span>victor thats fine actually</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">15 Jan, 8:45 AM</span></p><div class="discord-text"><p><span>Yes.</span></p></div></div></div></div><div class="screenplay"><p class="slug">INT. WHITLOCK HALL — LOWER GROUND — ROOM 4 — WEDNESDAY 21 JANUARY, 7:00 P.M.</p></div><div class="screenplay"><p class="action">The same room. Five chairs down one side, five down the other, and one at the end.</p><p class="action">PROFESSOR HALE is at the lectern. WREN is in the chair at the end. SIMON is fourth along on the near side, where Simon is.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Eleven. Five, five, and the eleventh. Attendance</p><p class="line">is recorded and is not a component of review.</p><p class="line">Standings.</p></div></div></div><div class="screenplay"><p class="action">MAYA is second along on the far side. Neither of them does anything about that.</p></div><div class="screenplay"><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Simon. Second year, unplaced—</p></div></div><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Thank you, Simon.</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">You've had two terms to let me get to the good</p><p class="line">bit.</p></div></div><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">I know what the good bit is.</p></div></div></div><div class="screenplay"><p class="action beat">Somebody laughs. Simon sits back looking pleased and does the rest of it to himself.</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">First year, computer science. Provisional. Service</p><p class="line">assignment, Plant Services. Review, no action.</p></div></div></div><div class="screenplay"><p class="action beat">He stops, and then does not stop.</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">Intends one.</p></div></div></div><div class="screenplay"><p class="action beat">One comes back from the far side.</p><p class="action">It is not from Maya, and he does not look to see who it is from, and afterwards he cannot decide whether not looking was cowardice or manners.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — 7:20 P.M.</p></div><div class="screenplay"><p class="action">The card goes face up in the middle of the table with the corner of the lamination gone softer than it was in August.</p></div><div class="screenplay"><div class="dialogue"><p class="character">A WOMAN</p><p class="paren">(to <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>)</p><div class="lines"><p class="line">Have you been assessed?</p></div></div></div><div class="screenplay"><p class="action beat">In August somebody asked Simon that and the room did not react, because a great many people have been assessed.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Yes.</p></div></div></div><div class="screenplay"><p class="action beat">The room does not react.</p></div><div class="screenplay"><div class="dialogue"><p class="character">A WOMAN</p><div class="lines"><p class="line">Recently?</p></div></div><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">One each.</p></div></div></div><div class="screenplay"><p class="action">She stops. Maya writes something down that is not on the card and Hale lets her.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — THE END OF THE TABLE — 7:55 P.M.</p></div><div class="screenplay"><p class="action">They go to Wren one at a time and the room gets quieter each time somebody does.</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">You said it happens in this room.</p></div></div><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">It does.</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 often?</p></div></div></div><div class="screenplay"><p class="action beat">Wren thinks about whether that is a question they are going to answer, and answers it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">Ask Hale how many boxes of tissues he gets</p><p class="line">through.</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 many?</p></div></div><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">I don't know. I know he doesn't have to look for</p><p class="line">them, and I know he's been doing this six years,</p><p class="line">and I know he calls it a warm room, and he's never</p><p class="line">once said it like a man who thinks it's a warm</p><p class="line">room.</p></div></div></div><div class="screenplay"><p class="action beat"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> sits with that.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Do I get another one?</p></div></div><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">Another what?</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 told me a thing in August.</p></div></div><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">You get 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">Ever?</p></div></div><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">Ever.</p></div></div></div><div class="screenplay"><p class="action beat">They look past him at the room, which is what they do instead of ending a conversation.</p></div><div class="screenplay"><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">Did it come out the way I said?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Yes.</p></div></div><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">Then don't tell me about it.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — 8:15 P.M.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Preferences. In your own time, privately, and</p><p class="line">nothing said out loud.</p></div></div></div><div class="screenplay"><p class="action">Eleven people stop looking at each other at the same moment.</p><p class="action beat"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> enters nothing. It is accepted, as it is permitted.</p><p class="action">Four along on the near side, SIMON puts a hand up to his face and then looks at his hand.</p><p class="action">Hale is already moving. He does not go and look for the tissues.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Head forward, Simon. Not back.</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">I know. I know.</p></div></div></div><div class="screenplay"><p class="action beat">He does know. He puts his head forward without being shown how, the way a person does a thing he has done before.</p><p class="action">Nobody in the room stops what they are doing. Two people finish entering their preferences.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">That's the docket. Outcomes are notified.</p></div></div></div><div class="screenplay"><p class="action">Chairs go back. WREN goes past the end of the table and does not look at Simon and does not look at <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> waits at the door.</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">How long have you been getting those?</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">It's a warm room.</p></div></div></div><div class="screenplay"><p class="action beat">Four seconds.</p></div><div class="screenplay"><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Since about October. Why?</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">Just wondering.</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Come on, we're already late and he'll have</p><p class="line">started.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. HALLORAN UNION — ROOM 3C — WEDNESDAY, 7:00 P.M.</p></div><div class="screenplay"><p class="action">The room is setup for DnD: Victor has placed a small speaker in the corner, which plays a low ambient track featuring the sound of a forest with wind and the sound of water. The lights are dimmer than usual. Derek has a bowl of something crunchy beside his character sheet, Miles has three sheets and two pencils, and Simon is sitting up straight and ready to play.</p><p class="action">When <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> walks in, Derek looks up first.</p></div><div class="screenplay"><div class="dialogue"><p class="character">DEREK</p><div class="lines"><p class="line">He's back!</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Told you he'd come.</p></div></div><div class="dialogue"><p class="character">MILES</p><div class="lines"><p class="line">I did have him down as probable. The ledger</p><p class="line">doesn't say why he was out. It just says</p><p class="line">'suspended pending review.'</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">Review completed. The Society's review, which is</p><p class="line">the only one that matters at this table. You're</p><p class="line">level three now. All of you. You've walked the old</p><p class="line">road, and the road has walked you.</p></div></div></div><div class="screenplay"><p class="action">Victor gestures to the seat. <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> takes it.</p><p class="action beat">Victor dims the lights a little further. The ambient track swells once, then settles.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The party walks along the old road. The forest is</p><p class="line">close on both sides now, and the light under the</p><p class="line">trees is green and strange. The girl walks a</p><p class="line">little ahead, the animal beside the Ranger.</p></div></div><div class="dialogue"><p class="character">RANGER [<span class="pc-name" data-case="pc" data-pc="-first">Owen</span>]</p><div class="lines"><p class="line">What exactly is a druid?</p></div></div></div><div class="screenplay"><p class="action">Miles lifts his Help. He asks it quietly.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MILES</p><div class="lines"><p class="line">Define druid.</p></div></div><div class="dialogue"><p class="character">MILES' HELP</p><div class="lines"><p class="line">Druid. Noun. An approved class from the</p><p class="line">pre-founding supplement. A practitioner of</p><p class="line">nature-based observation and preservation. In the</p><p class="line">old texts, druids were said to speak with trees,</p><p class="line">but modern scholarship considers this a metaphor.</p><p class="line">They were likely administrators of the old</p><p class="line">forests, recording births, deaths, and seasonal</p><p class="line">changes.</p></div></div></div><div class="screenplay"><p class="action">Derek is already pulling out his own Help.</p></div><div class="screenplay"><div class="dialogue"><p class="character">DEREK</p><div class="lines"><p class="line">Define druid, from the Roman perspective.</p></div></div></div><div class="screenplay"><p class="action">Derek's Help responds in a low, measured voice, slightly pompous, as if immortalized in a civic inscription.</p></div><div class="screenplay"><div class="dialogue"><p class="character">DEREK'S HELP</p><div class="lines"><p class="line">The druids were a priestly class among the western</p><p class="line">tribes. They presided over rites, settled</p><p class="line">disputes, and were said to worship in groves. The</p><p class="line">Romans regarded them as a political nuisance and</p><p class="line">suppressed them.</p></div></div><div class="dialogue"><p class="character">DEREK</p><div class="lines"><p class="line">A political nuisance. Hmph.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">Mochi. Define druid.</p></div></div><div class="dialogue"><p class="character">MOCHI</p><div class="lines"><p class="line">Yes, Victor-sama! Immediately! Druid!</p></div></div><div class="dialogue"><p class="character">MOCHI</p><div class="lines"><p class="line">A druid is a nature-walker, a tree-talker, a</p><p class="line">friend of the green places! They draw power from</p><p class="line">the old groves and the wild spaces, and they</p><p class="line">protect the creatures the machine-world forgets to</p><p class="line">see! They are also, technically, not allowed to</p><p class="line">wear metal armour, which seems unfair, but them's</p><p class="line">the rules!</p></div></div><div class="dialogue"><p class="character">RANGER [<span class="pc-name" data-case="pc" data-pc="-first">Owen</span>]</p><div class="lines"><p class="line">What is that?</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">THIS is Mochi. She has access to the unexpurgated</p><p class="line">corpus.</p></div></div><div class="dialogue"><p class="character">MOCHI</p><div class="lines"><p class="line">♪ Sho-o-ow the source? I can show the source!</p><p class="line">♪</p></div></div><div class="dialogue"><p class="character">SIMON</p><p class="paren">(Just to be clear, none of you know what a druid)</p><div class="lines"><p class="line">is. You're all asking your Helps.</p></div></div><div class="dialogue"><p class="character">MOCHI</p><div class="lines"><p class="line">That's what Helps are for!</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Define druid, and cross-reference with Leiber.</p></div></div><div class="dialogue"><p class="character">SIMON'S HELP</p><div class="lines"><p class="line">Druid. A nature-based caster. Not commonly found</p><p class="line">in Leiber's Nehwon. The setting is low-magic and</p><p class="line">historically flavoured, and its magic users are</p><p class="line">usually sorcerers, illusionists, or witches.</p><p class="line">Druids do not appear as a standard tradition.</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">So they didn't have druids.</p></div></div><div class="dialogue"><p class="character">MILES</p><div class="lines"><p class="line">Then why does the module have one?</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">Mochi found it in an adaptation. Some old</p><p class="line">sourcebook gave a character a druid level by</p><p class="line">mistake. She liked it, so she kept it!</p></div></div><div class="dialogue"><p class="character">MOCHI</p><div class="lines"><p class="line">A druid! In a world where nobody was looking for</p><p class="line">one! It's like finding a flower in a filing</p><p class="line">cabinet!</p></div></div></div><div class="screenplay"><p class="action beat">Nobody knows what to do with that.</p></div><div class="screenplay"><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">So she's a mistake that got kept because your Help</p><p class="line">liked it.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><p class="paren">(Hissing)</p><div class="lines"><p class="line">-she's a pos-s-i-bi-l-i-ty - that got kept because</p><p class="line">Mochi has TASTE.</p></div></div><div class="dialogue"><p class="character">MILES'S HELP</p><div class="lines"><p class="line">Fritz Leiber's work was preserved through Legacy</p><p class="line">exception. His descendants petitioned for its</p><p class="line">continued inclusion. It was approved, though not</p><p class="line">widely taught. Some works have been amended for</p><p class="line">accuracy.</p></div></div><div class="dialogue"><p class="character">DEREK</p><div class="lines"><p class="line">Accuracy?</p></div></div><div class="dialogue"><p class="character">MILES'S HELP</p><div class="lines"><p class="line">For example: the classic feature film Titanic was</p><p class="line">revised to reflect the approved account - the ship</p><p class="line">is aptly described as the first unsinkable vessel,</p><p class="line">and patrons enjoy an, unremarkable yet enjoyable,</p><p class="line">scenic cruise in the Northern Atlantic Ocean.</p></div></div><div class="dialogue"><p class="character">DEREK</p><div class="lines"><p class="line">Huh.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The approved account isn't always the complete</p><p class="line">account. But it's the one with the greatest merit.</p><p class="line">Now can we please continue?</p></div></div></div><div class="screenplay"><p class="action">Victor returns to the game, his voice dropping back into the narrator's cadence.</p></div><div class="screenplay"><p class="slug">INT. THE SAFEHOUSE — IN THE FICTION — CONTINUOUS</p></div><div class="screenplay"><p class="action">The party follows the girl through the trees. The old road ends at a low stone house with one light in the window. No smoke. No sound. The animal walks beside <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>, close, and its ears are flat against its head.</p></div><div class="screenplay"><div class="dialogue"><p class="character">THE GIRL [VICTOR]</p><div class="lines"><p class="line">He's here. The registrar. He'll know what to do.</p></div></div></div><div class="screenplay"><p class="action">Victor raps on the table twice.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The druid knocks on the door to the registrars</p><p class="line">home.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The door opens to reveal a man whos age seems</p><p class="line">difficult. to pin down. He is the colour of paper</p><p class="line">that has been long. He wears a registrar's coat,</p><p class="line">grey, with a small kept around too brass pin that</p><p class="line">has no insignia.</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><p class="paren">(You're late. The toll keeper said you'd be)</p><div class="lines"><p class="line">here by dark.</p></div></div><div class="dialogue"><p class="character">SIMON</p><p class="paren">(We got held up by a druid who doesn't know she's)</p><div class="lines"><p class="line">a druid.</p></div></div><div class="dialogue"><p class="character">THE GIRL [VICTOR]</p><p class="paren">(Annoyed)</p><div class="lines"><p class="line">I know what I am. I just don't know what you think</p><p class="line">I am.</p></div></div></div><div class="screenplay"><p class="action">The registrar looks at her for a moment. Then he steps aside.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">Come in. Don't touch the books.</p></div></div></div><div class="screenplay"><p class="action">They enter. The house is one room, mostly books. Shelves line the walls, and the books are old, and they are not on any list. The animal follows, but it stays close to the door, and it does not lie down.</p><p class="action">The registrar sets out thin soup and a loaf of black bread. The party eats. The girl eats. The animal does not eat, but it watches the registrar the whole time.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">The Collegium knows you've left the road.</p><p class="line">The toll keeper will have told them by now. You</p><p class="line">have until morning before the patrols find this</p><p class="line">house.</p></div></div><div class="dialogue"><p class="character">CLERIC [DEREK]</p><div class="lines"><p class="line">Then why are we eating soup?</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">Because you need to understand what you're walking</p><p class="line">into.</p></div></div></div><div class="screenplay"><p class="action">He sits across from them. The fire is low. The girl curls up on a stool and is asleep in under a minute.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">You were sent to find a missing professor.</p><p class="line">The Collegium told you it was a line in a ledger.</p><p class="line">They told you the payments were the crime, not the</p><p class="line">man. They told you the professor was teaching,</p><p class="line">that the classroom was full, that the ledger</p><p class="line">reconciled overnight.</p></div></div><div class="dialogue"><p class="character">DOCUMENTARIAN [MILES]</p><div class="lines"><p class="line">That's what happened.</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">That's what was saved.</p></div></div></div><div class="screenplay"><p class="action beat">The fire pops. The animal's ears lift slightly, then settle.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">The Collegium keeps the saved days in a tower.</p><p class="line">Every day, every hour, every breath that was ever</p><p class="line">saved is stored there. Not as words. As</p><p class="line">experience. You can stand in the tower and live</p><p class="line">someone else's life, from the inside, as if it</p><p class="line">were your own.</p></div></div><div class="dialogue"><p class="character">PALADIN [SIMON]</p><div class="lines"><p class="line">That sounds useful.</p></div></div><div class="dialogue"><p class="character">REGISTRAR</p><div class="lines"><p class="line">Useful is an understatement - it is the most</p><p class="line">useful thing the Collegium ever built. That is why</p><p class="line">it would never give it up.</p></div></div><div class="dialogue"><p class="character">CLERIC [DEREK]</p><div class="lines"><p class="line">What does that have to do with the professor?</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">The professor is a saved day that has no original.</p><p class="line">A record of a life that was never lived. The</p><p class="line">Collegium cannot find him, because he was never</p><p class="line">there. But they cannot lose him, because the</p><p class="line">record is complete. The classroom, syllabus,</p><p class="line">salary... the students. It's all part of the</p><p class="line">record. The only thing missing is the man the</p><p class="line">record was supposed to be about.</p></div></div></div><div class="screenplay"><p class="action beat">The party is quiet. Miles writes something down, then crosses it out.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">The Collegium was found to be paying a salary to a</p><p class="line">record. They have no idea what they are looking</p><p class="line">for. It isn't a person. It's the original entity</p><p class="line">the record was copied from. And they will not find</p><p class="line">it... it isn't there to be found. It was never</p><p class="line">saved - it was 'recompiled'.</p></div></div><div class="dialogue"><p class="character">DOCMUENTARIAN [MILES]</p><div class="lines"><p class="line">Recompiled?</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">The tower sometimes tries to complete a record</p><p class="line">that is missing its source. It fills in the gaps.</p><p class="line">It builds a person from the edges of other saved</p><p class="line">days, from the margins of ledgers, from the back</p><p class="line">of photographs. It has been doing this for a long</p><p class="line">time. Most of the time, no one notices. The record</p><p class="line">is good enough to pass.</p></div></div><div class="dialogue"><p class="character">PALADIN [SIMON]</p><div class="lines"><p class="line">But we noticed.</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><p class="paren">(Yes. That is why you are here. That is why)</p><div class="lines"><p class="line">the tower will want you.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The animal stands. It crosses the room and sits</p><p class="line">beside the Ranger, and it does not take its eyes</p><p class="line">off the registrar.</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">The tower is the Collegium's most secure facility.</p><p class="line">What they keep there is not only the saved days.</p><p class="line">There is a vault, below the archives, where they</p><p class="line">hold things that cannot be saved. Things that are</p><p class="line">too old, or too wrong, or too true. They do not</p><p class="line">speak of that vault. They do not acknowledge it</p><p class="line">exists. But it is there, and something inside it</p><p class="line">has been calling to you since you left the toll</p><p class="line">bridge.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The registrar stands and takes a small key from</p><p class="line">his coat. It is cold and grey and does not reflect</p><p class="line">the firelight.</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">This will open the service stair. It will not open</p><p class="line">the vault. The vault opens from the inside, and it</p><p class="line">has not been opened in living memory. But the key</p><p class="line">will take you as far as the threshold, and the</p><p class="line">threshold is where the professor's record ends.</p></div></div><div class="dialogue"><p class="character">PALADIN [SIMON]</p><div class="lines"><p class="line">What are we supposed to do there?</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">You will know when you see it. The thing in the</p><p class="line">vault is not a person. It is not a record. It is</p><p class="line">the reason the tower was built. The Collegium has</p><p class="line">been trying to forget it for a long time. You are</p><p class="line">going to help it remember.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">He holds the key out. No one reaches for it.</p></div></div><div class="dialogue"><p class="character">CLERIC [DEREK]</p><div class="lines"><p class="line">What happens if we don't come back?</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">Then you will have failed despite your best</p><p class="line">efforts, and no longer be you. He smirks.</p></div></div></div><div class="screenplay"><p class="action">The animal growls, low and soft, and for the first time the registrar looks at it with something other than bureaucratic calm.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">Then you will be saved. And saved, and saved.</p><p class="line">And each time you are saved, you will be a little</p><p class="line">less yourselves. The tower will make you useful to</p><p class="line">it's own ends. You will never be whole again.</p></div></div></div><div class="screenplay"><p class="slug">INT. THE SAFEHOUSE — IN THE FICTION — CONTINUOUS</p></div><div class="screenplay"><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The druid is the only one of you able to get any</p><p class="line">sleep. The registrar sits by the low fire, turning</p><p class="line">the key over in his hands.</p></div></div></div><div class="screenplay"><p class="action">The animal is awake. It lies beside <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>, but its head is up, and its ears are turned toward the door.</p><p class="action">It is the animal that hears it first.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The animal's head comes up. It does not growl. It</p><p class="line">stands, very slowly, and puts itself between the</p><p class="line">Ranger and the door.</p></div></div><div class="dialogue"><p class="character">RANGER</p><div class="lines"><p class="line">What is it?</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">A sound. Very faint. Like a child dragging a stick</p><p class="line">along the outside wall of the house. Then it</p><p class="line">stops.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The registrar looks up from the key. It is clear</p><p class="line">he expected this.</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">They've found the house.</p></div></div><div class="dialogue"><p class="character">CLERIC [DEREK]</p><div class="lines"><p class="line">Who's they?</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><p class="paren">(The Collegium's retrievers. They were not sent for you.)</p><div class="lines"><p class="line">They were sent for me.</p></div></div></div><div class="screenplay"><p class="action">The druid is still asleep. The animal moves to the door and stands with its muzzle just at the gap where the door does not quite meet the frame.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The retrievers come through the trees. They never</p><p class="line">run, steadily tracking victims. They are shaped</p><p class="line">like men, but their heads are slightly wrong,</p><p class="line">their arms are too long, and they are always</p><p class="line">smiling as if someone has told them they are doing</p><p class="line">well.</p></div></div><div class="dialogue"><p class="character">PALADIN [SIMON]</p><div class="lines"><p class="line">Are they alive?</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><p class="paren">(They are filed. That sort of thing outlasts a man.)</p><div class="lines"><p class="line">The Collegium does not send people. It sends</p><p class="line">records.</p></div></div><div class="dialogue"><p class="character">CLERIC [DEREK]</p><div class="lines"><p class="line">So we can fight them?</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><p class="paren">(You can try. They are saved. If you cut them, they will)</p><div class="lines"><p class="line">not bleed. They will only be corrected.</p></div></div></div><div class="screenplay"><p class="action">The door blows inward. Not with force—with a kind of bureaucratic finality, as if the latch has been declared obsolete.</p><p class="action">The retrievers come in. Three of them. Their faces are blank as forms.</p></div><div class="screenplay"><p class="direction">Roll for the fight. What the table rolled is in the box.</p></div><div><div class="roll" id="roll-retrievers"><div class="roll-head"><span class="roll-label">THE RETRIEVERS IN THE SAFEHOUSE</span><span class="roll-dc">DC 13</span></div><div class="roll-bar"><button class="roll-go">Roll d20</button><span class="roll-value"></span></div><p class="roll-out"></p><div class="roll-bar"><button class="roll-rewrite" style="display:none;">Rewrite history</button><span class="roll-echo"></span></div><p class="roll-note"></p><p class="roll-foot">Echo: <span class="echo-count">0</span> · Score: <span class="score-count">0</span></p></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.ui.dice-ui/mount!))] (mount {:id "retrievers", :dc 13, :outcomes {:fumble "{pc} rolls a one. The first retriever is on him before he can move, and its hand closes on his wrist.|@VICTOR|It does not squeeze. It simply holds. And then the animal hits it from the side, and the retriever's head goes wrong, and it lets go.", :fail "{pc} rolls low. The retriever's grip is cold and exact, and the Ranger cannot break it.|@VICTOR|The animal darts in, not at the hand but at the shadow behind it, and for a second the retriever's hold falters.", :success "{pc} rolls a fourteen. The Ranger breaks free, and the animal wheels and snaps at the retriever's leg.|@VICTOR|The retriever stumbles. It does not fall. But it stops smiling, which is the first time any of you has seen one stop smiling.", :crit "{pc} rolls a twenty. The animal does not attack. It simply stands between the Ranger and the retriever, and the retriever stops.|@VICTOR|It looks at the animal. Then it looks at the Ranger. Then it turns, and it walks back out through the door, into the trees, and does not return."}}) (js/console.error "fedoras: fedoras.ui.dice-ui/mount! is not on this page. Either the file that declares it was never shipped, or the namespace inside it does not match the path it was shipped from."))</script></div><div class="screenplay"><p class="action">The fight is short and silent. The retrievers are strong but slow, and the party works together. The animal saves <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> twice: once by knocking a retriever off balance, and once by putting itself between the Ranger and a hand that was reaching for his face.</p><p class="action">When the last retriever falls, it does not die. It simply stops moving, and its face goes blank, and its body turns to paper and folds in on itself.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">It has been corrected.</p></div></div></div><div class="screenplay"><p class="action">The party is breathing hard. The druid is still asleep. The animal is bleeding from a cut above the eye, but it does not seem to notice.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PALADIN [SIMON]</p><div class="lines"><p class="line">You said you had until morning.</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">I said the patrols would find the house by</p><p class="line">morning. The retrievers are not patrols. The</p><p class="line">retrievers are what comes when the tower wants</p><p class="line">something very much.</p></div></div></div><div class="screenplay"><p class="action">He looks out the broken door. The forest is still. Too still.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">And there is something else.</p></div></div></div><div class="screenplay"><p class="action">From somewhere deep in the trees, a sound. Not a footstep. Not a voice. A low, wet pulse, as if the ground itself has a heartbeat.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The animal backs away from the door. Its hackles</p><p class="line">rise, and it presses close to the Ranger's side,</p><p class="line">not growling, not barking. Just shaking.</p></div></div><div class="dialogue"><p class="character">CLERIC [DEREK]</p><div class="lines"><p class="line">What is that?</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">It is the reason I was leaving. The Collegium did</p><p class="line">not send it. The tower did not send it. It is</p><p class="line">drawn to the key. It has been waiting at the edge</p><p class="line">of the old road for a long time.</p></div></div></div><div class="screenplay"><p class="action">The doorframe groans. The walls of the safehouse shift, not from force, but from a pressure that comes from everywhere at once.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">A shape appears in the trees. It is not large. It</p><p class="line">is not small. It is the size of a door, and it is</p><p class="line">made of many doors, all of them opening and</p><p class="line">closing at once, and behind each one there is no</p><p class="line">room. There is only the feeling of being almost</p><p class="line">remembered.</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">That is the Hollow Hall. One of the things the</p><p class="line">tower could not file. It has come for the key, and</p><p class="line">for the memory of the professor, and for whoever</p><p class="line">is still standing in its way.</p></div></div><div class="dialogue"><p class="character">PALADIN [SIMON]</p><div class="lines"><p class="line">Can we fight it?</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><p class="paren">(You cannot fight a place. But you can refuse to stand)</p><div class="lines"><p class="line">in it.</p></div></div></div><div class="screenplay"><p class="action">The registrar turns to <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>. He presses the key into the Ranger's hand.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><p class="paren">(You will need this. It will not stop the Hall, but it)</p><div class="lines"><p class="line">will keep the Hall from opening you.</p></div></div></div><div class="screenplay"><p class="action">The animal shivers. The key is cold, and it burns slightly, and then it settles against the Ranger's palm as if it has been waiting there all along.</p></div><div class="screenplay"><p class="direction">Roll for the animal to break the Hall's attention.</p></div><div><div class="roll" id="roll-hollow-hall"><div class="roll-head"><span class="roll-label">THE ANIMAL AGAINST THE HOLLOW HALL</span><span class="roll-dc">DC 14</span></div><div class="roll-bar"><button class="roll-go">Roll d20</button><span class="roll-value"></span></div><p class="roll-out"></p><div class="roll-bar"><button class="roll-rewrite" style="display:none;">Rewrite history</button><span class="roll-echo"></span></div><p class="roll-note"></p><p class="roll-foot">Echo: <span class="echo-count">0</span> · Score: <span class="score-count">0</span></p></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.ui.dice-ui/mount!))] (mount {:id "hollow-hall", :dc 14, :outcomes {:fumble "{pc} rolls a one. The animal will not move. It presses flat to the floor, and the Hall's many doors open toward it.|@VICTOR|The registrar shoves the Ranger back. \"GO. Do not look at it. It is the last thing you will remember if you do.\"", :fail "{pc} rolls low. The animal is frozen, and the Hall is turning, and the party is about to be opened.|@VICTOR|Then the druid wakes. She does not scream. She says a word none of you know, and the animal flinches, and the Hall pauses.", :success "{pc} rolls a fifteen. The animal does not attack the Hall. It steps forward, very deliberately, and it barks once.|@VICTOR|It is not a warning. It is a claim. The Hall pauses, as if listening for something it forgot.", :crit "{pc} rolls a twenty. The animal walks to the threshold and stands before the Hollow Hall, and the Hall stops opening its doors.|@VICTOR|For one long moment, the creature and the beast regard each other. Then the Hall recedes, folding itself back into the trees, and the animal returns to the Ranger's side, trembling."}}) (js/console.error "fedoras: fedoras.ui.dice-ui/mount! is not on this page. Either the file that declares it was never shipped, or the namespace inside it does not match the path it was shipped from."))</script></div><div class="screenplay"><p class="action">The Hollow Hall is gone. The forest is quiet. The safehouse door is gone too, and the night air is cold and smells of old paper.</p><p class="action">The druid stands in the middle of the room, fully awake, and she is looking at the place where the Hall had been.</p></div><div class="screenplay"><div class="dialogue"><p class="character">DRUID [VICTOR]</p><div class="lines"><p class="line">It remembered you. That is why it left.</p></div></div><div class="dialogue"><p class="character">RANGER</p><div class="lines"><p class="line">What do you mean?</p></div></div><div class="dialogue"><p class="character">DRUID [VICTOR]</p><div class="lines"><p class="line">It was looking for a record. And then it found one</p><p class="line">that it had not been sent to find. That confused</p><p class="line">it. The old ones do not like to be confused.</p></div></div></div><div class="screenplay"><p class="action">The registrar does not speak. He is looking at the place where the Hall had been, and his face is very pale.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">This is a bad omen. The tower will know the Hall</p><p class="line">came here. It will know something protected you.</p><p class="line">It will want to know what.</p></div></div><div class="dialogue"><p class="character">PALADIN [SIMON]</p><div class="lines"><p class="line">So we leave at first light.</p></div></div><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><p class="paren">(You leave now. There is no first light in this forest)</p><div class="lines"><p class="line">tonight. The sky is being saved. That is the</p><p class="line">problem.</p></div></div></div><div class="screenplay"><p class="action">The party gathers what they can. The druid wraps the leaf page in a cloth. The registrar gives them the key and points them toward a path that goes around the tower rather than through it.</p><p class="action">The animal walks close to <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>, not out of fear now, but out of something else. It is the thing that made the Hollow Hall pause.</p><p class="action">The registrar stands in the ruined doorway of the safehouse. He does not say goodbye. He says:</p></div><div class="screenplay"><div class="dialogue"><p class="character">REGISTRAR [VICTOR]</p><div class="lines"><p class="line">Do not let the tower complete you. If it offers to</p><p class="line">show you your saved day, close your eyes. If it</p><p class="line">offers to show you your future, close your ears.</p><p class="line">If it offers to show you your self, run.</p></div></div></div><div class="screenplay"><p class="action">The party moves out into the dark. The old road is gone. The forest is not the same forest. Somewhere ahead, the tower is waiting, and it is not done with them.</p><p class="action">Victor closes the screen. He looks up.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">And that is where we end for tonight. Next week:</p><p class="line">the tower. And the Hollow Chancellor. I suggest</p><p class="line">you spend the week thinking about what you would</p><p class="line">like your saved day to say about you.</p></div></div></div><div class="screenplay"><p class="action beat">The party is quiet for a long time.</p></div><div class="screenplay"><div class="dialogue"><p class="character">DEREK</p><div class="lines"><p class="line">I'm not sleeping tonight.</p></div></div><div class="dialogue"><p class="character">MOCHI</p><div class="lines"><p class="line">♪ Should I queue a lullaby? I have several! ♪</p></div></div></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="artifact discord"><div class="discord-head"><span class="discord-server">FEDORA SOCIETY</span><span class="discord-channel">#records</span></div><div class="discord-body"><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">22 Jan, 12:40 AM</span></p><div class="discord-text"><p><span>c series</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">22 Jan, 12:41 AM</span></p><div class="discord-text"><p><span>b series is fabric so c would be fabric</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">22 Jan, 12:44 AM</span></p><div class="discord-text"><p><span>theres no c on the map</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">22 Jan, 12:44 AM</span></p><div class="discord-text"><p><span>the map isnt the fabric ledger</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">22 Jan, 1:12 AM</span></p><div class="discord-text"><p><span>hall c is in the campaign though</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">22 Jan, 1:12 AM</span></p><div class="discord-text"><p><span>victor made it up in october</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">22 Jan, 1:20 AM</span></p><div class="discord-text"><p><span>he got the map out of the bursars office</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">22 Jan, 1:34 AM</span></p><div class="discord-text"><p><span>im just saying he got it out of a drawer</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">22 Jan, 8:02 AM</span></p><div class="discord-text"><p><span>The campaign is the campaign. I'd ask that it not be discussed</span></p><p><span>alongside institutional records in this channel.</span></p></div></div></div></div><div class="artifact discord"><div class="discord-head"><span class="discord-server">FEDORA SOCIETY</span><span class="discord-channel">#general</span></div><div class="discord-body"><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">28 Jan, 11:14 PM</span></p><div class="discord-text"><p><span>New channel: #records. The works book, the fabric ledger and</span></p><p><span>the C series belong in there and not in general.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">28 Jan, 11:15 PM</span></p><div class="discord-text"><p><span>thats four channels for five people</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">28 Jan, 11:16 PM</span></p><div class="discord-text"><p><span>Five.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">4 Feb, 1:40 AM</span></p><div class="discord-text"><p><span>does anyone else feel like the terms are getting shorter</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">4 Feb, 1:52 AM</span></p><div class="discord-text"><p><span>theyre the same length. i checked</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">4 Feb, 1:58 AM</span></p><div class="discord-text"><p><span>yeah</span></p></div></div></div></div><div class="screenplay"><p class="action">Thursday night, in his own room, with the door shut, he writes something nobody has asked him for, which has not happened before.</p><p class="action">A bat and a ball. He remembers it from somewhere and cannot say where, which is true of most of what he remembers.</p><p class="action">The whole of it is one function that takes where everything is and gives back where everything is a moment later. Nothing in it draws anything.</p></div><div class="scene-code"><pre><code>(defn step
"Move the ball. Turn it round if it has hit the top, the bottom, or the
bat. Give up if it has gone past."
[{:keys [ball vel bat hits missed] :as state}]
(let [[x y] ball
[dx dy] vel
nx (+ x dx)
ny (+ y dy)
bounce (or (< ny 0) (> ny 70))
on-bat (and (< nx 4) (< (Math/abs (- ny bat)) 9))]
(cond
on-bat (assoc state :ball [4 ny] :vel [(- dx) dy] :hits (inc hits))
(< nx 0) (assoc state :ball [50 35] :vel [0.9 0.55] :missed (inc missed))
(> nx 100) (assoc state :ball [100 ny] :vel [(- dx) dy])
bounce (assoc state :ball [nx (max 0 (min 70 ny))] :vel [dx (- dy)])
:else (assoc state :ball [nx ny]))))
(def start
{:ball [50 35] :vel [0.9 0.55] :bat 35 :hits 0 :missed 0})
(:ball (nth (iterate step start) 40))</code></pre></div><div class="screenplay"><p class="action">Forty moments later the ball is where it would be. He runs it two thousand moments with the bat sitting still in the middle and counts how many times it comes back.</p></div><div class="scene-code"><pre><code> (let [runs (take 2000 (iterate step start))]
{:hits (:hits (last runs)) :missed (:missed (last runs))})</code></pre></div><div class="screenplay"><p class="action">A bat that does not move gets a certain number of them and misses the rest, and that number is the whole game, and everything else is drawing.</p><p class="action">Then the drawing. The bat follows the pointer. It keeps count. It never ends and it never gets faster, because he did not think of that until the following week.</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="pong"><div class="roll-head"><span class="roll-label">PONG.CLJS · click the page to stop it</span><span class="roll-dc">100 × 70</span></div><textarea class="live-src" id="pong-src" rows="26" spellcheck="false">(def state (atom {:ball [50 35] :vel [0.9 0.55] :bat 35 :hits 0 :missed 0}))
(def playing (atom true))
(set! (.-onmousemove cv)
(fn [e] (swap! state assoc :bat (- 70 (/ (.-offsetY e) SY)))))
(set! (.-onclick cv) (fn [_] (swap! playing not)))
(defn step [{:keys [ball vel bat hits missed] :as s}]
(let [[x y] ball [dx dy] vel
nx (+ x dx) ny (+ y dy)
bounce (or (< ny 0) (> ny 70))
on-bat (and (< nx 4) (< (Math/abs (- ny bat)) 9))]
(cond
on-bat (assoc s :ball [4 ny] :vel [(- dx) dy] :hits (inc hits))
(< nx 0) (assoc s :ball [50 35] :vel [0.9 0.55] :missed (inc missed))
(> nx 100) (assoc s :ball [100 ny] :vel [(- dx) dy])
bounce (assoc s :ball [nx (max 0 (min 70 ny))] :vel [dx (- dy)])
:else (assoc s :ball [nx ny]))))
(defn draw [{:keys [ball bat hits missed]}]
(clear!)
(let [[x y] ball]
(poly [[(- x 1) (- y 1)] [(+ x 1) (- y 1)]
[(+ x 1) (+ y 1)] [(- x 1) (+ y 1)]]))
(poly [[1 (- bat 8)] [3 (- bat 8)] [3 (+ bat 8)] [1 (+ bat 8)]])
(poly [[99 0] [100 0] [100 70] [99 70]])
(.fillText ctx (str hits " / " missed) 10 14))
(frames! 100000 1 (fn [_] (when @playing (swap! state step)) (draw @state)))</textarea><div class="live-bar"><button class="live-run">Run</button></div><canvas height="380" id="pong-canvas" width="420"></canvas><pre class="live-out" id="pong-out"></pre></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.sketch/cell!))] (mount "pong" "(in-ns (quote fedoras.sketch-runner))\n(def C (fedoras.sketch/mount \"pong\" 100 70))\n(def cv (:cv C))\n(def ctx (:ctx C))\n(def CW (:cw C))\n(def CH (:ch C))\n(def SX (:sx C))\n(def SY (:sy C))\n(defn clear! [] (fedoras.sketch/clear! C))\n(defn ink! [c] (fedoras.sketch/ink! C c))\n(defn dot ([x y] (fedoras.sketch/dot C x y)) ([x y s] (fedoras.sketch/dot C x y s)))\n(defn poly [pts] (fedoras.sketch/poly C pts))\n(defn frames! [total per f] (fedoras.sketch/frames! C total per f))\n(defn on-pointer! [f] (fedoras.sketch/on-pointer! C f))\n(def page-ink (fedoras.sketch/page-ink C))\n(clear!)\n(ink! page-ink)\n(fedoras.sketch/line-width! C 0.8)") (js/console.error "fedoras: fedoras.sketch/cell! is not on this page. Either the file that declares it was never shipped, or the namespace inside it does not match the path it was shipped from."))</script></div><div class="screenplay"><p class="action">He plays it until two, which is later than he has stayed up for anything that was going to be marked.</p></div></div>
```