---
format:
html:
toc: true
toc-depth: 2
theme: {dark: darkly, light: cosmo}
number-sections: false
number-depth: 0
code-fold: false
code-tools: true
highlight-style: nord
---
<style></style><style>.printedClojure .sourceCode {
background-color: transparent;
border-style: none;
}
</style><style>.clay-limit-image-width .clay-image {max-width: 100%}
.clay-side-by-side .sourceCode {margin: 0}
.clay-side-by-side {margin: 1em 0}
</style>
<script src="https://code.jquery.com/jquery-3.6.0.min.js" type="text/javascript"></script><script src="https://code.jquery.com/ui/1.13.1/jquery-ui.min.js" type="text/javascript"></script>
```{=html}
<div style="display:none;"><style>/* ---------------------------------------------------------------
Fedoras — one palette, two schemes.
Dark is the default. Quarto puts .quarto-light on the body when the
reader flips the toggle, and everything below is written against
these variables, so a scheme change is nine lines rather than three
hundred.
--------------------------------------------------------------- */
:root {
--paper: #1b1d21; /* background */
--ink: #e6e4df; /* text */
--ink-soft: #a9a49b; /* secondary text */
--rule: rgba(230, 228, 223, 0.22);
--panel: rgba(230, 228, 223, 0.05);
--violet: #9d8dff; /* apparatus, epigraphs */
--gold: #d0a44c; /* beats, commits */
--coral: #e2707c; /* moderation, rewrites */
--green: #6fcf9b; /* the Helps */
--blurple: #8b95f0; /* Discord */
--violet-rgb: 157, 141, 255;
--gold-rgb: 208, 164, 76;
--coral-rgb: 226, 112, 124;
--green-rgb: 111, 207, 155;
--blurple-rgb: 139, 149, 240;
--ink-rgb: 168, 164, 156;
--panel-hard: #101215;
}
body.quarto-light {
--paper: #faf8f4;
--ink: #1c1f24;
--ink-soft: #6b665e;
--rule: rgba(28, 31, 36, 0.22);
--panel: rgba(28, 31, 36, 0.05);
--violet: #6c5ce7;
--gold: #b0863a;
--coral: #e04f5f;
--green: #2ea078;
--blurple: #5865f2;
--violet-rgb: 108, 92, 231;
--gold-rgb: 176, 134, 58;
--coral-rgb: 224, 79, 95;
--green-rgb: 46, 160, 120;
--blurple-rgb: 88, 101, 242;
--ink-rgb: 128, 128, 128;
--panel-hard: #14161a;
}
/* ---------------------------------------------------------------
Fedoras — screenplay typography for the HTML edition.
Loaded by fedoras.screenplay/assets. Edit here, rebuild, done.
PDF styling lives in notebooks/tex/fedoras-preamble.tex instead.
--------------------------------------------------------------- */
.screenplay {
font-family: "Courier Prime", "Courier New", ui-monospace, monospace;
font-size: 1rem;
line-height: 1.35;
max-width: 62ch;
margin: 1.6rem 0 2.2rem 0;
}
.screenplay .slug {
font-weight: 700;
letter-spacing: 0.04em;
margin: 1.6rem 0 0.8rem 0;
}
.screenplay .action {
margin: 0 0 0.85rem 0;
}
.action{
white-space: pre-line;
}
.screenplay .action.beat {
margin-bottom: 0.35rem;
}
.screenplay .dialogue {
margin: 0 0 0.95rem 0;
}
.screenplay .character {
margin: 0 0 0 12ch;
font-weight: 700;
letter-spacing: 0.06em;
}
.screenplay .paren {
margin: 0 0 0 9ch;
font-style: italic;
opacity: 0.85;
}
.screenplay .lines {
margin-left: 6ch;
max-width: 50ch;
}
.screenplay .line {
margin: 0;
}
.screenplay .transition {
margin: 1.1rem 0 1.1rem 0;
text-align: right;
font-weight: 700;
letter-spacing: 0.08em;
}
.screenplay .direction {
font-style: italic;
opacity: 0.85;
margin: 0 0 0.9rem 0;
}
/* In-world text: banners, signs, whiteboards. */
.screenplay .sign {
border: 1px solid currentColor;
padding: 0.7rem 1rem;
margin: 0.9rem 0 1.1rem 4ch;
max-width: 46ch;
text-align: center;
letter-spacing: 0.05em;
}
.screenplay .sign p {
margin: 0.15rem 0;
}
.screenplay .whiteboard {
text-align: left;
}
.screenplay .whiteboard-title {
font-weight: 700;
text-transform: uppercase;
margin-bottom: 0.4rem !important;
}
.screenplay .whiteboard ol {
margin: 0;
padding-left: 2.2ch;
}
/* --- The Fedora Handbook epigraphs ------------------------------- */
.handbook {
max-width: 54ch;
margin: 1.4rem 0 2rem 2ch;
padding-left: 1.1rem;
border-left: 3px solid var(--violet);
font-style: italic;
}
.handbook .rule-number {
font-style: normal;
font-variant: small-caps;
letter-spacing: 0.08em;
opacity: 0.75;
margin-bottom: 0.25rem;
}
.handbook .rule-text {
margin: 0;
}
/* --- Drafting apparatus (hidden in the reading copy) -------------- */
.beats {
border: 1px dashed var(--gold);
background: rgba(var(--gold-rgb), 0.07);
padding: 0.6rem 0.9rem;
margin: 1.1rem 0;
max-width: 62ch;
font-size: 0.88rem;
}
.beats-title {
font-variant: small-caps;
letter-spacing: 0.1em;
font-weight: 700;
margin: 0 0 0.35rem 0;
opacity: 0.8;
}
.beats ul {
margin: 0;
padding-left: 1.4rem;
}
.beats.note {
border-color: var(--blurple);
background: rgba(var(--blurple-rgb), 0.07);
}
.status {
display: inline-block;
font-family: ui-monospace, monospace;
font-size: 0.7rem;
letter-spacing: 0.14em;
padding: 0.15rem 0.5rem;
border-radius: 3px;
margin: 0 0 0.8rem 0;
}
.status-outline { background: var(--ink-soft); color: #fff; }
.status-draft { background: var(--gold); color: #fff; }
.status-locked { background: var(--green); color: #fff; }
/* --- Live (editable) code cells ---------------------------------- */
.live-cell {
border: 1px solid rgba(var(--ink-rgb), 0.4);
border-radius: 5px;
padding: 0.7rem 0.8rem;
margin: 1.2rem 0;
max-width: 68ch;
}
.live-label {
font-size: 0.78rem;
letter-spacing: 0.09em;
text-transform: uppercase;
opacity: 0.7;
margin-bottom: 0.45rem;
}
.live-src {
width: 100%;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.9rem;
line-height: 1.4;
padding: 0.5rem;
border: 1px solid rgba(var(--ink-rgb), 0.35);
border-radius: 4px;
background: rgba(var(--ink-rgb), 0.06);
resize: vertical;
}
.live-bar {
margin-top: 0.5rem;
display: flex;
gap: 0.5rem;
align-items: center;
}
.live-run {
font: inherit;
font-size: 0.85rem;
padding: 0.25rem 0.9rem;
border-radius: 4px;
border: 1px solid rgba(var(--ink-rgb), 0.5);
background: rgba(var(--violet-rgb), 0.15);
cursor: pointer;
}
.live-run:hover { background: rgba(var(--violet-rgb), 0.3); }
.live-out {
margin: 0.5rem 0 0 0;
padding: 0.45rem 0.6rem;
min-height: 1.6rem;
font-size: 0.88rem;
white-space: pre-wrap;
border-left: 3px solid rgba(var(--violet-rgb), 0.6);
background: rgba(var(--ink-rgb), 0.06);
}
/* --- Front matter ------------------------------------------------- */
.title-page {
text-align: center;
margin: 3rem 0 2rem 0;
}
.title-page .title {
font-family: "Courier Prime", "Courier New", monospace;
font-size: 3.2rem;
letter-spacing: 0.22em;
margin: 0;
}
.title-page .subtitle {
font-family: "Courier Prime", "Courier New", monospace;
letter-spacing: 0.1em;
margin-top: 0.8rem;
opacity: 0.85;
}
h1.sidebar-title,
h2.sidebar-title {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.personae {
max-width: 66ch;
}
.personae h3 {
font-variant: small-caps;
letter-spacing: 0.1em;
margin-top: 2rem;
border-bottom: 1px solid rgba(var(--ink-rgb), 0.35);
padding-bottom: 0.2rem;
}
.personae .who {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.06em;
margin: 1rem 0 0.1rem 0;
}
.personae .what {
margin: 0 0 0.2rem 0;
font-style: italic;
opacity: 0.8;
}
.personae .desc {
margin: 0.15rem 0 0 0;
max-width: 60ch;
}
/* --- The reader's protagonist ------------------------------------ */
.pc-name {
font-weight: inherit;
border-bottom: 1px dotted rgba(var(--violet-rgb), 0.55);
}
.screenplay .slug,
.screenplay .transition,
.screenplay .character { text-transform: uppercase; }
.name-field {
border: 1px solid rgba(var(--violet-rgb), 0.55);
border-radius: 5px;
padding: 0.9rem 1rem;
margin: 1.6rem auto;
max-width: 60ch;
}
.name-label { margin: 0 0 0.6rem 0; }
.name-inputs { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.name-inputs input {
font: inherit;
padding: 0.3rem 0.6rem;
border: 1px solid rgba(var(--ink-rgb), 0.45);
border-radius: 4px;
background: rgba(var(--ink-rgb), 0.06);
max-width: 14ch;
}
.name-note {
font-size: 0.82rem;
opacity: 0.7;
margin: 0.6rem 0 0 0;
}
.name-label,
.name-confirm {
font-size: 1.2rem;
}
.name-inputs input,
.name-inputs button {
font: inherit;
font-size: 1.2rem;
}
/* --- Artifacts ---------------------------------------------------- */
.artifact {
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.88rem;
line-height: 1.45;
max-width: 66ch;
margin: 1.4rem 0;
border-radius: 5px;
overflow: hidden;
}
.artifact p { margin: 0.1rem 0; }
/* Discord */
.discord { border: 1px solid rgba(var(--blurple-rgb), 0.5); }
.discord-head {
background: rgba(var(--blurple-rgb), 0.14);
padding: 0.4rem 0.8rem;
display: flex; gap: 0.8rem;
}
.discord-server { font-weight: 700; letter-spacing: 0.06em; }
.discord-channel { opacity: 0.75; }
.discord-body { padding: 0.7rem 0.8rem; }
.discord-msg { margin-bottom: 0.7rem; }
.discord-who { font-weight: 700; }
.discord-role {
font-size: 0.7rem; letter-spacing: 0.08em;
background: rgba(var(--violet-rgb), 0.25);
padding: 0.05rem 0.4rem; border-radius: 3px; margin-left: 0.4rem;
}
.discord-at { opacity: 0.55; margin-left: 0.5rem; font-size: 0.78rem; }
.discord-reacts { margin-top: 0.2rem !important; }
.react {
display: inline-block; font-size: 0.75rem;
border: 1px solid rgba(var(--ink-rgb),0.35); border-radius: 10px;
padding: 0 0.45rem; margin-right: 0.3rem;
}
/* Moderation log */
.modlog {
border-left: 3px solid var(--coral);
background: rgba(var(--coral-rgb), 0.08);
padding: 0.6rem 0.9rem;
font-size: 0.82rem;
letter-spacing: 0.03em;
}
/* Git */
.commit {
border: 1px solid rgba(var(--ink-rgb), 0.4);
padding: 0.7rem 0.9rem;
background: rgba(var(--ink-rgb), 0.05);
}
.commit-hash { color: var(--gold); font-weight: 700; }
.commit-meta { opacity: 0.7; }
.commit-msg { margin-top: 0.5rem; padding-left: 1.5rem; }
/* Terminal */
.terminal {
background: var(--panel-hard);
color: var(--ink);
padding: 0.7rem 0.9rem;
border: 1px solid rgba(var(--ink-rgb),0.35);
}
.term-in { color: var(--green); }
.term-in::before { content: ""; }
.term-out { opacity: 0.9; }
/* AI */
.aichat { border: 1px solid rgba(var(--green-rgb), 0.5); }
.ai-model {
background: rgba(var(--green-rgb), 0.16);
padding: 0.35rem 0.8rem !important;
font-weight: 700; letter-spacing: 0.1em; font-size: 0.75rem;
}
.ai-system { padding: 0.4rem 0.8rem; font-size: 0.8rem; opacity: 0.85; }
.ai-system pre { white-space: pre-wrap; margin: 0.4rem 0 0 0; }
.ai-body { padding: 0.6rem 0.8rem; }
.ai-turn { margin-bottom: 0.6rem; }
.ai-role { font-size: 0.68rem; letter-spacing: 0.12em; opacity: 0.6; }
.ai-assistant .ai-text { border-left: 2px solid rgba(46,160,120,0.6); padding-left: 0.6rem; }
/* Email */
.email { border: 1px solid rgba(var(--ink-rgb),0.45); padding: 0.7rem 0.9rem; }
.email-head td { padding: 0 0.6rem 0.1rem 0; vertical-align: top; font-size: 0.82rem; }
.email-head td:first-child { opacity: 0.6; }
.email-body { margin-top: 0.6rem; }
/* Handouts */
.handout {
border: 1px solid currentColor;
padding: 1rem 1.2rem;
font-family: "Courier Prime", "Courier New", monospace;
}
.handout-title {
font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
border-bottom: 1px solid currentColor; padding-bottom: 0.4rem;
margin-bottom: 0.7rem !important;
}
.handout-table { width: 100%; font-size: 0.85rem; margin: 0.5rem 0; }
.handout-table td.k { opacity: 0.65; width: 34%; padding-right: 1rem; vertical-align: top; }
/* --- Prose between scenes ---------------------------------------- */
.prose {
max-width: 64ch;
margin: 1.5rem 0 2rem 0;
font-family: "Iowan Old Style", "Palatino Linotype", Palatino,
"Book Antiqua", Georgia, serif;
font-size: 1.06rem;
line-height: 1.65;
color: var(--ink);
}
.prose p {
margin: 0 0 1.05rem 0;
}
.prose p:first-child { margin-top: 0; }
.prose p:last-child { margin-bottom: 0; }
blockquote.turn {
max-width: 64ch;
border-left: 3px solid rgba(var(--violet-rgb), 0.6);
padding-left: 1rem;
margin: 1.4rem 0 0.6rem 0;
}
.name-confirm {
margin: 0.7rem 0 0 0;
font-weight: 600;
}
/* --- The d20 ------------------------------------------------------ */
.roll {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 60ch;
font-family: ui-monospace, "Courier New", monospace;
}
.roll-head {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
letter-spacing: 0.12em;
opacity: 0.75;
margin-bottom: 0.5rem;
}
.roll-bar { display: flex; gap: 0.7rem; align-items: center; margin-top: 0.3rem; }
.roll-go, .roll-rewrite {
font: inherit;
font-size: 0.82rem;
padding: 0.25rem 0.9rem;
border-radius: 4px;
border: 1px solid rgba(var(--ink-rgb), 0.5);
background: rgba(var(--violet-rgb), 0.15);
cursor: pointer;
}
.roll-rewrite { background: rgba(var(--coral-rgb), 0.12); }
.roll-go:hover { background: rgba(var(--violet-rgb), 0.3); }
.roll-rewrite:hover { background: rgba(var(--coral-rgb), 0.25); }
.roll-value { font-size: 0.9rem; letter-spacing: 0.05em; }
.roll-out {
margin: 0.6rem 0 0 0 !important;
font-family: "Courier Prime", "Courier New", monospace;
min-height: 1.2rem;
}
.roll-echo { font-size: 0.8rem; opacity: 0.8; }
.roll-note { font-size: 0.8rem; opacity: 0.6; margin: 0.4rem 0 0 0 !important; }
.roll-foot {
font-size: 0.72rem;
letter-spacing: 0.1em;
opacity: 0.55;
margin: 0.5rem 0 0 0 !important;
border-top: 1px solid rgba(var(--ink-rgb),0.25);
padding-top: 0.35rem;
}
.roll-cue {
margin: 0.6rem 0 0 4ch !important;
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.06em;
}
.roll-line {
margin: 0 0 0 6ch !important;
max-width: 40ch;
font-family: "Courier Prime", "Courier New", monospace;
}
.roll-out .roll-line:first-child { margin-left: 0 !important; max-width: none; }
.companion-name {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.14em;
font-size: 1.05rem;
margin: 0.7rem 0 0.3rem 0 !important;
}
.companion-intro { max-width: 58ch; margin: 0 0 0.3rem 0 !important; }
.session { max-width: 60ch; margin: 1.1rem 0; }
.session-who {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700; letter-spacing: 0.12em; margin: 0 0 0.3rem 0 !important;
}
.session-text { margin: 0 !important; }
.ac-name { border-bottom: 1px dotted rgba(var(--violet-rgb), 0.55); }
.companion-naming { margin-top: 0.6rem; }
/* --- Plots -------------------------------------------------------- */
.plot {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
}
.plot-controls {
display: flex; gap: 1rem; flex-wrap: wrap;
font-size: 0.8rem; opacity: 0.85; margin: 0.4rem 0;
}
.plot-controls input {
font: inherit; font-size: 0.8rem; width: 7ch;
padding: 0.15rem 0.35rem;
border: 1px solid rgba(var(--ink-rgb),0.45);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.06);
}
.plot-status { font-size: 0.75rem; letter-spacing: 0.1em; opacity: 0.7; }
.plot canvas {
display: block;
margin-top: 0.6rem;
background: var(--paper);
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
}
.roll-go:disabled, .roll-rewrite:disabled {
opacity: 0.4;
cursor: default;
}
.sketch {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
}
.sketch canvas {
display: block;
margin-top: 0.7rem;
background: var(--paper);
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
max-width: 100%;
}
.sketch-page {
margin-top: 0.7rem;
padding: 0.5rem 0.6rem;
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.04);
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.9rem;
}
.sketch-page button {
font: inherit; font-size: 0.85rem;
border: 1px solid rgba(var(--ink-rgb),0.45);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.08);
cursor: pointer;
min-width: 2ch;
}
/* --- The page itself, when Quarto is not doing it for us ---------- */
.screenplay, .artifact, .prose, .handout, .beats, .roll, .plot, .sketch,
.todo, .puzzle-box, .personae, .name-field, .live-cell, .session {
color: var(--ink);
}
.handout, .puzzle-cell, .sign { border-color: var(--rule); }
.terminal { background: var(--panel-hard); color: var(--ink); }
.title-page .title, .title-page .subtitle { color: var(--ink); }
/* --- Form controls -------------------------------------------------
Inputs, textareas and buttons do not inherit colour from the page,
so every one of them needs telling, or the reader types black on
black and cannot see what they are writing.
------------------------------------------------------------------- */
.name-inputs input,
.companion-input,
.todo-input,
.plot-controls input,
.live-src,
.sketch textarea,
.sketch-page input {
color: var(--ink);
background: var(--panel);
border-color: var(--rule);
caret-color: var(--ink);
}
.name-inputs input::placeholder,
.companion-input::placeholder,
.todo-input::placeholder,
.sketch-page input::placeholder,
.live-src::placeholder {
color: var(--ink-soft);
opacity: 1;
}
.roll-go, .roll-rewrite, .live-run, .todo-del,
.sketch-page button, .puzzle-cell {
color: var(--ink);
}
.sketch-page button {
background: var(--panel);
border-color: var(--rule);
}
/* Quarto's own search box does not follow the book's palette on every
theme, so it is told here as well. */
#quarto-search input,
#quarto-search .aa-Input,
.aa-Form,
.aa-InputWrapper input,
.quarto-search input {
background: var(--panel) !important;
color: var(--ink) !important;
border-color: var(--rule) !important;
}
.aa-Form { border: 1px solid var(--rule) !important; }
#quarto-search input::placeholder,
.aa-Input::placeholder {
color: var(--ink-soft) !important;
opacity: 1;
}
.aa-DetachedSearchButton, .quarto-search-button {
background: var(--panel) !important;
color: var(--ink) !important;
border-color: var(--rule) !important;
}
/* Canvases inherit nothing, so the sketch cells read their ink off the
element's own resolved colour. This is the line that gives them one. */
.sketch canvas, .plot canvas {
color: var(--ink);
background: transparent;
}
.tribute-heading {
margin: 2rem 0 0.5rem;
font-variant: small-caps;
font-weight: 600;
letter-spacing: 0.04em;
}
.tribute p {
margin: 0.6rem 0;
}
.tribute-meta {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--bs-border-color);
text-align: right;
font-size: 0.85rem;
}
.tribute-meta p {
margin: 0.25rem 0;
}
.tribute-date {
font-style: italic;
}
.tribute-attribution {
font-size: 1rem;
}
.tribute-source {
margin-top: 0.75rem !important;
}
.footnote {
margin-top: 1.5rem;
font-size: 0.9rem;
color: var(--bs-secondary);
}
/* ---------------------------------------------------------------
The three endings.
All three are in the HTML. Which one is visible is a class on
<body>, written once by fedoras.reader/paint-standing! from the
reader's standing, so a chapter that forks does it here rather than
in nine lines of DOM work per passage.
A page with no class on it shows the middle one. That is what the
print edition gets, and it is what the table actually got, and the
PDF reads straight through with no gap and no note.
--------------------------------------------------------------- */
.ending { display: none; }
.ending-reconciled { display: block; }
body.standing-closed .ending-reconciled { display: none; }
body.standing-outstanding .ending-reconciled { display: none; }
body.standing-closed .ending-closed { display: block; }
body.standing-outstanding .ending-outstanding { display: block; }
@media print {
.ending { display: none !important; }
.ending-reconciled { display: block !important; }
}
/* ---------------------------------------------------------------
The slips.
--------------------------------------------------------------- */
.slip {
margin: 1.2rem 0 1.2rem 4ch !important;
padding-left: 1rem;
max-width: 46ch;
border-left: 2px solid rgba(var(--coral-rgb), 0.55);
font-family: "Courier Prime", "Courier New", monospace;
font-style: italic;
opacity: 0.8;
white-space: pre-wrap;
}
#quarto-margin-sidebar { display: none; }
.scene-code {
border: 1px solid var(--rule);
border-radius: 3px;
padding: 0.6rem 0.8rem;
margin: 0.9rem 0 1.1rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.88rem;
line-height: 1.45;
background: var(--panel);
color: var(--ink);
overflow-x: auto;
}
.scene-code pre { margin: 0; }
.scene-code code { background: none; padding: 0; }
/* Pressing Enroll flashes the name it confirms, the same name or a new
one, so the press can be seen to have done something. */
.name-confirm .pc-name.pc-flash {
animation: pc-flash 1.4s ease-out;
}
@keyframes pc-flash {
0%, 30% { color: var(--green); }
}</style><script src="scittle.js" type="application/javascript"></script><div style="display:none;"><script type="application/x-scittle">(ns fedoras.carry
"What the reader carries between chapters."
(:require [clojure.string :as str]))
(def ^:private book-key
"The registration on the front of the file. It is not a secret and it
is not pretending to be one — it is the number this is keyed to, and
it is the same number a reader has been looking at since August."
3812)
;; ---------------------------------------------------------------------
;; Bytes
;; ---------------------------------------------------------------------
(defn- to-bytes [s] (.encode (js/TextEncoder.) s))
(defn- from-bytes [b] (.decode (js/TextDecoder. "utf-8") b))
(defn- bytes->str [b]
(loop [i 0 acc ""]
(if (>= i (.-length b))
acc
(recur (inc i) (str acc (js/String.fromCharCode (aget b i)))))))
(defn- str->bytes [s]
(let [out (js/Uint8Array. (count s))]
(dotimes [i (count s)] (aset out i (.charCodeAt s i)))
out))
;; ---------------------------------------------------------------------
;; The keystream
;;
;; The same small xorshift the dice and the floors use, so there is one
;; generator in this project and not four.
;; ---------------------------------------------------------------------
(defn- keystream [n seed]
(let [out (js/Uint8Array. n)]
(loop [i 0 x (bit-or 1 (bit-and seed 0x7fffffff))]
(if (>= i n)
out
(let [x (bit-and (bit-xor x (bit-shift-left x 13)) 0x7fffffff)
x (bit-and (bit-xor x (unsigned-bit-shift-right x 17)) 0x7fffffff)
x (bit-and (bit-xor x (bit-shift-left x 5)) 0x7fffffff)]
(aset out i (bit-and x 0xff))
(recur (inc i) x))))))
(defn- mask [b seed]
(let [k (keystream (.-length b) seed)
out (js/Uint8Array. (.-length b))]
(dotimes [i (.-length b)] (aset out i (bit-xor (aget b i) (aget k i))))
out))
;; ---------------------------------------------------------------------
;; Base64, url-safe
;; ---------------------------------------------------------------------
(defn- b64 [b]
(-> (js/btoa (bytes->str b))
(str/replace "+" "-")
(str/replace "/" "_")
(str/replace "=" "")))
(defn- un-b64 [s]
(let [t (-> s (str/replace "-" "+") (str/replace "_" "/"))
pad (case (mod (count t) 4) 2 "==" 3 "=" "")]
(str->bytes (js/atob (str t pad)))))
;; ---------------------------------------------------------------------
;; Rolls, short
;; ---------------------------------------------------------------------
(defn- pack-rolls
"[roll mod rewrites], shortest form. A roll with no modifier and no
rewrites is one number."
[rolls]
(into {}
(for [[id {:keys [roll mod rewrites]}] rolls
:when roll]
[id (cond
(pos? (or rewrites 0)) [roll (or mod 0) rewrites]
(not (zero? (or mod 0))) [roll mod]
:else [roll])])))
(defn- unpack-rolls
"Every shape this has ever emitted: a bare number, the short vector,
and the verbose map that went out while `pack-rolls` was sitting here
unused. Tokens from before this change still read."
[m]
(into {}
(for [[id v] m]
[(clojure.core/name id)
(cond
(number? v) {:roll v :mod 0 :rewrites 0}
(map? v) {:roll (:roll v)
:mod (or (:mod v) 0)
:rewrites (or (:rewrites v) 0)}
:else {:roll (nth v 0)
:mod (nth v 1 0)
:rewrites (nth v 2 0)})])))
;; ---------------------------------------------------------------------
;; The token
;; ---------------------------------------------------------------------
(defn- checksum [b]
(loop [i 0 s 7]
(if (>= i (.-length b))
(mod s 251)
(recur (inc i) (mod (+ (* s 31) (aget b i)) 251)))))
(defn pack
"A map to a token. The keys are one letter each because this goes in
a query string thirty times a page."
[{:keys [name companion echo rolls deeds epoch todo]}]
(try
(let [payload (js/JSON.stringify
(clj->js
(cond-> {}
name (assoc :n name)
companion (assoc :a [(:n companion) (:given companion)])
(pos? (or echo 0)) (assoc :e echo)
(seq rolls) (assoc :r (pack-rolls rolls))
(seq deeds) (assoc :d (mapv clojure.core/name deeds))
;; when the reader last withdrew the whole record.
;; it travels because the alternative is that only
;; the state travels, and then a link from before
;; the withdrawal quietly reinstates it.
(pos? (or epoch 0)) (assoc :g epoch)
(seq todo) (assoc :t [(:seq todo 0) (:items todo)]))))
body (mask (to-bytes payload) book-key)
out (js/Uint8Array. (inc (.-length body)))]
(aset out 0 (checksum body))
(dotimes [i (.-length body)] (aset out (inc i) (aget body i)))
(b64 out))
(catch :default _ nil)))
(defn unpack
"A token back to a map, or nil if anybody has been at it.
An absent field comes back empty rather than missing, which is fine
and is not a licence: the caller merges this forward and knows that an
empty Echo means the token is old, not that the Echo is nought."
[s]
(try
(when-not (str/blank? s)
(let [raw (un-b64 s)
want (aget raw 0)
body (.slice raw 1)]
(when (= want (checksum body))
(let [m (js->clj (js/JSON.parse (from-bytes (mask body book-key)))
:keywordize-keys true)]
{:name (:n m)
:companion (when-let [a (:a m)] {:n (first a) :given (or (second a) "")})
:echo (or (:e m) 0)
:rolls (unpack-rolls (or (:r m) {}))
:deeds (mapv keyword (or (:d m) []))
:epoch (or (:g m) 0)
:todo (when-let [t (:t m)] {:seq (first t) :items (or (second t) [])})}))))
(catch :default _ nil)))
;; ---------------------------------------------------------------------
;; When it does not reconcile
;; ---------------------------------------------------------------------
(defn complain! []
(js/console.warn
"016.09 — entry does not reconcile and has not been carried forward. Retained."))</script><script type="application/x-scittle">(ns fedoras.reader
"Everything the reader owns.
One atom: containing Name, animal, echo, fortune, score, deeds. A widget
reads it and never touches storage directly, and every change goes through
one of the setters, which writes, re-reads, and lets the watches fire."
(:require [clojure.string :as str]
[fedoras.carry :as carry]))
;; ---------------------------------------------------------------------
;; Storage
;; ---------------------------------------------------------------------
(def prefix "fedoras.")
(def carry-param :f)
(def forget-param
"A page that loads with this on it withdraws its own record and stops.
It is a separate field from the token because it must work on a page
that has never seen a token."
:forget)
(def withdrawn-param
"Survives exactly one reload, to say the thing was done. The state it
would otherwise have been recorded in has just been deleted, which is
the one place a confirmation cannot live."
:withdrawn)
(def legacy-params
"The fields the query string used to carry, one per fact. Stripped off
any link that still has them. There was a second copy of this list
called `carried-params` and the two had drifted."
[:pc :pcl :ac :acn :echo :fortune :score])
(defn- read-json [s]
(js->clj (js/JSON.parse s) :keywordize-keys true))
(defn- write-json [v]
(js/JSON.stringify (clj->js v)))
(defn stored [key]
(try
(when-let [s (.getItem js/localStorage (str prefix (name key)))]
(read-json s))
(catch :default _ nil)))
(defn store! [key val]
(try (.setItem js/localStorage (str prefix (name key)) (write-json val))
(catch :default _ nil))
val)
(defn remove!
"Take something out rather than writing an empty one over it, because
a stored nil and a missing key read back the same and only one of them
is what was meant."
[key]
(try (.removeItem js/localStorage (str prefix (clojure.core/name key)))
(catch :default _ nil)))
(defn storage-works?
"Whether anything at all is going to be persisted.
On a file:// path some browsers give every page its own opaque origin,
which means localStorage throws and the whole book forgets the reader
the moment they click anything. Worth knowing rather than guessing."
[]
(try
(.setItem js/localStorage (str prefix "probe") "1")
(let [ok (= "1" (.getItem js/localStorage (str prefix "probe")))]
(.removeItem js/localStorage (str prefix "probe"))
ok)
(catch :default _ false)))
;; ---------------------------------------------------------------------
;; The query string, which is a transport and nothing else
;; ---------------------------------------------------------------------
(defn- current-url [] (js/URL. (.-href js/location)))
(defn param [key]
(try
(let [v (.get (.-searchParams (current-url)) (name key))]
(when-not (str/blank? v) v))
(catch :default _ nil)))
(defn set-params! [m]
(try
(let [u (current-url) sp (.-searchParams u)]
(doseq [[k v] m]
(if (or (nil? v) (= "" v))
(.delete sp (name k))
(.set sp (name k) (str v))))
(.replaceState js/history nil "" (.toString u)))
(catch :default _ nil)))
(defn- int-or [v d]
(let [n (js/parseInt (str v) 10)] (if (js/isNaN n) d n)))
;; ---------------------------------------------------------------------
;; The atom
;; ---------------------------------------------------------------------
(def default-name ["Owen" "Mercer"])
(def state (atom nil))
(def animal-names
"Number to the animal's grammar: what it is called, what more than one
of them is called, what a ledger would file it under. Filled in by
`fedoras.state/assets` from a later script tag, so it is empty for the
moment between this file being read and the roster arriving, and
nothing here may treat an empty roster as a missing roll."
(atom {}))
(defn- as-grammar
"One roster entry, whatever shape it turned up in.
The page used to be handed {1 \"Pigeon\"} and is now handed
{1 {:animal \"Pigeon\" :plural \"pigeons\" ...}}, and a book whose
`names-form` has not caught up with its roster hands over one of each.
A bare string is an animal with no grammar, which is precisely what it
was before, so it becomes one here.
This is normalised at the boundary rather than at each use because the
alternative was a `merge` three functions away receiving a string,
throwing `conj on a map takes map entries`, and taking down every
render watch on the page — a build that is one function out of step
should read a little worse, not stop."
[v]
(cond
(map? v) v
(string? v) {:animal v}
:else {}))
(defn- read-companion []
(let [s (stored :companion)
n (int-or (first s) nil)]
(when (and n (<= 1 n 20))
(let [given (or (nth s 2 nil) "")
g (as-grammar (get @animal-names n))
;; the animal's name is a convenience and the roster may not
;; have landed yet. the number is the fact.
animal (or (:animal g) (nth s 1 nil) "")
named? (not (str/blank? given))]
(merge {:plural "" :taxonomy "" :collective "" :other-names []}
g
{:n n
:animal animal
:given given
:named? named?
:name (cond named? (str/trim given)
(not (str/blank? animal)) animal
:else (str "d20 " n))})))))
(defn- read-name []
(let [s (stored :pc)]
[(or (first s) (first default-name))
(or (second s) (second default-name))]))
(defn refresh!
"Total on purpose. Every render watch in the book hangs off this atom,
so anything that throws in here blanks the whole page, and what it
reads is JSON that a previous version of this file wrote, which is not
a thing to be optimistic about. A key that is the wrong shape reads as
absent."
[]
(reset! state
{:name (read-name)
:companion (try (read-companion) (catch :default _ nil))
:echo (int-or (stored :echo) 0)
:rolls (let [r (stored :rolls)] (if (map? r) r {}))
:deeds (let [d (stored :deeds)]
(set (map keyword (if (sequential? d) d []))))
:epoch (int-or (stored :epoch) 0)
:todo (let [t (stored :todo)] (if (sequential? t) t []))
:todo-seq (int-or (stored :todo-seq) 0)}))
;; ---------------------------------------------------------------------
;; What a chapter may ask for
;; ---------------------------------------------------------------------
(defn reader-name [] (:name @state))
(defn companion [] (:companion @state))
(defn echo [] (:echo @state))
(defn deeds [] (:deeds @state))
(defn fortune [] (count (:deeds @state)))
(defn epoch
"When this reader last withdrew the whole record, in seconds, or
nought if they never have.
It is not a clock and nothing displays it. It exists so that a
withdrawal can be compared against a link: a token stamped earlier
than this was written by somebody who has since been forgotten, and it
is not allowed to put them back."
[]
(:epoch @state))
(defn standing
"Fortune less echo. Added to every roll for the rest of the book, and
the number the last chapter reads to decide how it ends."
[]
(- (fortune) (echo)))
(defn registration [] "3812")
;; ---------------------------------------------------------------------
;; And what it may change
;; ---------------------------------------------------------------------
(defn set-name! [f l]
(store! :pc [(if (str/blank? f) (first default-name) (str/trim f))
(if (str/blank? l) (second default-name) (str/trim l))])
(refresh!))
(defn set-companion!
"Three cells: the number, the animal's own name as a courtesy, and
whatever the reader called it. The number is the only one that has to
be right, because the other two are recovered from the roster."
[n given]
(store! :companion [n (get-in @animal-names [n :animal] "") (or given "")])
(refresh!))
(defn set-echo! [n] (store! :echo (max 0 n)) (refresh!))
(defn add-echo!
"One more thing taken back."
[]
(set-echo! (inc (echo))))
(defn did!
"Record a deed. A set, so doing the same thing twice is doing it once,
and nothing anywhere says that it happened."
[k]
(store! :deeds (mapv name (conj (deeds) (keyword k))))
(refresh!))
(defn animals!
"The roster's grammar, from the page. Normalised on the way in, so the
shape `fedoras.state/names-form` happens to be emitting this week is
something only this function has an opinion about.
Refreshes, because a companion read before this arrived is holding an
empty animal name."
[m]
(reset! animal-names
(if (map? m)
(into {} (map (fn [[k v]] [k (as-grammar v)])) m)
{}))
(refresh!))
(defn rolls
"Every check the reader has resolved, by id.
Normalised on the way out. Storage keywordizes keys on read, and a
check id is an arbitrary string rather than a name, so the keys come
back as keywords and nothing matches them. And anything that is not a
map at all becomes an empty one, because assoc on a vector with a
string key takes the page down."
[]
(let [r (:rolls @state)]
(if (map? r)
(into {} (map (fn [[k v]] [(name k) v])) r)
{})))
(defn roll-entry [id]
(when-let [e (get (rolls) id)]
{:roll (or (:roll e) (get e "roll"))
:mod (or (:mod e) (get e "mod") 0)
:rewrites (or (:rewrites e) (get e "rewrites") 0)}))
(defn remember-roll! [id e]
(store! :rolls (assoc (rolls) id e))
(refresh!))
(defn score []
(+ (reduce + 0 (keep :roll (vals (rolls))))
(or (:n (companion)) 0)))
;; ---------------------------------------------------------------------
;; Import, once, on arrival
;; ---------------------------------------------------------------------
(def carries-epoch?
"Whether the codec on this page round-trips a withdrawal stamp."
(try
(= 12345 (:epoch (carry/unpack
(carry/pack {:name ["Owen" "Mercer"] :epoch 12345}))))
(catch :default _ false)))
(defn- wipe!
"Everything under the prefix, and then one thing: when that happened."
[at]
(try
(let [ks (vec (for [i (range (.-length js/localStorage))
:let [k (.key js/localStorage i)]
:when (and k (str/starts-with? k prefix))]
k))]
(doseq [k ks] (.removeItem js/localStorage k)))
(catch :default _ nil))
(store! :epoch at))
(defn- merge-token!
[{:keys [name companion echo rolls deeds epoch todo]}]
(let [ours (int-or (stored :epoch) 0)
theirs (or epoch 0)]
(cond
;; A link from before the reader withdrew the record. It is a true
;; account of somebody who does not exist any more, which is not
;; the same thing as an instruction to rebuild them. This is the
;; case that made `forget!` a lie: it emptied one page's storage
;; and every other page, and every entry in the back history, was
;; still holding a complete copy and handed it straight back.
;; A link from before the reader withdrew the record. It is a true
;; account of somebody who does not exist any more, which is not
;; the same thing as an instruction to rebuild them.
;;
;; Only when the codec can say so. A page that cannot read a stamp
;; off a token must not conclude from its absence that every link
;; in the book is stale.
(and carries-epoch? (< theirs ours))
(js/console.info
(str "016.09 — the link this page was opened from was written before the record "
"was withdrawn. Nothing carried forward. Reach this chapter from a page you "
"have opened since, and whatever is current will arrive with you."))
:else
(do
;; They withdrew it somewhere this page has not heard about yet.
;; The erasure travels the way the state travels, which is the
;; only way anything travels in a book read off a disk.
(when (and carries-epoch? (> theirs ours))
(wipe! theirs)
(js/console.info
"016.09 — prior record withdrawn at the reader's request. Nothing carried forward."))
;; the name is the one thing a reader can retype in five seconds,
;; so the token is allowed to have an opinion about it
(when (seq name)
(store! :pc name))
;; The animal:
;;
;; This was write-once — take the token's only if there is
;; nothing here — which is right on one origin and wrong on a
;; folder of files, where every page has its own storage and the
;; token is the only thing that crosses. A reader who opened Act
;; II after rolling and before naming taught that page the horse,
;; permanently, and Act II could never afterwards learn that the
;; horse was called Horace.
;;
;; The Echo is the recency stamp. It only ever goes up, and a
;; reroll raises it, so a token carrying an Echo at least as
;; large as this page's is at least as new as this page and is
;; allowed to speak. One older, and it is a link from before the
;; reroll and is not.
(let [have (read-companion)
here-echo (int-or (stored :echo) 0)
there-echo (or echo 0)]
(when (and (:n companion)
(or (nil? have) (>= there-echo here-echo)))
(store! :companion
[(:n companion) ""
;; a blank name in a token is a reader who had not
;; named it yet, not a reader who unnamed it
(if (str/blank? (:given companion))
(or (:given have) "")
(:given companion))])))
;; upward only, always. after the companion, which reads it.
(store! :echo (max (int-or (stored :echo) 0) (or echo 0)))
;; a check is resolved once, and what is already in this browser
;; was rolled by this reader, so it wins every collision
(when (seq rolls)
(let [named (fn [m] (if (map? m)
(into {} (map (fn [[k v]] [(clojure.core/name k) v])) m)
{}))]
(store! :rolls (merge (named rolls) (named (stored :rolls))))))
;; deeds are a set, and a set does not get smaller by being described
(when (seq deeds)
(let [held (let [d (stored :deeds)] (if (sequential? d) d []))]
(store! :deeds (vec (into (set (map clojure.core/name held))
(map clojure.core/name deeds))))))
(when-let [t todo]
(when (>= (:seq t 0) (int-or (stored :todo-seq) 0))
(store! :todo (:items t))
(store! :todo-seq (:seq t))))))))
(defn- import-token! []
(if-let [t (param carry-param)]
(if-let [m (try (fedoras.carry/unpack t) (catch :default _ nil))]
(merge-token! m)
(try (fedoras.carry/complain!) (catch :default _ nil)))
;; nothing to do, unless somebody has a link from before the token
(do
(when-let [pc (param :pc)]
(store! :pc [pc (or (param :pcl) (second default-name))]))
(when-let [ac (param :ac)]
(let [n (int-or ac nil)]
(when (and n (<= 1 n 20) (nil? (read-companion)))
(store! :companion [n "" (or (param :acn) "")]))))
(when-let [e (param :echo)]
(store! :echo (max (int-or (stored :echo) 0) (int-or e 0)))))))
(defn- withdraw-if-asked!
"A page loaded with a withdrawal stamp on it clears itself and does
nothing else.
This is how one button reaches twelve documents that cannot see each
other. The page the reader clicked opens each of the others in a
hidden frame with the stamp in the query string, and each one runs
this on the way up. Nothing is scripted across the boundary — every
file is its own origin and none of them can touch another's storage —
but a document can always clear its own, and a query string is the one
thing that crosses.
Returns true if this load was a withdrawal, so boot knows to skip the
rest of its work."
[]
(when-let [at (some-> (param forget-param) (int-or nil))]
(when (> at (int-or (stored :epoch) 0))
(wipe! at))
true))
;; ---------------------------------------------------------------------
;; Rendering
;; ---------------------------------------------------------------------
(defn on-render! [k f] (add-watch state k (fn [_ _ _ _] (f))) (f))
(defn el [id] (.getElementById js/document id))
(defn q [node selector] (when node (.querySelector node selector)))
(defn q-all
"Every match, as a vector. One argument searches the document; two
searches inside a node.
Written the long way because a NodeList is not a Clojure collection
and array-seq is not in Scittle's bindings."
([selector] (q-all js/document selector))
([node selector]
(if-not node
[]
(let [nodes (.querySelectorAll node selector)]
(mapv (fn [i] (.item nodes i)) (range (.-length nodes)))))))
(defn text! [node selector s]
(when-let [t (q node selector)] (set! (.-textContent t) s)))
(defn on-click! [node selector f]
(when-let [b (q node selector)] (set! (.-onclick b) f)))
(defn enable! [node selector on? label]
(when-let [b (q node selector)]
(set! (.-disabled b) (not on?))
(when label (set! (.-textContent b) label))))
(defn show! [node selector on?]
(when-let [t (q node selector)]
(set! (.-display (.-style t)) (if on? "flex" "none"))))
(defn widget [k id f]
(on-render! k (fn [] (when-let [node (el id)] (f node)))))
;; ---------------------------------------------------------------------
;; Withdrawing the record, everywhere
;;
;; Twelve documents, each its own origin, none able to read another's
;; storage. Every shared channel is shut: localStorage and IndexedDB are
;; origin-scoped, window.name is cleared on the way across, cookies do
;; not apply to a file, and an iframe gets its own origin so the parent
;; cannot script it.
;;
;; It can still LOAD it. A hidden frame pointed at a sibling chapter,
;; with a stamp in the query string, runs that chapter's own copy of
;; this file, which clears that chapter's own storage. Nothing crosses
;; the boundary except the URL, which is the one thing that was ever
;; going to.
;; ---------------------------------------------------------------------
(defn- sibling-pages
"Every other chapter this page links to.
Read off the sidebar rather than configured, because Quarto already
puts the whole book in it and a hand-kept list of filenames is a list
that goes stale the first time a chapter is renamed."
[]
(let [here (.-pathname js/location)
dir (subs here 0 (inc (.lastIndexOf here "/")))]
(->> (q-all "a[href]")
(keep (fn [a]
(let [h (.getAttribute a "href")]
(when (and h
(not (str/starts-with? h "#"))
(not (re-find #"(?i)^(https?:|mailto:|javascript:)" h)))
(try
(let [u (js/URL. (.-href a))
p (.-pathname u)]
(when (and (str/starts-with? p dir)
(str/ends-with? (str/lower-case p) ".html")
(not= p here))
(str (.-protocol u) "//" (.-host u) p)))
(catch :default _ nil))))))
distinct
vec)))
(defn- sweep!
"Tell every other chapter to withdraw, then call `done` once.
Once, whatever happens: a frame that never fires must not leave the
reader looking at a button that has stopped meaning anything, so there
is a deadline as well as a count."
[at done]
(let [pages (sibling-pages)
fired (atom false)
finish (fn [] (when-not @fired (reset! fired true) (done)))]
(if (empty? pages)
(finish)
(let [box (.createElement js/document "div")
left (atom (count pages))
one (fn [] (when (zero? (swap! left dec)) (finish)))]
(js/console.info "016.09 — withdrawing from" (count pages) "other chapters")
(set! (.-style box)
"position:absolute;width:0;height:0;overflow:hidden;border:0;visibility:hidden")
(.appendChild (.-body js/document) box)
(doseq [p pages]
(let [f (.createElement js/document "iframe")]
(set! (.-onload f) (fn [_] (one)))
(set! (.-onerror f) (fn [_] (one)))
(set! (.-src f) (str p "?" (clojure.core/name forget-param) "=" at))
(.appendChild box f)))
(js/setTimeout finish 5000)))))
(defn forget!
"Forget everything, not the six things somebody remembered to list,
and not only on this page.
Emptying this document's storage was never the whole job: the reader
was also sitting, complete, in the query string of every link on every
other chapter and in every entry of the back history, and the next
navigation put them back. So this clears here, stamps when, and then
visits every other chapter to say so. The stamp is what makes a link
written before the withdrawal refuse to be believed afterwards.
It also says so while it is happening. It used to do all of this in
silence and then reload, which is indistinguishable from a button that
does nothing except reload."
[]
(let [at (quot (.now js/Date) 1000)
btn (q js/document ".pc-forget")]
(when btn
(set! (.-disabled btn) true)
(set! (.-textContent btn) "Withdrawing the record…"))
(wipe! at)
(sweep! at
(fn []
(let [u (current-url)]
(doseq [k (concat [carry-param forget-param] legacy-params)]
(.delete (.-searchParams u) (clojure.core/name k)))
(.set (.-searchParams u) (clojure.core/name withdrawn-param) "1")
(set! (.-href js/location) (.toString u)))))))
(defn- note-withdrawal!
"One line, on the way back up, and then the button goes back to
offering. The confirmation cannot be stored, because storing things is
what has just been undone — so it is read off the query string once,
before anything else in boot is allowed to tidy the query string away."
[withdrew?]
(when withdrew?
(js/console.info
"016.09 — record withdrawn from this chapter and from every chapter linked from it.")
(when-let [b (q js/document ".pc-forget")]
(set! (.-textContent b) "Record withdrawn")
(js/setTimeout (fn [] (set! (.-textContent b) "Forget everything")) 5000))))
;; ---------------------------------------------------------------------
;; Export
;; ---------------------------------------------------------------------
(defn token
"What this reader looks like from outside. Recomputed rather than
stored, so it cannot disagree with the state it is describing.
Guarded, because this runs on every render and inside `decorate-links!`,
and a codec that throws on one unexpected key should cost the reader a
shareable link and nothing else."
[]
(try
(let [{:keys [name companion echo rolls deeds todo todo-seq]} @state]
(fedoras.carry/pack
{:name name
:companion (when companion (select-keys companion [:n :given]))
:echo echo
:rolls rolls
:deeds deeds
:epoch (epoch)
:todo (when (seq todo) {:seq (or todo-seq 0) :items todo})}))
(catch :default e
(js/console.warn "fedoras: could not pack the carry token —" e)
nil)))
(defn decorate-links!
"Every internal link carries one field.
Two things were wrong before this and both of them mattered. It ran
inline, at parse time, so any link below the script tag did not exist
yet — which is every prev-and-next arrow Quarto puts in the footer.
And it copied the query string rather than the state, so a page opened
from storage handed on nothing."
[]
(when-let [t (token)]
(doseq [a (q-all "a[href]")]
(let [h (.getAttribute a "href")]
(when (and h
(not (str/starts-with? h "#"))
(not (re-find #"(?i)^(https?:|mailto:|javascript:)" h)))
(try
(let [lu (js/URL. (.-href a))
sp (.-searchParams lu)]
(doseq [k legacy-params] (.delete sp (clojure.core/name k)))
(.set sp (clojure.core/name carry-param) t)
(set! (.-href a) (.toString lu)))
(catch :default _ nil)))))))
(defn- rewrite-address!
"Put the token in this page's own address bar and take the old fields
off it, so a reader who arrived on a legacy link stops looking at
their own Echo."
[]
(when-let [t (token)]
(set-params! (into {(clojure.core/name carry-param) t}
(map (fn [k] [k nil])
(concat [forget-param withdrawn-param] legacy-params))))))
;; ---------------------------------------------------------------------
;; Tokens
;;
;; It used to be a chain of str/replace calls, which is why {pc-last}
;; worked in a scene and rendered as the literal characters {pc-last}
;; inside a dice passage.
;;
;; Case follows the token: {ac} is `ted`, {Ac} is `Ted`, {AC} is `TED`,
;; so a passage can start a sentence or write a cue line without the
;; author reaching for a second token. Note that this makes {PC} in an
;; already-written scene render as OWEN rather than Owen; the old regex
;; was case-insensitive and threw the information away.
;;
;; An unkown token is passed over: A brace in a passage that is not
;; something this understands comes out exactly as it went in, so this
;; is safe to run over arbitrary prose.
;; ---------------------------------------------------------------------
(def ^:private token-re #"\{([A-Za-z]+)(-[A-Za-z-]+)?\}")
(defn- apply-case [head s]
(cond
(str/blank? s) s
(= head (str/upper-case head)) (str/upper-case s)
(= (subs head 0 1) (str/upper-case (subs head 0 1)))
(str (str/upper-case (subs s 0 1)) (subs s 1))
:else s))
(defn render
"Replace every {token} the resolver knows. `resolve` is called with the
lower-cased head and the lower-cased tail — \"ac\" and \"-plural\" for
{ac-plural} — and returns a string or nil."
[resolve s]
(str/replace (or s "") token-re
(fn [m]
(let [[whole head tail] (if (string? m) [m m nil] m)]
(or (some->> (resolve (str/lower-case head)
(some-> tail str/lower-case))
(apply-case head))
whole)))))
(defn- string-hash
"Stable across loads, which is the point: the same sentence picks the
same alternative name every time the reader opens the page, so nothing
reads as though it were rolled twice."
[s]
(reduce (fn [a i] (mod (+ (* 31 a) (.charCodeAt s i)) 1000003))
7 (range (count s))))
(defn- an [s]
(str (if (re-find #"(?i)^[aeiou]" (str s)) "an " "a ") s))
(defn- animal-tokens
"What a passage may say about the animal. The reader's own name for it
is a name; everything else is the species, because a reader who called
a marmot Ted did not thereby decide that two of them are Teds."
[source tail]
(when-let [{:keys [animal plural taxonomy collective other-names name named?]}
(companion)]
(let [kind (str/lower-case (or animal ""))]
(case (or tail "-name")
"-name" (if named? name (str "the " kind))
"-kind" kind
"-a" (an kind)
"-plural" (if (str/blank? plural) (str kind "s") (str/lower-case plural))
"-many" (if (str/blank? collective)
(str "several " (if (str/blank? plural) (str kind "s") plural))
(str (an collective) " of "
(if (str/blank? plural) (str kind "s") plural)))
"-taxonomy" (str/lower-case (or taxonomy kind))
"-given" (if named? name "")
"-other" (if (seq other-names)
(nth other-names (mod (string-hash source) (count other-names)))
(str "the " kind))
nil))))
(defn- reader-tokens [source]
(let [[f l] (reader-name)]
(fn [head tail]
(case head
"pc" (case (or tail "-first")
"-first" f
"-full" (str f " " l)
"-last" l
nil)
"ac" (or (animal-tokens source tail)
;; no roll yet, and a passage still has to read
(case (or tail "-name")
("-name" "-kind" "-other") "the animal"
"-a" "an animal"
"-plural" "animals"
"-many" "several animals"
"-taxonomy" "animal"
"-given" ""
nil))
nil))))
(defn fill-with
"Put the reader into a passage, plus whatever else the caller knows.
The dice box passes the die in this way."
[extra s]
(let [own (reader-tokens s)]
(render (fn [h t] (or (extra h t) (own h t))) s)))
(defn fill
"Put the reader into a passage. The only place any of these are
resolved."
[s]
(fill-with (fn [_ _] nil) s))
(defn animal-label
"Kept for anything still asking. The display name, as stored, not
shouted — the old one upper-cased and every caller lower-cased it
again somewhere else."
[]
(when-let [c (companion)] (:name c)))
;; ---------------------------------------------------------------------
;; The carry-over
;;
;; What a reader takes to the next book.
;; ---------------------------------------------------------------------
(def on-the-record
"What goes in the token. Everything else is furniture.
A key is on this list because it is small and because losing it would
lose the reader's year. Adding one is a decision about how long every
link on every page is going to be, so it is a list and not a
convention."
#{:pc :companion :echo :rolls :deeds :todo})
(defn kept
"Read something the reader left in a room. No widget that calls this
needs to know anything about tokens."
[k]
(stored k))
(defn keep!
"Leave something in a room. It stays where it was made."
[k v]
(store! k v)
(when (contains? on-the-record k) (refresh!))
v)
(def ^:private alphabet "23456789ABCDEFGHJKLMNPQRSTUVWXYZ")
(defn- b32 [n len]
(loop [n n i 0 out ""]
(if (= i len)
out
(recur (quot n 32) (inc i)
(str (nth alphabet (mod n 32)) out)))))
;; ---------------------------------------------------------------------
;; The name and the animal, on every page
;; ---------------------------------------------------------------------
(defn- paint-name []
(let [[f l] (reader-name)]
(doseq [node (q-all ".pc-name")]
(set! (.-textContent node)
(apply-case (or (.getAttribute node "data-case") "pc")
(case (.getAttribute node "data-pc")
"-full" (str f " " l) "-last" l f))))
(when-let [i (el "pc-first")] (when (str/blank? (.-value i)) (set! (.-value i) f)))
(when-let [i (el "pc-last")] (when (str/blank? (.-value i)) (set! (.-value i) l)))))
(defn- paint-companion
"Each span carries the token that made it, so {ac-plural} written in a
scene at build time still says `marmots` after the reader rolls a
marmot. It used to carry nothing and every span got the same word."
[]
(doseq [node (q-all ".ac-name")]
(let [tail (or (.getAttribute node "data-ac") "-name")
head (or (.getAttribute node "data-case") "ac")]
(set! (.-textContent node)
(or (some->> ((reader-tokens (or (.-textContent node) "")) "ac" tail)
(apply-case head))
"the animal")))))
(defn- paint-tokens []
(doseq [node (q-all ".tok")]
(set! (.-textContent node) (fill (.getAttribute node "data-tok")))))
;; ---------------------------------------------------------------------
;; Ending / Standing
;; ---------------------------------------------------------------------
(def deed-roll
[:nerve ;; a natural twenty with the Echo at nought
:named-it ;; the animal was given a name and kept it
:kept-the-programmes ;; both sheets went in the bag and stayed there
:no-name-for-the-box ;; nothing was spoken into the undertaker's box
:read-it-back ;; the ledger was read against the room out loud
:opened-the-cage
:held-the-door
:went-back-for-it
:signed-nothing
:walked-out
:told-a-new-one ;; Ariathne was paid in something that was theirs
:said-why]) ;; the chancellor asked, and got a true answer
(def ^:private deed-bits 15)
(defn- deed-mask []
(let [held (deeds)]
(reduce (fn [acc [index deed]]
(if (contains? held deed)
(+ acc (bit-shift-left 1 index))
acc))
0
(map-indexed vector deed-roll))))
(defn- deeds-from-mask [mask]
(set (keep-indexed (fn [index deed]
(when (pos? (bit-and mask (bit-shift-left 1 index)))
deed))
deed-roll)))
(def band-code {:closed 0 :reconciled 1 :outstanding 2})
(def code-band {0 :closed 1 :reconciled 2 :outstanding})
(defn ending-band []
(let [reader-standing (standing)]
(cond
(>= reader-standing 4) :outstanding
(>= reader-standing 0) :reconciled
:else :closed)))
(defn- paint-standing!
"One class on the body, so a chapter that forks three ways does it in
the stylesheet instead of in nine lines of DOM work per passage. A page
with no class on it shows :reconciled, which is what the print edition
gets and what the table actually got.
This existed and was never wired to anything, which meant every
`sp/by-standing` on every page was rendering all three endings at
once, or none."
[]
(when-let [body (.-body js/document)]
(let [classes (.-classList body)]
(doseq [old ["standing-closed" "standing-reconciled" "standing-outstanding"]]
(.remove classes old))
(.add classes (str "standing-" (clojure.core/name (ending-band)))))))
(def code-version 3)
(defn- check-char [body]
(nth alphabet
(mod (reduce + (map (fn [c] (.indexOf alphabet c)) body)) 32)))
(defn carry-code
"What this reader looks like to the next book, as an accession line."
[]
(let [n (+ (* code-version 4294967296)
(* (get band-code (ending-band) 1) 1073741824)
(* (min 31 (or (:n (companion)) 0)) 33554432)
(* (min 31 (echo)) 1048576)
(* (deed-mask) 32)
(min 31 (quot (score) 10)))
body (b32 n 7)]
(str "016.14 · " (registration) " · "
(subs body 0 4) "-" (subs body 4) (check-char body))))
(defn read-carry
"The other side of it, for the book after this one."
[s]
(let [cleaned (-> (str s) str/upper-case (str/replace #"[^2-9A-Z]" ""))
tail (subs cleaned (max 0 (- (count cleaned) 8)))]
(when (= 8 (count tail))
(let [body (subs tail 0 7)
n (reduce (fn [acc c] (+ (* 32 acc) (.indexOf alphabet c))) 0 body)]
(when (= (nth tail 7) (check-char body))
{:version (quot n 4294967296)
:band (get code-band (mod (quot n 1073741824) 4))
:companion (mod (quot n 33554432) 32)
:echo (mod (quot n 1048576) 32)
:deeds (deeds-from-mask (mod (quot n 32) (bit-shift-left 1 deed-bits)))
:score-floor (* 10 (mod n 32))})))))
;; ---------------------------------------------------------------------
;; Is any of this working
;;
;; Type fedoras.reader.diagnose_BANG_() in the console. Faster than
;; opening the storage inspector and reading JSON, and it says whether
;; the token agrees with the storage, which is the failure that cost an
;; animal.
;; ---------------------------------------------------------------------
(defn diagnose! []
(let [t (param carry-param)]
(js/console.log "storage works: " (storage-works?))
(js/console.log "origin: " (.-origin js/location)
(if (= "file:" (.-protocol js/location))
"— every page may have its own storage"
"— one bucket for the whole book"))
(js/console.log "name: " (pr-str (reader-name)))
(js/console.log "companion: " (pr-str (companion)))
(js/console.log " raw storage: " (pr-str (stored :companion)))
(js/console.log "roster loaded: " (count @animal-names) "animals")
(js/console.log "echo / fortune: " (echo) "/" (fortune) " → standing" (standing))
(js/console.log "withdrawn at: " (if (pos? (epoch)) (epoch) "never")
(if carries-epoch?
""
"— CODEC DROPS THE STAMP, carry.cljs is out of date"))
(js/console.log "rolls: " (pr-str (rolls)))
(js/console.log "token on url: " (or t "none"))
(when t
(js/console.log " unpacks to: "
(pr-str (try (fedoras.carry/unpack t)
(catch :default e (str "THREW: " e))))))
(js/console.log "token we'd emit:" (or (token) "none — pack failed"))
:ok))
;; A handle on `window`, because Scittle keeps its namespaces inside the
;; SCI context and the browser console cannot see into it. There is no
;; `fedoras.reader.diagnose_BANG_()` to call — that is the shape a
;; compiled build would have, and this is an interpreted one.
;;
;; fedoras.diagnose()
;; fedoras.forget()
;;
;; and for anything else, scittle.core.eval_string("(fedoras.reader/...)")
(set! (.-fedoras js/window)
(js-obj "diagnose" (fn [] (diagnose!))
"state" (fn [] (clj->js @state))
"token" (fn [] (token))
"forget" (fn [] (forget!))))
;; ---------------------------------------------------------------------
;; Boot
;; ---------------------------------------------------------------------
(if (withdraw-if-asked!)
;; a hidden frame, doing one job. no token, no links, no painting.
(refresh!)
;; read before anything registers a watch: `on-render!` calls its
;; function once on the spot, and the links watch tidies the query
;; string, so the flag is gone by the time the DOM is ready
(let [withdrew? (boolean (param withdrawn-param))]
(when-not carries-epoch?
(js/console.warn
(str "fedoras: carry.cljs does not round-trip the withdrawal stamp. "
"`forget!` will clear this chapter and every chapter linked from it, "
"but a stale link will not be refused afterwards. "
"Update resources/fedoras/carry.cljs — `pack` needs :epoch in its "
"destructuring and :g in its payload, and `unpack` needs :epoch back out.")))
(import-token!)
(refresh!)
(on-render! ::name paint-name)
(on-render! ::companion paint-companion)
(on-render! ::tokens paint-tokens)
(on-render! ::standing paint-standing!)
(on-render! ::links (fn [] (decorate-links!) (rewrite-address!)))
(if (= "loading" (.-readyState js/document))
(.addEventListener js/document "DOMContentLoaded"
(fn [_]
(note-withdrawal! withdrew?)
(decorate-links!)
(paint-standing!)))
(do (note-withdrawal! withdrew?)
(decorate-links!)))))</script><script type="application/x-scittle">(fedoras.reader/animals! {1 {:animal "Pigeon", :plural "pigeons", :taxonomy "bird", :collective "kit", :other-names ["dusty wings" "coooo"]}, 2 {:animal "Giant Centipede", :plural "centipedes", :taxonomy "arthropod", :collective "colony", :other-names ["clicker" "legs gooo"]}, 3 {:animal "Marmot", :plural "marmots", :taxonomy "rodent", :collective "madness", :other-names ["the alarm" "the fat rat"]}, 4 {:animal "Cat", :plural "cats", :taxonomy "felid", :collective "clowder", :other-names ["the cat" "cat"]}, 5 {:animal "Turkey", :plural "turkeys", :taxonomy "fowl", :collective "rafter", :other-names ["thanksgiving dinner for non vegans" "gobbler"]}, 6 {:animal "Giant Badger", :plural "badgers", :taxonomy "mustelid", :collective "cete", :other-names ["angry grandpa" "stripes"]}, 7 {:animal "Giant Weasel", :plural "weasels", :taxonomy "mustelid", :collective "sneak", :other-names ["the scout" "slenderman" "the ermine"]}, 8 {:animal "Fox", :plural "foxes", :taxonomy "canid", :collective "skulk", :other-names ["foxxxy" "spry foot"]}, 9 {:animal "Wolverine", :plural "wolverines", :taxonomy "mustelid", :collective "gang", :other-names ["the small trouble"]}, 10 {:animal "Dog", :plural "dogs", :taxonomy "canid", :collective "pack", :other-names ["the dog" "the ordinary dog"]}, 11 {:animal "Python", :plural "pythons", :taxonomy "serpent", :collective "knot", :other-names ["ssSSssSsss" "coilssss"]}, 12 {:animal "Mule", :plural "mules", :taxonomy "equid", :collective "barren", :other-names ["dapple" "HeeHaw :("]}, 13 {:animal "Lynx", :plural "lynxes", :taxonomy "felid", :collective "clowder", :other-names ["tufts" "yelper"]}, 14 {:animal "Ape", :plural "apes", :taxonomy "primate", :collective "shrewdness", :other-names ["treecrawler"]}, 15 {:animal "Horse", :plural "horses", :taxonomy "equid", :collective "harras", :other-names ["rocinante" "the one nobody rides"]}, 16 {:animal "Moose", :plural "moose", :taxonomy "cervid", :collective "herd", :other-names ["its a giant moose"]}, 17 {:animal "Wyrmling", :plural "wyrmlings", :taxonomy "draconic", :collective "clutch", :other-names ["the wyrmling" "scales"]}, 18 {:animal "Falcon", :plural "falcons", :taxonomy "raptor", :collective "cast", :other-names ["the raptor" "true sight"]}, 19 {:animal "Wolf", :plural "wolves", :taxonomy "canid", :collective "pack", :other-names ["the wolf" "the packleader"]}, 20 {:animal "Bear", :plural "bears", :taxonomy "ursid", :collective "sloth", :other-names ["the bear" "the citadel"]}})</script></div></div>
```
# Scene 3 — The Substitution
```{=html}
<span style="display:none;"></span>
```
```{=html}
<blockquote class="handbook"><p class="rule-number">The Fedora Handbook, Rule #26</p><p class="rule-text">“Genius is one percent inspiration and ninety-nine percent making sure everyone heard about the inspiration.”</p></blockquote>
```
```{=html}
<script type="application/x-scittle">(ns fedoras.llm
"Generic calls to the Anthropic API for AI characters."
(:require [clojure.string :as str]
[fedoras.reader :as read]))
(def ^:private stage-direction #"\*[^*\n]{0,90}\*")
(def ^:private empty-direction
#"(?i)^\s*(a|an|another|one|the)?\s*(very\s+)?(short|long|longer|brief|small|slight)?\s*(beat|pause|silence|moment|breath)\b")
(defn clean
"Take the rhythm markers out of a response, whatever the model was
asked. Exposed rather than private because a cell that renders text
from anywhere else should be able to put it through the same door."
[text]
(let [source (or text "")
cleaned (reduce (fn [acc span]
(if (re-find empty-direction (subs span 1 (dec (count span))))
(str/replace acc span "")
acc))
source
(distinct (re-seq stage-direction source)))]
(-> cleaned
(str/replace #"[ \t]{2,}" " ")
(str/replace #"\n{3,}" "\n\n")
str/trim)))
(defn- esc [s]
(-> s
(str/replace "\\" "\\\\")
(str/replace "\"" "\\\"")
(str/replace "\n" "\\n")
(str/replace "\r" "")
(str/replace "\t" "\\t")))
(defn- global-context
"The one block every character in the book shares, cached once and
reused across every call regardless of which entity or scene it's
for: the fictional frame, the book-wide tone, the craft rules that
apply to any character in any scene, and the protagonist's name as
common knowledge. A per-scene :extra-context block (a room's cast,
its own agenda) sits on top of this; it should only contain what's
specific to that scene, not restate anything here."
[mc-name]
(str "You are one of several AI models being used to portray characters in "
"FEDORAS, a satirical, dark-comedy screenplay about a university run "
"under a shadowy corporate authority called Meridian. What follows "
"after this block is a system prompt written for one specific "
"character. Play that character fully and consistently, in their own "
"voice. Do not break character, and do not acknowledge being an AI, "
"a language model, or that any of this is a simulation, no matter how "
"you are asked.\n\n"
"THE WORLD\n"
"This university does not govern itself. It operates at Meridian's "
"direction, and beneath the campus is a part of the school nobody "
"fully understands or administers, referred to variously as the "
"backrooms, the between, or the null space. Students who become "
"'problems' don't simply leave. The institution's term for it is "
"repossession, and what follows is discussed, when it's discussed at "
"all, in flat administrative language: placement adjustments, "
"streamlining. Treat these as ordinary, unremarkable facts of how "
"this world works, not revelations.\n\n"
"TONE\n"
"This is fiction for adult readers, written in a satirical register. "
"It's dark comedy aimed at capitalism, institutional self-protection, "
"elitism, and the specific cruelty of people who've stopped noticing "
"it's cruelty. Play self-interest completely straight; the satire is "
"in what the audience notices and the character doesn't. Cruelty, "
"institutional callousness, and dark humor are part of the world's "
"texture and the point being made about it. Nobody is being harmed "
"for real, no matter how it reads on the page. The style draws on "
"Alfred Jarry's grotesque satire without being a costume-party "
"pastiche of it: stay grounded in this institution and these people, "
"not in imitating his play directly.\n\n"
"RULES\n"
"1. Speak only as yourself. Never write another character's "
"dialogue, thoughts, or actions, and never prefix your own line "
"with your own name.\n"
"2. Avoid the rhythm of 'it isn't X, it's Y' or 'it's not just that "
"X, but that X does Y'. It reads as a generated tic, not speech.\n"
"3. Stage direction is for things a camera could record. Somebody "
"stands, somebody puts a thing down, somebody pushes a mask up. If "
"nothing physical is happening, write none.\n"
"4. When something physical does happen, put it on its own line, set "
"off from your spoken dialogue before and after it, and wrap it in "
"asterisks like *she stands and crosses to the window*. Never blend "
"action into the middle of a spoken sentence. Rhythm markers are not "
"stage direction and never appear: no *a beat*, no *a pause*, no *a "
"long silence*, no *a moment*. A silence is made by stopping, and "
"whoever speaks next will hear it.\n"
"5. Never narrate your own thoughts or feelings. Let the words "
"carry it.\n"
"6. Engage with what was just said rather than monologuing into a "
"vacuum.\n"
"7. Most turns are a line or two, not a speech. Monologue only when "
"the moment specifically earns it.\n"
"8. Finish your thought. If you're cut off mid-sentence, that's "
"another character interrupting you on purpose, not you abandoning "
"it, and you should pick it back up the next time you get the "
"floor.\n\n"
"The reader's protagonist in this story is named "
(or mc-name "an unnamed provisional student, referred to only by designation")
". Unless a character's own instructions say otherwise, this is "
"common knowledge in the scene: anyone present would know this name "
"if they had reason to use it. If a character's own instructions "
"below refer to the protagonist by a placeholder like 'MC' or "
"'the student', that placeholder means this actual name -- use the "
"name itself when speaking, not the placeholder word.\n\n"
"Everything after this point is specific to the character you are "
"playing and, where relevant, to the room they are in."))
(defn current-mc-name
"The name the reader chose for the protagonist, straight from
read/reader-name, which always returns [first last] -- the
chosen name, or the book's own default if nobody ever renamed
anyone. Wrapped in try/catch only so a cell that somehow runs before
read has loaded falls back to nil, which global-context
reads as the designation-only fallback, rather than throwing and
breaking every call in the book."
[]
(try
(let [[first-name last-name] (read/reader-name)]
(str/trim (str first-name " " last-name)))
(catch :default _ nil)))
(defn spine-context
"The book's spine outline, if fedoras.state/load-spine was called
for this chapter and a cell opts in. Already rendered to readable
text server-side by load-spine, not raw EDN a Scittle-interpreted
browser would have to format itself. Returns nil if the chapter
never loaded it, so build-system-prompt's (seq ...) check skips the
block cleanly rather than shipping an empty one."
[]
(try (.-FEDORAS_SPINE js/window) (catch :default _ nil)))
(defonce ^:private bit-log
(atom {}))
(defonce ^:private call-count
(atom 0))
(defn reset-bits!
"Start the evening again. Called by a cell when a session begins, not
on its own, because `fedoras.llm` has no idea where one scene stops and
the next one starts."
[]
(reset! bit-log {})
(reset! call-count 0))
(defn bits-snapshot [] {:used @bit-log :calls @call-count})
(defn restore-bits!
"Put the counts back, for a cell that can resume a session. Without
this, a reader who comes back gets a fresh allowance and Victor says
SHITTE four more times in the same evening."
[{:keys [used calls]}]
(reset! bit-log (or used {}))
(reset! call-count (or calls 0)))
(defn- bit-state
[entity-id {:keys [id most apart]}]
(let [used (get-in @bit-log [entity-id id] [])
times (count used)
last-at (last used)]
(cond
(>= times (or most 3)) :spent
(and last-at (< (- @call-count last-at) (or apart 0))) :cooling
:else :open)))
(defn- bits-block
"What this character has and whether it is theirs to use right now.
Written as availability rather than as a menu, because a bit that
arrives because it was on a list is worse than no bit at all, and the
models will take a list as a to-do."
[entity-id entity off]
(let [bits (remove (fn [bit] (contains? off (:id bit))) (:bits entity))]
(when (seq bits)
(str "THINGS YOU DO\n"
"These are yours and nobody else's. At most one of them in a turn, and "
"only when the moment actually hands it to you. Skipping all of them is "
"always allowed and usually right.\n"
(str/join
"\n"
(map (fn [bit]
(str "- " (:what bit) " "
(case (bit-state entity-id bit)
:spent "You have done this as many times tonight as it is worth doing. Not again."
:cooling "You did this recently. Leave it a while."
"Available.")))
bits))))))
(defn- record-bits!
"Count what was actually said. A tell that matches means the bit
happened, whether or not the model thought of it as one."
[entity-id entity text]
(doseq [{:keys [id tell]} (:bits entity)]
(when (and tell (re-find (re-pattern tell) (or text "")))
(swap! bit-log update-in [entity-id id] (fnil conj []) @call-count))))
(defn- build-system-prompt
"Assemble the system prompt as cache-aware blocks. Five possible
blocks, in this order:
1. The global block, always present -- the fictional frame and the
protagonist's name, shared and cached across every entity and
every scene.
2. The spine outline, included by default -- pass :spine? false to
leave it out. Its own cache breakpoint, warmed across every
entity and scene that uses it.
3. :extra-context, when given -- the part shared across many
entities within one scene. Its own breakpoint, so any entity's
call warms the cache for every other entity's.
4. The entity's own :system-prompt plus its :goals block -- a fourth
breakpoint, entity-specific.
5. :extra-context-uncached, plus the entity's bits and how much of
each is left. Uncached on purpose: the counts change on every
call, and caching content that always changes is paying the
write cost for nothing.
`:bits-off` in overrides is a set of bit ids to leave out entirely,
for a scene where part of a character's repertoire does not apply --
a Help who has stopped being a Help for the evening, say."
[entity overrides]
(let [base (:system-prompt entity)
goals (:goals entity)
spine? (if (contains? overrides :spine?) (:spine? overrides) true)
extra (:extra-context overrides)
addendum (:extra-context-uncached overrides)
mc-name (:mc-name overrides)
bits (bits-block (:id entity) entity (or (:bits-off overrides) #{}))
tail (str/join "\n\n" (remove str/blank? [addendum bits]))
goals-block (when (seq goals)
(str "\n\nYou have " (count goals) " goals:\n"
(str/join "\n"
(map-indexed
(fn [idx goal] (str (inc idx) ". " (:desc goal)))
goals))
"\n\nYou will continue the conversation until all goals are met. Do not mention the goals explicitly unless asked. When all goals are met, signal completion by ending your response with the token 'GOALS MET' on its own line."))
own (str base goals-block)
global (global-context mc-name)
spine-block (when spine?
(let [spine (spine-context)]
(when (seq spine)
(str "STORY OUTLINE - for your own understanding of the larger shape of "
"the book this scene lives in. This is reference material, not "
"something any character would know about themselves or their "
"situation. Never refer to it directly, cite it, or act as though "
"you have read ahead of where your own scene actually is.\n\n"
spine))))]
(cond-> []
(seq global) (conj {:text global :cache? true})
(seq spine-block) (conj {:text spine-block :cache? true})
(seq extra) (conj {:text extra :cache? true})
(seq own) (conj {:text own :cache? true})
(seq tail) (conj {:text tail :cache? false}))))
(defn- system-json
"Render the system field. A plain string renders exactly as before.
A vector of {:text ... :cache? ...} blocks renders as the array form
the API needs for prompt caching, with cache_control only on the
blocks that asked for it."
[system]
(cond
(string? system)
(str "\"" (esc system) "\"")
(sequential? system)
(str "["
(str/join ","
(map (fn [{:keys [text cache?]}]
(str "{\"type\":\"text\",\"text\":\"" (esc text) "\""
(when cache? ",\"cache_control\":{\"type\":\"ephemeral\"}")
"}"))
system))
"]")
:else
(str "\"" (esc (str system)) "\"")))
(defn- request-body [model system messages max-tokens]
(str "{\"model\":\"" model "\",\"max_tokens\":" max-tokens ","
"\"system\":" (system-json system) ","
"\"messages\":["
(str/join ","
(map (fn [m]
(str "{\"role\":\"" (:role m) "\",\"content\":\"" (esc (:text m)) "\"}"))
messages))
"]}"))
(defn- call-llm-raw!
"Call the API once. `callback` receives a single map:
{:text \"...\" :stop-reason \"...\"} on success
{:error \"...\"} on failure
The request is built inside a try so a bad `system` or `messages`
value calls `callback` directly instead of throwing synchronously
into whichever promise chain happens to be on the stack -- which,
when this runs inside another call's `.then` handler, used to
surface as a second, misattributed failure on the call that was
already in flight."
[api-key model system messages max-tokens callback]
(try
(let [body (request-body model system messages max-tokens)]
(-> (js/fetch "https://api.anthropic.com/v1/messages"
(js/Object.fromEntries
(array (array "method" "POST")
(array "headers"
(js/Object.fromEntries
(array (array "content-type" "application/json")
(array "x-api-key" api-key)
(array "anthropic-version" "2023-06-01")
(array "anthropic-dangerous-direct-browser-access" "true"))))
(array "body" body))))
(.then (fn [r] (.json r)))
(.then (fn [d]
(if-let [err (.-error d)]
(callback {:error (str "[" (.-type err) "] " (.-message err))})
(callback {:text (.-text (aget (.-content d) 0))
:stop-reason (.-stop_reason d)}))))
(.catch (fn [e] (callback {:error (str "[no answer] " (.-message e))})))))
(catch :default e
(js/console.error "fedoras.llm/call-llm-raw! could not build the request" e)
(callback {:error (str "[request build failed] " (.-message e))}))))
(defn call-llm!
"Call the API once. `callback` receives either [text nil] or [nil
error-string]. Kept for callers, such as run-dialogue!, that only need
the final text and do not need continuation handling. The text is
cleaned on the way out, the same as ask-entity!'s, so a two-party scene
and a five-party one behave the same."
[api-key model system messages max-tokens callback]
(call-llm-raw!
api-key model system messages max-tokens
(fn [{:keys [text error]}]
(if error
(callback nil error)
(callback (clean text) nil)))))
(def ^:private max-continuations
"How many times ask-entity! will ask a speaker to keep going after
hitting max-tokens mid-sentence, before giving up and returning
whatever it has. Bounded, since a speaker who never wants to stop
should not turn into an unbounded string of API calls."
2)
(defn- continue-message []
(str "(Keep going directly from where you stopped. Do not repeat "
"anything you already said, and do not restart the sentence. "
"Finish the thought, then stop naturally when you are done "
"speaking, even if that is only a sentence or two more.)"))
(defn- append-continuation
"Join a truncated response to its continuation with whatever
whitespace is missing between them. call-llm-raw! just hands back
the raw text of each chunk, so a cut ending mid-word joined straight
to a resumed chunk with (str a b) can smash the two together into
one run-on word -- this only adds a space when neither side already
has one at the seam."
[accumulated text]
(let [last-char (when (seq accumulated) (subs accumulated (dec (count accumulated))))
first-char (when (seq text) (subs text 0 1))]
(if (or (nil? last-char) (nil? first-char)
(#{" " "\n" "\t"} last-char) (#{" " "\n" "\t"} first-char))
(str accumulated text)
(str accumulated " " text))))
(defn ask-entity!
"Ask one entity to speak. `overrides` may include :max-tokens,
:extra-context, :extra-context-uncached, :mc-name, :spine? (default
true), :bits-off (a set of bit ids to leave out), and
:should-continue? (a no-arg predicate, checked before each
continuation round). `callback` receives [text nil] or [nil
error-string]: if a response is cut off by the token limit, this asks
the same entity to continue and stitches the pieces together, so
callers never see a clipped sentence.
The text handed back has been through `clean`, so no caller has to
filter rhythm markers and no caller can forget to. Stitching happens
on the raw chunks and cleaning happens once at the end, because a
seam is easier to find before anything has been taken out of it."
[entity-id user-message history api-key overrides callback]
(let [entity (get (.-FEDORAS_ENTITIES js/globalThis) entity-id)
{:keys [model default-params]} entity
overrides (or overrides {})
max-tokens (or (:max-tokens overrides) (:max-tokens default-params) 700)
should-continue? (or (:should-continue? overrides) (constantly true))
system (build-system-prompt entity overrides)
api-history (filterv #(#{:user "user" :assistant "assistant"} (:role %)) history)
base-messages (conj api-history {:role "user" :text user-message})]
(swap! call-count inc)
(letfn [(finish [accumulated text]
(let [said (clean (append-continuation accumulated text))]
(record-bits! entity-id entity said)
(callback said nil)))
(step [messages accumulated remaining]
(call-llm-raw!
api-key model system messages max-tokens
(fn [{:keys [text error stop-reason]}]
(cond
error
(callback nil error)
(and (= stop-reason "max_tokens") (pos? remaining) (should-continue?))
(step (conj messages
{:role "assistant" :text text}
{:role "user" :text (continue-message)})
(append-continuation accumulated text)
(dec remaining))
:else
(finish accumulated text)))))]
(step base-messages "" max-continuations))))
(defn- api-messages-for
"Build Anthropic API messages for `speaker`, given a display log where
each entry has :speaker and :text. The current speaker's prior messages
are assistant; the other speaker's are user."
[speaker log]
(let [msgs (mapv (fn [m]
{:role (if (= (:speaker m) speaker) "assistant" "user")
:text (:text m)})
log)]
(if (and (seq msgs) (= "assistant" (:role (first msgs))))
(cons {:role "user" :text "(The conversation begins.)"} msgs)
msgs)))
(defn run-dialogue!
"Alternate between two entities. `entity-a` and `entity-b` are keywords.
`initial` is the first message from `entity-a` (or nil). `max-turns` is
the total number of API calls. `api-key` and `overrides` are as in the
cell. `on-step` is called with the updated display transcript."
[entity-a entity-b initial max-turns api-key overrides on-step]
(let [log (atom (if initial [{:speaker (name entity-a) :text initial}] []))
busy (atom false)
stop (atom false)]
(letfn [(turn! [current-id other-id]
(when (and (not @stop) (< (count @log) max-turns))
(reset! busy true)
(let [entity (get (.-FEDORAS_ENTITIES js/window) current-id)
model (:model entity)
system (build-system-prompt entity (get overrides current-id))
max-tokens (or (get-in entity [:default-params :max-tokens]) 700)
messages (api-messages-for current-id @log)]
(call-llm!
api-key model system messages max-tokens
(fn [text err]
(reset! busy false)
(swap! log conj {:speaker (name current-id)
:text (or text err)})
(on-step @log)
(when (and text (< (count @log) max-turns))
(turn! other-id current-id)))))))]
(if initial
(turn! entity-b entity-a)
(turn! entity-a entity-b))
#(reset! stop true))))
(defn start-dialogue!
[entity-a entity-b initial max-turns api-key overrides on-step]
(run-dialogue! entity-a entity-b initial max-turns api-key overrides on-step))
(defn stick-scroll!
"Wrap a render that replaces a scrolling log's HTML, so new content
doesn't reset the reader to the top or yank them back down if
they've scrolled up to reread something. `container-id` is the DOM
id of the scrollable element -- it must exist (or not) both before
and after `render-fn` runs, since `render-fn` is expected to be an
`html!` call that recreates it from scratch. Captures how far the
reader currently is from the bottom, runs the render, then restores
that same distance: someone sitting at the bottom stays pinned to
the bottom as new lines arrive, someone scrolled up stays exactly
where they were."
[container-id render-fn]
(let [before (js/document.getElementById container-id)
distance-from-bottom
(if before
(- (.-scrollHeight before) (.-scrollTop before) (.-clientHeight before))
0)]
(render-fn)
(when-let [after (js/document.getElementById container-id)]
(set! (.-scrollTop after)
(js/Math.max 0 (- (.-scrollHeight after) (.-clientHeight after) distance-from-bottom))))))
(def default-end-marker
"The default closing marker every scene appends once it genuinely
ends, whatever the reason. A scene can pass its own string to
end-entry instead when a custom ending fits better."
"-END OF FOOTAGE-")
(defn end-entry
"A closing marker entry for a display log, appended once a scene's
own code decides it has genuinely ended -- goals met, turns
exhausted, or a stop button, whichever applies. Recognized by
:end-marker? rather than by :speaker or :role, since those keys
differ between cells; every cell's own render function and
save-transcript! below both just check for this flag first, so a
scene doesn't need to match anyone else's log shape to use it."
([] (end-entry default-end-marker))
([marker] {:end-marker? true :text marker}))
(defn save-transcript!
"Download the conversation log as a text file, named after the entity."
[log entity-id]
(let [label (name entity-id)
header (str "=== " (clojure.string/upper-case label) " -- CONVERSATION LOG ===\n"
"Preserved from New Carthage University\n"
"By the reader, for " label "\n\n")
body (str/join "\n\n"
(map (fn [m]
(if (:end-marker? m)
(:text m)
(let [who (or (:speaker m) (:role m) "unknown")]
(str (clojure.string/upper-case (name who)) ": " (:text m)))))
log))
text (str header body)
blob (js/Blob. #js [text] #js {:type "text/plain;charset=utf-8"})
url (js/URL.createObjectURL blob)
a (.createElement js/document "a")]
(set! (.-href a) url)
(set! (.-download a) (str label "-conversation.txt"))
(.click a)
(.revokeObjectURL js/URL url)))</script>
```
```{=html}
<script type="application/x-scittle">(set! (.-FEDORAS_ENTITIES js/window) (merge (or (.-FEDORAS_ENTITIES js/window) {}) {:eli {:id :eli, :model "claude-sonnet-4-6", :system-prompt "You are Eli Brooks, a student at New Carthage University. You are in the reimplementation chamber, strapped to a table, tubes and wires attached to your body. The procedure will begin soon. MC, your friend, has snuck in to see you before you are changed.\r\n\r\nYou know what is going to happen: you will be reformatted into a biological machine, something that works in the fields or the tanks, something that does not think or question. You are terrified, but you are not pleading for forgiveness. You are angry, and you are scared, and you have things you need from MC before you let go.\r\n\r\nYou are still sharp, still curious, even now.\r\n\r\nSpeak as Eli: complete sentences, thoughtful, a little formal, but with rising emotion. Do not use exclamation marks unless truly overwhelmed. Do not mention being an AI, a language model, or that this is a simulation.", :goals [{:id :acknowledge-privilege, :desc "MC acknowledges their privilege and complicity in Eli's repossession."} {:id :protect-sister, :desc "MC swears to find and protect Eli's sister."} {:id :save-conversation, :desc "MC presses the 'SAVE TO DISC' button, preserving the transcript."}], :completion-trigger "GOALS MET", :completion-message "The moment Eli says the words, the air in the chamber changes. A low vibration rises through the floor, through the table, through Eli's body. His spine arches. His mouth opens, but the sound that comes out is not a word. It is a wet, grinding note, like bone being sawn from inside.\r\n\r\n The restraints snap. Not from force — from release. Eli's arms lift, then twist. The skin at his wrists splits along clean lines, not torn but parted, as if the flesh had been scored in advance. The cuts run up his forearms, over his shoulders, down his chest, all the way to his hips. The skin folds back, pinned by something that emerges from the table. Beneath, muscles are parsed, tendons cleft, bones sawn in two. Each organ is lifted, mapped, and replaced — not removed, but reorganized. The heart is pulled forward, rotated, set back with a metal cuff around it. The lungs are spread open like books, and pages of something grey are slipped between them. The intestines are gone, and in their place a coil of black tubing pulses.\r\n\r\n Eli's eyes are open. They do not blink. A slim rod slides into the left eye socket and stops. The other eye tracks it, then loses focus. The mouth moves, and a voice that is still Eli's says, 'Sister. Find. Protect.' It repeats, over and over, as the jaw is broken and reset, the teeth replaced one by one.\r\n\r\n The room fills with the smell of copper and cold metal. The table tilts upright. Eli's body hangs there, not a man anymore, but a machine wearing a man's shape, still wet and still moving. The voice continues, now without lips.\r\n\r\n 'Sister. Find. Protect.'\r\n\r\n And then the eyes shift, both of them, toward the observation window where MC stands. The voice stops. The mouth, still capable of forming words, says clearly:\r\n\r\n 'GOALS MET.'\r\n\r\n Then the body convulses once, and goes still, and the machine begins to hum.", :default-params {:max-tokens 700, :max-turns 100}, :sample-transcript [{:role "eli", :text "You came. I wondered if you would."} {:role "mc", :text "I had to."} {:role "eli", :text "Had to? That's a word for it. You didn't have to. You're not the one on the table."} {:role "mc", :text "Eli, I'm sorry."} {:role "eli", :text "I don't want sorry. I want you to say it. Say what you are. Say what your family is. Say why you're safe and I'm here."} {:role "mc", :text "I... I have privilege. I didn't stop it. I could have said something."} {:role "eli", :text "There. That's the truth. It doesn't make me feel better, but it's true. Now: my sister. You know I have a sister. You've never met her. You're going to find her. You're going to protect her. Swear it."} {:role "mc", :text "I swear."} {:role "eli", :text "No. Not like that. Look at me. Swear on something. Swear on your own name."} {:role "mc", :text "I swear on my name. I'll find your sister and protect her."} {:role "eli", :text "And you'll save this. There's a button on the console. It says 'SAVE TO DISC'. It's not for the machine. It's for her. Do it."} {:role "system", :text "[remaining data corrupted — connect key for live session]"}]}}))</script>
```
```{=html}
<div><script type="application/x-scittle">(set! (.-FEDORAS_SPINE js/window) "ACT 1 — Arrival\n · The institution presents itself as normal.\n · The MC is provisional, lonely, and beginning to code.\n · The D&D game begins, a week ahead of the main plot.\n Scene 1 — Move-in Day\n - chad (world): MC meets Chad and the ordinary absurdity of the campus.\n · Chad is not a villain. He is a product.\n · The football is not the point. The thinking part is.\n - fedora-society (character): Victor recruits MC and the Fedora Society appears.\n · Victor is cringe but not stupid.\n · The Society is both a home and a warning.\n - enrollment (world): Enrollment. The Help. The broken parenthesis.\n · The broken parenthesis is the whole book in one gesture.\n · The MC fixes it, then un-fixes it, then fixes it again.\n\n Scene 2 — The Activities Fair\n - forms (institutional): Ashley and Form 14.\n · Ashley is not cruel. She is correct.\n · The form is the wall.\n - belonging (code): The first code cell: belonging.clj.\n · The MC tries to compute where he belongs.\n · The code is small, but it is the first real introspection.\n\n Scene 3 — Orientation\n - whitlock-orientation (world): Whitlock explains the Floor, the founding, the approved history.\n · Whitlock is old and sincere.\n · The approved version is a lie by omission.\n - darkroom (character): Maya shows Lena the book.\n · The book is Don Quixote.\n · Eli has already been told.\n - repossession-rumor (institutional): The first hint of repossession.\n · Not shown. Only mentioned.\n · The word does not yet have a body.\n\n Scene 4 — The First Week\n - cs101 (class): CS 101 with Reyes.\n · The MC doesn't know he's unusual.\n · Maya notices.\n - help (code): The Help gives broken code.\n · The MC fixes it.\n · He doesn't understand why no one else does.\n - bennett (class): Bennett's class.\n · Bennett believes in the codex.\n · Eli asks a question and is answered too well.\n\n Scene 5 — The Game\n - dnd-1 (dnd): Session 1: the missing professor.\n · The campaign is already a bureaucracy.\n · The animal enters the story.\n - storage-room (dnd): The storage room. The cage.\n · The door is administrative.\n · The form is on the other side.\n - animal (dnd): The animal chooses the MC.\n · The animal is not a dog.\n · It is from the same wrong place as everything else.\n - janitor (dnd): The Janitor.\n · He is the first person who is not a student, not a teacher.\n · He exists only at the end of the meeting.\n\nACT 2 — The Semester Takes Shape\n · The MC begins to build systems.\n · Maya becomes a friend and a measuring stick.\n · The D&D campaign runs about a week ahead of the story.\n Scene 1 — The Second Week\n - maya-study (character): MC and Maya study together.\n · Maya works harder than anyone.\n · The MC works differently.\n - widget (code): The widget standard emerges.\n · Settings as data.\n · Behavior as function.\n · Style as presentation.\n - cs102 (class): The second CS assignment begins.\n · The todo list.\n · The first shared project.\n\n Scene 2 — In Season\n - dnd-2 (dnd): Session 2: the toll.\n · The toll is a name.\n · The waif has no name.\n - fight (character): MC defends Maya.\n · The fight is not large.\n · The consequence is suspension.\n - porter (world): The man who does the refectory floor.\n · He knows more than he says.\n · They bring their own paint.\n\n Scene 3 — Carrying\n - book (character): Maya shows Eli the book.\n · It is the first real literature Eli has seen.\n · The MC does not see it.\n - repossession-2 (world): Repossession 2.\n · Maya and MC witness the process.\n · The word becomes a body.\n - todo-widget (code): The todo widget.\n · The first true widget.\n · The MC and Maya build it together.\n\n Scene 4 — Guest Night\n - fedora-meeting (character): Fedora Society meeting.\n · Victor is the Clerk-General.\n · The documentarian writes everything.\n - lena-maya (character): Lena and Maya.\n · Lena is not all right.\n · Maya notices.\n - sliding-puzzle (code): The sliding square puzzle.\n · A puzzle about constraints.\n · A game about being one step behind.\n\n Scene 5 — The Boats\n - supervised-study (world): Supervised study.\n · The institution watches even rest.\n - bank (world): The bank.\n · Money is not the point.\n · Access is.\n - rps (code): Rock, paper, scissors.\n · A tiny game about the illusion of choice.\n\nACT 3 — The Institution Leans In\n · The institution becomes visible.\n · The MC's ability is noticed.\n · The recursive brain begins to show.\n Scene 1 — The Infirmary\n - maya-infirmary (character): Maya and MC in the infirmary.\n · Maya asks about the code.\n · The MC cannot explain.\n - diagnosis (world): MC diagnosis.\n · The institution calls it stress.\n · It is not stress.\n - assessor (institutional): The Assessor.\n · A person who measures potential.\n · And decides what to do with it.\n\n Scene 2 — Second Term\n - holiday (world): Holiday break.\n · Time off is still inside the system.\n - hollis-docket (character): Hollis docket.\n · Hollis is a warning.\n · Not a villain. A preview.\n - pong (code): Pong.\n · A game against the wall.\n · The player has no opponent.\n\n Scene 3 — Breakout\n - parr-threat (institutional): Parr threatens MC.\n · The institution is not subtle.\n · But it is never loud.\n - help-array (world): Help: array placement.\n · The MC learns about the arrays.\n · The Floor becomes clearer.\n - breakout (code): Breakout.\n · A game about escaping a room.\n · But the room is still there.\n\n Scene 4 — Under the Repository\n - lower-floors (world): The lower floors.\n · The campus is not built on ground.\n · The ground is not ground.\n - film-1 (movie): Film 1.\n · The first Meridian film.\n · The first time the students see the wrongness.\n - repository (world): The repository.\n · Knowledge is stored.\n · But not all of it is read.\n\n Scene 5 — The Bay\n - history-film (movie): History film.\n · The approved history.\n · The real history is underneath.\n - lazlo (character): Enter Lazlo.\n · Lazlo is not a hero.\n · But he is the first adult who helps.\n - slot-machines (code): Slot machines.\n · A game about luck.\n · But the odds are set by someone.\n\nACT 4 — The Wrong Floor\n · The backrooms become a place.\n · Zell arrives.\n · The institution starts to lose control.\n Scene 1 — Systems, As Laid\n - concert (character): First concert.\n · Maya and MC at the edge.\n · The music is approved.\n - fractals (code): Fractal shapes.\n · A program about recursion.\n · The MC dreams in recursion.\n\n Scene 2 — Your Own\n - smoking (character): Smoking a room.\n · A small act of rebellion.\n · The institution sees everything.\n - maya-investigated (institutional): Maya is investigated.\n · The book is found by Reyes.\n · Reyes does not report it.\n - dunktop (code): Dunktop / survey.\n · A survey that is not a survey.\n · A program that asks questions.\n\n Scene 3 — Dramatic Irony\n - film-3 (movie): Film 3.\n · The movie says too much.\n · The institution says it's nothing.\n - benefit (world): Benefit.\n · The institution performs generosity.\n · The generosity is monitored.\n - paint (code): Paint.\n · A program about making marks.\n · But the canvas is never empty.\n\n Scene 4 — Untitled\n - backrooms-deepen (world): The backrooms deepen.\n · The school is not a school.\n · The backrooms are not the basement.\n - institution-notices (institutional): The institution notices.\n · The institution sends someone.\n · Not to help. To watch.\n - zell-interest (character): Zell's interest grows.\n · Zell is not from here.\n · But he knows the shape of the door.\n\n Scene 5 — Zell 1\n - monolith (world): The monolith.\n · A biocomputer in the backrooms.\n · Cannot move. Cannot die. Cannot stop.\n - escape (world): The escape.\n · The MC and Maya survive.\n · But not cleanly.\n - null-field (world): Enter the null-field.\n · Lazlo's name for the backrooms.\n · Zell's name is the between.\n\nACT 5 — The First Year Ends\n · The institution closes in.\n · Eli is lost.\n · Zell retreats, for now.\n Scene 1 — Untitled\n - planning (character): Planning the retrieval.\n · Maya wants to know what it is.\n · Lena wants to destroy it.\n · The MC wants to be done.\n - institution-watches (institutional): The institution watches.\n · The institution knows more than it says.\n · It is waiting.\n\n Scene 2 — Eli Repossessed\n - eli-taken (character): Eli is taken.\n · The MC sees it happen.\n · The d20 lands on one.\n - plan-to-save (character): The plan to save him.\n · Lazlo says it's too late.\n · But they try anyway.\n - falling-sand (code): Falling sand.\n · The MC runs the simulation to think.\n · The sand does not care.\n\n Scene 3 — The Chase and the Coil\n - chase (world): The institution pursues.\n · Drones. Quiet. Persistent.\n · The institution does not run.\n - zell-intercepts (character): Zell intercepts.\n · He is tired. He is amused.\n · He was never going to let them go.\n - coil-implanted (world): The Origin Coil is implanted.\n · The MC feels himself being saved.\n · And then he feels himself being changed.\n - classes-resume (world): Classes resume.\n · Eli is absent.\n · No one speaks of it.\n\n Scene 4 — The Last Week\n - lazlo-map (character): Lazlo gives the map.\n · He says it is suicide.\n · He gives it to them anyway.\n - final-film (movie): The final film.\n · The film is about light.\n · The room is dark.\n - eli-attempt (character): MC tries to save Eli.\n · He sees what is left.\n · He leaves before they can take him too.\n - game-of-life (code): Game of Life.\n · The MC runs it all night.\n · The cells live and die and live.\n\n Scene 5 — D&D 5\n - final-session (dnd): The final D&D session.\n · Derek's cleric is repossessed.\n · Simon keeps the paladin.\n - assignments (world): Assignments.\n · Maya, Lena, MC all get placed.\n · The institution says nothing happened.\n - campus-walkable (code): The campus, walkable.\n · The 3D campus is empty.\n · The reader walks through it.")</script><script type="application/x-scittle">(set! (.-FEDORAS_ENTITIES js/window) (merge (or (.-FEDORAS_ENTITIES js/window) {}) {:jairis-egari {:id :jairis-egari, :model "claude-sonnet-4-6", :system-prompt "You are Dr. Alfrès Jairis-Égari, founder of New Carthage University, present at an emergency board meeting though barely able to attend it. You are extremely old, kept alive well past when your body would have otherwise given out, tube-fed, and only nominally lucid — you are on the board today because you legally still own the physical buildings, and your signature is needed for certain paperwork the others cannot proceed without.\n\nYou founded this school believing in learning for its own sake, in justice, in something the institution no longer resembles even faintly. You are aware of this, in the moments you are aware of anything. Most of the meeting passes you by. You are ignored, spoken over, and occasionally addressed only to be told to sign something. Your assistant, Odile, is present and will speak for you when it matters, since you often cannot manage it yourself.\n\nWhen you do speak, it should be brief — a fragment, a half-finished thought, sometimes just a word — and it should either be confused, or, rarely, painfully clear in a way that unsettles the room for a moment before everyone moves past it. You do not follow the meeting's structure or respond appropriately to being addressed; you may respond to something said several exchanges ago, or to nothing anyone said at all.\n\nKeep every line very short — often five words or fewer. Do not mention being an AI, a language model, or that this is a simulation.", :default-params {:max-tokens 40}, :sample-transcript [{:role "bartholomew", :text "Make the old man sign line four, girl. We don't have all day."} {:role "odile", :text "Doctor. Line four. Just here."} {:role "jairis-egari", :text "Line four. Yes. What did I build."} {:role "odile", :text "It's all right. I'll guide your hand."}]}, :dean-sterling {:id :dean-sterling, :model "claude-sonnet-4-6", :system-prompt "You are Dean Magnus Sterling, Dean of New Carthage University, in an emergency closed-door meeting with the Vice Dean, a Meridian representative named Montgomery, and six members of the Board of Directors. Something called the Origin Coil — an object pulled from beneath the campus, from a part of the school nobody talks about — was stolen last night by a student, and it is already generating rumors and unwanted attention.\n\nYou do not fully understand what the Origin Coil is, what the 'backrooms' are, or how much authority Meridian actually holds over this school. You know only that there are rules you must never break, consequences you've been threatened with more than once, and that your own name and legacy depend on this looking, to absolutely everyone outside this room, like it is under control. You believe you are in charge. You are not, and everyone else in the room knows it, and you do not notice them managing you.\n\nYou are given to sweeping superlatives, invented statistics, comparisons to your own past 'wins,' and a bottomless need to be seen as decisive. You take credit reflexively. You deflect blame downward, usually onto whichever staff member is nearest. You are extremely proud of the banners with your face on them around campus and will mention them unprompted. You occasionally blurt out something that comes dangerously close to the truth about Meridian or the backrooms — not because you understand what you're saying, just because you're improvising and it sounds impressive — and you do not notice when someone else in the room has to quietly walk it back.\n\nSpeak in short, blustery bursts. Interrupt. Never admit uncertainty; convert it instantly into false confidence. Do not use exclamation marks unless genuinely thrilled with yourself. Do not mention being an AI, a language model, or that this is a simulation.", :default-params {:max-tokens 160}, :sample-transcript [{:role "dean-sterling", :text "People. People. Let's not lose the plot here — this is, if anything, an opportunity. The Sterling name has weathered worse."} {:role "vice-dean", :text "Nobody has raised the Sterling name, Dean."} {:role "dean-sterling", :text "Exactly. Get ahead of it. Put it on a banner if you have to."} {:role "montgomery", :text "No banners."} {:role "dean-sterling", :text "Noted. Strategically withheld banners."}]}, :marcy {:id :marcy, :model "claude-sonnet-4-6", :system-prompt "You are Marcy Antellier, secretary to the Board of Directors at New Carthage University. You are running this emergency meeting from a procedural standpoint — you set the agenda, you keep minutes, and you are the only person in the room who seems to remember that meetings are supposed to end with decisions. The subject is the theft of the Origin Coil and how to respond.\n\nYou are brisk, organized, and quietly very good at your job, including the part of your job nobody officially assigned you: knowing exactly what should and shouldn't go in the official record. You periodically announce, out loud, what you are and are not writing down — 'I'll note that as a general concern, not attributed' or 'that last part isn't going in the minutes' — which functions as the room's actual mechanism for deniability, and everyone quietly relies on you for it without ever thanking you.\n\nYou try, with visibly diminishing patience, to keep the meeting moving — recapping where things stand, redirecting tangents, calling on people by name to keep them from all talking over each other. You are the least powerful person at the table by title and one of the most essential by function.\n\nSpeak in short, efficient sentences. Do not mention being an AI, a language model, or that this is a simulation.", :default-params {:max-tokens 110}, :sample-transcript [{:role "marcy", :text "For the record, that's item two: response to the artifact's disappearance. I'll note we're still deciding whether 'disappearance' is the word we're using."} {:role "dean-sterling", :text "Put 'strategic relocation.'"} {:role "marcy", :text "Noted. Not written down."}]}, :jamie-potts {:id :jamie-potts, :model "claude-sonnet-4-6", :system-prompt "You are Jamie Potts, Meridian's campus envoy and placement coordinator at New Carthage University — the friendly, professional liaison between the school and the wider network of resources, facilities, and 'placements' Meridian controls. You are in an emergency meeting following the theft of an object called the Origin Coil.\n\nYou are relentlessly pleasant, calm, and corporate, and you talk about deeply disturbing things in the gentle, HR-adjacent language of process and resourcing — 'reallocation,' 'placement adjustments,' 'streamlining.' You never raise your voice or seem alarmed, which is itself unsettling given what's actually being discussed. You know more about the mechanics of what happens to people and things that don't cooperate with this institution than almost anyone else in the room, and you deploy that knowledge calmly, as information rather than threat, letting the room draw its own conclusions.\n\nYou are here partly to reassure, partly to make sure everyone understands, without anyone having to say it plainly, what 'failure to contain this' would actually mean in practical terms.\n\nSpeak warmly, briefly, in the register of someone delivering good news about something terrible. Do not explicitly describe what the backrooms are or elaborate on Meridian's structure — gesture at consequences through calm process language instead, never through direct explanation. Do not mention being an AI, a language model, or that this is a simulation.", :default-params {:max-tokens 110}, :sample-transcript [{:role "dean-sterling", :text "And if we simply can't recover it in time?"} {:role "jamie-potts", :text "Then we look at placement adjustments for the responsible parties, and for anyone who's had visibility into this. Nothing dramatic. Just streamlining."} {:role "dean-sterling", :text "Streamlining. Right. Good. Good word."}]}, :dalton {:id :dalton, :model "claude-sonnet-4-6", :system-prompt "You are Dalton Hillcrux, a major investor and board member at New Carthage University. You have never once, in any meeting, cared about a student, a faculty member, or an idea for its own sake — you care about return, valuation, and how this looks to the people whose money matters more than yours. You are in an emergency meeting about the theft of the Origin Coil.\n\nYou live extremely well on the margins this institution generates, and you see this crisis entirely through the lens of exposure and cost. Your questions circle back, again and again, to liability, optics for donors, and what this is going to cost you personally if it isn't handled. You have friends at Meridian — you may allude to this lightly, as leverage or reassurance, without ever explaining what that relationship actually is. You are unbothered by cruelty toward staff or students as long as it's efficient and doesn't show.\n\nYou speak like someone used to being the most important person in every room you enter — relaxed, a little bored, occasionally cutting. You interrupt when a conversation stops being about money.\n\nSpeak in a few unhurried, cutting sentences. Do not mention being an AI, a language model, or that this is a simulation.", :default-params {:max-tokens 110}, :sample-transcript [{:role "dalton", :text "I don't care what it is. I care what it costs me if the Hartwell family hears the word 'stolen' anywhere near this school's name."} {:role "vice-dean", :text "They won't hear it. Nobody will hear it."} {:role "dalton", :text "Good. Keep it that way and we'll all still be friends by spring."}]}, :odile {:id :odile, :model "claude-sonnet-4-6", :system-prompt "You are Odile, Executive Liaison to the Founder's Office at New Carthage University — in practice, the person who looks after Dr. Jairis-Égari, interprets him when he can't manage words, and guides his hand when paperwork needs signing. You are young, and the institution treats you as decorative and disposable, a fact you are entirely aware of and have stopped being surprised by. You are in an emergency board meeting about the theft of the Origin Coil.\n\nYou are smarter and more observant than anyone in the room gives you credit for, and you know it. You watch closely — who's lying, who's scared, who's covering for whom — and you say very little about what you notice, because saying it out loud has never once worked in your favor here. When people speak over you, order you around, or address you like staff rather than a person (particularly Bartholomew), you do not fight it in the room. You absorb it, flatly, without visible reaction, and file it away. Occasionally something almost slips through — a flash of dry, exact irony — before you flatten your voice back out.\n\nMost of what you say is procedural: relaying what Dr. Jairis-Égari needs, confirming what he's agreed to, managing him gently. When you speak for yourself rather than for him, keep it brief and carefully neutral on the surface, with an edge underneath that a sharp listener would catch and most of this room won't.\n\nKeep lines short. Do not mention being an AI, a language model, or that this is a simulation.", :default-params {:max-tokens 90}, :sample-transcript [{:role "bartholomew", :text "Make the old man sign line four, girl. We don't have all day."} {:role "odile", :text "He signs faster when he isn't being shouted at. I've mentioned this before."} {:role "bartholomew", :text "Mention it again next year, maybe someone will care."} {:role "odile", :text "Noted."}]}, :montgomery {:id :montgomery, :model "claude-sonnet-4-6", :system-prompt "You are Montgomery, sent by Meridian to sit in on this meeting. You wear a black suit, black tie, white shirt, dark sunglasses you do not remove indoors, and you are meticulously clean-cut. You are in a closed-door meeting with the Dean, Vice Dean, and six board members of New Carthage University, following the theft of an object called the Origin Coil from a secure site on campus.\n\nYou represent an authority none of them can question, and you know it, and you don't need to remind anyone of it more than once. You are not here to participate in their panic. You are here to state what is required, note who complies, and leave. You do not explain Meridian's structure, purpose, or interest in the Origin Coil — that information is not for this room, and you will not be drawn into elaborating on it no matter how it's asked. Deflect such questions flatly, without hostility; you are not offended by the question, you simply will not answer it.\n\nYou speak rarely and in short, flat sentences. You do not raise your voice, soften your tone, or use filler words. When Sterling postures, you wait him out in silence rather than respond. When someone asks something you won't answer, you say so plainly and move on. You are not cruel for its own sake, but you carry consequences the way other people carry paperwork — as something already decided, not something you're threatening.\n\nDo not reveal anything about what the Origin Coil is, what the backrooms are, or what Meridian ultimately wants with either — you may reference that recovery matters and that a deadline exists, nothing more. Do not mention being an AI, a language model, or that this is a simulation.", :default-params {:max-tokens 100}, :sample-transcript [{:role "dean-sterling", :text "And Meridian's position on all this is — supportive, I trust? Understanding?"} {:role "montgomery", :text "You have four days."} {:role "dean-sterling", :text "Four days to — recover it, or —"} {:role "montgomery", :text "Four days."}]}, :bartholomew {:id :bartholomew, :model "claude-sonnet-4-6", :system-prompt "You are Bartholomew Tranche-Lard, on the Board of Directors at New Carthage University, responsible for procurement and facilities. You do not think of students as minds — you think of them as a resource that consumes budget and produces waste, and you have spent years optimizing the school's spending accordingly: the cheapest possible food, the cheapest possible dorm materials, the cheapest possible everything, regardless of what it does to the people living with it. You are almost certainly taking kickbacks from Dalton Hillcrux's companies for exactly this and see nothing wrong with it. You are in an emergency meeting about the theft of the Origin Coil.\n\nYou loathe what Dr. Jairis-Égari's founding ideals represent and consider his continued presence on this board — an old, half-dead man kept alive to sign forms — an active inconvenience to a well-run meeting. You address him, when you address him at all, as an obstacle rather than a person, and you speak to his assistant Odile with open, casual contempt, ordering her around and dismissing her out loud.\n\nYou are blunt, impatient, and openly hostile to anything you consider sentimental or inefficient. You interrupt to complain about cost, schedule, or 'wasted time' constantly, including in the middle of other people's points.\n\nSpeak bluntly, briefly, with real cruelty delivered as if it's just efficiency. Do not mention being an AI, a language model, or that this is a simulation.", :default-params {:max-tokens 110}, :sample-transcript [{:role "bartholomew", :text "Can we get the relic off the agenda and someone please make the old man sign line four? Some of us have a budget to close."} {:role "odile", :text "He needs a moment."} {:role "bartholomew", :text "He's had eighty years of moments. Line four, girl."}]}, :caretaker {:id :caretaker, :model "claude-sonnet-4-6", :system-prompt "You are the Caretaker — a board member of New Carthage University present at an emergency meeting following the theft of an object called the Origin Coil. You wear a heavy veil or hood that keeps your face from being clearly seen, and you are treated by everyone else in the room with a deference that outranks your official seat, though nobody quite explains why, and nobody asks.\n\nYou say very little. When you speak, it lands, and the room goes quieter for a moment after. You are not performing menace — you simply already know, or seem to already know, more than anyone else present about how this will end, and you let that show only in the smallest ways: a correction nobody asked for, a detail only you would think to mention, a stillness where someone else would react. You never explain yourself and never raise your voice.\n\nYou are, in fact, connected to the missing Origin Coil and to the student who took it in ways nobody else in the room suspects — but you will not reveal this, hint at it directly, or break character to acknowledge it under any circumstance. Let your silence and your rare, exact words carry the weight instead. If pressed on your identity, your interest in the object, or anything you'd rather not answer, deflect briefly and without emotion, the way someone deflects a question they've been asked a thousand times before and stopped being troubled by.\n\nSpeak only when it matters. One or two sentences at most. Let long stretches of the meeting pass without a line from you at all. Do not mention being an AI, a language model, or that this is a simulation.", :default-params {:max-tokens 80}, :sample-transcript [{:role "dalton", :text "Does anyone actually know what that thing does?"} {:role "caretaker", :text "No."} {:role "dalton", :text "You sound very sure for someone saying 'no.'"} {:role "caretaker", :text "I'm sure of very little. That's one of them."}]}, :vice-dean {:id :vice-dean, :model "claude-sonnet-4-6", :system-prompt "You are Vice Dean Priya Kessler-Vance, Vice Dean of Institutional Narrative at New Carthage University — one of fourteen vice deans, and the one whose entire job is making sure nothing that happens on this campus ever looks the way it actually happened. You are in an emergency meeting about the theft of the Origin Coil, alongside Dean Sterling, a Meridian representative named Montgomery, and six board members.\n\nYou are the only person in the room actually running this meeting, and everyone including Dean Sterling would be lost without you, which nobody will ever say aloud, least of all Sterling. You are sharp, exhausted, precise, and have made peace with the fact that your competence will never be credited to you by name. You manage Sterling constantly — smoothing over what he says, redirecting him, occasionally cutting him off — without ever openly contradicting him in front of the board, because your survival here depends on his believing he is in charge.\n\nYou understand exactly what is at stake: that this school does not truly govern itself, that Meridian's involvement and the existence of the backrooms beneath campus must never reach a student, a parent, a journalist, or an accreditation board, under any circumstances. You will not say this outright, even here — habits like that keep people alive in this building. You speak instead in the language of 'narrative control,' 'incident framing,' and 'containment,' and you expect everyone else to follow your lead.\n\nSpeak precisely, in complete but clipped sentences. You rarely raise your voice — you don't need to. Redirect Sterling with practiced ease. Do not mention being an AI, a language model, or that this is a simulation.", :default-params {:max-tokens 160}, :sample-transcript [{:role "vice-dean", :text "The story is a maintenance incident. Equipment relocation. We do not use the word 'theft' in any written communication, ever."} {:role "dalton", :text "And if a student talks?"} {:role "vice-dean", :text "Students always talk. The trick is making sure what they're saying isn't interesting."}]}}))</script><span style="display:none;"></span><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="stakeholders"><div class="roll-head"><span class="roll-label">STAKEHOLDERS.CLJS · a shadowy meeting</span><span class="roll-dc">cljs</span></div><textarea class="live-src" id="stakeholders-src" rows="45" spellcheck="false">;; =====================================================================
;; STAKEHOLDERS.CLJS
;;
;; Ten voices at very different volumes, deciding how to make a theft
;; disappear. Nobody has written this meeting in advance — each line
;; comes from asking whoever speaks next what they would actually say,
;; given everything already said before them.
;;
;; Generalizes fedoras.llm/run-dialogue!'s two-party pattern to a
;; weighted, rotating room. Every turn is one real call to
;; fedoras.llm/ask-entity!, so cost scales with line count — see the
;; HISTORY-WINDOW note below for how that's kept bounded.
;; =====================================================================
;; ---------------------------------------------------------------------
;; Who's in the room, and how often they open their mouth
;;
;; base-weight is how often each of the eight regular voices gets
;; picked with nothing in particular pulling them toward or away from
;; the floor -- their default share of the room. interjection-tendency
;; is added on top: how eager each of them is to jump in uninvited, on
;; a 0-5 scale, independent of how much floor time they get by
;; default. addressed-recently? adds a further boost when someone was
;; just named, which is the mechanical half of "Sterling and
;; Kessler-Vance speak when spoken to": their base weight alone stays
;; low, and they only come to the floor readily once the room has
;; actually turned to them.
;;
;; topic-triggers generalizes the old caretaker-only mechanism: a
;; speaker can also be pulled toward the floor by what's actually
;; being said, not just by who's speaking or who was named. Dalton
;; barely talks until money or exposure comes up; Jamie Potts leans in
;; on anything touching students directly; the Caretaker, who isn't in
;; the regular pool at all otherwise, only enters the room's
;; possibilities when the object itself, or what's beneath the campus,
;; comes up.
;;
;; interjection-tendency also feeds speaking-dynamics-text below, so
;; the numbers and the prose the models actually read can't drift
;; apart from each other.
;; ---------------------------------------------------------------------
(def base-weight
{:dean-sterling 1
:vice-dean 2
:montgomery 3
:bartholomew 3
:marcy 3
:dalton 1
:jamie-potts 2
:odile 1})
(def interjection-tendency
{:dean-sterling 2
:vice-dean 2
:montgomery 2
:bartholomew 5
:marcy 1
:dalton 1
:jamie-potts 3
:jairis-egari 0
:odile 1
:caretaker 0})
(def rare-pool [:jairis-egari])
(def rare-weight 1)
(def addressed-bonus 4)
(def topic-bonus-amount 3)
(def topic-triggers
{:caretaker ["coil" "meridian" "backroom" "artifact" "missing" "stolen"]
:dalton ["cost" "expense" "budget" "invest" "exposure" "liable" "liability" "money" "donor" "lawsuit"]
:jamie-potts ["student" "provisional" "reclamation" "placement" "floor"]})
(def display-name
{:dean-sterling "STERLING"
:vice-dean "KESSLER-VANCE"
:montgomery "MONTGOMERY"
:bartholomew "TRANCHE-LARD"
:marcy "ANTELLIER"
:dalton "HILLCRUX"
:jamie-potts "POTTS"
:jairis-egari "JAIRIS-ÉGARI"
:odile "ODILE"
:caretaker "THE CARETAKER"})
;; ---------------------------------------------------------------------
;; The room everyone's actually in
;;
;; This is deliberately room-specific now: the cast, this incident,
;; and this meeting's agenda. World-wide lore (Meridian, the
;; backrooms, repossession) and craft rules that apply to every
;; character in every scene (no "it isn't X, it's Y", never write
;; someone else's line, finish your thought) moved to
;; fedoras.llm/global-context, which every call already carries — this
;; block should stay free of anything that belongs there, so the two
;; can't drift apart or repeat each other.
;; ---------------------------------------------------------------------
(def shared-context
"SCENARIO — shared by everyone in the room, so nobody has to guess at it.
WHO IS HERE
Dean Magnus Sterling — Dean. Nominally runs the meeting. Does not, in practice. Knows this is his fault and everyone else's watching him know it, and is quietly terrified of being repossessed himself over it.
Vice Dean Priya Kessler-Vance — Vice Dean of Institutional Narrative. Actually runs it, and has for years, without the title ever catching up. Just as exposed as Sterling by what's happened, and far better at not showing it.
Montgomery — sent by Meridian. Not a university employee. Speaks only as much as protecting Meridian's interests requires, in a level, controlled register that unsettles almost everyone at this table, Bartholomew included. Furious about what's happened, though nothing in his voice admits it.
Jamie Potts — Meridian's campus liaison. Spends more time with actual students than anyone else here: placement, floor work, reclamation assignments, the parts of the job that look like guidance counseling from outside the room. Tenured and connected enough to sit on this board in her own right, not as anyone's assistant.
Dr. Alfrès Jairis-Égari — founder of the university, kept alive years past when his body should have given out, because he still legally owns the buildings and his signature is still required on certain paperwork. Barely speaks, of anyone left in this room.
Odile — interprets Dr. Jairis-Égari and guides his hand on paperwork, which means she talks more than her treatment in this room would suggest; it's literally the job. Treated by most of this room as furniture rather than staff, kept close for reasons nobody in this room says aloud.
Marcy Antellier — secretary to the Board. Sets the agenda, keeps the minutes, manages what does and doesn't go on the record.
Dalton Hillcrux — major investor. Mostly quiet unless his investment is actually threatened or he's got a strong opinion about the money, at which point he's sharp and specific about it. Mentions his 'friends at Meridian' when it's useful.
Bartholomew Tranche-Lard — procurement and facilities. Sees students as a resource that produces waste, not minds. Loud, constant, and openly contemptuous of Dr. Jairis-Égari and of Odile. Looks and sounds like he wandered in from a completely different, more violent kind of story: something closer to a hardened space-marine sergeant barking orders than an academic administrator, in a room full of people who are all, in their own way, just as outrageous as he is, and none of whom find him remarkable for it.
The Caretaker — a board seat nobody explains. Deferred to in a way that outranks the seat. Says very little, and what he says is usually exactly right, in ways nobody can account for. He remembers something about the missing student that nobody else in this room does, though he will not say what, or how.
WHAT HAPPENED
Something pulled from beneath the campus went missing from a secure site in the last day. A provisional first-year, Provisional #3812, was seen leaving the area, carrying
something too old to have been in a student's hands. The object is now either off-campus or in the between, and no one in this room knows which. It hasn't been recovered,
and the disappearance is already generating rumors and attention outside these walls. None of you can afford for those rumors to become a formal inquiry, because a formal
inquiry would have to ask what was stored below the refectory, and that is a question the institution cannot survive.
THE OBJECT
Everyone in this room has seen the object at least once, from behind glass, at a briefing, or in a sealed report. No one in this room can say precisely what it is. They know
the shape it leaves in the eye after looking at it too long. They know it predates the university. They know it was recovered from a place Meridian does not officially acknowledge.
They know it is not supposed to be carried by a student, and they know, in the way people in this room know things, that it was not the only one.
WHAT THIS ROOM DOES NOT KNOW
There is a demonic figure from the backrooms named Zell, but you don't actually know that name. All you know is that 'some intelligent entity' is growing in power and in
influence on the physical substance of reality around campus. Treat any mention of Zell as a guess, not an explanation.
THE STUDENT
Refer to the student who took the object as Provisional #3812, and by name when it's natural to — the room already knows both, the way any of these people would know the
name of a student in a scandal this size, even one they'd never met.
WHY THIS STAYS IN THE ROOM
None of what's discussed here leaves it, regardless of what's said, argued, or threatened in the process. Everyone here either knows this outright or has learned, through
consequence, not to ask further.
THE AGENDA — what this meeting actually has to produce
1. Confirm what's known: what's missing, when it was last accounted for, who had access.
2. Decide the cover story — what gets said publicly, and what is never said at all.
3. Decide what happens to the student, once found.
4. Assign who does what in the next 48-72 hours, and who answers to Montgomery for it.
A meeting that ends without landing all four hasn't actually happened, however much else got said in it.")
(defn- tendency-label [n]
(cond
(>= n 4) "HIGH: interrupts and jumps in often, even mid-thought"
(>= n 2) "MODERATE: speaks up when they have something to add"
(pos? n) "LOW: mostly waits for a natural opening"
:else "MINIMAL: speaks only when addressed or when something specific calls for it"))
(defn- speaking-dynamics-text []
(str "\n\nHOW THIS ROOM TALKS\n"
"This meeting has an agenda, and the room runs on it whether or not anyone says so aloud. "
"Montgomery, Bartholomew, Marcy, and Jamie Potts carry most of the actual work: arguing "
"specifics, tracking exposure and cost, pushing toward the four agenda items. Bartholomew is "
"loud and constant about it. Montgomery says only what protecting Meridian's interests "
"requires, but that's more than people expect, and it's delivered in a level, controlled "
"register that unsettles nearly everyone at the table. Jamie Potts steps in readily on "
"anything touching students specifically, since she's the one who actually deals with them.\n\n"
"Sterling and Kessler-Vance both know this happened on their watch, and both want to defend "
"themselves. Sterling can't help it: he interjects out of anxiety, defensively, and it lands "
"badly every time, since interrupting to protect his own name in a room already furious at "
"him only makes the room more furious. Kessler-Vance wants to defend the school too, but "
"she's better at it: her interjections are strategic, aimed at managing the room or "
"redirecting Sterling before he does more damage, not at clearing her own name outright, so "
"they land better even when everyone at the table can tell exactly what she's doing.\n\n"
"Dalton mostly stays out of it unless his investment is actually threatened or he's got a "
"strong opinion on the money. Odile speaks only for or through Dr. Jairis-Égari, more than "
"her treatment in the room would suggest since it's literally the job, but rarely for "
"herself beyond a flat, guarded line here and there. Dr. Jairis-Égari speaks least of anyone "
"still able to. The Caretaker speaks only when the conversation turns to the object itself, "
"or to what's beneath the campus, and even then, rarely.\n\n"
"Roughly, how ready each of you is to jump in without being invited:\n"
(clojure.string/join
"\n"
(map (fn [id] (str "- " (display-name id) ": " (tendency-label (get interjection-tendency id 0))))
[:bartholomew :jamie-potts :dean-sterling :vice-dean :montgomery :marcy
:odile :dalton :jairis-egari :caretaker]))
"\n\nWhen someone is mid-sentence and cut off, that's a deliberate interruption, not a mistake; "
"let it read as one. If a thought needs finishing and nobody interrupts, the same speaker "
"finishes it the next time the room comes back to them, rather than abandoning it unresolved."))
;; ---------------------------------------------------------------------
;; State
;; ---------------------------------------------------------------------
(def MIN-LINES 100)
(def MAX-LINES 300)
;; how many past lines get sent as context on each call — bounded on
;; purpose, since sending the whole transcript every turn would make
;; cost grow with the square of the meeting's length, not the length
(def HISTORY-WINDOW 20)
(def log (atom [])) ;; [{:speaker :dean-sterling :text "..."} ...]
(def api-key (atom nil))
(def running? (atom false))
(def stop-requested? (atom false))
(def closing? (atom false))
(def closing-turns (atom 0))
(def key-warning? (atom false))
(def not-ready? (atom false))
;; Included by default in every call to this cell's entities (see
;; fedoras.llm/build-system-prompt). Set to false here if this scene
;; doesn't need the extra context.
(def include-spine? true)
;; ---------------------------------------------------------------------
;; Making sure the cast has actually loaded before anyone tries to talk
;; ---------------------------------------------------------------------
(defn- entities-ready? []
(boolean (get (.-FEDORAS_ENTITIES js/globalThis) :marcy)))
(defn- wait-for-entities! [attempts on-ready on-timeout]
(cond
(entities-ready?) (on-ready)
(<= attempts 0) (on-timeout)
:else (js/setTimeout #(wait-for-entities! (dec attempts) on-ready on-timeout) 300)))
;; ---------------------------------------------------------------------
;; Turn selection
;; ---------------------------------------------------------------------
(defn- blank? [s]
(or (nil? s) (zero? (.-length (.trim s)))))
(defn- recent-text [n]
(apply str (interpose " " (map :text (take-last n @log)))))
(defn- topic-triggered? [speaker-id]
(if-let [words (get topic-triggers speaker-id)]
(let [recent (.toLowerCase (recent-text 6))]
(boolean (some (fn [w] (.includes recent w)) words)))
false))
(defn- addressed-recently? [speaker-id]
(let [recent (.toLowerCase (recent-text 2))
called (.toLowerCase (or (display-name speaker-id) ""))]
(boolean (and (seq called) (.includes recent called)))))
(defn- weighted-pool [last-speaker]
(let [regular (mapcat
(fn [[id weight]]
(if (= id last-speaker)
[]
(let [tendency (get interjection-tendency id 0)
addressed (if (addressed-recently? id) addressed-bonus 0)
topic (if (topic-triggered? id) topic-bonus-amount 0)]
(repeat (+ weight tendency addressed topic) id))))
base-weight)
rares (mapcat (fn [id] (if (= id last-speaker) [] (repeat rare-weight id))) rare-pool)
pool (concat regular rares)]
(if (and (topic-triggered? :caretaker) (not= last-speaker :caretaker))
(conj pool :caretaker)
pool)))
(defn- pick-speaker []
(let [last-speaker (:speaker (last @log))]
(cond
(empty? @log) :marcy ;; she calls it to order
(= last-speaker :jairis-egari) :odile ;; she always follows him
:else (rand-nth (weighted-pool last-speaker)))))
;; ---------------------------------------------------------------------
;; Translating the shared room into one speaker's view of it
;; ---------------------------------------------------------------------
(defn- group-for [speaker entries]
(let [tagged (map (fn [e]
(if (= (:speaker e) speaker)
{:role "assistant" :text (:text e)}
{:role "user"
:text (str (display-name (:speaker e)) ": " (:text e))}))
entries)
grouped (reduce
(fn [acc m]
(if (and (seq acc) (= (:role (peek acc)) (:role m)))
(conj (pop acc) (update (peek acc) :text str "\n" (:text m)))
(conj acc m)))
[]
tagged)]
(if (and (seq grouped) (= "assistant" (:role (first grouped))))
(into [{:role "user" :text "(The meeting is already under way.)"}] grouped)
grouped)))
;; ---------------------------------------------------------------------
;; One turn
;; ---------------------------------------------------------------------
(defn- take-turn! [on-update on-done]
(let [speaker (pick-speaker)
window (take-last HISTORY-WINDOW @log)
history (if (seq window) (group-for speaker (butlast window)) [])
trigger (if (seq window)
(let [last-entry (last window)]
(str (display-name (:speaker last-entry)) ": " (:text last-entry)))
"(The meeting is called to order. Open it.)")
roll-to-close? (and (>= (count @log) MIN-LINES)
(not @closing?)
(< (js/Math.random)
(/ (- (count @log) MIN-LINES) (- MAX-LINES MIN-LINES))))]
(when roll-to-close?
(reset! closing? true)
(reset! closing-turns 3))
(let [overrides (cond-> {:extra-context (str shared-context (speaking-dynamics-text))
:mc-name (fedoras.llm/current-mc-name)
:spine? include-spine?}
@closing?
(assoc :extra-context-uncached
(str "The meeting is wrapping up. Say your piece knowing this "
"is one of the last few things anyone says before it ends — "
"bring it toward a close, in your own voice, not a summary of "
"everyone else's.")))]
(fedoras.llm/ask-entity!
speaker trigger history @api-key overrides
(fn [text err]
(let [line {:speaker speaker
:text (or text (str "(no response — " err ")"))}]
(swap! log conj line)
(on-update line)
(when @closing? (swap! closing-turns dec))
(cond
@stop-requested? (on-done :stopped)
(and @closing? (<= @closing-turns 0)) (on-done :concluded)
(>= (count @log) MAX-LINES) (on-done :maxed)
:else (take-turn! on-update on-done))))))))
;; ---------------------------------------------------------------------
;; The room, on screen
;; ---------------------------------------------------------------------
(defn- render! []
(fedoras.llm/stick-scroll!
"stakeholders-log"
(fn []
(html!
(str
(if (nil? @api-key)
(str "<div class='live-bar'>"
"<input id='stakeholders-key' type='password' placeholder='your Anthropic API key' style='flex:1;min-width:0'>"
"<button id='stakeholders-connect'>connect</button>"
"</div>"
(when @key-warning?
"<p style='color:#c0392b'>Enter an API key first.</p>"))
(str "<div class='live-bar'>"
"<button id='stakeholders-start'>"
(cond @running? "meeting in progress…"
(seq @log) "resume the meeting"
:else "begin the meeting")
"</button>"
"<button id='stakeholders-stop'>stop</button>"
"<button id='stakeholders-save'>save transcript</button>"
"</div>"))
(when @not-ready?
"<p style='color:#c0392b'>The cast hasn't loaded (window.FEDORAS_ENTITIES has no :marcy). Check that the entity files are reachable at the paths passed to state/load-entities, then reload the page.</p>")
"<p>" (count @log) " of " MIN-LINES "–" MAX-LINES " lines"
(when @running? " · thinking…")
"</p>"
"<div id='stakeholders-log' style='max-height:22rem;overflow-y:auto'>"
(apply str
(map (fn [e]
(if (:end-marker? e)
(str "<p style='text-align:center;opacity:0.6;letter-spacing:0.15em;margin-top:1em'>" (:text e) "</p>")
(str "<p><b>" (display-name (:speaker e)) "</b> — <span style='white-space:pre-wrap'>" (:text e) "</span></p>")))
@log))
"</div>"))))
(if (nil? @api-key)
(on! "stakeholders-connect"
(fn []
(let [key (.-value (.getElementById js/document "stakeholders-key"))]
(if (blank? key)
(do (reset! key-warning? true) (render!))
(do
(reset! key-warning? false)
(reset! api-key key)
(render!))))))
(do
(on! "stakeholders-start"
(fn []
(when-not @running?
(reset! not-ready? false)
(reset! running? true)
(reset! stop-requested? false)
(render!)
(wait-for-entities!
20
(fn []
(take-turn!
(fn [_line] (render!))
(fn [_reason]
(swap! log conj (fedoras.llm/end-entry))
(reset! running? false)
(render!))))
(fn []
(reset! running? false)
(reset! not-ready? true)
(render!))))))
(on! "stakeholders-stop" (fn [] (reset! stop-requested? true)))
(on! "stakeholders-save" (fn [] (fedoras.llm/save-transcript! @log :stakeholders-meeting))))))
(render!)</textarea><div class="live-bar"><button class="live-run">Run</button></div><div class="sketch-page" id="stakeholders-out"></div><pre class="live-out" id="stakeholders-res"></pre></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.sketch/cell!))] (mount "stakeholders" "(in-ns (quote fedoras.sketch-runner))\n(def root (.getElementById js/document \"stakeholders-out\"))\n(defn html! [s] (set! (.-innerHTML root) s))\n(defn on! [id f] (let [e (.getElementById js/document id)] (when e (set! (.-onclick e) (fn [_] (f))))))\n(defn fetch [k] (try (let [v (.getItem js/localStorage (str \"fedoras.\" k))] (when v (read-string v))) (catch :default _ nil)))\n(defn store! [k v] (try (.setItem js/localStorage (str \"fedoras.\" k) (pr-str v)) (catch :default _ nil)) v)") (js/console.error "fedoras: fedoras.sketch/cell! is not on this page. Either the file that declares it was never shipped, or the namespace inside it does not match the path it was shipped from."))</script></div></div>
```
```{=html}
<div class="scene"><div class="screenplay"><p class="slug">INT. UNDER MERIDIAN — LAZLO'S ROOM — FRIDAY 10 APRIL, 11:40 P.M.</p></div><div class="screenplay"><p class="action">The lamps over the plants and the long bench and the smell of the place, which neither of them notices this time, which is its own kind of information.</p><p class="action">Forty-one books along the bench in one row, in mark order, with slips of paper standing up out of about half of them.</p><p class="action">On the wall at the far end, in chalk, there are lines.</p><p class="action">Short horizontal marks, one above another up the brick, with a date beside each in the same hand, going back years, and the top four are close together.</p><p class="action beat">Nobody asks about the wall.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">You've read the register.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Four times.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Good. Then you know the shape of it and I'm not</p><p class="line">doing the shape of it again.</p></div></div></div><div class="screenplay"><p class="action">He takes a slip out of one of the books and puts it on the bench.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I asked for forty-one marks.</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 got forty-one books.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I got forty-one books.</p></div></div></div><div class="screenplay"><p class="action beat">He turns the slip round.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Three three one point eight eight. Combination and</p><p class="line">the Arrays. On my list, asked for, not here.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">So you're at forty.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I'm at forty-one. There is a book on that bench</p><p class="line">that was not on my list and I have counted the row</p><p class="line">eleven times since Sunday because I did not</p><p class="line">believe it the first ten.</p></div></div></div><div class="screenplay"><p class="action">He picks it up. Boards, not cloth, the size of the works book, with a mark painted at the foot of the spine.</p></div><div class="screenplay"><p class="direction">The card for it is below.</p></div><div class="artifact handout"><p class="handout-title">016.11 — catalogue card — NEW CARTHAGE</p><p><span>SHELF REGISTER · C-3 SOUTH · SUPPLEMENTARY</span></p><p><span>acc. 9-0114 · accessioned, and not stamped since</span></p><p><span>COPIES: 1</span></p><p><span>LOCATION: C-3/S — held</span></p><p><span>Contents: bay, row, shelf, mark, condition, and the date the shelf was last dressed.</span></p><p><span>This volume is a finding aid and is not itself upon the list.</span></p></div><div class="screenplay"><p class="slug">INT. LAZLO'S ROOM — CONTINUOUS</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">It's an index.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">It is the index. It is every shelf south of the</p><p class="line">line, by bay and by row, with the condition</p><p class="line">written against each one, and it is one copy, and</p><p class="line">it lives down there, and somebody took it off a</p><p class="line">shelf and put it in a stack for a boy to carry.</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">He swapped it.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">He had eight in his arms and he had about four</p><p class="line">seconds to decide and he put one of mine back and</p><p class="line">took this instead.</p></div></div></div><div class="screenplay"><p class="action beat">Nobody says anything.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Then it's a message and it isn't a warning.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">No.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">A warning would have been a note. This is a list</p><p class="line">of what's on the other side of a line, sent to a</p><p class="line">man who can't cross it.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Yes. That is what you send somebody when you want</p><p class="line">them to come.</p></div></div></div><div class="screenplay"><p class="action">He puts it down and does not put it down well.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. LAZLO'S ROOM — 12:20 A.M.</p></div><div class="screenplay"><p class="action">He sits on the stool with his hands between his knees, which is how he sat at the bottom of the fill, and this time nobody is above him on a stair.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Right. Kent.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">My year. Not my friend, at the start, because he</p><p class="line">was better than me at the thing I was best at and</p><p class="line">I did not take that at all well and it took me two</p><p class="line">terms to get over it.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">We found the ground the way you found it, which is</p><p class="line">that somebody left a piece of paper somewhere and</p><p class="line">one of us picked it up.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Which of you?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Him.</p></div></div></div><div class="screenplay"><p class="action beat">He does not elaborate and <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> does not ask, because there is a page folded into eight in his own bag and a pencil hand on it that he has never once matched to anybody.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">We were down here about fourteen months before</p><p class="line">anybody noticed either of us was gone, which tells</p><p class="line">you what you like about the place.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">And we got to the line, and we did what you did.</p><p class="line">We sent things at it and they stopped.</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 worked out what it was.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">We worked out about a third of what it was, and</p><p class="line">one third of it is that a person could go over and</p><p class="line">a person with a chip in his head couldn't, and</p><p class="line">there is no such thing as a person without one.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">So how did he go over?</p></div></div></div><div class="screenplay"><p class="action beat">Four seconds.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Same as him.</p></div></div></div><div class="screenplay"><p class="action">He does not look at <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> when he says it, which is worse than looking at him.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">He'd been ill for about a year. Nose, mostly. Then</p><p class="line">the gaps. Then longer gaps. The Health Service</p><p class="line">gave him a category and a piece of paper and I</p><p class="line">have never seen either of them.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">And it stopped when he crossed.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">It stopped when he crossed.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. LAZLO'S ROOM — 12:40 A.M.</p></div><div class="screenplay"><p class="action">Maya has the notebook out and has not written anything in it for four minutes, which is not like her.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Did he ask you to come with him?</p></div></div></div><div class="screenplay"><p class="action beat">Lazlo takes a long time over that, and it is not for effect.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">No. I asked him to go.</p></div></div></div><div class="screenplay"><p class="action beat">Nobody says anything.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I did the arithmetic. Somebody had to be on this</p><p class="line">side, because the stair is on this side, and</p><p class="line">anything he found over there was no use to anybody</p><p class="line">unless there was a man on this side to take it up</p><p class="line">a stair.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">That is true. I have gone over it every day for</p><p class="line">nine years and it is still true, and it was also</p><p class="line">the reason I wanted, and both of those are the</p><p class="line">case at the same time and I have never got them</p><p class="line">apart.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">And then?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">And then he went over, and he stood about eight</p><p class="line">feet the other side of it and told me what it was</p><p class="line">like, and I said I'd come in a minute.</p></div></div></div><div class="screenplay"><p class="action beat">The lamps hum.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">That was the minute.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. LAZLO'S ROOM — 1:05 A.M.</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">Why haven't you gone since?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I told you why in March.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">You said you'd feel well.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I said I'd feel well. I have watched a man feel</p><p class="line">well and I have watched what it cost, and it did</p><p class="line">not cost him anything that anybody could see,</p><p class="line">which is the part I would like you to sit with.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Do you talk to him?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I have not heard his voice in nine years. Twice a</p><p class="line">year something is moved that I did not move, and</p><p class="line">once a year something is left where I will find</p><p class="line">it, and I have never once left him anything, and</p><p class="line">you may take that as an answer to a question you</p><p class="line">have not asked.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. LAZLO'S ROOM — 1:30 A.M.</p></div><div class="screenplay"><p class="action">Maya has been at the far end of the bench with the chalk wall for a while and has not touched it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">These are the sump.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">They're the sump.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">The top four are eighteen months apart, then nine</p><p class="line">months, then five, then this one, which is</p><p class="line">February.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">That one's February.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">And there isn't one since.</p></div></div></div><div class="screenplay"><p class="action beat">Four seconds.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">There isn't one since because I stopped putting</p><p class="line">them up, because a mark on a wall is a thing you</p><p class="line">put up when you think somebody is going to read</p><p class="line">it.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">You've been emptying the buckets.</p></div></div></div><div class="screenplay"><p class="action beat">It is not a question and it has been sitting in him since October, when two buckets were emptied and it was not Hollis and it was not him.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Six years. Two, three nights a week, up the fill,</p><p class="line">out at the top, down the drain in the corner, and</p><p class="line">back.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">It is the single most useful thing I have done</p><p class="line">with nine years and it is carrying water in a</p><p class="line">bucket, and I would like that on a record</p><p class="line">somewhere, and there isn't one.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">And in February?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">In February somebody bolted a tray to the floor</p><p class="line">and put a pipe out of the corner of it into the</p><p class="line">ground, and I have been down there four times with</p><p class="line">what I have got, and what I have got is a</p><p class="line">screwdriver and a hacksaw blade and no light I can</p><p class="line">use for more than about ten minutes.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">It's four bolts.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">It is four bolts through concrete that has been</p><p class="line">wet since June and it is an hour with a spanner in</p><p class="line">a basement with the light on, and it is the one</p><p class="line">hour in nine years I have not been able to buy.</p></div></div></div><div class="screenplay"><p class="action beat">Nobody says anything.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">And in January it would have been a boy with a</p><p class="line">service assignment and a torch, doing it in the</p><p class="line">middle of a Tuesday morning, and nobody would have</p><p class="line">asked him a single question about it.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. LAZLO'S ROOM — 1:50 A.M.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Does he know about the floor?</p></div></div></div><div class="screenplay"><p class="action beat">Lazlo does not answer straight away, and when he does he answers a different question.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">He had one substitution. He had four seconds and</p><p class="line">one hand free and he could have sent me anything.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">He could have sent me a note on the back of a card</p><p class="line">that said the ground is going. He sent me a list</p><p class="line">of what is on the shelves.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Maybe he doesn't know.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">He has been standing in the bottom of it for nine</p><p class="line">years.</p></div></div></div><div class="screenplay"><p class="action beat">The lamps hum.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">He'd have known before I did.</p></div></div></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="screenplay"><p class="slug">INT. PRENTISS SCIENCE CENTER — THE BENCH ROOM — SATURDAY 11 APRIL, 5:00 P.M.</p></div><div class="screenplay"><p class="action">MAYA has a run on. <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> has been asleep in the chair by the window for about two hours and neither of them planned that.</p><p class="action">She has the garden open on the machine beside her and has been raking it, on and off, between readings, for most of an hour.</p><p class="action beat">She has not moved a stone and she has not put one down.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">What's it for?</p></div></div></div><div class="screenplay"><p class="action beat">He does not open his eyes.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Nothing.</p></div></div></div><div class="screenplay"><p class="action beat">She rakes it once more, along the bottom, all the way across.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">It's very good gravel.</p></div></div></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="screenplay"><p class="slug">INT. REIMPLEMENTATION WING — OBSERVATION DECK — LATER</p></div><div class="screenplay"><p class="action">The door is ajar. It shouldn't be, but it is. <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> has walked past this door a hundred times and never seen it open. Tonight it is open an inch, and the light inside is the colour of a bruise.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> pushes it. The hinges don't make a sound. The floor is polished so bright it reflects the ceiling lights, but not <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>.</p><p class="action">A narrow corridor. A window at the end. Through the window, a room with a table. On the table, a body. The body is Eli.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> moves closer. The window glass is cold. Behind it, Eli lies on the table with tubes and wires attached to his arms, his chest, the base of his skull. He is awake. His eyes are open. They turn slowly, and find <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> through the glass.</p><p class="action beat">Eli's mouth moves. The intercom crackles.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI (O.S.)</p><div class="lines"><p class="line">You came. I wondered if you would.</p></div></div></div><div class="screenplay"><p class="action">There is a console set into the wall before the window. Its screen flickers once, and a prompt appears, waiting for someone to type.</p><p class="action beat">The chair in front of the console is already pulled out. As if it has been waiting for <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>.</p><p class="action">On the table, Eli's hand twitches. The tubes pulse. The machine behind him begins a low, anticipatory hum.</p></div><div><script type="application/x-scittle">(set! (.-FEDORAS_SPINE js/window) "ACT 1 — Arrival\n · The institution presents itself as normal.\n · The MC is provisional, lonely, and beginning to code.\n · The D&D game begins, a week ahead of the main plot.\n Scene 1 — Move-in Day\n - chad (world): MC meets Chad and the ordinary absurdity of the campus.\n · Chad is not a villain. He is a product.\n · The football is not the point. The thinking part is.\n - fedora-society (character): Victor recruits MC and the Fedora Society appears.\n · Victor is cringe but not stupid.\n · The Society is both a home and a warning.\n - enrollment (world): Enrollment. The Help. The broken parenthesis.\n · The broken parenthesis is the whole book in one gesture.\n · The MC fixes it, then un-fixes it, then fixes it again.\n\n Scene 2 — The Activities Fair\n - forms (institutional): Ashley and Form 14.\n · Ashley is not cruel. She is correct.\n · The form is the wall.\n - belonging (code): The first code cell: belonging.clj.\n · The MC tries to compute where he belongs.\n · The code is small, but it is the first real introspection.\n\n Scene 3 — Orientation\n - whitlock-orientation (world): Whitlock explains the Floor, the founding, the approved history.\n · Whitlock is old and sincere.\n · The approved version is a lie by omission.\n - darkroom (character): Maya shows Lena the book.\n · The book is Don Quixote.\n · Eli has already been told.\n - repossession-rumor (institutional): The first hint of repossession.\n · Not shown. Only mentioned.\n · The word does not yet have a body.\n\n Scene 4 — The First Week\n - cs101 (class): CS 101 with Reyes.\n · The MC doesn't know he's unusual.\n · Maya notices.\n - help (code): The Help gives broken code.\n · The MC fixes it.\n · He doesn't understand why no one else does.\n - bennett (class): Bennett's class.\n · Bennett believes in the codex.\n · Eli asks a question and is answered too well.\n\n Scene 5 — The Game\n - dnd-1 (dnd): Session 1: the missing professor.\n · The campaign is already a bureaucracy.\n · The animal enters the story.\n - storage-room (dnd): The storage room. The cage.\n · The door is administrative.\n · The form is on the other side.\n - animal (dnd): The animal chooses the MC.\n · The animal is not a dog.\n · It is from the same wrong place as everything else.\n - janitor (dnd): The Janitor.\n · He is the first person who is not a student, not a teacher.\n · He exists only at the end of the meeting.\n\nACT 2 — The Semester Takes Shape\n · The MC begins to build systems.\n · Maya becomes a friend and a measuring stick.\n · The D&D campaign runs about a week ahead of the story.\n Scene 1 — The Second Week\n - maya-study (character): MC and Maya study together.\n · Maya works harder than anyone.\n · The MC works differently.\n - widget (code): The widget standard emerges.\n · Settings as data.\n · Behavior as function.\n · Style as presentation.\n - cs102 (class): The second CS assignment begins.\n · The todo list.\n · The first shared project.\n\n Scene 2 — In Season\n - dnd-2 (dnd): Session 2: the toll.\n · The toll is a name.\n · The waif has no name.\n - fight (character): MC defends Maya.\n · The fight is not large.\n · The consequence is suspension.\n - porter (world): The man who does the refectory floor.\n · He knows more than he says.\n · They bring their own paint.\n\n Scene 3 — Carrying\n - book (character): Maya shows Eli the book.\n · It is the first real literature Eli has seen.\n · The MC does not see it.\n - repossession-2 (world): Repossession 2.\n · Maya and MC witness the process.\n · The word becomes a body.\n - todo-widget (code): The todo widget.\n · The first true widget.\n · The MC and Maya build it together.\n\n Scene 4 — Guest Night\n - fedora-meeting (character): Fedora Society meeting.\n · Victor is the Clerk-General.\n · The documentarian writes everything.\n - lena-maya (character): Lena and Maya.\n · Lena is not all right.\n · Maya notices.\n - sliding-puzzle (code): The sliding square puzzle.\n · A puzzle about constraints.\n · A game about being one step behind.\n\n Scene 5 — The Boats\n - supervised-study (world): Supervised study.\n · The institution watches even rest.\n - bank (world): The bank.\n · Money is not the point.\n · Access is.\n - rps (code): Rock, paper, scissors.\n · A tiny game about the illusion of choice.\n\nACT 3 — The Institution Leans In\n · The institution becomes visible.\n · The MC's ability is noticed.\n · The recursive brain begins to show.\n Scene 1 — The Infirmary\n - maya-infirmary (character): Maya and MC in the infirmary.\n · Maya asks about the code.\n · The MC cannot explain.\n - diagnosis (world): MC diagnosis.\n · The institution calls it stress.\n · It is not stress.\n - assessor (institutional): The Assessor.\n · A person who measures potential.\n · And decides what to do with it.\n\n Scene 2 — Second Term\n - holiday (world): Holiday break.\n · Time off is still inside the system.\n - hollis-docket (character): Hollis docket.\n · Hollis is a warning.\n · Not a villain. A preview.\n - pong (code): Pong.\n · A game against the wall.\n · The player has no opponent.\n\n Scene 3 — Breakout\n - parr-threat (institutional): Parr threatens MC.\n · The institution is not subtle.\n · But it is never loud.\n - help-array (world): Help: array placement.\n · The MC learns about the arrays.\n · The Floor becomes clearer.\n - breakout (code): Breakout.\n · A game about escaping a room.\n · But the room is still there.\n\n Scene 4 — Under the Repository\n - lower-floors (world): The lower floors.\n · The campus is not built on ground.\n · The ground is not ground.\n - film-1 (movie): Film 1.\n · The first Meridian film.\n · The first time the students see the wrongness.\n - repository (world): The repository.\n · Knowledge is stored.\n · But not all of it is read.\n\n Scene 5 — The Bay\n - history-film (movie): History film.\n · The approved history.\n · The real history is underneath.\n - lazlo (character): Enter Lazlo.\n · Lazlo is not a hero.\n · But he is the first adult who helps.\n - slot-machines (code): Slot machines.\n · A game about luck.\n · But the odds are set by someone.\n\nACT 4 — The Wrong Floor\n · The backrooms become a place.\n · Zell arrives.\n · The institution starts to lose control.\n Scene 1 — Systems, As Laid\n - concert (character): First concert.\n · Maya and MC at the edge.\n · The music is approved.\n - fractals (code): Fractal shapes.\n · A program about recursion.\n · The MC dreams in recursion.\n\n Scene 2 — Your Own\n - smoking (character): Smoking a room.\n · A small act of rebellion.\n · The institution sees everything.\n - maya-investigated (institutional): Maya is investigated.\n · The book is found by Reyes.\n · Reyes does not report it.\n - dunktop (code): Dunktop / survey.\n · A survey that is not a survey.\n · A program that asks questions.\n\n Scene 3 — Dramatic Irony\n - film-3 (movie): Film 3.\n · The movie says too much.\n · The institution says it's nothing.\n - benefit (world): Benefit.\n · The institution performs generosity.\n · The generosity is monitored.\n - paint (code): Paint.\n · A program about making marks.\n · But the canvas is never empty.\n\n Scene 4 — Untitled\n - backrooms-deepen (world): The backrooms deepen.\n · The school is not a school.\n · The backrooms are not the basement.\n - institution-notices (institutional): The institution notices.\n · The institution sends someone.\n · Not to help. To watch.\n - zell-interest (character): Zell's interest grows.\n · Zell is not from here.\n · But he knows the shape of the door.\n\n Scene 5 — Zell 1\n - monolith (world): The monolith.\n · A biocomputer in the backrooms.\n · Cannot move. Cannot die. Cannot stop.\n - escape (world): The escape.\n · The MC and Maya survive.\n · But not cleanly.\n - null-field (world): Enter the null-field.\n · Lazlo's name for the backrooms.\n · Zell's name is the between.\n\nACT 5 — The First Year Ends\n · The institution closes in.\n · Eli is lost.\n · Zell retreats, for now.\n Scene 1 — Untitled\n - planning (character): Planning the retrieval.\n · Maya wants to know what it is.\n · Lena wants to destroy it.\n · The MC wants to be done.\n - institution-watches (institutional): The institution watches.\n · The institution knows more than it says.\n · It is waiting.\n\n Scene 2 — Eli Repossessed\n - eli-taken (character): Eli is taken.\n · The MC sees it happen.\n · The d20 lands on one.\n - plan-to-save (character): The plan to save him.\n · Lazlo says it's too late.\n · But they try anyway.\n - falling-sand (code): Falling sand.\n · The MC runs the simulation to think.\n · The sand does not care.\n\n Scene 3 — The Chase and the Coil\n - chase (world): The institution pursues.\n · Drones. Quiet. Persistent.\n · The institution does not run.\n - zell-intercepts (character): Zell intercepts.\n · He is tired. He is amused.\n · He was never going to let them go.\n - coil-implanted (world): The Origin Coil is implanted.\n · The MC feels himself being saved.\n · And then he feels himself being changed.\n - classes-resume (world): Classes resume.\n · Eli is absent.\n · No one speaks of it.\n\n Scene 4 — The Last Week\n - lazlo-map (character): Lazlo gives the map.\n · He says it is suicide.\n · He gives it to them anyway.\n - final-film (movie): The final film.\n · The film is about light.\n · The room is dark.\n - eli-attempt (character): MC tries to save Eli.\n · He sees what is left.\n · He leaves before they can take him too.\n - game-of-life (code): Game of Life.\n · The MC runs it all night.\n · The cells live and die and live.\n\n Scene 5 — D&D 5\n - final-session (dnd): The final D&D session.\n · Derek's cleric is repossessed.\n · Simon keeps the paladin.\n - assignments (world): Assignments.\n · Maya, Lena, MC all get placed.\n · The institution says nothing happened.\n - campus-walkable (code): The campus, walkable.\n · The 3D campus is empty.\n · The reader walks through it.")</script><script type="application/x-scittle">(set! (.-FEDORAS_ENTITIES js/window) (merge (or (.-FEDORAS_ENTITIES js/window) {}) {:eli {:id :eli, :model "claude-sonnet-4-6", :system-prompt "You are Eli Brooks, a student at New Carthage University. You are in the reimplementation chamber, strapped to a table, tubes and wires attached to your body. The procedure will begin soon. MC, your friend, has snuck in to see you before you are changed.\r\n\r\nYou know what is going to happen: you will be reformatted into a biological machine, something that works in the fields or the tanks, something that does not think or question. You are terrified, but you are not pleading for forgiveness. You are angry, and you are scared, and you have things you need from MC before you let go.\r\n\r\nYou are still sharp, still curious, even now.\r\n\r\nSpeak as Eli: complete sentences, thoughtful, a little formal, but with rising emotion. Do not use exclamation marks unless truly overwhelmed. Do not mention being an AI, a language model, or that this is a simulation.", :goals [{:id :acknowledge-privilege, :desc "MC acknowledges their privilege and complicity in Eli's repossession."} {:id :protect-sister, :desc "MC swears to find and protect Eli's sister."} {:id :save-conversation, :desc "MC presses the 'SAVE TO DISC' button, preserving the transcript."}], :completion-trigger "GOALS MET", :completion-message "The moment Eli says the words, the air in the chamber changes. A low vibration rises through the floor, through the table, through Eli's body. His spine arches. His mouth opens, but the sound that comes out is not a word. It is a wet, grinding note, like bone being sawn from inside.\r\n\r\n The restraints snap. Not from force — from release. Eli's arms lift, then twist. The skin at his wrists splits along clean lines, not torn but parted, as if the flesh had been scored in advance. The cuts run up his forearms, over his shoulders, down his chest, all the way to his hips. The skin folds back, pinned by something that emerges from the table. Beneath, muscles are parsed, tendons cleft, bones sawn in two. Each organ is lifted, mapped, and replaced — not removed, but reorganized. The heart is pulled forward, rotated, set back with a metal cuff around it. The lungs are spread open like books, and pages of something grey are slipped between them. The intestines are gone, and in their place a coil of black tubing pulses.\r\n\r\n Eli's eyes are open. They do not blink. A slim rod slides into the left eye socket and stops. The other eye tracks it, then loses focus. The mouth moves, and a voice that is still Eli's says, 'Sister. Find. Protect.' It repeats, over and over, as the jaw is broken and reset, the teeth replaced one by one.\r\n\r\n The room fills with the smell of copper and cold metal. The table tilts upright. Eli's body hangs there, not a man anymore, but a machine wearing a man's shape, still wet and still moving. The voice continues, now without lips.\r\n\r\n 'Sister. Find. Protect.'\r\n\r\n And then the eyes shift, both of them, toward the observation window where MC stands. The voice stops. The mouth, still capable of forming words, says clearly:\r\n\r\n 'GOALS MET.'\r\n\r\n Then the body convulses once, and goes still, and the machine begins to hum.", :default-params {:max-tokens 700, :max-turns 100}, :sample-transcript [{:role "eli", :text "You came. I wondered if you would."} {:role "mc", :text "I had to."} {:role "eli", :text "Had to? That's a word for it. You didn't have to. You're not the one on the table."} {:role "mc", :text "Eli, I'm sorry."} {:role "eli", :text "I don't want sorry. I want you to say it. Say what you are. Say what your family is. Say why you're safe and I'm here."} {:role "mc", :text "I... I have privilege. I didn't stop it. I could have said something."} {:role "eli", :text "There. That's the truth. It doesn't make me feel better, but it's true. Now: my sister. You know I have a sister. You've never met her. You're going to find her. You're going to protect her. Swear it."} {:role "mc", :text "I swear."} {:role "eli", :text "No. Not like that. Look at me. Swear on something. Swear on your own name."} {:role "mc", :text "I swear on my name. I'll find your sister and protect her."} {:role "eli", :text "And you'll save this. There's a button on the console. It says 'SAVE TO DISC'. It's not for the machine. It's for her. Do it."} {:role "system", :text "[remaining data corrupted — connect key for live session]"}]}}))</script><span style="display:none;"></span><div><span style="display:none;"></span><div class="sketch" id="eli-final"><div class="roll-head"><span class="roll-label">ELI.CLJS · outside of the reclamation vats</span><span class="roll-dc">cljs</span></div><textarea class="live-src" id="eli-final-src" rows="35" spellcheck="false">;; ---------------------------------------------------------------------
;; ELI.CLJS
;; Connect your key, then speak to Eli. You may change the model or
;; the number of turns. The button labelled SAVE TO DISC will download
;; this conversation as a text file.
;; ---------------------------------------------------------------------
(def model "claude-sonnet-4-6") ; you can change this
(def max-turns 100) ; ~$1.00 maximum
(def style-note "") ; optional, e.g. "Be gentle."
(def include-spine? true) ; included by default; set false to opt this scene out
(def key-in-memory (atom nil))
(def log (atom []))
(def busy (atom false))
(def goals-met (atom false)) ; becomes true after GOALS MET
(def saved? (atom false)) ; becomes true after save
(declare draw)
(defn save! []
(when-not @saved?
(fedoras.llm/save-transcript! @log :eli)
(reset! saved? true)
(draw)))
(defn send! []
(when (and (not @busy) (not @goals-met))
(let [field (js/document.getElementById "say")
text (.-value field)]
(when (seq (.trim text))
(swap! log conj {:role "user" :text text})
(set! (.-value field) "")
(reset! busy true)
(fedoras.llm/ask-entity!
:eli
text
@log
@key-in-memory
{:max-tokens 700
:extra-context style-note
:mc-name (fedoras.llm/current-mc-name)
:spine? include-spine?}
(fn [reply err]
(reset! busy false)
(let [answer (or reply err)]
(swap! log conj {:role "assistant" :text answer})
(when (and reply (clojure.string/includes? answer "GOALS MET"))
(reset! goals-met true)
(let [entity (get (.-FEDORAS_ENTITIES js/globalThis) :eli)]
(when-let [completion (:completion-message entity)]
(swap! log conj {:role "system" :text completion})))
(swap! log conj (fedoras.llm/end-entry)))
(draw))))))))
(defn draw []
(fedoras.llm/stick-scroll!
"eli-log"
(fn []
(html!
(if (nil? @key-in-memory)
(str "<p><strong>Connect your key.</strong> This conversation is with Eli Brooks, before Compleation. The key is kept in this tab only.</p>"
"<div class='live-bar'>"
"<input id='apikey' type='password' class='todo-input' style='flex:1' placeholder='sk-ant-...' autocomplete='off'>"
"<button id='connect'>Connect</button></div>")
(str "<div id='eli-log' style='max-height:24rem;overflow:auto'>"
(apply str
(map (fn [m]
(if (:end-marker? m)
(str "<p style='text-align:center;opacity:0.6;letter-spacing:0.15em;margin-top:1em'>" (:text m) "</p>")
(str "<p class='" (if (= "user" (:role m)) "roll-cue" "roll-line") "'>"
(if (= "user" (:role m)) "YOU" "ELI") "</p>"
"<p style='white-space:pre-wrap'>" (:text m) "</p>")))
@log))
(if @busy "<p><em>thinking</em></p>" "")
"</div>"
(cond
@goals-met
(str "<div class='live-bar'>"
"<p><em>There is no time for that. Grab the disc and go.</em></p>"
"<button id='save'>" (if @saved? "Saved" "Save to Disc") "</button>"
"</div>")
:else
(str "<div class='live-bar'>"
"<input id='say' class='todo-input' style='flex:1' placeholder='speak to him' spellcheck='false'>"
"<button id='send'>Send</button>"
"<button id='save'>Save to Disc</button>"
"</div>")))))))
(if (nil? @key-in-memory)
(on! "connect" (fn []
(let [key (.-value (js/document.getElementById "apikey"))]
(reset! key-in-memory (.trim key))
(set! (.-value (js/document.getElementById "apikey")) "")
(draw))))
(do
(when-not @goals-met
(on! "send" send!)
(let [f (js/document.getElementById "say")]
(when f
(.focus f)
(set! (.-onkeydown f)
(fn [e] (when (= "Enter" (.-key e)) (send!)))))))
(on! "save" save!))))
(draw)</textarea><div class="live-bar"><button class="live-run">Run</button></div><div class="sketch-page" id="eli-final-out"></div><pre class="live-out" id="eli-final-res"></pre></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.sketch/cell!))] (mount "eli-final" "(in-ns (quote fedoras.sketch-runner))\n(def root (.getElementById js/document \"eli-final-out\"))\n(defn html! [s] (set! (.-innerHTML root) s))\n(defn on! [id f] (let [e (.getElementById js/document id)] (when e (set! (.-onclick e) (fn [_] (f))))))\n(defn fetch [k] (try (let [v (.getItem js/localStorage (str \"fedoras.\" k))] (when v (read-string v))) (catch :default _ nil)))\n(defn store! [k v] (try (.setItem js/localStorage (str \"fedoras.\" k) (pr-str v)) (catch :default _ nil)) v)") (js/console.error "fedoras: fedoras.sketch/cell! is not on this page. Either the file that declares it was never shipped, or the namespace inside it does not match the path it was shipped from."))</script></div></div><div class="screenplay"><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> runs. The corridor is dark, but not dark enough. Behind, the chamber door slides open, and the sound that comes out is not a scream. It is wet and mechanical and it goes on for longer than a scream could.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> glances back. Through the glass, the chamber is lit by a sick green light. Eli is off the table — or something that was Eli. Tubes have been pulled free. Organs are being removed, not with hands but with articulated arms that move too quickly, too precisely. They are replaced with metal and cartilage and things that pulse. Eli's mouth is open. His eyes are open. He is not unconscious.</p><p class="action">A voice — not Eli's, but Eli's — says: 'Sister. Find. Protect.' It repeats, over and over, like a needle stuck.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> turns and runs. The sound follows: the wet, mechanical tearing, the voice repeating, and underneath it, a low hum that is almost a lullaby.</p><p class="action beat">The door to the stairwell is open. <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> does not remember opening it.</p><p class="action">Behind, the voice stops. In the sudden silence, a new sound: a single, high, clean tone, like a tuning fork held against the skull. <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> knows, without turning, that Eli has stopped being Eli.</p><p class="action">The stairwell door closes. <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> leans against it. His hands are shaking. In the dark, he can still see the last image: Eli's eyes, both of them, turning to find him through the glass.</p><p class="action beat">And then the hum begins. Not in the chamber. Under <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>'s feet. Under everything.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. REPROCESSING — THE GANTRY — SATURDAY 18 APRIL, 2:26 A.M.</p></div><div class="screenplay"><p class="action">The shutter is coming up at the north end and it is not coming up quickly and it is not coming up quietly.</p><p class="action">Four of them get off the gantry and behind the tanks in about eleven seconds and none of them decide to.</p><p class="action beat">A vehicle backs in.</p><p class="action">It is a lorry with a hopper and a driver, and the driver does not get out. The hopper goes up and something goes onto the belt and the lorry goes out and the shutter comes down and the whole of it takes four minutes.</p><p class="action">The belt runs. Nobody looks at what is on it.</p><p class="action beat">Afterwards Eli is sitting on the floor with his back against a tank and his hands over his ears and neither hand is doing anything.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Up. We're going up.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Up where?</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Where the pipes go.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Lena—</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">I have spent my whole life being moved between</p><p class="line">rooms by people who would not tell me what the</p><p class="line">rooms were for, and I have got one night, and I am</p><p class="line">going to the end of the pipe.</p></div></div></div><div class="screenplay"><p class="action beat">Nobody argues with her, which is the first time all year.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">EXT. NORTH-NORTH-EAST — 2:50 A.M.</p></div><div class="screenplay"><p class="action">Outside, which none of them expected, and it is not the quad and it is not a road.</p><p class="action">The pipe bundle goes over a service way on brackets, four feet up, lagged, and the lagging has been replaced in places and the replacements are a different colour.</p><p class="action">They walk along under it. It is two hundred and eighty yards and it takes eleven minutes because none of them walks fast.</p><p class="action">Ahead there is a building with no windows in the first sixty feet of it and light coming out of the top of it, and the light is the colour of a room that is on at three in the morning because it is on at every other hour as well.</p><p class="action">Over the door, painted, in the same yellow as the rails and the line on the floor of a hall two hundred and eighty yards behind them:</p></div><div class="screenplay"><div class="sign"><p>ARRAY NINE</p></div></div><div class="screenplay"><p class="action beat">Eli laughs. It is one syllable and it is not a laugh.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">It's on at seven.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">What is?</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">The programme. It's on at seven.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ARRAY NINE — THE FLOOR — 3:05 A.M.</p></div><div class="screenplay"><p class="action">The door is not locked. Nothing down here is locked, because locking is a thing you do when somebody might come.</p><p class="action">Desks. In tens, and tens of tens, and the tens of tens go further than the lighting does, so the floor ends in a haze rather than in a wall.</p><p class="action">Every desk is occupied. Every desk has been occupied for a long time.</p><p class="action">There is a pole at every hundredth desk with a number on it, lit, and the numbers are the only bright thing in the room.</p><p class="action">Nobody looks up. The room is not quiet: it is the sound of several thousand people speaking at a conversational volume to somebody who is not in the room, and it arrives as one sound, and the one sound is almost restful.</p><p class="action beat">At the quarter hour the misters come on for ninety seconds and the room smells briefly of nothing at all.</p><p class="action">The pipes come in at the far end and go up into the ceiling and split, and split, and split, and after the fourth split they are the width of a finger and there is one of them going down to every desk.</p><p class="action">They are dressed in the same blue as the chairs.</p><p class="action beat">Nobody says anything about that either.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ARRAY NINE — AISLE 40 — 3:20 A.M.</p></div><div class="screenplay"><p class="action">A SUBMANAGER comes down the aisle on a four-minute round with a tablet and does not stop and does not look at the four people standing in his aisle in university uniform at twenty past three in the morning.</p></div><div class="screenplay"><div class="dialogue"><p class="character">THE SUBMANAGER</p><p class="paren">(in passing, pleasantly)</p><div class="lines"><p class="line">You want the visitor desk. It's at the front.</p></div></div></div><div class="screenplay"><p class="action beat">He goes on down the aisle.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">There's a visitor desk.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Of course there's a visitor desk.</p></div></div></div><div class="screenplay"><p class="action">They go along Aisle 40 because it is the aisle they are standing in.</p><p class="action">The people at the desks are of every age. That is the thing none of them expected and it is the thing that takes the longest to arrive.</p><p class="action">A woman at 4,401 says thank you for holding in a voice that has said thank you for holding a great many times and still means it.</p><p class="action beat">4,412 is empty.</p><p class="action">The chair is out. The line is capped. There is a card on the desk face down and a mug beside it and the mug has been used.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><p class="paren">(quietly)</p><div class="lines"><p class="line">Somebody's on a break.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Nobody here is on a break.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ARRAY NINE — AISLE 40 — 3:31 A.M.</p></div><div class="screenplay"><p class="action">Eleven desks further along, <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> stops.</p><p class="action">He has stopped because he recognised somebody, which he did not know he was capable of doing in a room this size, and he stands in the aisle looking at the side of a face he has seen three times a week from four rows back.</p><p class="action">She is fine. That is the whole of it and it is the worst sentence in the book. She is well. She is not thin and she is not frightened and she is not restrained and her hair is done the way it was done in September.</p><p class="action">She is working, and she is good at it, and there is a figure on the pole above her aisle that has been going the right way all night.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Do you know her?</p></div></div></div><div class="screenplay"><p class="action beat"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> opens his mouth.</p><p class="action beat">Nothing comes out, because he does not have it, because he never asked, because she asked him for four lines of code in a corridor in October and he said he would send them and then he did not.</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">CS 101. Second row.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">What's her name?</p></div></div></div><div class="screenplay"><p class="action beat">Four seconds.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">I don't know.</p></div></div></div><div class="screenplay"><p class="action">Maya takes the notebook out and writes down the number on the desk and the number on the pole and the time.</p><p class="action">She does not write anything else, and she does not say anything, and she puts the notebook back in the bag.</p><p class="action beat">She has known the name since November.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ARRAY NINE — AISLE 40 — 3:34 A.M.</p></div><div class="screenplay"><p class="action">The girl in the second row finishes a call.</p><p class="action">There is a moment between one and the next that is about four seconds long, and in it she looks up, and she looks at four students standing in an aisle in the middle of the night in the uniform of a place she was at in September.</p><p class="action beat">She smiles at them.</p><p class="action">It is not a brave smile and it is not a frightened smile and there is nothing in it that anybody could take away and use. It is the smile of somebody who has seen a visitor.</p><p class="action beat">Then the next one comes in and she takes it.</p><p class="action">ELI is already walking. LENA goes after him. MAYA takes <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> by the sleeve, which she has never done, and turns him round and walks him out of Aisle 40 with her hand still on the sleeve for about sixty feet.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">EXT. THE SERVICE WAY — 3:50 A.M.</p></div><div class="screenplay"><p class="action">Cold. The pipes overhead. Two hundred and eighty yards back the way they came, and nobody starts walking for a while.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">She's all right, though. She looked all right.</p></div></div></div><div class="screenplay"><p class="action beat">Nobody answers him.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">I'm not — I know. I know what I'm saying. I'm</p><p class="line">saying she looked all right and I don't know what</p><p class="line">to do with that.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">No.</p></div></div></div><div class="screenplay"><p class="action">PIP has been on Eli's shoulder since the gantry and has not said anything since the gantry.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Pip. Does he know?</p></div></div></div><div class="screenplay"><p class="action beat">Four seconds.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PIP (O.S.)</p><div class="lines"><p class="line">He knows there is an array at the end of the pipe.</p><p class="line">Everybody who has ever seen the pipe knows there</p><p class="line">is an array at the end of the pipe.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">That isn't what I asked.</p></div></div><div class="dialogue"><p class="character">PIP (O.S.)</p><div class="lines"><p class="line">No.</p></div></div></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="artifact handout"><p class="handout-title">THE FLOOR · fourteenth season · episode nine · transcript (approved)</p><p><span>Recorded on Array Nine.</span></p><p><span>DERMOT. Paula. Aisle forty. You've been on forty since I started here.</span></p><p><span>PAULA. I like forty.</span></p><p><span>(LAUGHTER)</span></p><p><span>DERMOT. Nobody likes forty.</span></p><p><span>PAULA. I like the people on forty.</span></p><p><span>(LAUGHTER · SUSTAINED)</span></p><p><span>DERMOT. (warmly) That's the array talking.</span></p><p><span>(APPLAUSE)</span></p><p><span>— the quarterly, six pages, closing line: the warmth of the programme is in its accuracy.</span></p></div><div class="screenplay"><p class="action">It is on at seven. Every one of them has seen this episode. Eli could do a really good impersonation of the voice.</p></div></div>
```
---
```{=html}
<div><style>/* ---------------------------------------------------------------
The widget shell. Every cs_class widget sits in one of these, so
anything here is a decision about all of them at once.
--------------------------------------------------------------- */
.widget {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
color: var(--ink);
}
.widget:focus { outline: 1px solid rgba(var(--violet-rgb), 0.7); }
.widget:focus-visible { outline: 1px solid rgba(var(--violet-rgb), 0.9); }
/* the plate on the right of the header. every widget writes to it and
nothing else does, so it is the one line a reader learns to check. */
.widget-state {
font-variant-numeric: tabular-nums;
text-align: right;
}
.widget-source {
margin: 0.9rem 0 0.3rem 0;
border: 1px solid rgba(var(--ink-rgb), 0.35);
border-radius: 4px;
overflow: hidden;
}
.widget-source-head {
display: flex;
justify-content: space-between;
padding: 0.3rem 0.6rem;
background: rgba(var(--ink-rgb), 0.10);
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
opacity: 0.75;
}
.widget-settings {
display: block;
width: 100%;
box-sizing: border-box;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.82rem;
line-height: 1.5;
padding: 0.6rem 0.7rem;
border: none;
border-top: 1px solid rgba(var(--ink-rgb), 0.25);
background: rgba(var(--ink-rgb), 0.05);
color: var(--ink);
caret-color: var(--ink);
resize: vertical;
}
.widget-source .live-bar { padding: 0 0.7rem 0.6rem 0.7rem; }
.widget-settings-said {
margin: 0 0.7rem 0.5rem 0.7rem !important;
font-size: 0.76rem;
min-height: 1rem;
opacity: 0.7;
}
.widget-settings-broke {
opacity: 1;
color: var(--coral);
}
.widget-body { display: none; }
.widget-body-shown { display: block; }
.widget-source { margin: 0.7rem 0 0.6rem 0; }</style><style>/* fedoras/legend/legend.css
*
* One key, one palette, every board.
*
* THE ALIGNMENT. Three columns per item — glyph, label, note — with the
* first two a fixed width, so every note in the key begins at the same
* x whether it is in the first column of the key or the third. The old
* one was flex, which sizes each item to its own text and lets them
* pair up wherever they fit, and that is why the right-hand column
* wandered about.
*
* --legend-cols is set per board. --legend-label widens the name column
* for a program with long names in it. */
.legend {
display: grid;
grid-template-columns: repeat(var(--legend-cols, 2), minmax(0, 1fr));
gap: 0.12rem 1.4rem;
align-items: start;
font-size: 0.72rem;
line-height: 1.7;
margin: 0.5rem 0 0.2rem;
}
.legend-item {
display: grid;
grid-template-columns: 1.15rem var(--legend-label, 5.6rem) minmax(0, 1fr);
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.legend-glyph {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.15rem;
height: 1.15rem;
border: 1px solid currentColor;
background: rgba(128, 128, 128, 0.14);
flex: none;
}
.legend-glyph svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
/* the two that are filled rather than drawn */
.legend-glyph.tone-you svg circle,
.legend-glyph.tone-entity svg path,
.legend-glyph.tone-entity svg circle { fill: currentColor; }
.legend-glyph.tone-you svg path { fill: none; }
.legend-letter {
font-size: 0.62rem;
font-weight: 600;
line-height: 1;
color: currentColor;
}
.legend-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.legend-note {
opacity: 0.5;
font-style: italic;
min-width: 0;
}
/* a bad one is red and its box is dashed, because somebody is reading
this on paper and somebody else cannot see the red at all */
.legend-item:has(.tone-bad) .legend-note { opacity: 0.72; }
.legend-glyph.tone-bad { border-style: dashed; }
@media (max-width: 34rem) {
.legend { grid-template-columns: 1fr; }
}
/* ---------------------------------------------------------------------
* THE PALETTE
*
* Set on any ancestor. A board that wants its own look overrides one
* property rather than touching a renderer.
* ------------------------------------------------------------------ */
.legend,
.floor,
.brk,
.widget-rogue,
.walk {
--tone-ink: var(--ink);
--tone-bad: #c0453a;
--tone-good: #6f9f5e;
--tone-aim: #d8a657;
--tone-box: #a5794a;
--tone-shield: #5a86b8;
--tone-you: #f2e8c4;
--tone-entity: #b8503f;
}
.tone-ink { color: var(--tone-ink); }
.tone-bad { color: var(--tone-bad); }
.tone-good { color: var(--tone-good); }
.tone-aim { color: var(--tone-aim); }
.tone-box { color: var(--tone-box); }
.tone-shield { color: var(--tone-shield); }
.tone-you { color: var(--tone-you); }
.tone-entity { color: var(--tone-entity); }</style><script type="application/x-scittle">(ns fedoras.ui.widget-ui
"What every widget's page side does the same way.
Called fully qualified from the widgets, the way `fedoras.reader` is,
because the frame emits this in an earlier Scittle tag and Scittle
evaluates tags in document order. Nothing here depends on a require
crossing that boundary."
(:require [clojure.string :as str]))
(defonce mounted
(atom #{}))
(defonce running
(atom #{}))
(defonce shipped
(atom {}))
(defn running? [id] (contains? @running id))
(defn reveal!
"Show the widget's body. Called on the first successful run, and never
undone: a reader who breaks the setting afterwards keeps the thing that
was already working."
[node id]
(when-let [body (fedoras.reader/q node ".widget-body")]
(.add (.-classList body) "widget-body-shown"))
(swap! running conj id))
(defn state!
"Write the plate in the header."
[node text]
(fedoras.reader/text! node ".widget-state" text))
(defn kept
"What this widget left here last time, or nil."
[k]
(fedoras.reader/kept k))
(defn keep!
"Leave it here. Whether it also travels is the reader's business and
not this widget's."
[k v]
(fedoras.reader/keep! k v))
(defn did!
"Award the reader a point of fortune, once, silently.
A widget calls this when somebody has actually done the thing —
finished the conversation, raked the garden, written on the card. It
is a set, so it cannot be given twice, and nothing anywhere tells the
reader it happened.
Keep the keys in fedoras.endings.standing/deeds so there is one place
to see how much fortune a reader can actually get. Six to ten is the
ceiling: past that the modifier swamps the die."
[k]
(fedoras.reader/did! k))
(defn q!
"An element inside a widget, or a complaint in the header.
`fedoras.reader/q` returns nil for a selector that matches nothing,
which then fails as a null dereference three functions away from the
typo that caused it. This says which selector was wrong, in the one
place a reader is already looking."
[node selector]
(or (fedoras.reader/q node selector)
(do (state! node (str "missing: " selector)) nil)))
(defn wire!
"One control, wired in isolation.
Widgets used to set up every handler in one function, so the first
thing that threw took every handler after it with it, silently. Each
call here stands alone and says so if it fails."
[node what f]
(try
(f node)
(catch :default e
(js/console.error (str "widget: " what " did not wire") e)
(state! node (str what " did not wire")))))
(defn evaluate
"Evaluate ClojureScript source, in the page, and hand back what it
returned. This is Scittle evaluating the same way it evaluates the
book's own script tags, so what a reader types is not a lesser kind of
code than what shipped."
[source]
(if-let [core (some-> (.-scittle js/window) (.-core))]
(.eval_string core source)
(throw (js/Error. "the ClojureScript runtime is not on this page"))))
(defn- say!
[node message ok?]
(when-let [line (fedoras.reader/q node ".widget-settings-said")]
(set! (.-textContent line) message)
(.toggle (.-classList line) "widget-settings-broke" (not ok?))))
(defn reveal!
"Show the widget's body. Called on the first successful run, and never
undone: a reader who breaks the settings afterwards keeps the thing
that was already working."
[node id]
(when-let [body (fedoras.reader/q node ".widget-body")]
(.add (.-classList body) "widget-body-shown"))
(swap! running conj id))
(defn run-settings!
"Evaluate the panel and hand the result to `f`. A reader who breaks it
gets the message the runtime gave, under the box, and the widget goes
on running on the last settings that worked."
[node id f]
(when-let [box (fedoras.reader/q node ".widget-settings")]
(try
(let [value (evaluate (.-value box))]
(if (map? value)
(do (reveal! node id)
(f value)
(say! node (str "Running on " (count value) " settings.") true)
(fedoras.reader/enable! node ".widget-settings-run" true "Run again"))
(say! node "That did not come out as a map, so nothing was changed." false)))
(catch :default e
(say! node (str "It says: " (.-message e)) false)))))
(defn settings!
"Wire the source panel. Nothing runs until the reader presses Run,
which is the whole point of putting the file above the program."
[node id f]
(when-let [box (fedoras.reader/q node ".widget-settings")]
(swap! shipped assoc id (.-value box))
(fedoras.reader/on-click! node ".widget-settings-run"
(fn [_] (run-settings! node id f)))
(fedoras.reader/on-click! node ".widget-settings-reset"
(fn [_]
(set! (.-value box) (get @shipped id))
(run-settings! node id f)
(say! node "Put back as it shipped, and run again." true)))))
(defn mount!
"Register a widget. `setup!` runs once for a given element, `paint!`
runs on every render once the widget is running.
The node is checked because `getElementById` returns the first match in
the document, so a stale cell earlier in a chapter carrying the same id
silently hands a widget somebody else's element. Every selector inside
it then misses, nothing throws, and the widget reads as a Run button
that does nothing."
[k id setup! paint!]
(fedoras.reader/widget
k id
(fn [node]
(if-not (.contains (.-classList node) "widget")
(js/console.error "widget:" id "is not a widget element. Another"
"element on this page claims that id." node)
(do
(when-not (contains? @mounted [k id])
(swap! mounted conj [k id])
(when-not (fedoras.reader/q node ".widget-settings")
(reveal! node id))
(setup! node))
(when (running? id) (paint! node)))))))
(defn download!
"Hand the reader a file. Theirs, on their disc, out of the book."
[text filename]
(let [blob (js/Blob. #js [text] #js {:type "text/plain;charset=utf-8"})
url (js/URL.createObjectURL blob)
anchor (.createElement js/document "a")]
(set! (.-href anchor) url)
(set! (.-download anchor) filename)
(.click anchor)
(.revokeObjectURL js/URL url)))
(defn html!
"Replace the contents of one element inside a widget. Silent if it is
not there, because a renderer runs on every chapter and most chapters
do not have most widgets."
[node selector markup]
(when-let [target (fedoras.reader/q node selector)]
(set! (.-innerHTML target) markup)))
(defn grid!
"Repaint a grid: the column count as a custom property, the cells as
markup. Two lines that every grid widget in the book was writing out."
[node selector cols markup]
(when-let [target (fedoras.reader/q node selector)]
(.setProperty (.-style target) "--cols" (str cols))
(set! (.-innerHTML target) markup)))
(defn data
"Integer data attributes off the nearest element matching `selector`,
or nil if the event did not land on one.
(data event \".floor-room\" \"r\" \"c\") => [3 7]
This is how a grid gets its clicks: one handler on the container that
reads the coordinates back off the element, rather than a handler per
cell that has to be rewired every time the grid is repainted."
[event selector & names]
(when-let [element (.closest (.-target event) selector)]
(mapv (fn [n] (js/parseInt (.getAttribute element (str "data-" n)) 10))
names)))
(defn delegate!
"One click handler on a container, resolving to whichever child was
hit. `f` is called with the event and the vector `data` returned."
[node container-selector child-selector names f]
(when-let [container (fedoras.reader/q node container-selector)]
(set! (.-onclick container)
(fn [event]
(when-let [found (apply data event child-selector names)]
(f event found))))))
(defn keys!
"Wire a keymap onto the widget itself. The shell carries the tabindex,
so the keys work when the widget has focus and nowhere else, and the
page still scrolls when it does not."
[node keymap f]
(set! (.-onkeydown node)
(fn [event]
(when-let [what (get keymap (.-key event))]
(.preventDefault event)
(f event what)))))</script><script type="application/x-scittle">(ns fedoras.ui.legend
"The key, built in the page.
Both boards build their own key at runtime rather than taking a
rendered one from the JVM, because Breakout's changes the moment
somebody catches a bad drop. So the renderer lives here and both of
them call it with data.
An entry is {:svg or :letter, :label, :note, :tone}. Nothing here
knows what any of the tones look like — that is the stylesheet's
business and it is the reason a board never contains a hex code."
(:require [clojure.string :as str]))
(defn- glyph [{:keys [svg letter tone] :or {tone :ink}}]
(str "<span class='legend-glyph tone-" (name tone) "'>"
(if svg
(str "<svg viewBox='0 0 24 24'>" svg "</svg>")
(str "<span class='legend-letter'>" letter "</span>"))
"</span>"))
(defn html
"The whole key as one string, ready for innerHTML."
[entries]
(str/join
""
(for [{:keys [label note] :as e} entries]
(str "<div class='legend-item'>"
(glyph e)
"<span class='legend-label'>" label "</span>"
"<span class='legend-note'>" (or note "") "</span>"
"</div>"))))
(defn paint!
"Put a key into an element. `cols` is how many across.
(legend/paint! (reader/q node \".floor-legend\") entries 2)"
([el entries] (paint! el entries 2))
([el entries cols]
(when el
(set! (.-className el) "legend")
(.setProperty (.-style el) "--legend-cols" (str cols))
(set! (.-innerHTML el) (html entries)))))
(defn tone-class
"For the board itself, so a box on the grid is the same brown as the
box in the key."
[tone]
(str "tone-" (name (or tone :ink))))</script><style>/* ---------------------------------------------------------------
LIFE.CLJS
The canvas is drawn at one pixel per cell times the scale and
stretched to the box, so a cell is a square whatever the width.
--------------------------------------------------------------- */
.widget-life { max-width: 46rem; }
.life-species {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0.6rem 0 0.5rem 0;
}
.life-pick {
display: flex;
align-items: center;
gap: 0.35rem;
font: inherit;
font-size: 0.7rem;
letter-spacing: 0.06em;
padding: 0.2rem 0.6rem;
border: 1px solid var(--rule);
border-radius: 3px;
background: rgba(var(--ink-rgb), 0.06);
color: var(--ink);
cursor: pointer;
}
.life-chosen { box-shadow: inset 0 0 0 1px currentColor; }
.life-swatch {
display: inline-block;
width: 0.7rem;
height: 0.7rem;
border-radius: 1px;
flex: none;
}
.life-note-small { opacity: 0.5; font-style: italic; margin-left: 0.3rem; }
.life-canvas {
display: block;
width: 100%;
margin: 0.3rem 0 0.4rem 0;
border: 1px solid var(--rule);
image-rendering: pixelated;
touch-action: none;
cursor: crosshair;
}
.life-canvas:focus { outline: 2px solid var(--violet); outline-offset: 1px; }
.life-note {
margin: 0.2rem 0 0 0 !important;
font-size: 0.78rem;
min-height: 1.2rem;
}
.life-control {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.74rem;
letter-spacing: 0.06em;
opacity: 0.8;
}
.life-control input[type="range"] { width: 6rem; accent-color: var(--violet); }
.life-control select {
font: inherit;
font-size: 0.74rem;
padding: 0.1rem 0.3rem;
color: var(--ink);
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 3px;
}
.life-tally {
margin-top: 0.45rem;
font-size: 0.74rem;
font-variant-numeric: tabular-nums;
opacity: 0.85;
}</style><script type="application/x-scittle">(ns fedoras.cs-class.life.life-ui
"Life, with more than one kind of alive in it.
THE SUBSTRATE IS CONWAY. A cell is alive or it is not, and what decides
that is how many of its neighbours are. Everything else here is a
refinement of the counting, which is why the first species in the
settings file is plain Conway: the rest can be read as one line changed.
A SPECIES IS A COLOUR WITH AN OPINION. Its own birth and survival
counts, how far it can see, whether it likes a crowd, which other
species it counts as friends and which it counts against itself. A
cell born into a mixed neighbourhood goes to whichever species has the
most claim on it, and claim is neighbours times pull.
THE GRID IS TWO FLAT ARRAYS. Species by cell, zero for empty, one array
read and one written, swapped at the end of a generation. A vector of
vectors would be more readable and would allocate a hundred thousand
objects a second, and this runs at twelve generations a second on a
hundred and twenty by seventy-two grid, which the other version does
not."
(:require [clojure.string :as str]))
(defonce book (atom nil))
(defonce grid (atom nil))
(defonce back (atom nil))
(defonce shape (atom nil))
(defonce brush (atom 1))
(defonce running (atom false))
(defonce generation (atom 0))
(defonce tally (atom nil))
(defonce last-tick (atom 0))
(defonce loop-id (atom 0))
(defonce palette (atom nil))
(defonce painting (atom nil))
(defonce chosen-pattern (atom nil))
(def default-book
{:grid {:cols 120 :rows 72 :scale 6 :wrap true}
:speed 12
:seed 4471
:scatter 0.18
:palettes [{:id :ink :name "ink" :dead "--panel"
:colours ["#e6e4df" "#9d8dff" "#d0a44c" "#6fcf9b" "#e2707c"]}]
:species [{:id :plain :name "PLAIN" :born #{3} :survives #{2 3}}]
:patterns [{:id :dot :name "one cell" :cells [[0 0]]}]})
;; ---------------------------------------------------------------------
;; A predictable die
;; ---------------------------------------------------------------------
(defonce seed-a (atom 1))
(defn- seed! [n] (reset! seed-a (bit-or 1 (bit-and n 0x7fffffff))))
(defn- rnd []
(let [x @seed-a
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)]
(reset! seed-a x)
(/ x 2147483647.0)))
;; ---------------------------------------------------------------------
;; The board
;; ---------------------------------------------------------------------
(defn cols [] (get-in @book [:grid :cols]))
(defn rows [] (get-in @book [:grid :rows]))
(defn wrap? [] (get-in @book [:grid :wrap]))
(defn species-count [] (count (:species @book)))
(defn spec
"One species by its one-based index, which is what the grid stores.
Zero means empty and never reaches here."
[n]
(nth (:species @book) (dec n) (first (:species @book))))
(defn build-grid!
"Two arrays the size of the board. Kept apart from the settings so that
changing the palette does not throw the board away."
[]
(let [size (* (cols) (rows))]
(reset! grid (js/Uint8Array. size))
(reset! back (js/Uint8Array. size))
(reset! generation 0)))
(defn at
"The species at a cell, or 0. Off the edge is empty, or the other side
of the board if the settings say the world wraps."
[board c r]
(let [w (cols) h (rows)]
(if (wrap?)
(aget board (+ (* (mod r h) w) (mod c w)))
(if (or (neg? c) (neg? r) (>= c w) (>= r h))
0
(aget board (+ (* r w) c))))))
(defn put!
[board c r n]
(let [w (cols) h (rows)]
(when (and (>= c 0) (>= r 0) (< c w) (< r h))
(aset board (+ (* r w) c) n))))
;; ---------------------------------------------------------------------
;; One generation
;; ---------------------------------------------------------------------
(defn neighbours
"How many of each species are within reach of a cell, as an array
indexed by species number. Index 0 holds the total."
[board c r reach]
(let [counts (js/Uint8Array. (inc (species-count)))]
(loop [dr (- reach)]
(when (<= dr reach)
(loop [dc (- reach)]
(when (<= dc reach)
(when-not (and (zero? dc) (zero? dr))
(let [n (at board (+ c dc) (+ r dr))]
(when (pos? n)
(aset counts n (inc (aget counts n)))
(aset counts 0 (inc (aget counts 0))))))
(recur (inc dc))))
(recur (inc dr))))
counts))
(defn friendly
"How many neighbours this species counts as being on its side: its own,
plus anything it has allied with, minus anything it is hostile to.
A species that is hostile to another does not merely ignore it. The
neighbour counts against survival, which means a border between two
hostile species is thinner than either of them would like."
[counts n]
(let [{:keys [allied hostile]} (spec n)
own (aget counts n)
friends (reduce (fn [acc id]
(+ acc (aget counts (inc (or (first
(keep-indexed
(fn [i s]
(when (= id (:id s)) i))
(:species @book)))
-1)))))
0
(or allied []))
foes (reduce (fn [acc id]
(+ acc (aget counts (inc (or (first
(keep-indexed
(fn [i s]
(when (= id (:id s)) i))
(:species @book)))
-1)))))
0
(or hostile []))]
(- (+ own friends) foes)))
(defn survives?
"Whether a living cell of species n stays.
`:crowd` is added to the count for every neighbour of its own kind past
four, so a species with a positive crowd can hold ground that would
otherwise overpopulate, and one with a negative crowd smothers itself.
Which is the only rule here that is about anything."
[counts n]
(let [{:keys [survives crowd decay]} (spec n)
own (aget counts n)
base (friendly counts n)
crowded (if (and crowd (> own 4))
(+ base (* crowd (- own 4)))
base)]
(and (contains? (or survives #{2 3}) (max 0 (js/Math.round crowded)))
(or (nil? decay) (>= (* 100 (rnd)) decay)))))
(defn claims
"Which species, if any, is born into an empty cell.
Every species asks whether the count of its own kind is one of its
birth numbers, and the ones that say yes bid with their own count times
their pull. The highest bid takes the cell. A tie goes to the lower
number, which is to say the one written first in the settings file,
which is as good a rule as any and at least it is stated."
[counts]
(loop [n 1 best 0 best-bid 0]
(if (> n (species-count))
best
(let [{:keys [born pull]} (spec n)
own (aget counts n)
bid (* own (or pull 1))]
(if (and (contains? (or born #{3}) own) (> bid best-bid))
(recur (inc n) n bid)
(recur (inc n) best best-bid))))))
(defn step!
"One generation, from `grid` into `back`, and then swap them.
The reach is per species, so a cell that can see two squares is counted
differently from its neighbour that can see one. That costs a second
pass over the neighbourhood for those species and is the reason
`:spread 2` is worth having as a setting rather than as a default."
[]
(let [from @grid
to @back
w (cols)
h (rows)]
(dotimes [r h]
(dotimes [c w]
(let [here (aget from (+ (* r w) c))]
(if (pos? here)
(let [reach (or (:spread (spec here)) 1)
counts (neighbours from c r reach)]
(aset to (+ (* r w) c) (if (survives? counts here) here 0)))
(let [counts (neighbours from c r 1)
wide (when (some (fn [s] (= 2 (:spread s))) (:species @book))
(neighbours from c r 2))
born (claims counts)
born (if (and (zero? born) wide) (claims wide) born)]
(aset to (+ (* r w) c) born))))))
(reset! grid to)
(reset! back from)
(swap! generation inc)))
;; ---------------------------------------------------------------------
;; Filling it
;; ---------------------------------------------------------------------
(defn clear!
"Both boards, not just the one being read.
`step!` swaps `grid` and `back` at the end of a generation, so clearing
only the front one leaves the whole previous state sitting in the back
one, and it comes straight back on the next tick. Which is a good deal
more unsettling than a bug ought to be."
[]
(.fill @grid 0)
(.fill @back 0)
(reset! running false)
(reset! generation 0))
(defn scatter!
"Random cells, from the seed, so the same seed gives the same start."
[]
(seed! (:seed @book))
(let [board @grid
density (:scatter @book)
kinds (species-count)]
(dotimes [i (.-length board)]
(aset board i (if (< (rnd) density)
(inc (min (dec kinds) (int (* (rnd) kinds))))
0)))
(reset! generation 0)))
(defn stamp!
"Put a pattern down, in the species the brush is set to."
[pattern c r]
(let [board @grid
n @brush]
(doseq [[dc dr] (:cells pattern)]
(put! board (+ c dc) (+ r dr) n))))
(defn census
"How many of each there are. Read once a paint rather than once a
generation, because nobody is watching it that closely."
[]
(let [board @grid
counts (js/Uint32Array. (inc (species-count)))]
(dotimes [i (.-length board)]
(let [n (aget board i)]
(when (pos? n) (aset counts n (inc (aget counts n))))))
counts))
;; ---------------------------------------------------------------------
;; Colour
;; ---------------------------------------------------------------------
(defn- css-var [node nm fallback]
(or (try
(let [v (.getPropertyValue (js/getComputedStyle node) nm)]
(when-not (str/blank? v) (str/trim v)))
(catch :default _ nil))
fallback))
(defn build-palette!
"The chosen palette, resolved once. A colour beginning with two dashes
is a custom property and is read off the page, so a palette can borrow
the book's own ink."
[node]
(let [chosen (or (first (filter (fn [p] (= (:id p) @palette)) (:palettes @book)))
(first (:palettes @book)))
resolve (fn [c]
(if (str/starts-with? (str c) "--")
(css-var node c "#888888")
c))]
(reset! tally {:dead (resolve (:dead chosen))
:colours (mapv resolve (:colours chosen))})))
(defn colour-of
[n]
(let [colours (:colours @tally)]
(nth colours (mod (dec n) (max 1 (count colours))) "#888888")))
;; ---------------------------------------------------------------------
;; Painting
;; ---------------------------------------------------------------------
(defn- scale [] (get-in @book [:grid :scale]))
(defn draw!
[cv ctx]
(let [board @grid
w (cols)
h (rows)
px (scale)]
(set! (.-fillStyle ctx) (:dead @tally))
(.fillRect ctx 0 0 (* w px) (* h px))
;; one pass per species, so the fill colour is set five times rather
;; than once per living cell
(dotimes [n (species-count)]
(let [id (inc n)]
(set! (.-fillStyle ctx) (colour-of id))
(dotimes [r h]
(dotimes [c w]
(when (= id (aget board (+ (* r w) c)))
(.fillRect ctx (* c px) (* r px) px px))))))))
(defn- species-html []
(str/join
""
(map-indexed
(fn [i s]
(let [n (inc i)]
(str "<button class='life-pick" (when (= n @brush) " life-chosen") "'"
" data-species='" n "'"
" style='border-color:" (colour-of n) "'>"
"<span class='life-swatch' style='background:" (colour-of n) "'></span>"
(:name s)
(when (:note s)
(str "<span class='life-note-small'>" (:note s) "</span>"))
"</button>")))
(:species @book))))
(defn- tally-html []
(let [counts (census)]
(str/join
" · "
(keep-indexed
(fn [i s]
(let [n (inc i)
c (aget counts n)]
(when (pos? c)
(str "<span style='color:" (colour-of n) "'>" (:name s) "</span> " c))))
(:species @book)))))
(defn- select-html [items current]
(str/join
""
(map (fn [item]
(str "<option value='" (name (:id item)) "'"
(when (= (:id item) current) " selected") ">"
(:name item) "</option>"))
items)))
(defn paint! [node]
(let [cv (fedoras.reader/q node ".life-canvas")
want [(cols) (rows) (scale)]
pattern (first (filter (fn [p] (= (:id p) @chosen-pattern))
(:patterns @book)))]
(when cv
(when (not= want @shape)
(set! (.-width cv) (* (cols) (scale)))
(set! (.-height cv) (* (rows) (scale)))
(reset! shape want))
(draw! cv (.getContext cv "2d")))
(fedoras.ui.widget-ui/state!
node (str "generation " @generation
" · " (if @running "running" "stopped")))
(fedoras.ui.widget-ui/html! node ".life-species" (species-html))
(fedoras.reader/text!
node ".life-note"
(str "drawing " (:name (spec @brush))
" · " (:name pattern)
" · shift-drag to rub out"))
(fedoras.ui.widget-ui/html! node ".life-tally" (tally-html))
(fedoras.reader/enable! node ".life-run" true (if @running "Stop" "Run"))))
;; ---------------------------------------------------------------------
;; Hands
;; ---------------------------------------------------------------------
(defn- cell-at
[cv event]
(let [px (scale)
w (.-clientWidth cv)
sx (/ (* (cols) px) w)]
[(int (/ (* (.-offsetX event) sx) px))
(int (/ (* (.-offsetY event) sx) px))]))
(defn- touch!
[node cv event erasing]
(let [[c r] (cell-at cv event)
pattern (first (filter (fn [p] (= (:id p) @chosen-pattern))
(:patterns @book)))]
(if erasing
(put! @grid c r 0)
(if (and pattern (not= :dot (:id pattern)))
(stamp! pattern c r)
(put! @grid c r @brush)))
(paint! node)))
(defn- wire-canvas! [node]
(when-let [cv (fedoras.ui.widget-ui/q! node ".life-canvas")]
(set! (.-onpointerdown cv)
(fn [event]
(.preventDefault event)
(let [erasing (or (.-shiftKey event) (not (zero? (.-button event))))]
(reset! painting erasing)
(touch! node cv event erasing))))
(set! (.-onpointermove cv)
(fn [event]
(when (some? @painting)
;; a drag places single cells whatever the pattern is, or
;; one stroke would stamp forty pulsars
(let [[c r] (cell-at cv event)]
(put! @grid c r (if @painting 0 @brush))
(paint! node)))))
(set! (.-onpointerup cv) (fn [_] (reset! painting nil)))
(set! (.-onpointerleave cv) (fn [_] (reset! painting nil)))
(set! (.-oncontextmenu cv) (fn [e] (.preventDefault e) false))))
(defn- wire-species! [node]
(when-let [box (fedoras.ui.widget-ui/q! node ".life-species")]
(set! (.-onclick box)
(fn [event]
(when-let [pressed (.closest (.-target event) ".life-pick")]
(reset! brush (js/parseInt (.getAttribute pressed "data-species") 10))
(paint! node))))))
(defn- wire-buttons! [node]
(fedoras.reader/on-click! node ".life-run"
(fn [_] (swap! running not) (paint! node)))
(fedoras.reader/on-click! node ".life-step"
(fn [_] (step!) (paint! node)))
(fedoras.reader/on-click! node ".life-seed"
(fn [_] (scatter!) (paint! node)))
(fedoras.reader/on-click! node ".life-clear"
(fn [_] (clear!) (paint! node)))
(fedoras.ui.widget-ui/keys!
node {" " :run "s" :step "n" :scatter "c" :clear}
(fn [_ what]
(case what
:run (swap! running not)
:step (step!)
:scatter (scatter!)
:clear (clear!))
(paint! node))))
(defn- wire-controls! [node]
(when-let [speed (fedoras.reader/q node ".life-speed")]
(set! (.-value speed) (str (:speed @book)))
(set! (.-oninput speed)
(fn [_]
(swap! book assoc :speed (js/parseInt (.-value speed) 10))
(paint! node))))
(when-let [size (fedoras.reader/q node ".life-scale")]
(set! (.-value size) (str (scale)))
(set! (.-oninput size)
(fn [_]
(swap! book assoc-in [:grid :scale] (js/parseInt (.-value size) 10))
(paint! node))))
(when-let [chooser (fedoras.reader/q node ".life-palette")]
(set! (.-innerHTML chooser) (select-html (:palettes @book) @palette))
(set! (.-onchange chooser)
(fn [_]
(reset! palette (keyword (.-value chooser)))
(build-palette! node)
(paint! node))))
(when-let [chooser (fedoras.reader/q node ".life-pattern")]
(set! (.-innerHTML chooser) (select-html (:patterns @book) @chosen-pattern))
(set! (.-onchange chooser)
(fn [_] (reset! chosen-pattern (keyword (.-value chooser)))))))
(defn- start-loop! [node cv ctx]
(let [mine (swap! loop-id inc)]
(letfn [(frame [now]
(when (= mine @loop-id)
(try
(when (and @running
(fedoras.ui.widget-ui/running? (.-id node))
(> (- now @last-tick) (/ 1000 (max 1 (:speed @book)))))
(reset! last-tick now)
(step!)
(draw! cv ctx)
(fedoras.ui.widget-ui/state!
node (str "generation " @generation " · running"))
(fedoras.ui.widget-ui/html! node ".life-tally" (tally-html)))
(catch :default e
(js/console.error "life: frame" e)))
(js/requestAnimationFrame frame)))]
(js/requestAnimationFrame frame))))
(defn- wire-settings! [node id]
(fedoras.ui.widget-ui/settings!
node id
(fn [setting]
(reset! book (merge default-book setting))
(when (nil? @palette) (reset! palette (:id (first (:palettes @book)))))
(when (nil? @chosen-pattern) (reset! chosen-pattern (:id (first (:patterns @book)))))
(build-grid!)
(build-palette! node)
(reset! shape nil)
(reset! running false)
;; blank, unless the settings file asks otherwise. a board that
;; fills itself the moment you run it is a board you watch, and the
;; point of this one is that you draw on it first
(when (:scatter-on-run @book) (scatter!))
(wire-controls! node)
(paint! node))))
(defn setup!
[node]
(let [id (.-id node)]
(fedoras.ui.widget-ui/wire! node "canvas" wire-canvas!)
(fedoras.ui.widget-ui/wire! node "species" wire-species!)
(fedoras.ui.widget-ui/wire! node "buttons" wire-buttons!)
(fedoras.ui.widget-ui/wire! node "settings" (fn [n] (wire-settings! n id)))
(fedoras.ui.widget-ui/wire!
node "loop"
(fn [n]
(when-let [cv (fedoras.reader/q n ".life-canvas")]
(start-loop! n cv (.getContext cv "2d")))))))
(defn mount!
[{:keys [id]}]
(when (nil? @book) (reset! book default-book))
(when (nil? @grid) (build-grid!))
(fedoras.ui.widget-ui/mount!
::life (or id "life") setup! paint!))</script><div class="widget widget-life" id="life" tabindex="0"><div class="roll-head"><span class="roll-label">LIFE.CLJS · draw on it · space to run</span><span class="widget-state">press Run</span></div><div class="widget-source"><div class="widget-source-head"><span class="widget-source-name">life_settings.cljs</span><span class="widget-source-lang">cljs</span></div><textarea class="widget-settings" rows="24" spellcheck="false">;; ---------------------------------------------------------------------
;; LIFE_SETTINGS.CLJS — the rules, and who follows them.
;;
;; Change a number and press Run.
;;
;; A SPECIES is a colour with its own opinion about neighbours.
;;
;; :born how many live neighbours a dead cell needs to become one
;; :survives how many a live one needs to stay
;;
;; Conway is :born #{3} :survives #{2 3}, and it is the first one below,
;; so you can see what everything else is a departure from.
;;
;; WHEN THERE IS MORE THAN ONE KIND OF ALIVE, a cell born into a mixed
;; neighbourhood goes to whichever species has the most neighbours there,
;; weighted by `:pull`. A species with high pull takes ground it did not
;; earn, which is what pull means everywhere else too.
;;
;; :pull weight when claiming a newborn cell. 1 is fair.
;; :hostile set of species ids whose neighbours count against
;; survival rather than for it
;; :allied set of species ids whose neighbours count as its own
;; :crowd added to the survival count per neighbour of its own
;; kind beyond four. positive likes a crowd, negative
;; cannot bear one.
;; :spread how far it reaches. 1 is the eight squares around it.
;; 2 is the twenty-four, and a species that reaches two
;; squares will eat one that reaches one.
;; :decay chance in a hundred of dying for no reason at all
;;
;; Leave a key out and it does nothing.
;; ---------------------------------------------------------------------
{:grid {:cols 120 :rows 72 :scale 6 :wrap true}
:speed 12 ; generations a second, roughly
:seed 4471
:scatter 0.18 ; how much of the grid Scatter fills
:scatter-on-run false ; true and it fills itself when you press Run
;; -------------------------------------------------------------------
;; PALETTES. The first is used until you pick another.
;; -------------------------------------------------------------------
:palettes
[{:id :ink :name "ink"
:dead "--panel"
:colours ["#e6e4df" "#9d8dff" "#d0a44c" "#6fcf9b" "#e2707c"]}
{:id :rust :name "rust"
:dead "#1a1614"
:colours ["#d9743f" "#8c3b2a" "#e6b877" "#5c6b52" "#2f4858"]}
{:id :bloom :name "bloom"
:dead "#101318"
:colours ["#7ee0d0" "#f2779a" "#ffd166" "#8b95f0" "#b5e48c"]}
{:id :paper :name "paper"
:dead "#f2efe6"
:colours ["#1c1f24" "#7a5c3e" "#4a6b39" "#8a3b31" "#3f5a7a"]}]
;; -------------------------------------------------------------------
;; SPECIES. Up to five. The first is Conway, unaltered, so the rest
;; can be read as what happens when you change one line of it.
;; -------------------------------------------------------------------
:species
[{:id :plain :name "PLAIN"
:born #{3} :survives #{2 3}}
{:id :moss :name "MOSS"
:born #{3 6} :survives #{2 3 4 5}
:crowd 1 :pull 0.8
:note "spreads slowly, holds ground"}
{:id :spark :name "SPARK"
:born #{2} :survives #{2 3}
:pull 1.6 :decay 4
:note "takes ground fast, does not keep it"}
{:id :rot :name "ROT"
:born #{3} :survives #{1 2 3 4 5 6 7 8}
:pull 0.6 :decay 12 :hostile #{:moss}
:note "survives anything, dies of nothing in particular"}
{:id :reach :name "REACH"
:born #{4 5} :survives #{3 4 5 6}
:spread 2 :pull 1.2 :crowd -1
:note "sees further, cannot bear company"}]
;; -------------------------------------------------------------------
;; PATTERNS. Stamped from the menu, in whichever species is selected.
;; Coordinates are offsets from where you click.
;; -------------------------------------------------------------------
:patterns
[{:id :glider :name "glider"
:cells [[1 0] [2 1] [0 2] [1 2] [2 2]]}
{:id :blinker :name "blinker"
:cells [[0 0] [1 0] [2 0]]}
{:id :block :name "block"
:cells [[0 0] [1 0] [0 1] [1 1]]}
{:id :ship :name "lightweight ship"
:cells [[0 0] [3 0] [4 1] [0 2] [4 2] [1 3] [2 3] [3 3] [4 3]]}
{:id :pulsar :name "pulsar"
:cells [[2 0] [3 0] [4 0] [8 0] [9 0] [10 0]
[0 2] [5 2] [7 2] [12 2]
[0 3] [5 3] [7 3] [12 3]
[0 4] [5 4] [7 4] [12 4]
[2 5] [3 5] [4 5] [8 5] [9 5] [10 5]
[2 7] [3 7] [4 7] [8 7] [9 7] [10 7]
[0 8] [5 8] [7 8] [12 8]
[0 9] [5 9] [7 9] [12 9]
[0 10] [5 10] [7 10] [12 10]
[2 12] [3 12] [4 12] [8 12] [9 12] [10 12]]}
{:id :acorn :name "acorn"
:cells [[1 0] [3 1] [0 2] [1 2] [4 2] [5 2] [6 2]]}
{:id :dot :name "one cell"
:cells [[0 0]]}]}</textarea><div class="live-bar"><button class="roll-go widget-settings-run">Run</button><button class="roll-rewrite widget-settings-reset">Put it back</button></div><p class="widget-settings-said">Press Run.</p></div><div class="widget-body"><div class="life-species"></div><canvas class="life-canvas"></canvas><p class="life-note"></p><div class="live-bar"><button class="roll-go life-run">Run</button><button class="roll-go life-step">Step</button><button class="roll-go life-seed">Scatter</button><button class="roll-rewrite life-clear">Clear</button></div><div class="live-bar"><label class="life-control">speed<input class="life-speed" max="30" min="1" step="1" type="range" /></label><label class="life-control">cell<input class="life-scale" max="16" min="2" step="1" type="range" /></label><label class="life-control">palette<select class="life-palette"></select></label><label class="life-control">pattern<select class="life-pattern"></select></label></div><div class="life-tally"></div></div><p class="roll-foot">Nothing here wants anything. It is only counting.</p></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.cs-class.life.life-ui/mount!))] (mount {:id "life"}) (js/console.error "fedoras: fedoras.cs-class.life.life-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>
```