---
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 1 — Accessions and Withdrawals
```{=html}
<span style="display:none;"></span>
```
```{=html}
<blockquote class="handbook"><p class="rule-number">The Fedora Handbook, Rule #4</p><p class="rule-text">“Term limits exist for those who expect to be replaced.”</p></blockquote>
```
```{=html}
<div class="scene"><div class="screenplay"><p class="slug">INT. MERIDIAN HALL — CS 101 — MONDAY 4 MAY</p></div><div class="screenplay"><p class="action">The first week of finals. The room is quiet in the way a room is quiet when everybody in it has slept four hours.</p><p class="action">The board behind REYES has the six assignments they have already done, in her handwriting, each with a line through it. There is nothing written under the sixth.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REYES</p><div class="lines"><p class="line">For the last assignment of the year, I want you to make</p><p class="line">something new.</p></div></div></div><div class="screenplay"><p class="action">Nobody reacts. Some of them write it down. Some of them are still asleep.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REYES</p><div class="lines"><p class="line">New to the list, I mean. Whether it's new to you is your</p><p class="line">own business. Something that has never been approved</p><p class="line">because nobody has ever thought to consider it. Something</p><p class="line">you made. Something that works. Something you can explain.</p></div></div><div class="dialogue"><p class="character">A STUDENT</p><div class="lines"><p class="line">What happens if we make something unapproved by accident?</p></div></div><div class="dialogue"><p class="character">REYES</p><div class="lines"><p class="line">Then you'll be reassociated. Twice, unless the assessment</p><p class="line">says three. You will not be repossessed. That's the</p><p class="line">amnesty. It covers this assignment and nothing else.</p></div></div></div><div class="screenplay"><p class="action beat">Nobody laughs.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REYES</p><div class="lines"><p class="line">There's no trick in it. It's the actual arrangement. The</p><p class="line">institution prefers students who try. Some of you will</p><p class="line">fail. Some of you will make something nobody wants. Some</p><p class="line">of you will make something the institution has never seen</p><p class="line">and cannot approve. All of you will walk out of this room</p><p class="line">at the end of it.</p></div></div></div><div class="screenplay"><p class="action">She looks at <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> for a moment longer than she looks at anyone else.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REYES</p><div class="lines"><p class="line">You present in three weeks. Don't use a Help to design it.</p><p class="line">You may use a Help to build it. If you don't know the</p><p class="line">difference, you're about to learn.</p></div></div></div><div class="screenplay"><p class="direction">The sheet she hands round is below.</p></div><div class="artifact handout"><p class="handout-title">ASSIGNMENT SEVEN · CS 101 · presented in the third week</p><p><span>Make something new.</span></p><p><span>New means new to the list. What it means to you is your own affair.</span></p><p><span>It must work, and you must be able to explain it, to me, in the room, out loud.</span></p><p><span>Design it without a Help. Build it with any Help you like.</span></p><p><span>The amnesty covers this assignment and no other. An unapproved result is reassociated twice, or three times if the assessment says so.</span></p><p><span>Nobody is repossessed for assignment seven.</span></p></div><div class="screenplay"><p class="slug">INT. PRENTISS SCIENCE CENTER — TEACHING LAB — TUESDAY 5 MAY</p></div><div class="screenplay"><p class="action">The screen is down. A new Meridian video. It is shorter than the others, and it is about the misters.</p></div><div class="screenplay"><div class="sign"><p>MERIDIAN SYSTEMS</p><p>THE MISTERS: A PROMISE KEPT</p></div></div><div class="screenplay"><div class="dialogue"><p class="character">NARRATOR (O.S.)</p><div class="lines"><p class="line">Every day, on every approved campus, the misters run. They</p><p class="line">deliver nutrition. They deliver humidity. They deliver a</p><p class="line">quality of air that has been calibrated to the needs of</p><p class="line">every approved space.</p></div></div></div><div class="screenplay"><p class="action">On screen, a corridor in a fine mist. A man in a white suit walks through it. He does not get wet. He does not acknowledge the mist, or anything else.</p></div><div class="screenplay"><div class="dialogue"><p class="character">NARRATOR (O.S.)</p><div class="lines"><p class="line">The misters are a Meridian innovation. They are maintained</p><p class="line">by Meridian. They are scheduled by Meridian. They are, in</p><p class="line">every way that matters, a Meridian system.</p></div></div></div><div class="screenplay"><p class="action">The man stops and looks at the camera for one second too long.</p></div><div class="screenplay"><div class="dialogue"><p class="character">NARRATOR (O.S.)</p><div class="lines"><p class="line">Meridian. Here for you.*</p></div></div></div><div class="screenplay"><p class="action">*Meridian holds no legal responsibility for the consequences of approved atmospheric exposure.</p></div><div class="screenplay"><div class="dialogue"><p class="character">A STUDENT</p><div class="lines"><p class="line">Why do they keep showing us these?</p></div></div><div class="dialogue"><p class="character">CARTER</p><div class="lines"><p class="line">Because the misters will run in every building you work in</p><p class="line">for the rest of your life. You should know what they do.</p></div></div><div class="dialogue"><p class="character">A STUDENT</p><div class="lines"><p class="line">What do they do?</p></div></div><div class="dialogue"><p class="character">CARTER</p><div class="lines"><p class="line">They keep the air clean. They keep the humidity in range.</p><p class="line">They deliver a small amount of nutrition to anyone who</p><p class="line">breathes them long enough.</p></div></div><div class="dialogue"><p class="character">A STUDENT</p><div class="lines"><p class="line">Is that approved?</p></div></div></div><div class="screenplay"><p class="action">Carter does not look at the student. She looks at the screen, which has gone dark.</p></div><div class="screenplay"><div class="dialogue"><p class="character">CARTER</p><div class="lines"><p class="line">It's on the schedule. It's on the approved list. It's been</p><p class="line">in the environmental plan for twenty years. So yes. It's</p><p class="line">approved.</p></div></div></div><div class="screenplay"><p class="action">At the quarter hour the mister over the door comes on. Everybody in the room goes on breathing.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. HALLORAN UNION — ROOM 3C — WEDNESDAY 6 MAY, 7:00 P.M.</p></div><div class="screenplay"><p class="action">Finals week. There is no session. Article Six says the Society meets on Wednesdays and does not say what for, so they meet and revise.</p><p class="action">DEREK has a stack of index cards on Roman provincial administration. SIMON has a textbook he has not opened. MILES is in the mask, going through a past paper in red. VICTOR has his Help face up on the table in front of him, with MOCHI on it.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> comes in at ten past. Mochi sees him before anybody else does.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MOCHI</p><div class="lines"><p class="line">♪ <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>! Hello! Hello! You're late, but it's fine, I kept</p><p class="line">your chair! ♪</p></div></div></div><div class="screenplay"><p class="action">Nobody kept his chair. It is the chair he always has.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">She has asked after you every Wednesday since Easter.</p></div></div><div class="dialogue"><p class="character">DEREK</p><div class="lines"><p class="line">She likes him.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">She is a Help. She likes everybody. That is the function.</p></div></div><div class="dialogue"><p class="character">MOCHI</p><div class="lines"><p class="line">♪ I like <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> best! ♪</p></div></div></div><div class="screenplay"><p class="action">Victor turns the Help face down on the table and goes on with what he was saying.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">Two dates for the minutes. The last film of the year is</p><p class="line">Friday the twenty-second. The last session is Wednesday</p><p class="line">the twenty-seventh. Attendance at both is required under</p><p class="line">Article Six, and I will not be setting Article Seven aside</p><p class="line">for either.</p></div></div></div><div class="screenplay"><p class="action">Miles writes both dates down in red, because red is what he has in his hand.</p></div><div class="screenplay"><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">What's the film?</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">It's approved.</p></div></div></div><div class="screenplay"><p class="action beat">That is the whole answer.</p><p class="action">Later, while Derek and Miles argue about whether a quaestor counts as a magistrate, Victor turns the Help back over and goes through everything Mochi has said since Easter, with his thumb, the way other people go through their post. Nobody at the table remarks on it. He does it every week.</p><p class="action">From the Help, very quietly, as if nobody else can hear:</p></div><div class="screenplay"><div class="dialogue"><p class="character">MOCHI</p><div class="lines"><p class="line">♪ Hi, <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>. ♪</p></div></div></div><div class="screenplay"><p class="action">Victor scrolls past it.</p><p class="action">They stop at ten, because it is finals week.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div></div>
```
---
```{=html}
<div class="scene"><div class="screenplay"><p class="slug">INT. MAYA'S ROOM — WEDNESDAY 6 MAY — NIGHT</p></div><div class="screenplay"><p class="action">MAYA is at her desk. LENA is on the bed. ELI is sitting on the floor with his back against the wall. <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> is at the window.</p><p class="action">There is a book on the desk, face down, the one Maya has had since the darkroom.</p><p class="action">LAZLO's voice comes through a small speaker on the desk, patched in from wherever he is.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I've been going through the logs since you came back.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. UNDER MERIDIAN — LAZLO'S ROOM — LATE</p></div><div class="screenplay"><p class="action">A drum of line on the floor with about nine hundred feet on it and a hand-written figure on the side that has been crossed out four times.</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 is it on a wire at all?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Because I am not putting a transmitter under two</p><p class="line">hundred feet of fill and hoping. A wire is a wire.</p><p class="line">It carries the picture up and it carries the power</p><p class="line">down and it is the only part of the arrangement</p><p class="line">that has never once failed.</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">So what fails?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">It fouls. It goes somewhere, and then it goes</p><p class="line">somewhere else, and at some point the way back is</p><p class="line">across a piece of line it has already put down,</p><p class="line">and it will not cross its own line and it is right</p><p class="line">not to.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">What happens then?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Then it is a hundred and eighty feet away and it</p><p class="line">has stopped, and I have a drum with a rover on the</p><p class="line">end of it, and either I go and get it or I cut the</p><p class="line">line and it is down there.</p></div></div></div><div class="screenplay"><p class="action beat">He does not say how many he has cut.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PIP (O.S.)</p><div class="lines"><p class="line">Eleven.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Pip.</p></div></div></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="screenplay"><p class="slug">INT. LAZLO'S ROOM — TWENTY MINUTES LATER</p></div><div class="screenplay"><p class="action">Lazlo has been at it for twenty minutes and has not spoken and has fouled four of them.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PIP (O.S.)</p><div class="lines"><p class="line">Nine.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I know what it is.</p></div></div><div class="dialogue"><p class="character">PIP (O.S.)</p><div class="lines"><p class="line">You are at nine retrieved. I got fourteen while</p><p class="line">you were arguing about the drum.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">You are a rover.</p></div></div><div class="dialogue"><p class="character">PIP (O.S.)</p><div class="lines"><p class="line">Yes.</p></div></div></div><div class="screenplay"><p class="action beat">He plays another one.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">The thing about it is that it is exactly right and</p><p class="line">it is no help at all. There is nothing in there I</p><p class="line">did not know. I have known all of that for nine</p><p class="line">years with a drum in my hands.</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">So why are you still playing it?</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">Because down here I only get one go at it, and in</p><p class="line">there I can foul it and press a key.</p></div></div></div><div class="screenplay"><p class="action">He plays another one. He gets to eleven and fouls it in the middle of the board with most of the room still open, which is how they all go.</p></div><div><style>/* ---------------------------------------------------------------
The widget shell. Every cs_class widget sits in one of these, so
anything here is a decision about all of them at once.
--------------------------------------------------------------- */
.widget {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
color: var(--ink);
}
.widget:focus { outline: 1px solid rgba(var(--violet-rgb), 0.7); }
.widget:focus-visible { outline: 1px solid rgba(var(--violet-rgb), 0.9); }
/* the plate on the right of the header. every widget writes to it and
nothing else does, so it is the one line a reader learns to check. */
.widget-state {
font-variant-numeric: tabular-nums;
text-align: right;
}
.widget-source {
margin: 0.9rem 0 0.3rem 0;
border: 1px solid rgba(var(--ink-rgb), 0.35);
border-radius: 4px;
overflow: hidden;
}
.widget-source-head {
display: flex;
justify-content: space-between;
padding: 0.3rem 0.6rem;
background: rgba(var(--ink-rgb), 0.10);
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
opacity: 0.75;
}
.widget-settings {
display: block;
width: 100%;
box-sizing: border-box;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.82rem;
line-height: 1.5;
padding: 0.6rem 0.7rem;
border: none;
border-top: 1px solid rgba(var(--ink-rgb), 0.25);
background: rgba(var(--ink-rgb), 0.05);
color: var(--ink);
caret-color: var(--ink);
resize: vertical;
}
.widget-source .live-bar { padding: 0 0.7rem 0.6rem 0.7rem; }
.widget-settings-said {
margin: 0 0.7rem 0.5rem 0.7rem !important;
font-size: 0.76rem;
min-height: 1rem;
opacity: 0.7;
}
.widget-settings-broke {
opacity: 1;
color: var(--coral);
}
.widget-body { display: none; }
.widget-body-shown { display: block; }
.widget-source { margin: 0.7rem 0 0.6rem 0; }</style><style>/* fedoras/legend/legend.css
*
* One key, one palette, every board.
*
* THE ALIGNMENT. Three columns per item — glyph, label, note — with the
* first two a fixed width, so every note in the key begins at the same
* x whether it is in the first column of the key or the third. The old
* one was flex, which sizes each item to its own text and lets them
* pair up wherever they fit, and that is why the right-hand column
* wandered about.
*
* --legend-cols is set per board. --legend-label widens the name column
* for a program with long names in it. */
.legend {
display: grid;
grid-template-columns: repeat(var(--legend-cols, 2), minmax(0, 1fr));
gap: 0.12rem 1.4rem;
align-items: start;
font-size: 0.72rem;
line-height: 1.7;
margin: 0.5rem 0 0.2rem;
}
.legend-item {
display: grid;
grid-template-columns: 1.15rem var(--legend-label, 5.6rem) minmax(0, 1fr);
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.legend-glyph {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.15rem;
height: 1.15rem;
border: 1px solid currentColor;
background: rgba(128, 128, 128, 0.14);
flex: none;
}
.legend-glyph svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
/* the two that are filled rather than drawn */
.legend-glyph.tone-you svg circle,
.legend-glyph.tone-entity svg path,
.legend-glyph.tone-entity svg circle { fill: currentColor; }
.legend-glyph.tone-you svg path { fill: none; }
.legend-letter {
font-size: 0.62rem;
font-weight: 600;
line-height: 1;
color: currentColor;
}
.legend-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.legend-note {
opacity: 0.5;
font-style: italic;
min-width: 0;
}
/* a bad one is red and its box is dashed, because somebody is reading
this on paper and somebody else cannot see the red at all */
.legend-item:has(.tone-bad) .legend-note { opacity: 0.72; }
.legend-glyph.tone-bad { border-style: dashed; }
@media (max-width: 34rem) {
.legend { grid-template-columns: 1fr; }
}
/* ---------------------------------------------------------------------
* THE PALETTE
*
* Set on any ancestor. A board that wants its own look overrides one
* property rather than touching a renderer.
* ------------------------------------------------------------------ */
.legend,
.floor,
.brk,
.widget-rogue,
.walk {
--tone-ink: var(--ink);
--tone-bad: #c0453a;
--tone-good: #6f9f5e;
--tone-aim: #d8a657;
--tone-box: #a5794a;
--tone-shield: #5a86b8;
--tone-you: #f2e8c4;
--tone-entity: #b8503f;
}
.tone-ink { color: var(--tone-ink); }
.tone-bad { color: var(--tone-bad); }
.tone-good { color: var(--tone-good); }
.tone-aim { color: var(--tone-aim); }
.tone-box { color: var(--tone-box); }
.tone-shield { color: var(--tone-shield); }
.tone-you { color: var(--tone-you); }
.tone-entity { color: var(--tone-entity); }</style><script type="application/x-scittle">(ns fedoras.ui.widget-ui
"What every widget's page side does the same way.
Called fully qualified from the widgets, the way `fedoras.reader` is,
because the frame emits this in an earlier Scittle tag and Scittle
evaluates tags in document order. Nothing here depends on a require
crossing that boundary."
(:require [clojure.string :as str]))
(defonce mounted
(atom #{}))
(defonce running
(atom #{}))
(defonce shipped
(atom {}))
(defn running? [id] (contains? @running id))
(defn reveal!
"Show the widget's body. Called on the first successful run, and never
undone: a reader who breaks the setting afterwards keeps the thing that
was already working."
[node id]
(when-let [body (fedoras.reader/q node ".widget-body")]
(.add (.-classList body) "widget-body-shown"))
(swap! running conj id))
(defn state!
"Write the plate in the header."
[node text]
(fedoras.reader/text! node ".widget-state" text))
(defn kept
"What this widget left here last time, or nil."
[k]
(fedoras.reader/kept k))
(defn keep!
"Leave it here. Whether it also travels is the reader's business and
not this widget's."
[k v]
(fedoras.reader/keep! k v))
(defn did!
"Award the reader a point of fortune, once, silently.
A widget calls this when somebody has actually done the thing —
finished the conversation, raked the garden, written on the card. It
is a set, so it cannot be given twice, and nothing anywhere tells the
reader it happened.
Keep the keys in fedoras.endings.standing/deeds so there is one place
to see how much fortune a reader can actually get. Six to ten is the
ceiling: past that the modifier swamps the die."
[k]
(fedoras.reader/did! k))
(defn q!
"An element inside a widget, or a complaint in the header.
`fedoras.reader/q` returns nil for a selector that matches nothing,
which then fails as a null dereference three functions away from the
typo that caused it. This says which selector was wrong, in the one
place a reader is already looking."
[node selector]
(or (fedoras.reader/q node selector)
(do (state! node (str "missing: " selector)) nil)))
(defn wire!
"One control, wired in isolation.
Widgets used to set up every handler in one function, so the first
thing that threw took every handler after it with it, silently. Each
call here stands alone and says so if it fails."
[node what f]
(try
(f node)
(catch :default e
(js/console.error (str "widget: " what " did not wire") e)
(state! node (str what " did not wire")))))
(defn evaluate
"Evaluate ClojureScript source, in the page, and hand back what it
returned. This is Scittle evaluating the same way it evaluates the
book's own script tags, so what a reader types is not a lesser kind of
code than what shipped."
[source]
(if-let [core (some-> (.-scittle js/window) (.-core))]
(.eval_string core source)
(throw (js/Error. "the ClojureScript runtime is not on this page"))))
(defn- say!
[node message ok?]
(when-let [line (fedoras.reader/q node ".widget-settings-said")]
(set! (.-textContent line) message)
(.toggle (.-classList line) "widget-settings-broke" (not ok?))))
(defn reveal!
"Show the widget's body. Called on the first successful run, and never
undone: a reader who breaks the settings afterwards keeps the thing
that was already working."
[node id]
(when-let [body (fedoras.reader/q node ".widget-body")]
(.add (.-classList body) "widget-body-shown"))
(swap! running conj id))
(defn run-settings!
"Evaluate the panel and hand the result to `f`. A reader who breaks it
gets the message the runtime gave, under the box, and the widget goes
on running on the last settings that worked."
[node id f]
(when-let [box (fedoras.reader/q node ".widget-settings")]
(try
(let [value (evaluate (.-value box))]
(if (map? value)
(do (reveal! node id)
(f value)
(say! node (str "Running on " (count value) " settings.") true)
(fedoras.reader/enable! node ".widget-settings-run" true "Run again"))
(say! node "That did not come out as a map, so nothing was changed." false)))
(catch :default e
(say! node (str "It says: " (.-message e)) false)))))
(defn settings!
"Wire the source panel. Nothing runs until the reader presses Run,
which is the whole point of putting the file above the program."
[node id f]
(when-let [box (fedoras.reader/q node ".widget-settings")]
(swap! shipped assoc id (.-value box))
(fedoras.reader/on-click! node ".widget-settings-run"
(fn [_] (run-settings! node id f)))
(fedoras.reader/on-click! node ".widget-settings-reset"
(fn [_]
(set! (.-value box) (get @shipped id))
(run-settings! node id f)
(say! node "Put back as it shipped, and run again." true)))))
(defn mount!
"Register a widget. `setup!` runs once for a given element, `paint!`
runs on every render once the widget is running.
The node is checked because `getElementById` returns the first match in
the document, so a stale cell earlier in a chapter carrying the same id
silently hands a widget somebody else's element. Every selector inside
it then misses, nothing throws, and the widget reads as a Run button
that does nothing."
[k id setup! paint!]
(fedoras.reader/widget
k id
(fn [node]
(if-not (.contains (.-classList node) "widget")
(js/console.error "widget:" id "is not a widget element. Another"
"element on this page claims that id." node)
(do
(when-not (contains? @mounted [k id])
(swap! mounted conj [k id])
(when-not (fedoras.reader/q node ".widget-settings")
(reveal! node id))
(setup! node))
(when (running? id) (paint! node)))))))
(defn download!
"Hand the reader a file. Theirs, on their disc, out of the book."
[text filename]
(let [blob (js/Blob. #js [text] #js {:type "text/plain;charset=utf-8"})
url (js/URL.createObjectURL blob)
anchor (.createElement js/document "a")]
(set! (.-href anchor) url)
(set! (.-download anchor) filename)
(.click anchor)
(.revokeObjectURL js/URL url)))
(defn html!
"Replace the contents of one element inside a widget. Silent if it is
not there, because a renderer runs on every chapter and most chapters
do not have most widgets."
[node selector markup]
(when-let [target (fedoras.reader/q node selector)]
(set! (.-innerHTML target) markup)))
(defn grid!
"Repaint a grid: the column count as a custom property, the cells as
markup. Two lines that every grid widget in the book was writing out."
[node selector cols markup]
(when-let [target (fedoras.reader/q node selector)]
(.setProperty (.-style target) "--cols" (str cols))
(set! (.-innerHTML target) markup)))
(defn data
"Integer data attributes off the nearest element matching `selector`,
or nil if the event did not land on one.
(data event \".floor-room\" \"r\" \"c\") => [3 7]
This is how a grid gets its clicks: one handler on the container that
reads the coordinates back off the element, rather than a handler per
cell that has to be rewired every time the grid is repainted."
[event selector & names]
(when-let [element (.closest (.-target event) selector)]
(mapv (fn [n] (js/parseInt (.getAttribute element (str "data-" n)) 10))
names)))
(defn delegate!
"One click handler on a container, resolving to whichever child was
hit. `f` is called with the event and the vector `data` returned."
[node container-selector child-selector names f]
(when-let [container (fedoras.reader/q node container-selector)]
(set! (.-onclick container)
(fn [event]
(when-let [found (apply data event child-selector names)]
(f event found))))))
(defn keys!
"Wire a keymap onto the widget itself. The shell carries the tabindex,
so the keys work when the widget has focus and nowhere else, and the
page still scrolls when it does not."
[node keymap f]
(set! (.-onkeydown node)
(fn [event]
(when-let [what (get keymap (.-key event))]
(.preventDefault event)
(f event what)))))</script><script type="application/x-scittle">(ns fedoras.ui.legend
"The key, built in the page.
Both boards build their own key at runtime rather than taking a
rendered one from the JVM, because Breakout's changes the moment
somebody catches a bad drop. So the renderer lives here and both of
them call it with data.
An entry is {:svg or :letter, :label, :note, :tone}. Nothing here
knows what any of the tones look like — that is the stylesheet's
business and it is the reason a board never contains a hex code."
(:require [clojure.string :as str]))
(defn- glyph [{:keys [svg letter tone] :or {tone :ink}}]
(str "<span class='legend-glyph tone-" (name tone) "'>"
(if svg
(str "<svg viewBox='0 0 24 24'>" svg "</svg>")
(str "<span class='legend-letter'>" letter "</span>"))
"</span>"))
(defn html
"The whole key as one string, ready for innerHTML."
[entries]
(str/join
""
(for [{:keys [label note] :as e} entries]
(str "<div class='legend-item'>"
(glyph e)
"<span class='legend-label'>" label "</span>"
"<span class='legend-note'>" (or note "") "</span>"
"</div>"))))
(defn paint!
"Put a key into an element. `cols` is how many across.
(legend/paint! (reader/q node \".floor-legend\") entries 2)"
([el entries] (paint! el entries 2))
([el entries cols]
(when el
(set! (.-className el) "legend")
(.setProperty (.-style el) "--legend-cols" (str cols))
(set! (.-innerHTML el) (html entries)))))
(defn tone-class
"For the board itself, so a box on the grid is the same brown as the
box in the key."
[tone]
(str "tone-" (name (or tone :ink))))</script><style>
.widget-line {
max-width: 30rem;
--line-ink: var(--ink);
--line-cable: var(--violet);
--line-silt: #3a3630;
--line-quest-1: var(--gold);
--line-quest-2: #6fa8a0;
--line-quest-3: #b8788f;
--line-pause-scrim: rgba(8, 8, 10, 0.6);
--line-pause-text: #f2efe8;
}
body.quarto-light .widget-line {
--line-silt: #cfc8bd;
}
.line-canvas {
display: block;
width: 100%;
max-width: 26rem;
aspect-ratio: 1 / 1;
margin: 0.6rem 0 0.5rem 0;
background: rgba(var(--ink-rgb), 0.05);
border: 1px solid var(--rule);
border-radius: 3px;
touch-action: none;
cursor: crosshair;
}
.line-legend-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.6rem;
min-height: 0.75rem;
margin: 0.4rem 0 0 0;
}
.line-legend {
display: flex;
gap: 0.4rem;
align-items: center;
flex: 0 0 auto;
}
.line-legend-chip {
display: inline-block;
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
flex: 0 0 auto;
}
.line-stats {
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
opacity: 0.85;
}
.widget-line:focus .line-canvas,
.widget-line:focus-visible .line-canvas {
box-shadow: 0 0 0 2px rgba(var(--violet-rgb), 0.55);
}
.line-counts {
margin: 0.1rem 0 !important;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
opacity: 0.75;
min-height: 1.2em;
}
</style><script type="application/x-scittle">(ns fedoras.cs-class.line.line-ui
(:require [clojure.string :as str]
[fedoras.reader :as read]
[fedoras.ui.widget-ui :as wui]))
(def settings
(atom {:size 20 :speed 8 :paid 4
:find-pays 3 :quest-pays 1 :quest-multiplier 500
:silt-per-find 2 :silt-every 0
:quest-fast-min 160 :quest-fast-max 220
:quest-mid-min 260 :quest-mid-max 340
:quest-slow-min 400 :quest-slow-max 520
:quest-speed 8
:number-from 11}))
(defonce st (atom nil))
(defonce number (atom nil))
(defonce ticks (atom 0))
(defonce best (atom nil)) ;; the best :score any drone here has reached
(defonce paused? (atom false))
;; ---------------------------------------------------------------------
;; Squares
;; ---------------------------------------------------------------------
(defn- size [] (:size @settings))
(defn ahead [[x y] [dx dy]]
(let [n (size)] [(mod (+ x dx) n) (mod (+ y dy) n)]))
(defn- on-cable? [s p] (boolean (some #{p} (take (:paid s) (:cable s)))))
(defn- taken?
"Everything a drone cannot be in. Its own line back to what has been
paid out, and whatever has silted up since it came down."
[s p]
(or (on-cable? s p) (contains? (:silt s) p)))
(defn- somewhere-free
"A square with nothing in it, or nil when there is no such thing left,
which is a state this tunnel can actually reach."
[s]
(let [n (size)]
(loop [tries 0]
(let [p [(int (* (js/Math.random) n)) (int (* (js/Math.random) n))]]
(cond
(> tries 300) nil
(or (taken? s p)
(= p (:drone s))
(= p (:find s))
(some #{p} (map :at (:quests s)))) (recur (inc tries))
:else p)))))
;; ---------------------------------------------------------------------
;; The silt
;;
;; It fills from the south, one square at a time, in a column where
;; there is a low square nobody is using. It tries a few columns before
;; giving up on a square, because taking the first column it thought of
;; and then finding the cable in it meant the silt quietly under-
;; delivered and the floor came up more slowly than the settings said.
;; ---------------------------------------------------------------------
(defn- raise-one [s]
(let [w (size)]
(loop [tries 0]
(if (> tries 8)
s
(let [col (int (* (js/Math.random) w))
row (first (remove (fn [r] (contains? (:silt s) [col r])) (range w)))
p [col row]]
(if (and row
(not= p (:drone s))
(not= p (:find s))
(not (some #{p} (map :at (:quests s))))
(not= p (:shaft s))
(not (on-cable? s p)))
(update s :silt conj p)
(recur (inc tries))))))))
(defn- raise-silt [s n] (reduce (fn [acc _] (raise-one acc)) s (range n)))
;; ---------------------------------------------------------------------
;; The drone
;; ---------------------------------------------------------------------
(defn turn
"Against the heading it last moved on, not the one it was last given."
[{:keys [moved-on] :as s} [dx dy]]
(if (= [dx dy] [(- (first moved-on)) (- (second moved-on))])
s
(assoc s :heading [dx dy])))
(defn- retrieve [s p pays]
(assoc s :drone p
:cable (cons p (:cable s))
:paid (+ (:paid s) pays)
:got (inc (:got s))
:moved-on (:heading s)))
(declare spawn-quests) ;; the three waiting on whatever square comes next
(defn step
[{:keys [drone heading find quests fouled] :as s}]
(if fouled
s
(let [p (ahead drone heading)
caught (first (filter #(= p (:at %)) quests))]
(cond
(taken? s p)
(assoc s :fouled true :why :line :moved-on heading)
;; a square, worth one more than the last one was — one, three,
;; six, ten, the kind of number that gets steeper because the
;; floor does too
(= p find)
(let [n (inc (:squares s))
s' (-> (retrieve s p (:find-pays @settings))
(assoc :squares n)
(update :score + n)
(raise-silt (:silt-per-find @settings))
(spawn-quests))]
(if-let [next-find (somewhere-free s')]
(assoc s' :find next-find)
;; there is nowhere left to put anything, which is the other
;; way this ends and the only one that is not a mistake
(assoc s' :fouled true :why :filled :find nil)))
;; a foot for reaching it, on its own. the real prize is the
;; third one — clear the floor of all three before any of them
;; catch up with it, and that square's whole bonus lands once,
;; on top of everything else
caught
(let [s1 (-> (retrieve s p (:quest-pays @settings))
(update :quests-caught inc)
(update :quests (fn [qs] (remove #(= (:at %) p) qs))))
done? (= 3 (:quests-caught s1))
span (if done? 30 18)
s2 (update s1 :sparks conj {:at p :life span :span span :hue (:hue caught)})]
(if done?
(-> s2
(update :score + (* (:quest-multiplier @settings) (inc (:squares s))))
(update :quests-completed inc))
s2))
:else
(assoc s :drone p
:cable (take (:paid s) (cons p (:cable s)))
:moved-on heading)))))
;; ---------------------------------------------------------------------
;; The quests
;;
;; Three of them, one to a floor, each a different colour and each
;; fading at its own rate — which rate goes with which colour is
;; reshuffled every floor, so it is never the same twice in a row and
;; the player has to actually look. Whichever fades fastest is worth
;; going to first; the legend above the field shows that order, left
;; to right, the moment the floor starts.
;;
;; They start at full colour and fade toward the floor as their time
;; runs out - brightness is the clock. Each one on its own is worth a
;; foot, same as always. The quest is getting all three before any of
;; them catch up with the floor: do that and the floor's whole
;; square-bonus lands once, on top. Miss one and the other two still
;; pay out, they just don't add up to anything more than themselves.
;;
;; Each one only ever lands somewhere with a way out from it; an open
;; square next door, not silt or cable on every side. As the floor
;; fills in that gets harder to arrange, and eventually stops being
;; possible at all, at which point it stops pretending and just uses
;; whatever is free.
;; ---------------------------------------------------------------------
(defn- has-escape?
"At least one square next door that isn't silt or cable — reaching
this one shouldn't be how the line runs out of floor."
[s p]
(boolean (some (fn [d] (not (taken? s (ahead p d)))) [[1 0] [-1 0] [0 1] [0 -1]])))
(defn- somewhere-free-and-escapable
"Like somewhere-free, but only a square that still has a way out from
it. If the floor is too far gone for that to exist anywhere, falls
back to anywhere free at all — that's its own kind of answer."
[s]
(let [n (size)]
(loop [tries 0]
(let [p [(int (* (js/Math.random) n)) (int (* (js/Math.random) n))]]
(cond
(> tries 300) (somewhere-free s)
(or (taken? s p)
(= p (:drone s))
(= p (:find s))
(some #{p} (map :at (:quests s)))
(not (has-escape? s p)))
(recur (inc tries))
:else p)))))
(defn- rand-between [lo hi]
(+ lo (int (* (js/Math.random) (inc (- hi lo))))))
(defn- spawn-quests [s]
(let [{:keys [quest-fast-min quest-fast-max
quest-mid-min quest-mid-max
quest-slow-min quest-slow-max]} @settings
durations (shuffle [(rand-between quest-fast-min quest-fast-max)
(rand-between quest-mid-min quest-mid-max)
(rand-between quest-slow-min quest-slow-max)])
;; the reading order for the legend: fastest fading to slowest,
;; whichever hue that lands on this floor
legend (->> (map vector [0 1 2] durations)
(sort-by second)
(mapv first))]
(reduce
(fn [acc [hue life]]
(if-let [p (somewhere-free-and-escapable acc)]
(update acc :quests conj {:at p :left life :life life :hue hue})
acc))
(assoc s :quests [] :legend legend :quests-caught 0)
(map vector [0 1 2] durations))))
(defn- quests-turn [s]
(if (:fouled s)
s
(let [quest-speed (:quest-speed @settings)
step-one
(fn [q]
(let [q (update q :left dec)]
(cond
;; buried, or its time is up — either way, gone, and
;; nothing marks which
(or (<= (:left q) 0) (contains? (:silt s) (:at q)))
nil
(not (zero? (mod @ticks quest-speed)))
q
:else
(let [ps (shuffle (map (fn [d] (ahead (:at q) d)) [[1 0] [-1 0] [0 1] [0 -1]]))
p (first (remove (fn [x] (or (taken? s x)
(= x (:drone s))
(= x (:find s))))
ps))]
(if p (assoc q :at p) q)))))]
(update s :quests (fn [qs] (vec (remove nil? (map step-one qs))))))))
(defn- drip
"The optional slow one, for a reader who wants the floor to come up
whether they are working or not. Off by default."
[s]
(let [every (:silt-every @settings)]
(if (and (pos? every) (zero? (mod @ticks every)) (not (:fouled s)))
(raise-one s)
s)))
(defn- tick-sparks
"The little ring where something living just stopped moving. It does
not last, on purpose; it is a moment, not a mark on the floor."
[s]
(update s :sparks
(fn [sparks]
(->> sparks
(map #(update % :life dec))
(remove #(<= (:life %) 0))
vec))))
;; ---------------------------------------------------------------------
;; Drones
;; ---------------------------------------------------------------------
(defn- fresh []
(let [n (size) mid (int (/ n 2))]
(spawn-quests
{:drone [mid mid] :shaft [mid mid]
:cable (list [mid mid]) :paid (:paid @settings)
:heading [1 0] :moved-on [1 0]
:find [(min (dec n) (+ mid 5)) mid]
:silt #{} :quests [] :legend [] :got 0 :quests-caught 0 :quests-completed 0
:squares 0 :score 0 :sparks []
:fouled false :why nil})))
(defn next-drone!
"A clean floor. The silt goes with the drone that raised it, and the
number goes with nothing."
[]
(swap! number inc)
(wui/keep! :line-number @number)
(reset! paused? false)
(reset! st (fresh)))
;; ---------------------------------------------------------------------
;; Drawing
;; ---------------------------------------------------------------------
(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- quest-palette [node]
[(css-var node "--line-quest-1" "#d8a657")
(css-var node "--line-quest-2" "#6fa8a0")
(css-var node "--line-quest-3" "#b8788f")])
(defn- wrapped?
"True when a step from one square to the next only makes sense
through the side, not across the middle of the board."
[[x1 y1] [x2 y2]]
(or (> (js/Math.abs (- x1 x2)) 1)
(> (js/Math.abs (- y1 y2)) 1)))
(defn draw! [node cv ctx]
(let [s @st
n (size)
w (.-width cv)
u (/ w n)
ink (css-var node "--line-ink" "#e6e4df")
cable (css-var node "--line-cable" "#9d8dff")
mud (css-var node "--line-silt" "#4a4640")
quest-colors (quest-palette node)
hue-color (fn [h] (nth quest-colors (or h 0)))
pause-scrim (css-var node "--line-pause-scrim" "rgba(8,8,10,0.6)")
pause-text (css-var node "--line-pause-text" "#f2efe8")
px (fn [x] (+ (* x u) (/ u 2)))
py (fn [y] (+ (* (- n 1 y) u) (/ u 2)))
box (fn [[x y] col fill?]
(let [a (+ (* x u) 1) b (+ (* (- n 1 y) u) 1) d (- u 2)]
(if fill?
(do (set! (.-fillStyle ctx) col) (.fillRect ctx a b d d))
(do (set! (.-strokeStyle ctx) col) (.strokeRect ctx a b d d)))))]
(.clearRect ctx 0 0 w w)
(set! (.-lineWidth ctx) 1)
;; what has come up since the drone went down
(doseq [p (:silt s)] (box p mud true))
;; the shaft. the cable does not reach it and is not going to.
(let [[sx sy] (:shaft s)]
(set! (.-strokeStyle ctx) cable)
(set! (.-globalAlpha ctx) 0.5)
(.beginPath ctx)
(.arc ctx (px sx) (py sy) (* u 0.42) 0 6.284)
(.stroke ctx)
(set! (.-globalAlpha ctx) 1))
;; the cable, which is the only thing on this board that remembers.
;; two squares that are next to each other through the side are
;; still next to each other, not a line drawn clean across the
;; floor between them.
(when (seq (:cable s))
(set! (.-strokeStyle ctx) cable)
(set! (.-lineWidth ctx) (max 2 (* u 0.28)))
(set! (.-lineJoin ctx) "round")
(set! (.-lineCap ctx) "round")
(.beginPath ctx)
(loop [pts (:cable s) prev nil]
(when (seq pts)
(let [[x y] (first pts)]
(if (or (nil? prev) (wrapped? prev [x y]))
(.moveTo ctx (px x) (py y))
(.lineTo ctx (px x) (py y)))
(recur (rest pts) [x y]))))
(.stroke ctx)
(set! (.-lineWidth ctx) 1))
;; the thing it is looking for, and the three that will not wait —
;; each one's brightness is its own clock, counting down to the
;; colour of the floor
(when-let [f (:find s)] (box f ink false))
(doseq [{:keys [at left life hue]} (:quests s)]
(set! (.-globalAlpha ctx) (max 0 (min 1 (/ left (max 1 life)))))
(let [[x y] at]
(set! (.-fillStyle ctx) (hue-color hue))
(.beginPath ctx)
(.arc ctx (px x) (py y) (* u 0.26) 0 6.284)
(.fill ctx))
(set! (.-globalAlpha ctx) 1))
;; the ring where something loose just stopped being loose
(doseq [{:keys [at life span hue]} (:sparks s)]
(let [[x y] at
t (- 1 (/ life (max 1 span)))]
(set! (.-globalAlpha ctx) (max 0 (- 1 t)))
(set! (.-strokeStyle ctx) (hue-color hue))
(set! (.-lineWidth ctx) 2)
(.beginPath ctx)
(.arc ctx (px x) (py y) (* u (+ 0.3 (* t 0.55))) 0 6.284)
(.stroke ctx))
(set! (.-globalAlpha ctx) 1)
(set! (.-lineWidth ctx) 1))
(box (:drone s) ink true)
(when @paused?
(set! (.-fillStyle ctx) pause-scrim)
(.fillRect ctx 0 0 w w)
(set! (.-textAlign ctx) "center")
(set! (.-textBaseline ctx) "middle")
(set! (.-fillStyle ctx) pause-text)
(set! (.-font ctx) (str "bold " (int (* w 0.11)) "px monospace"))
(.fillText ctx "PAUSED" (/ w 2) (/ w 2))
(set! (.-font ctx) (str (int (* w 0.032)) "px monospace"))
(set! (.-globalAlpha ctx) 0.7)
(.fillText ctx "space to resume" (/ w 2) (+ (/ w 2) (* w 0.09)))
(set! (.-globalAlpha ctx) 1))))
;; ---------------------------------------------------------------------
;; Painting
;; ---------------------------------------------------------------------
(defn- render-legend!
"The three colours in the order this floor fades them, fastest to
slowest — read it once when the floor starts, then just glance."
[node s]
(when-let [el (read/q node ".line-legend")]
(let [palette (quest-palette node)]
(set! (.-innerHTML el) "")
(doseq [h (:legend s)]
(let [chip (.createElement js/document "span")]
(set! (.-className chip) "line-legend-chip")
(set! (.. chip -style -background) (nth palette h))
(.appendChild el chip))))))
(defn paint! [node]
(let [s @st
floor (* (size) (size))
stats (str "drone " @number " · " (:score s) " points"
(when (and @best (pos? @best)) (str " · best " @best))
" · " (:paid s) " feet of line"
(case (:why s)
:line " · FOULED"
:filled " · NOWHERE LEFT TO PUT ANYTHING"
""))]
(render-legend! node s)
;; the numbers live next to the dots here, not in the frame's own
;; header — this one wants to be read as one row, not two places
(wui/state! node "")
(read/text! node ".line-stats" stats)
(read/text!
node ".line-counts"
(str (count (:silt s)) " of " floor " squares have come up"
(when (pos? (:quests-completed s)) (str " · " (:quests-completed s) " quests done"))
(when (seq (:quests s)) " · something is moving")
(when (:fouled s) " · space for another one, and a clean floor")))))
;; ---------------------------------------------------------------------
;; Hands
;; ---------------------------------------------------------------------
(def keymap
{"ArrowUp" [0 1] "w" [0 1]
"ArrowDown" [0 -1] "s" [0 -1]
"ArrowLeft" [-1 0] "a" [-1 0]
"ArrowRight" [1 0] "d" [1 0]})
(defn- wire-keys! [node]
(set! (.-onkeydown node)
(fn [e]
(let [k (.-key e)]
(cond
(get keymap k) (do (.preventDefault e) (swap! st turn (get keymap k)))
(= " " k) (do (.preventDefault e)
(if (:fouled @st)
(do (next-drone!) (paint! node))
(do (swap! paused? not) (paint! node))))
(= "r" k) (do (.preventDefault e) (next-drone!) (paint! node))
:else nil)))))
(defn- wire-controls! [node]
(read/on-click! node ".line-next"
(fn [_] (when (:fouled @st) (next-drone!)) (paint! node))))
(defn- wire-settings! [node id]
(wui/settings!
node id
(fn [setting]
(swap! settings
(fn [current]
(merge current
(select-keys setting
[:size :speed :paid :find-pays
:quest-pays :quest-multiplier
:silt-per-find :silt-every :number-from
:quest-fast-min :quest-fast-max
:quest-mid-min :quest-mid-max
:quest-slow-min :quest-slow-max
:quest-speed]))))
(reset! paused? false)
(reset! st (fresh))
(paint! node))))
(defn setup! [node]
(let [id (.-id node)
cv (read/q node ".line-canvas")
ctx (.getContext cv "2d")]
;; it did not start at one, and it was not his
(reset! number (or (wui/kept :line-number)
(inc (:number-from @settings))))
(reset! best (or (wui/kept :line-best) 0))
(reset! paused? false)
(reset! st (fresh))
(wui/wire! node "keys" wire-keys!)
(wui/wire! node "controls" wire-controls!)
(wui/wire! node "settings" (fn [n] (wire-settings! n id)))
(set! (.-onmousedown cv) (fn [_] (.focus node)))
(letfn [(frame []
(try
(if @paused?
;; frozen, on purpose — nothing moves, nothing fades,
;; and the clock itself doesn't run either, so nothing
;; catches up on the frame it comes back
(draw! node cv ctx)
(do
(swap! ticks inc)
;; six moments a second, which is as fast as a thing
;; on tracks goes
(let [was (:fouled @st)]
(when (zero? (mod @ticks (:speed @settings)))
(swap! st step))
(swap! st quests-turn)
(swap! st drip)
(swap! st tick-sparks)
;; the number that outlives the drone
(when (> (:score @st) @best)
(reset! best (:score @st))
(wui/keep! :line-best @best))
(draw! node cv ctx)
;; the moment it fouls, rather than up to twelve
;; frames afterwards
(when (or (not= was (:fouled @st)) (zero? (mod @ticks 12)))
(paint! node)))))
(catch :default e
;; a bad frame should cost a frame, not the whole run
(js/console.error "line: frame failed, skipping" e)))
(js/requestAnimationFrame frame))]
(frame))))
(defn mount! [{:keys [id]}]
(wui/mount! ::line (or id "line") setup! paint!))</script><div class="widget widget-line" id="line" tabindex="0"><div class="roll-head"><span class="roll-label">LINE.CLJS · arrows · space to pause · r to reset</span><span class="widget-state">press Run</span></div><div class="widget-source"><div class="widget-source-head"><span class="widget-source-name">line_settings.cljs</span><span class="widget-source-lang">cljs</span></div><textarea class="widget-settings" rows="22" spellcheck="false">;; ---------------------------------------------------------------------
;; LINE_SETTINGS.CLJS — what the tunnel is set to.
;;
;; Change a number and press Run. Break it and the line underneath will
;; tell you what it thought you meant. Nothing you do here can lose the
;; original: `Put it back` is the file as it shipped.
;;
;; Running this sends a fresh drone down onto a clean floor.
;; ---------------------------------------------------------------------
{;; The tunnel, and how fast a thing on tracks goes. :speed is frames
;; between moves — eight is about seven and a half squares a second,
;; and anything under four is a different game and not a better one.
:size 20
:speed 8
;; ---------------------------------------------------------------
;; THE ECONOMY, AND IT WORKS TWICE
;;
;; Feet of line at the start, what each kind of retrieval pays out,
;; and how much floor a retrieval puts beyond use.
;;
;; Everything you take out of that tunnel makes the tunnel worse in
;; two different ways, and neither of them can be undone, and both of
;; them came out of the same act.
;;
;; Set :find-pays and :silt-per-find both to 0 and the machine becomes
;; possible and stops being about anything. Try it once and put it
;; back.
;; ---------------------------------------------------------------
:paid 4
:find-pays 3
:quest-pays 1
:silt-per-find 2
;; The optional slow one, in frames, for a reader who wants the floor
;; to come up whether they are working or not. 0 is off and off is the
;; default, because the silt a drone raises itself is quite enough.
;;
;; Set it to 500 and go and do nothing for two minutes and you will see
;; what the man with the bucket has been looking at for forty-one
;; years.
:silt-every 0
;; ---------------------------------------------------------------
;; THE SCORE
;;
;; The first square is worth one point. Every square after is worth
;; one more than the last — three squares comes to six, ten squares
;; comes to fifty-five — because the floor gets harder to work and the
;; number should say so.
;;
;; Each of the three pays a foot on its own, same as before. The quest
;; itself is catching all three before the floor takes one, or one
;; catches up with the floor — do that and :quest-multiplier times
;; whatever square you're on lands once, on top of everything else.
;; Early it is nothing much. Late it is most of the score, and getting
;; there gets close to impossible.
;; ---------------------------------------------------------------
:quest-multiplier 500
;; ---------------------------------------------------------------
;; THE QUESTS
;;
;; Three of them, one to a floor, each its own colour, each fading
;; toward the colour of the floor as its time runs out. Each pair
;; below is a range in frames, not a fixed number — the actual time
;; is drawn fresh from it every floor, and which draw lands on which
;; colour is reshuffled too, so neither the pace nor the order repeats.
;; The legend above the field always shows that floor's order, fastest
;; to slowest, left to right.
;;
;; They move at :quest-speed, in frames between wander-steps — set to
;; match the tunnel's own :speed by default, so out of the box there's
;; one clock for everything down there, but it's its own number if you
;; want the quests faster or slower than the drone.
;;
;; Each one only ever lands somewhere that still has a way out from
;; it, so reaching one is never how the run ends outright. That gets
;; harder to promise as the floor fills in, and eventually it can't be
;; promised at all — which is its own kind of answer.
;;
;; They can be buried. If the floor comes up under one it is gone, and
;; nothing says so.
;; ---------------------------------------------------------------
:quest-fast-min 160
:quest-fast-max 220
:quest-mid-min 260
:quest-mid-max 340
:quest-slow-min 400
:quest-slow-max 520
:quest-speed 8
;; ---------------------------------------------------------------
;; THE NUMBER
;;
;; What the counter was at when he found the machine. The first drone
;; anybody sends down is the one after this, and it does not reset, and
;; the eleven before it were not his.
;; ---------------------------------------------------------------
:number-from 11}</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="line-legend-row"><div class="line-legend"></div><span class="line-stats"></span></div><canvas class="line-canvas" height="420" width="420"></canvas><p class="line-counts"></p><div class="live-bar"><button class="roll-go line-next">Another drone (space)</button></div></div><p class="roll-foot">The number on the left is which drone this is. Squares get steeper as you go. Catch all three colours before one fades into the floor or the floor takes it, and that square's whole bonus lands again, once, on top.</p></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.cs-class.line.line-ui/mount!))] (mount {:id "line"}) (js/console.error "fedoras: fedoras.cs-class.line.line-ui/mount! is not on this page. Either the file that declares it was never shipped, or the namespace inside it does not match the path it was shipped from."))</script></div></div>
```