---
format:
html:
toc: true
toc-depth: 2
theme: {dark: darkly, light: cosmo}
number-sections: false
number-depth: 0
code-fold: false
code-tools: true
highlight-style: nord
---
<style></style><style>.printedClojure .sourceCode {
background-color: transparent;
border-style: none;
}
</style><style>.clay-limit-image-width .clay-image {max-width: 100%}
.clay-side-by-side .sourceCode {margin: 0}
.clay-side-by-side {margin: 1em 0}
</style>
<script src="https://code.jquery.com/jquery-3.6.0.min.js" type="text/javascript"></script><script src="https://code.jquery.com/ui/1.13.1/jquery-ui.min.js" type="text/javascript"></script>
```{=html}
<div style="display:none;"><style>/* ---------------------------------------------------------------
Fedoras — one palette, two schemes.
Dark is the default. Quarto puts .quarto-light on the body when the
reader flips the toggle, and everything below is written against
these variables, so a scheme change is nine lines rather than three
hundred.
--------------------------------------------------------------- */
:root {
--paper: #1b1d21; /* background */
--ink: #e6e4df; /* text */
--ink-soft: #a9a49b; /* secondary text */
--rule: rgba(230, 228, 223, 0.22);
--panel: rgba(230, 228, 223, 0.05);
--violet: #9d8dff; /* apparatus, epigraphs */
--gold: #d0a44c; /* beats, commits */
--coral: #e2707c; /* moderation, rewrites */
--green: #6fcf9b; /* the Helps */
--blurple: #8b95f0; /* Discord */
--violet-rgb: 157, 141, 255;
--gold-rgb: 208, 164, 76;
--coral-rgb: 226, 112, 124;
--green-rgb: 111, 207, 155;
--blurple-rgb: 139, 149, 240;
--ink-rgb: 168, 164, 156;
--panel-hard: #101215;
}
body.quarto-light {
--paper: #faf8f4;
--ink: #1c1f24;
--ink-soft: #6b665e;
--rule: rgba(28, 31, 36, 0.22);
--panel: rgba(28, 31, 36, 0.05);
--violet: #6c5ce7;
--gold: #b0863a;
--coral: #e04f5f;
--green: #2ea078;
--blurple: #5865f2;
--violet-rgb: 108, 92, 231;
--gold-rgb: 176, 134, 58;
--coral-rgb: 224, 79, 95;
--green-rgb: 46, 160, 120;
--blurple-rgb: 88, 101, 242;
--ink-rgb: 128, 128, 128;
--panel-hard: #14161a;
}
/* ---------------------------------------------------------------
Fedoras — screenplay typography for the HTML edition.
Loaded by fedoras.screenplay/assets. Edit here, rebuild, done.
PDF styling lives in notebooks/tex/fedoras-preamble.tex instead.
--------------------------------------------------------------- */
.screenplay {
font-family: "Courier Prime", "Courier New", ui-monospace, monospace;
font-size: 1rem;
line-height: 1.35;
max-width: 62ch;
margin: 1.6rem 0 2.2rem 0;
}
.screenplay .slug {
font-weight: 700;
letter-spacing: 0.04em;
margin: 1.6rem 0 0.8rem 0;
}
.screenplay .action {
margin: 0 0 0.85rem 0;
}
.action{
white-space: pre-line;
}
.screenplay .action.beat {
margin-bottom: 0.35rem;
}
.screenplay .dialogue {
margin: 0 0 0.95rem 0;
}
.screenplay .character {
margin: 0 0 0 12ch;
font-weight: 700;
letter-spacing: 0.06em;
}
.screenplay .paren {
margin: 0 0 0 9ch;
font-style: italic;
opacity: 0.85;
}
.screenplay .lines {
margin-left: 6ch;
max-width: 50ch;
}
.screenplay .line {
margin: 0;
}
.screenplay .transition {
margin: 1.1rem 0 1.1rem 0;
text-align: right;
font-weight: 700;
letter-spacing: 0.08em;
}
.screenplay .direction {
font-style: italic;
opacity: 0.85;
margin: 0 0 0.9rem 0;
}
/* In-world text: banners, signs, whiteboards. */
.screenplay .sign {
border: 1px solid currentColor;
padding: 0.7rem 1rem;
margin: 0.9rem 0 1.1rem 4ch;
max-width: 46ch;
text-align: center;
letter-spacing: 0.05em;
}
.screenplay .sign p {
margin: 0.15rem 0;
}
.screenplay .whiteboard {
text-align: left;
}
.screenplay .whiteboard-title {
font-weight: 700;
text-transform: uppercase;
margin-bottom: 0.4rem !important;
}
.screenplay .whiteboard ol {
margin: 0;
padding-left: 2.2ch;
}
/* --- The Fedora Handbook epigraphs ------------------------------- */
.handbook {
max-width: 54ch;
margin: 1.4rem 0 2rem 2ch;
padding-left: 1.1rem;
border-left: 3px solid var(--violet);
font-style: italic;
}
.handbook .rule-number {
font-style: normal;
font-variant: small-caps;
letter-spacing: 0.08em;
opacity: 0.75;
margin-bottom: 0.25rem;
}
.handbook .rule-text {
margin: 0;
}
/* --- Drafting apparatus (hidden in the reading copy) -------------- */
.beats {
border: 1px dashed var(--gold);
background: rgba(var(--gold-rgb), 0.07);
padding: 0.6rem 0.9rem;
margin: 1.1rem 0;
max-width: 62ch;
font-size: 0.88rem;
}
.beats-title {
font-variant: small-caps;
letter-spacing: 0.1em;
font-weight: 700;
margin: 0 0 0.35rem 0;
opacity: 0.8;
}
.beats ul {
margin: 0;
padding-left: 1.4rem;
}
.beats.note {
border-color: var(--blurple);
background: rgba(var(--blurple-rgb), 0.07);
}
.status {
display: inline-block;
font-family: ui-monospace, monospace;
font-size: 0.7rem;
letter-spacing: 0.14em;
padding: 0.15rem 0.5rem;
border-radius: 3px;
margin: 0 0 0.8rem 0;
}
.status-outline { background: var(--ink-soft); color: #fff; }
.status-draft { background: var(--gold); color: #fff; }
.status-locked { background: var(--green); color: #fff; }
/* --- Live (editable) code cells ---------------------------------- */
.live-cell {
border: 1px solid rgba(var(--ink-rgb), 0.4);
border-radius: 5px;
padding: 0.7rem 0.8rem;
margin: 1.2rem 0;
max-width: 68ch;
}
.live-label {
font-size: 0.78rem;
letter-spacing: 0.09em;
text-transform: uppercase;
opacity: 0.7;
margin-bottom: 0.45rem;
}
.live-src {
width: 100%;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.9rem;
line-height: 1.4;
padding: 0.5rem;
border: 1px solid rgba(var(--ink-rgb), 0.35);
border-radius: 4px;
background: rgba(var(--ink-rgb), 0.06);
resize: vertical;
}
.live-bar {
margin-top: 0.5rem;
display: flex;
gap: 0.5rem;
align-items: center;
}
.live-run {
font: inherit;
font-size: 0.85rem;
padding: 0.25rem 0.9rem;
border-radius: 4px;
border: 1px solid rgba(var(--ink-rgb), 0.5);
background: rgba(var(--violet-rgb), 0.15);
cursor: pointer;
}
.live-run:hover { background: rgba(var(--violet-rgb), 0.3); }
.live-out {
margin: 0.5rem 0 0 0;
padding: 0.45rem 0.6rem;
min-height: 1.6rem;
font-size: 0.88rem;
white-space: pre-wrap;
border-left: 3px solid rgba(var(--violet-rgb), 0.6);
background: rgba(var(--ink-rgb), 0.06);
}
/* --- Front matter ------------------------------------------------- */
.title-page {
text-align: center;
margin: 3rem 0 2rem 0;
}
.title-page .title {
font-family: "Courier Prime", "Courier New", monospace;
font-size: 3.2rem;
letter-spacing: 0.22em;
margin: 0;
}
.title-page .subtitle {
font-family: "Courier Prime", "Courier New", monospace;
letter-spacing: 0.1em;
margin-top: 0.8rem;
opacity: 0.85;
}
h1.sidebar-title,
h2.sidebar-title {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.personae {
max-width: 66ch;
}
.personae h3 {
font-variant: small-caps;
letter-spacing: 0.1em;
margin-top: 2rem;
border-bottom: 1px solid rgba(var(--ink-rgb), 0.35);
padding-bottom: 0.2rem;
}
.personae .who {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.06em;
margin: 1rem 0 0.1rem 0;
}
.personae .what {
margin: 0 0 0.2rem 0;
font-style: italic;
opacity: 0.8;
}
.personae .desc {
margin: 0.15rem 0 0 0;
max-width: 60ch;
}
/* --- The reader's protagonist ------------------------------------ */
.pc-name {
font-weight: inherit;
border-bottom: 1px dotted rgba(var(--violet-rgb), 0.55);
}
.screenplay .slug,
.screenplay .transition,
.screenplay .character { text-transform: uppercase; }
.name-field {
border: 1px solid rgba(var(--violet-rgb), 0.55);
border-radius: 5px;
padding: 0.9rem 1rem;
margin: 1.6rem auto;
max-width: 60ch;
}
.name-label { margin: 0 0 0.6rem 0; }
.name-inputs { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.name-inputs input {
font: inherit;
padding: 0.3rem 0.6rem;
border: 1px solid rgba(var(--ink-rgb), 0.45);
border-radius: 4px;
background: rgba(var(--ink-rgb), 0.06);
max-width: 14ch;
}
.name-note {
font-size: 0.82rem;
opacity: 0.7;
margin: 0.6rem 0 0 0;
}
.name-label,
.name-confirm {
font-size: 1.2rem;
}
.name-inputs input,
.name-inputs button {
font: inherit;
font-size: 1.2rem;
}
/* --- Artifacts ---------------------------------------------------- */
.artifact {
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.88rem;
line-height: 1.45;
max-width: 66ch;
margin: 1.4rem 0;
border-radius: 5px;
overflow: hidden;
}
.artifact p { margin: 0.1rem 0; }
/* Discord */
.discord { border: 1px solid rgba(var(--blurple-rgb), 0.5); }
.discord-head {
background: rgba(var(--blurple-rgb), 0.14);
padding: 0.4rem 0.8rem;
display: flex; gap: 0.8rem;
}
.discord-server { font-weight: 700; letter-spacing: 0.06em; }
.discord-channel { opacity: 0.75; }
.discord-body { padding: 0.7rem 0.8rem; }
.discord-msg { margin-bottom: 0.7rem; }
.discord-who { font-weight: 700; }
.discord-role {
font-size: 0.7rem; letter-spacing: 0.08em;
background: rgba(var(--violet-rgb), 0.25);
padding: 0.05rem 0.4rem; border-radius: 3px; margin-left: 0.4rem;
}
.discord-at { opacity: 0.55; margin-left: 0.5rem; font-size: 0.78rem; }
.discord-reacts { margin-top: 0.2rem !important; }
.react {
display: inline-block; font-size: 0.75rem;
border: 1px solid rgba(var(--ink-rgb),0.35); border-radius: 10px;
padding: 0 0.45rem; margin-right: 0.3rem;
}
/* Moderation log */
.modlog {
border-left: 3px solid var(--coral);
background: rgba(var(--coral-rgb), 0.08);
padding: 0.6rem 0.9rem;
font-size: 0.82rem;
letter-spacing: 0.03em;
}
/* Git */
.commit {
border: 1px solid rgba(var(--ink-rgb), 0.4);
padding: 0.7rem 0.9rem;
background: rgba(var(--ink-rgb), 0.05);
}
.commit-hash { color: var(--gold); font-weight: 700; }
.commit-meta { opacity: 0.7; }
.commit-msg { margin-top: 0.5rem; padding-left: 1.5rem; }
/* Terminal */
.terminal {
background: var(--panel-hard);
color: var(--ink);
padding: 0.7rem 0.9rem;
border: 1px solid rgba(var(--ink-rgb),0.35);
}
.term-in { color: var(--green); }
.term-in::before { content: ""; }
.term-out { opacity: 0.9; }
/* AI */
.aichat { border: 1px solid rgba(var(--green-rgb), 0.5); }
.ai-model {
background: rgba(var(--green-rgb), 0.16);
padding: 0.35rem 0.8rem !important;
font-weight: 700; letter-spacing: 0.1em; font-size: 0.75rem;
}
.ai-system { padding: 0.4rem 0.8rem; font-size: 0.8rem; opacity: 0.85; }
.ai-system pre { white-space: pre-wrap; margin: 0.4rem 0 0 0; }
.ai-body { padding: 0.6rem 0.8rem; }
.ai-turn { margin-bottom: 0.6rem; }
.ai-role { font-size: 0.68rem; letter-spacing: 0.12em; opacity: 0.6; }
.ai-assistant .ai-text { border-left: 2px solid rgba(46,160,120,0.6); padding-left: 0.6rem; }
/* Email */
.email { border: 1px solid rgba(var(--ink-rgb),0.45); padding: 0.7rem 0.9rem; }
.email-head td { padding: 0 0.6rem 0.1rem 0; vertical-align: top; font-size: 0.82rem; }
.email-head td:first-child { opacity: 0.6; }
.email-body { margin-top: 0.6rem; }
/* Handouts */
.handout {
border: 1px solid currentColor;
padding: 1rem 1.2rem;
font-family: "Courier Prime", "Courier New", monospace;
}
.handout-title {
font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
border-bottom: 1px solid currentColor; padding-bottom: 0.4rem;
margin-bottom: 0.7rem !important;
}
.handout-table { width: 100%; font-size: 0.85rem; margin: 0.5rem 0; }
.handout-table td.k { opacity: 0.65; width: 34%; padding-right: 1rem; vertical-align: top; }
/* --- Prose between scenes ---------------------------------------- */
.prose {
max-width: 64ch;
margin: 1.5rem 0 2rem 0;
font-family: "Iowan Old Style", "Palatino Linotype", Palatino,
"Book Antiqua", Georgia, serif;
font-size: 1.06rem;
line-height: 1.65;
color: var(--ink);
}
.prose p {
margin: 0 0 1.05rem 0;
}
.prose p:first-child { margin-top: 0; }
.prose p:last-child { margin-bottom: 0; }
blockquote.turn {
max-width: 64ch;
border-left: 3px solid rgba(var(--violet-rgb), 0.6);
padding-left: 1rem;
margin: 1.4rem 0 0.6rem 0;
}
.name-confirm {
margin: 0.7rem 0 0 0;
font-weight: 600;
}
/* --- The d20 ------------------------------------------------------ */
.roll {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 60ch;
font-family: ui-monospace, "Courier New", monospace;
}
.roll-head {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
letter-spacing: 0.12em;
opacity: 0.75;
margin-bottom: 0.5rem;
}
.roll-bar { display: flex; gap: 0.7rem; align-items: center; margin-top: 0.3rem; }
.roll-go, .roll-rewrite {
font: inherit;
font-size: 0.82rem;
padding: 0.25rem 0.9rem;
border-radius: 4px;
border: 1px solid rgba(var(--ink-rgb), 0.5);
background: rgba(var(--violet-rgb), 0.15);
cursor: pointer;
}
.roll-rewrite { background: rgba(var(--coral-rgb), 0.12); }
.roll-go:hover { background: rgba(var(--violet-rgb), 0.3); }
.roll-rewrite:hover { background: rgba(var(--coral-rgb), 0.25); }
.roll-value { font-size: 0.9rem; letter-spacing: 0.05em; }
.roll-out {
margin: 0.6rem 0 0 0 !important;
font-family: "Courier Prime", "Courier New", monospace;
min-height: 1.2rem;
}
.roll-echo { font-size: 0.8rem; opacity: 0.8; }
.roll-note { font-size: 0.8rem; opacity: 0.6; margin: 0.4rem 0 0 0 !important; }
.roll-foot {
font-size: 0.72rem;
letter-spacing: 0.1em;
opacity: 0.55;
margin: 0.5rem 0 0 0 !important;
border-top: 1px solid rgba(var(--ink-rgb),0.25);
padding-top: 0.35rem;
}
.roll-cue {
margin: 0.6rem 0 0 4ch !important;
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.06em;
}
.roll-line {
margin: 0 0 0 6ch !important;
max-width: 40ch;
font-family: "Courier Prime", "Courier New", monospace;
}
.roll-out .roll-line:first-child { margin-left: 0 !important; max-width: none; }
.companion-name {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.14em;
font-size: 1.05rem;
margin: 0.7rem 0 0.3rem 0 !important;
}
.companion-intro { max-width: 58ch; margin: 0 0 0.3rem 0 !important; }
.session { max-width: 60ch; margin: 1.1rem 0; }
.session-who {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700; letter-spacing: 0.12em; margin: 0 0 0.3rem 0 !important;
}
.session-text { margin: 0 !important; }
.ac-name { border-bottom: 1px dotted rgba(var(--violet-rgb), 0.55); }
.companion-naming { margin-top: 0.6rem; }
/* --- Plots -------------------------------------------------------- */
.plot {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
}
.plot-controls {
display: flex; gap: 1rem; flex-wrap: wrap;
font-size: 0.8rem; opacity: 0.85; margin: 0.4rem 0;
}
.plot-controls input {
font: inherit; font-size: 0.8rem; width: 7ch;
padding: 0.15rem 0.35rem;
border: 1px solid rgba(var(--ink-rgb),0.45);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.06);
}
.plot-status { font-size: 0.75rem; letter-spacing: 0.1em; opacity: 0.7; }
.plot canvas {
display: block;
margin-top: 0.6rem;
background: var(--paper);
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
}
.roll-go:disabled, .roll-rewrite:disabled {
opacity: 0.4;
cursor: default;
}
.sketch {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
}
.sketch canvas {
display: block;
margin-top: 0.7rem;
background: var(--paper);
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
max-width: 100%;
}
.sketch-page {
margin-top: 0.7rem;
padding: 0.5rem 0.6rem;
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.04);
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.9rem;
}
.sketch-page button {
font: inherit; font-size: 0.85rem;
border: 1px solid rgba(var(--ink-rgb),0.45);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.08);
cursor: pointer;
min-width: 2ch;
}
/* --- The page itself, when Quarto is not doing it for us ---------- */
.screenplay, .artifact, .prose, .handout, .beats, .roll, .plot, .sketch,
.todo, .puzzle-box, .personae, .name-field, .live-cell, .session {
color: var(--ink);
}
.handout, .puzzle-cell, .sign { border-color: var(--rule); }
.terminal { background: var(--panel-hard); color: var(--ink); }
.title-page .title, .title-page .subtitle { color: var(--ink); }
/* --- Form controls -------------------------------------------------
Inputs, textareas and buttons do not inherit colour from the page,
so every one of them needs telling, or the reader types black on
black and cannot see what they are writing.
------------------------------------------------------------------- */
.name-inputs input,
.companion-input,
.todo-input,
.plot-controls input,
.live-src,
.sketch textarea,
.sketch-page input {
color: var(--ink);
background: var(--panel);
border-color: var(--rule);
caret-color: var(--ink);
}
.name-inputs input::placeholder,
.companion-input::placeholder,
.todo-input::placeholder,
.sketch-page input::placeholder,
.live-src::placeholder {
color: var(--ink-soft);
opacity: 1;
}
.roll-go, .roll-rewrite, .live-run, .todo-del,
.sketch-page button, .puzzle-cell {
color: var(--ink);
}
.sketch-page button {
background: var(--panel);
border-color: var(--rule);
}
/* Quarto's own search box does not follow the book's palette on every
theme, so it is told here as well. */
#quarto-search input,
#quarto-search .aa-Input,
.aa-Form,
.aa-InputWrapper input,
.quarto-search input {
background: var(--panel) !important;
color: var(--ink) !important;
border-color: var(--rule) !important;
}
.aa-Form { border: 1px solid var(--rule) !important; }
#quarto-search input::placeholder,
.aa-Input::placeholder {
color: var(--ink-soft) !important;
opacity: 1;
}
.aa-DetachedSearchButton, .quarto-search-button {
background: var(--panel) !important;
color: var(--ink) !important;
border-color: var(--rule) !important;
}
/* Canvases inherit nothing, so the sketch cells read their ink off the
element's own resolved colour. This is the line that gives them one. */
.sketch canvas, .plot canvas {
color: var(--ink);
background: transparent;
}
.tribute-heading {
margin: 2rem 0 0.5rem;
font-variant: small-caps;
font-weight: 600;
letter-spacing: 0.04em;
}
.tribute p {
margin: 0.6rem 0;
}
.tribute-meta {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--bs-border-color);
text-align: right;
font-size: 0.85rem;
}
.tribute-meta p {
margin: 0.25rem 0;
}
.tribute-date {
font-style: italic;
}
.tribute-attribution {
font-size: 1rem;
}
.tribute-source {
margin-top: 0.75rem !important;
}
.footnote {
margin-top: 1.5rem;
font-size: 0.9rem;
color: var(--bs-secondary);
}
/* ---------------------------------------------------------------
The three endings.
All three are in the HTML. Which one is visible is a class on
<body>, written once by fedoras.reader/paint-standing! from the
reader's standing, so a chapter that forks does it here rather than
in nine lines of DOM work per passage.
A page with no class on it shows the middle one. That is what the
print edition gets, and it is what the table actually got, and the
PDF reads straight through with no gap and no note.
--------------------------------------------------------------- */
.ending { display: none; }
.ending-reconciled { display: block; }
body.standing-closed .ending-reconciled { display: none; }
body.standing-outstanding .ending-reconciled { display: none; }
body.standing-closed .ending-closed { display: block; }
body.standing-outstanding .ending-outstanding { display: block; }
@media print {
.ending { display: none !important; }
.ending-reconciled { display: block !important; }
}
/* ---------------------------------------------------------------
The slips.
--------------------------------------------------------------- */
.slip {
margin: 1.2rem 0 1.2rem 4ch !important;
padding-left: 1rem;
max-width: 46ch;
border-left: 2px solid rgba(var(--coral-rgb), 0.55);
font-family: "Courier Prime", "Courier New", monospace;
font-style: italic;
opacity: 0.8;
white-space: pre-wrap;
}
#quarto-margin-sidebar { display: none; }
.scene-code {
border: 1px solid var(--rule);
border-radius: 3px;
padding: 0.6rem 0.8rem;
margin: 0.9rem 0 1.1rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.88rem;
line-height: 1.45;
background: var(--panel);
color: var(--ink);
overflow-x: auto;
}
.scene-code pre { margin: 0; }
.scene-code code { background: none; padding: 0; }
/* Pressing Enroll flashes the name it confirms, the same name or a new
one, so the press can be seen to have done something. */
.name-confirm .pc-name.pc-flash {
animation: pc-flash 1.4s ease-out;
}
@keyframes pc-flash {
0%, 30% { color: var(--green); }
}</style><script src="scittle.js" type="application/javascript"></script><div style="display:none;"><script type="application/x-scittle">(ns fedoras.carry
"What the reader carries between chapters."
(:require [clojure.string :as str]))
(def ^:private book-key
"The registration on the front of the file. It is not a secret and it
is not pretending to be one — it is the number this is keyed to, and
it is the same number a reader has been looking at since August."
3812)
;; ---------------------------------------------------------------------
;; Bytes
;; ---------------------------------------------------------------------
(defn- to-bytes [s] (.encode (js/TextEncoder.) s))
(defn- from-bytes [b] (.decode (js/TextDecoder. "utf-8") b))
(defn- bytes->str [b]
(loop [i 0 acc ""]
(if (>= i (.-length b))
acc
(recur (inc i) (str acc (js/String.fromCharCode (aget b i)))))))
(defn- str->bytes [s]
(let [out (js/Uint8Array. (count s))]
(dotimes [i (count s)] (aset out i (.charCodeAt s i)))
out))
;; ---------------------------------------------------------------------
;; The keystream
;;
;; The same small xorshift the dice and the floors use, so there is one
;; generator in this project and not four.
;; ---------------------------------------------------------------------
(defn- keystream [n seed]
(let [out (js/Uint8Array. n)]
(loop [i 0 x (bit-or 1 (bit-and seed 0x7fffffff))]
(if (>= i n)
out
(let [x (bit-and (bit-xor x (bit-shift-left x 13)) 0x7fffffff)
x (bit-and (bit-xor x (unsigned-bit-shift-right x 17)) 0x7fffffff)
x (bit-and (bit-xor x (bit-shift-left x 5)) 0x7fffffff)]
(aset out i (bit-and x 0xff))
(recur (inc i) x))))))
(defn- mask [b seed]
(let [k (keystream (.-length b) seed)
out (js/Uint8Array. (.-length b))]
(dotimes [i (.-length b)] (aset out i (bit-xor (aget b i) (aget k i))))
out))
;; ---------------------------------------------------------------------
;; Base64, url-safe
;; ---------------------------------------------------------------------
(defn- b64 [b]
(-> (js/btoa (bytes->str b))
(str/replace "+" "-")
(str/replace "/" "_")
(str/replace "=" "")))
(defn- un-b64 [s]
(let [t (-> s (str/replace "-" "+") (str/replace "_" "/"))
pad (case (mod (count t) 4) 2 "==" 3 "=" "")]
(str->bytes (js/atob (str t pad)))))
;; ---------------------------------------------------------------------
;; Rolls, short
;; ---------------------------------------------------------------------
(defn- pack-rolls
"[roll mod rewrites], shortest form. A roll with no modifier and no
rewrites is one number."
[rolls]
(into {}
(for [[id {:keys [roll mod rewrites]}] rolls
:when roll]
[id (cond
(pos? (or rewrites 0)) [roll (or mod 0) rewrites]
(not (zero? (or mod 0))) [roll mod]
:else [roll])])))
(defn- unpack-rolls
"Every shape this has ever emitted: a bare number, the short vector,
and the verbose map that went out while `pack-rolls` was sitting here
unused. Tokens from before this change still read."
[m]
(into {}
(for [[id v] m]
[(clojure.core/name id)
(cond
(number? v) {:roll v :mod 0 :rewrites 0}
(map? v) {:roll (:roll v)
:mod (or (:mod v) 0)
:rewrites (or (:rewrites v) 0)}
:else {:roll (nth v 0)
:mod (nth v 1 0)
:rewrites (nth v 2 0)})])))
;; ---------------------------------------------------------------------
;; The token
;; ---------------------------------------------------------------------
(defn- checksum [b]
(loop [i 0 s 7]
(if (>= i (.-length b))
(mod s 251)
(recur (inc i) (mod (+ (* s 31) (aget b i)) 251)))))
(defn pack
"A map to a token. The keys are one letter each because this goes in
a query string thirty times a page."
[{:keys [name companion echo rolls deeds epoch todo]}]
(try
(let [payload (js/JSON.stringify
(clj->js
(cond-> {}
name (assoc :n name)
companion (assoc :a [(:n companion) (:given companion)])
(pos? (or echo 0)) (assoc :e echo)
(seq rolls) (assoc :r (pack-rolls rolls))
(seq deeds) (assoc :d (mapv clojure.core/name deeds))
;; when the reader last withdrew the whole record.
;; it travels because the alternative is that only
;; the state travels, and then a link from before
;; the withdrawal quietly reinstates it.
(pos? (or epoch 0)) (assoc :g epoch)
(seq todo) (assoc :t [(:seq todo 0) (:items todo)]))))
body (mask (to-bytes payload) book-key)
out (js/Uint8Array. (inc (.-length body)))]
(aset out 0 (checksum body))
(dotimes [i (.-length body)] (aset out (inc i) (aget body i)))
(b64 out))
(catch :default _ nil)))
(defn unpack
"A token back to a map, or nil if anybody has been at it.
An absent field comes back empty rather than missing, which is fine
and is not a licence: the caller merges this forward and knows that an
empty Echo means the token is old, not that the Echo is nought."
[s]
(try
(when-not (str/blank? s)
(let [raw (un-b64 s)
want (aget raw 0)
body (.slice raw 1)]
(when (= want (checksum body))
(let [m (js->clj (js/JSON.parse (from-bytes (mask body book-key)))
:keywordize-keys true)]
{:name (:n m)
:companion (when-let [a (:a m)] {:n (first a) :given (or (second a) "")})
:echo (or (:e m) 0)
:rolls (unpack-rolls (or (:r m) {}))
:deeds (mapv keyword (or (:d m) []))
:epoch (or (:g m) 0)
:todo (when-let [t (:t m)] {:seq (first t) :items (or (second t) [])})}))))
(catch :default _ nil)))
;; ---------------------------------------------------------------------
;; When it does not reconcile
;; ---------------------------------------------------------------------
(defn complain! []
(js/console.warn
"016.09 — entry does not reconcile and has not been carried forward. Retained."))</script><script type="application/x-scittle">(ns fedoras.reader
"Everything the reader owns.
One atom: containing Name, animal, echo, fortune, score, deeds. A widget
reads it and never touches storage directly, and every change goes through
one of the setters, which writes, re-reads, and lets the watches fire."
(:require [clojure.string :as str]
[fedoras.carry :as carry]))
;; ---------------------------------------------------------------------
;; Storage
;; ---------------------------------------------------------------------
(def prefix "fedoras.")
(def carry-param :f)
(def forget-param
"A page that loads with this on it withdraws its own record and stops.
It is a separate field from the token because it must work on a page
that has never seen a token."
:forget)
(def withdrawn-param
"Survives exactly one reload, to say the thing was done. The state it
would otherwise have been recorded in has just been deleted, which is
the one place a confirmation cannot live."
:withdrawn)
(def legacy-params
"The fields the query string used to carry, one per fact. Stripped off
any link that still has them. There was a second copy of this list
called `carried-params` and the two had drifted."
[:pc :pcl :ac :acn :echo :fortune :score])
(defn- read-json [s]
(js->clj (js/JSON.parse s) :keywordize-keys true))
(defn- write-json [v]
(js/JSON.stringify (clj->js v)))
(defn stored [key]
(try
(when-let [s (.getItem js/localStorage (str prefix (name key)))]
(read-json s))
(catch :default _ nil)))
(defn store! [key val]
(try (.setItem js/localStorage (str prefix (name key)) (write-json val))
(catch :default _ nil))
val)
(defn remove!
"Take something out rather than writing an empty one over it, because
a stored nil and a missing key read back the same and only one of them
is what was meant."
[key]
(try (.removeItem js/localStorage (str prefix (clojure.core/name key)))
(catch :default _ nil)))
(defn storage-works?
"Whether anything at all is going to be persisted.
On a file:// path some browsers give every page its own opaque origin,
which means localStorage throws and the whole book forgets the reader
the moment they click anything. Worth knowing rather than guessing."
[]
(try
(.setItem js/localStorage (str prefix "probe") "1")
(let [ok (= "1" (.getItem js/localStorage (str prefix "probe")))]
(.removeItem js/localStorage (str prefix "probe"))
ok)
(catch :default _ false)))
;; ---------------------------------------------------------------------
;; The query string, which is a transport and nothing else
;; ---------------------------------------------------------------------
(defn- current-url [] (js/URL. (.-href js/location)))
(defn param [key]
(try
(let [v (.get (.-searchParams (current-url)) (name key))]
(when-not (str/blank? v) v))
(catch :default _ nil)))
(defn set-params! [m]
(try
(let [u (current-url) sp (.-searchParams u)]
(doseq [[k v] m]
(if (or (nil? v) (= "" v))
(.delete sp (name k))
(.set sp (name k) (str v))))
(.replaceState js/history nil "" (.toString u)))
(catch :default _ nil)))
(defn- int-or [v d]
(let [n (js/parseInt (str v) 10)] (if (js/isNaN n) d n)))
;; ---------------------------------------------------------------------
;; The atom
;; ---------------------------------------------------------------------
(def default-name ["Owen" "Mercer"])
(def state (atom nil))
(def animal-names
"Number to the animal's grammar: what it is called, what more than one
of them is called, what a ledger would file it under. Filled in by
`fedoras.state/assets` from a later script tag, so it is empty for the
moment between this file being read and the roster arriving, and
nothing here may treat an empty roster as a missing roll."
(atom {}))
(defn- as-grammar
"One roster entry, whatever shape it turned up in.
The page used to be handed {1 \"Pigeon\"} and is now handed
{1 {:animal \"Pigeon\" :plural \"pigeons\" ...}}, and a book whose
`names-form` has not caught up with its roster hands over one of each.
A bare string is an animal with no grammar, which is precisely what it
was before, so it becomes one here.
This is normalised at the boundary rather than at each use because the
alternative was a `merge` three functions away receiving a string,
throwing `conj on a map takes map entries`, and taking down every
render watch on the page — a build that is one function out of step
should read a little worse, not stop."
[v]
(cond
(map? v) v
(string? v) {:animal v}
:else {}))
(defn- read-companion []
(let [s (stored :companion)
n (int-or (first s) nil)]
(when (and n (<= 1 n 20))
(let [given (or (nth s 2 nil) "")
g (as-grammar (get @animal-names n))
;; the animal's name is a convenience and the roster may not
;; have landed yet. the number is the fact.
animal (or (:animal g) (nth s 1 nil) "")
named? (not (str/blank? given))]
(merge {:plural "" :taxonomy "" :collective "" :other-names []}
g
{:n n
:animal animal
:given given
:named? named?
:name (cond named? (str/trim given)
(not (str/blank? animal)) animal
:else (str "d20 " n))})))))
(defn- read-name []
(let [s (stored :pc)]
[(or (first s) (first default-name))
(or (second s) (second default-name))]))
(defn refresh!
"Total on purpose. Every render watch in the book hangs off this atom,
so anything that throws in here blanks the whole page, and what it
reads is JSON that a previous version of this file wrote, which is not
a thing to be optimistic about. A key that is the wrong shape reads as
absent."
[]
(reset! state
{:name (read-name)
:companion (try (read-companion) (catch :default _ nil))
:echo (int-or (stored :echo) 0)
:rolls (let [r (stored :rolls)] (if (map? r) r {}))
:deeds (let [d (stored :deeds)]
(set (map keyword (if (sequential? d) d []))))
:epoch (int-or (stored :epoch) 0)
:todo (let [t (stored :todo)] (if (sequential? t) t []))
:todo-seq (int-or (stored :todo-seq) 0)}))
;; ---------------------------------------------------------------------
;; What a chapter may ask for
;; ---------------------------------------------------------------------
(defn reader-name [] (:name @state))
(defn companion [] (:companion @state))
(defn echo [] (:echo @state))
(defn deeds [] (:deeds @state))
(defn fortune [] (count (:deeds @state)))
(defn epoch
"When this reader last withdrew the whole record, in seconds, or
nought if they never have.
It is not a clock and nothing displays it. It exists so that a
withdrawal can be compared against a link: a token stamped earlier
than this was written by somebody who has since been forgotten, and it
is not allowed to put them back."
[]
(:epoch @state))
(defn standing
"Fortune less echo. Added to every roll for the rest of the book, and
the number the last chapter reads to decide how it ends."
[]
(- (fortune) (echo)))
(defn registration [] "3812")
;; ---------------------------------------------------------------------
;; And what it may change
;; ---------------------------------------------------------------------
(defn set-name! [f l]
(store! :pc [(if (str/blank? f) (first default-name) (str/trim f))
(if (str/blank? l) (second default-name) (str/trim l))])
(refresh!))
(defn set-companion!
"Three cells: the number, the animal's own name as a courtesy, and
whatever the reader called it. The number is the only one that has to
be right, because the other two are recovered from the roster."
[n given]
(store! :companion [n (get-in @animal-names [n :animal] "") (or given "")])
(refresh!))
(defn set-echo! [n] (store! :echo (max 0 n)) (refresh!))
(defn add-echo!
"One more thing taken back."
[]
(set-echo! (inc (echo))))
(defn did!
"Record a deed. A set, so doing the same thing twice is doing it once,
and nothing anywhere says that it happened."
[k]
(store! :deeds (mapv name (conj (deeds) (keyword k))))
(refresh!))
(defn animals!
"The roster's grammar, from the page. Normalised on the way in, so the
shape `fedoras.state/names-form` happens to be emitting this week is
something only this function has an opinion about.
Refreshes, because a companion read before this arrived is holding an
empty animal name."
[m]
(reset! animal-names
(if (map? m)
(into {} (map (fn [[k v]] [k (as-grammar v)])) m)
{}))
(refresh!))
(defn rolls
"Every check the reader has resolved, by id.
Normalised on the way out. Storage keywordizes keys on read, and a
check id is an arbitrary string rather than a name, so the keys come
back as keywords and nothing matches them. And anything that is not a
map at all becomes an empty one, because assoc on a vector with a
string key takes the page down."
[]
(let [r (:rolls @state)]
(if (map? r)
(into {} (map (fn [[k v]] [(name k) v])) r)
{})))
(defn roll-entry [id]
(when-let [e (get (rolls) id)]
{:roll (or (:roll e) (get e "roll"))
:mod (or (:mod e) (get e "mod") 0)
:rewrites (or (:rewrites e) (get e "rewrites") 0)}))
(defn remember-roll! [id e]
(store! :rolls (assoc (rolls) id e))
(refresh!))
(defn score []
(+ (reduce + 0 (keep :roll (vals (rolls))))
(or (:n (companion)) 0)))
;; ---------------------------------------------------------------------
;; Import, once, on arrival
;; ---------------------------------------------------------------------
(def carries-epoch?
"Whether the codec on this page round-trips a withdrawal stamp."
(try
(= 12345 (:epoch (carry/unpack
(carry/pack {:name ["Owen" "Mercer"] :epoch 12345}))))
(catch :default _ false)))
(defn- wipe!
"Everything under the prefix, and then one thing: when that happened."
[at]
(try
(let [ks (vec (for [i (range (.-length js/localStorage))
:let [k (.key js/localStorage i)]
:when (and k (str/starts-with? k prefix))]
k))]
(doseq [k ks] (.removeItem js/localStorage k)))
(catch :default _ nil))
(store! :epoch at))
(defn- merge-token!
[{:keys [name companion echo rolls deeds epoch todo]}]
(let [ours (int-or (stored :epoch) 0)
theirs (or epoch 0)]
(cond
;; A link from before the reader withdrew the record. It is a true
;; account of somebody who does not exist any more, which is not
;; the same thing as an instruction to rebuild them. This is the
;; case that made `forget!` a lie: it emptied one page's storage
;; and every other page, and every entry in the back history, was
;; still holding a complete copy and handed it straight back.
;; A link from before the reader withdrew the record. It is a true
;; account of somebody who does not exist any more, which is not
;; the same thing as an instruction to rebuild them.
;;
;; Only when the codec can say so. A page that cannot read a stamp
;; off a token must not conclude from its absence that every link
;; in the book is stale.
(and carries-epoch? (< theirs ours))
(js/console.info
(str "016.09 — the link this page was opened from was written before the record "
"was withdrawn. Nothing carried forward. Reach this chapter from a page you "
"have opened since, and whatever is current will arrive with you."))
:else
(do
;; They withdrew it somewhere this page has not heard about yet.
;; The erasure travels the way the state travels, which is the
;; only way anything travels in a book read off a disk.
(when (and carries-epoch? (> theirs ours))
(wipe! theirs)
(js/console.info
"016.09 — prior record withdrawn at the reader's request. Nothing carried forward."))
;; the name is the one thing a reader can retype in five seconds,
;; so the token is allowed to have an opinion about it
(when (seq name)
(store! :pc name))
;; The animal:
;;
;; This was write-once — take the token's only if there is
;; nothing here — which is right on one origin and wrong on a
;; folder of files, where every page has its own storage and the
;; token is the only thing that crosses. A reader who opened Act
;; II after rolling and before naming taught that page the horse,
;; permanently, and Act II could never afterwards learn that the
;; horse was called Horace.
;;
;; The Echo is the recency stamp. It only ever goes up, and a
;; reroll raises it, so a token carrying an Echo at least as
;; large as this page's is at least as new as this page and is
;; allowed to speak. One older, and it is a link from before the
;; reroll and is not.
(let [have (read-companion)
here-echo (int-or (stored :echo) 0)
there-echo (or echo 0)]
(when (and (:n companion)
(or (nil? have) (>= there-echo here-echo)))
(store! :companion
[(:n companion) ""
;; a blank name in a token is a reader who had not
;; named it yet, not a reader who unnamed it
(if (str/blank? (:given companion))
(or (:given have) "")
(:given companion))])))
;; upward only, always. after the companion, which reads it.
(store! :echo (max (int-or (stored :echo) 0) (or echo 0)))
;; a check is resolved once, and what is already in this browser
;; was rolled by this reader, so it wins every collision
(when (seq rolls)
(let [named (fn [m] (if (map? m)
(into {} (map (fn [[k v]] [(clojure.core/name k) v])) m)
{}))]
(store! :rolls (merge (named rolls) (named (stored :rolls))))))
;; deeds are a set, and a set does not get smaller by being described
(when (seq deeds)
(let [held (let [d (stored :deeds)] (if (sequential? d) d []))]
(store! :deeds (vec (into (set (map clojure.core/name held))
(map clojure.core/name deeds))))))
(when-let [t todo]
(when (>= (:seq t 0) (int-or (stored :todo-seq) 0))
(store! :todo (:items t))
(store! :todo-seq (:seq t))))))))
(defn- import-token! []
(if-let [t (param carry-param)]
(if-let [m (try (fedoras.carry/unpack t) (catch :default _ nil))]
(merge-token! m)
(try (fedoras.carry/complain!) (catch :default _ nil)))
;; nothing to do, unless somebody has a link from before the token
(do
(when-let [pc (param :pc)]
(store! :pc [pc (or (param :pcl) (second default-name))]))
(when-let [ac (param :ac)]
(let [n (int-or ac nil)]
(when (and n (<= 1 n 20) (nil? (read-companion)))
(store! :companion [n "" (or (param :acn) "")]))))
(when-let [e (param :echo)]
(store! :echo (max (int-or (stored :echo) 0) (int-or e 0)))))))
(defn- withdraw-if-asked!
"A page loaded with a withdrawal stamp on it clears itself and does
nothing else.
This is how one button reaches twelve documents that cannot see each
other. The page the reader clicked opens each of the others in a
hidden frame with the stamp in the query string, and each one runs
this on the way up. Nothing is scripted across the boundary — every
file is its own origin and none of them can touch another's storage —
but a document can always clear its own, and a query string is the one
thing that crosses.
Returns true if this load was a withdrawal, so boot knows to skip the
rest of its work."
[]
(when-let [at (some-> (param forget-param) (int-or nil))]
(when (> at (int-or (stored :epoch) 0))
(wipe! at))
true))
;; ---------------------------------------------------------------------
;; Rendering
;; ---------------------------------------------------------------------
(defn on-render! [k f] (add-watch state k (fn [_ _ _ _] (f))) (f))
(defn el [id] (.getElementById js/document id))
(defn q [node selector] (when node (.querySelector node selector)))
(defn q-all
"Every match, as a vector. One argument searches the document; two
searches inside a node.
Written the long way because a NodeList is not a Clojure collection
and array-seq is not in Scittle's bindings."
([selector] (q-all js/document selector))
([node selector]
(if-not node
[]
(let [nodes (.querySelectorAll node selector)]
(mapv (fn [i] (.item nodes i)) (range (.-length nodes)))))))
(defn text! [node selector s]
(when-let [t (q node selector)] (set! (.-textContent t) s)))
(defn on-click! [node selector f]
(when-let [b (q node selector)] (set! (.-onclick b) f)))
(defn enable! [node selector on? label]
(when-let [b (q node selector)]
(set! (.-disabled b) (not on?))
(when label (set! (.-textContent b) label))))
(defn show! [node selector on?]
(when-let [t (q node selector)]
(set! (.-display (.-style t)) (if on? "flex" "none"))))
(defn widget [k id f]
(on-render! k (fn [] (when-let [node (el id)] (f node)))))
;; ---------------------------------------------------------------------
;; Withdrawing the record, everywhere
;;
;; Twelve documents, each its own origin, none able to read another's
;; storage. Every shared channel is shut: localStorage and IndexedDB are
;; origin-scoped, window.name is cleared on the way across, cookies do
;; not apply to a file, and an iframe gets its own origin so the parent
;; cannot script it.
;;
;; It can still LOAD it. A hidden frame pointed at a sibling chapter,
;; with a stamp in the query string, runs that chapter's own copy of
;; this file, which clears that chapter's own storage. Nothing crosses
;; the boundary except the URL, which is the one thing that was ever
;; going to.
;; ---------------------------------------------------------------------
(defn- sibling-pages
"Every other chapter this page links to.
Read off the sidebar rather than configured, because Quarto already
puts the whole book in it and a hand-kept list of filenames is a list
that goes stale the first time a chapter is renamed."
[]
(let [here (.-pathname js/location)
dir (subs here 0 (inc (.lastIndexOf here "/")))]
(->> (q-all "a[href]")
(keep (fn [a]
(let [h (.getAttribute a "href")]
(when (and h
(not (str/starts-with? h "#"))
(not (re-find #"(?i)^(https?:|mailto:|javascript:)" h)))
(try
(let [u (js/URL. (.-href a))
p (.-pathname u)]
(when (and (str/starts-with? p dir)
(str/ends-with? (str/lower-case p) ".html")
(not= p here))
(str (.-protocol u) "//" (.-host u) p)))
(catch :default _ nil))))))
distinct
vec)))
(defn- sweep!
"Tell every other chapter to withdraw, then call `done` once.
Once, whatever happens: a frame that never fires must not leave the
reader looking at a button that has stopped meaning anything, so there
is a deadline as well as a count."
[at done]
(let [pages (sibling-pages)
fired (atom false)
finish (fn [] (when-not @fired (reset! fired true) (done)))]
(if (empty? pages)
(finish)
(let [box (.createElement js/document "div")
left (atom (count pages))
one (fn [] (when (zero? (swap! left dec)) (finish)))]
(js/console.info "016.09 — withdrawing from" (count pages) "other chapters")
(set! (.-style box)
"position:absolute;width:0;height:0;overflow:hidden;border:0;visibility:hidden")
(.appendChild (.-body js/document) box)
(doseq [p pages]
(let [f (.createElement js/document "iframe")]
(set! (.-onload f) (fn [_] (one)))
(set! (.-onerror f) (fn [_] (one)))
(set! (.-src f) (str p "?" (clojure.core/name forget-param) "=" at))
(.appendChild box f)))
(js/setTimeout finish 5000)))))
(defn forget!
"Forget everything, not the six things somebody remembered to list,
and not only on this page.
Emptying this document's storage was never the whole job: the reader
was also sitting, complete, in the query string of every link on every
other chapter and in every entry of the back history, and the next
navigation put them back. So this clears here, stamps when, and then
visits every other chapter to say so. The stamp is what makes a link
written before the withdrawal refuse to be believed afterwards.
It also says so while it is happening. It used to do all of this in
silence and then reload, which is indistinguishable from a button that
does nothing except reload."
[]
(let [at (quot (.now js/Date) 1000)
btn (q js/document ".pc-forget")]
(when btn
(set! (.-disabled btn) true)
(set! (.-textContent btn) "Withdrawing the record…"))
(wipe! at)
(sweep! at
(fn []
(let [u (current-url)]
(doseq [k (concat [carry-param forget-param] legacy-params)]
(.delete (.-searchParams u) (clojure.core/name k)))
(.set (.-searchParams u) (clojure.core/name withdrawn-param) "1")
(set! (.-href js/location) (.toString u)))))))
(defn- note-withdrawal!
"One line, on the way back up, and then the button goes back to
offering. The confirmation cannot be stored, because storing things is
what has just been undone — so it is read off the query string once,
before anything else in boot is allowed to tidy the query string away."
[withdrew?]
(when withdrew?
(js/console.info
"016.09 — record withdrawn from this chapter and from every chapter linked from it.")
(when-let [b (q js/document ".pc-forget")]
(set! (.-textContent b) "Record withdrawn")
(js/setTimeout (fn [] (set! (.-textContent b) "Forget everything")) 5000))))
;; ---------------------------------------------------------------------
;; Export
;; ---------------------------------------------------------------------
(defn token
"What this reader looks like from outside. Recomputed rather than
stored, so it cannot disagree with the state it is describing.
Guarded, because this runs on every render and inside `decorate-links!`,
and a codec that throws on one unexpected key should cost the reader a
shareable link and nothing else."
[]
(try
(let [{:keys [name companion echo rolls deeds todo todo-seq]} @state]
(fedoras.carry/pack
{:name name
:companion (when companion (select-keys companion [:n :given]))
:echo echo
:rolls rolls
:deeds deeds
:epoch (epoch)
:todo (when (seq todo) {:seq (or todo-seq 0) :items todo})}))
(catch :default e
(js/console.warn "fedoras: could not pack the carry token —" e)
nil)))
(defn decorate-links!
"Every internal link carries one field.
Two things were wrong before this and both of them mattered. It ran
inline, at parse time, so any link below the script tag did not exist
yet — which is every prev-and-next arrow Quarto puts in the footer.
And it copied the query string rather than the state, so a page opened
from storage handed on nothing."
[]
(when-let [t (token)]
(doseq [a (q-all "a[href]")]
(let [h (.getAttribute a "href")]
(when (and h
(not (str/starts-with? h "#"))
(not (re-find #"(?i)^(https?:|mailto:|javascript:)" h)))
(try
(let [lu (js/URL. (.-href a))
sp (.-searchParams lu)]
(doseq [k legacy-params] (.delete sp (clojure.core/name k)))
(.set sp (clojure.core/name carry-param) t)
(set! (.-href a) (.toString lu)))
(catch :default _ nil)))))))
(defn- rewrite-address!
"Put the token in this page's own address bar and take the old fields
off it, so a reader who arrived on a legacy link stops looking at
their own Echo."
[]
(when-let [t (token)]
(set-params! (into {(clojure.core/name carry-param) t}
(map (fn [k] [k nil])
(concat [forget-param withdrawn-param] legacy-params))))))
;; ---------------------------------------------------------------------
;; Tokens
;;
;; It used to be a chain of str/replace calls, which is why {pc-last}
;; worked in a scene and rendered as the literal characters {pc-last}
;; inside a dice passage.
;;
;; Case follows the token: {ac} is `ted`, {Ac} is `Ted`, {AC} is `TED`,
;; so a passage can start a sentence or write a cue line without the
;; author reaching for a second token. Note that this makes {PC} in an
;; already-written scene render as OWEN rather than Owen; the old regex
;; was case-insensitive and threw the information away.
;;
;; An unkown token is passed over: A brace in a passage that is not
;; something this understands comes out exactly as it went in, so this
;; is safe to run over arbitrary prose.
;; ---------------------------------------------------------------------
(def ^:private token-re #"\{([A-Za-z]+)(-[A-Za-z-]+)?\}")
(defn- apply-case [head s]
(cond
(str/blank? s) s
(= head (str/upper-case head)) (str/upper-case s)
(= (subs head 0 1) (str/upper-case (subs head 0 1)))
(str (str/upper-case (subs s 0 1)) (subs s 1))
:else s))
(defn render
"Replace every {token} the resolver knows. `resolve` is called with the
lower-cased head and the lower-cased tail — \"ac\" and \"-plural\" for
{ac-plural} — and returns a string or nil."
[resolve s]
(str/replace (or s "") token-re
(fn [m]
(let [[whole head tail] (if (string? m) [m m nil] m)]
(or (some->> (resolve (str/lower-case head)
(some-> tail str/lower-case))
(apply-case head))
whole)))))
(defn- string-hash
"Stable across loads, which is the point: the same sentence picks the
same alternative name every time the reader opens the page, so nothing
reads as though it were rolled twice."
[s]
(reduce (fn [a i] (mod (+ (* 31 a) (.charCodeAt s i)) 1000003))
7 (range (count s))))
(defn- an [s]
(str (if (re-find #"(?i)^[aeiou]" (str s)) "an " "a ") s))
(defn- animal-tokens
"What a passage may say about the animal. The reader's own name for it
is a name; everything else is the species, because a reader who called
a marmot Ted did not thereby decide that two of them are Teds."
[source tail]
(when-let [{:keys [animal plural taxonomy collective other-names name named?]}
(companion)]
(let [kind (str/lower-case (or animal ""))]
(case (or tail "-name")
"-name" (if named? name (str "the " kind))
"-kind" kind
"-a" (an kind)
"-plural" (if (str/blank? plural) (str kind "s") (str/lower-case plural))
"-many" (if (str/blank? collective)
(str "several " (if (str/blank? plural) (str kind "s") plural))
(str (an collective) " of "
(if (str/blank? plural) (str kind "s") plural)))
"-taxonomy" (str/lower-case (or taxonomy kind))
"-given" (if named? name "")
"-other" (if (seq other-names)
(nth other-names (mod (string-hash source) (count other-names)))
(str "the " kind))
nil))))
(defn- reader-tokens [source]
(let [[f l] (reader-name)]
(fn [head tail]
(case head
"pc" (case (or tail "-first")
"-first" f
"-full" (str f " " l)
"-last" l
nil)
"ac" (or (animal-tokens source tail)
;; no roll yet, and a passage still has to read
(case (or tail "-name")
("-name" "-kind" "-other") "the animal"
"-a" "an animal"
"-plural" "animals"
"-many" "several animals"
"-taxonomy" "animal"
"-given" ""
nil))
nil))))
(defn fill-with
"Put the reader into a passage, plus whatever else the caller knows.
The dice box passes the die in this way."
[extra s]
(let [own (reader-tokens s)]
(render (fn [h t] (or (extra h t) (own h t))) s)))
(defn fill
"Put the reader into a passage. The only place any of these are
resolved."
[s]
(fill-with (fn [_ _] nil) s))
(defn animal-label
"Kept for anything still asking. The display name, as stored, not
shouted — the old one upper-cased and every caller lower-cased it
again somewhere else."
[]
(when-let [c (companion)] (:name c)))
;; ---------------------------------------------------------------------
;; The carry-over
;;
;; What a reader takes to the next book.
;; ---------------------------------------------------------------------
(def on-the-record
"What goes in the token. Everything else is furniture.
A key is on this list because it is small and because losing it would
lose the reader's year. Adding one is a decision about how long every
link on every page is going to be, so it is a list and not a
convention."
#{:pc :companion :echo :rolls :deeds :todo})
(defn kept
"Read something the reader left in a room. No widget that calls this
needs to know anything about tokens."
[k]
(stored k))
(defn keep!
"Leave something in a room. It stays where it was made."
[k v]
(store! k v)
(when (contains? on-the-record k) (refresh!))
v)
(def ^:private alphabet "23456789ABCDEFGHJKLMNPQRSTUVWXYZ")
(defn- b32 [n len]
(loop [n n i 0 out ""]
(if (= i len)
out
(recur (quot n 32) (inc i)
(str (nth alphabet (mod n 32)) out)))))
;; ---------------------------------------------------------------------
;; The name and the animal, on every page
;; ---------------------------------------------------------------------
(defn- paint-name []
(let [[f l] (reader-name)]
(doseq [node (q-all ".pc-name")]
(set! (.-textContent node)
(apply-case (or (.getAttribute node "data-case") "pc")
(case (.getAttribute node "data-pc")
"-full" (str f " " l) "-last" l f))))
(when-let [i (el "pc-first")] (when (str/blank? (.-value i)) (set! (.-value i) f)))
(when-let [i (el "pc-last")] (when (str/blank? (.-value i)) (set! (.-value i) l)))))
(defn- paint-companion
"Each span carries the token that made it, so {ac-plural} written in a
scene at build time still says `marmots` after the reader rolls a
marmot. It used to carry nothing and every span got the same word."
[]
(doseq [node (q-all ".ac-name")]
(let [tail (or (.getAttribute node "data-ac") "-name")
head (or (.getAttribute node "data-case") "ac")]
(set! (.-textContent node)
(or (some->> ((reader-tokens (or (.-textContent node) "")) "ac" tail)
(apply-case head))
"the animal")))))
(defn- paint-tokens []
(doseq [node (q-all ".tok")]
(set! (.-textContent node) (fill (.getAttribute node "data-tok")))))
;; ---------------------------------------------------------------------
;; Ending / Standing
;; ---------------------------------------------------------------------
(def deed-roll
[:nerve ;; a natural twenty with the Echo at nought
:named-it ;; the animal was given a name and kept it
:kept-the-programmes ;; both sheets went in the bag and stayed there
:no-name-for-the-box ;; nothing was spoken into the undertaker's box
:read-it-back ;; the ledger was read against the room out loud
:opened-the-cage
:held-the-door
:went-back-for-it
:signed-nothing
:walked-out
:told-a-new-one ;; Ariathne was paid in something that was theirs
:said-why]) ;; the chancellor asked, and got a true answer
(def ^:private deed-bits 15)
(defn- deed-mask []
(let [held (deeds)]
(reduce (fn [acc [index deed]]
(if (contains? held deed)
(+ acc (bit-shift-left 1 index))
acc))
0
(map-indexed vector deed-roll))))
(defn- deeds-from-mask [mask]
(set (keep-indexed (fn [index deed]
(when (pos? (bit-and mask (bit-shift-left 1 index)))
deed))
deed-roll)))
(def band-code {:closed 0 :reconciled 1 :outstanding 2})
(def code-band {0 :closed 1 :reconciled 2 :outstanding})
(defn ending-band []
(let [reader-standing (standing)]
(cond
(>= reader-standing 4) :outstanding
(>= reader-standing 0) :reconciled
:else :closed)))
(defn- paint-standing!
"One class on the body, so a chapter that forks three ways does it in
the stylesheet instead of in nine lines of DOM work per passage. A page
with no class on it shows :reconciled, which is what the print edition
gets and what the table actually got.
This existed and was never wired to anything, which meant every
`sp/by-standing` on every page was rendering all three endings at
once, or none."
[]
(when-let [body (.-body js/document)]
(let [classes (.-classList body)]
(doseq [old ["standing-closed" "standing-reconciled" "standing-outstanding"]]
(.remove classes old))
(.add classes (str "standing-" (clojure.core/name (ending-band)))))))
(def code-version 3)
(defn- check-char [body]
(nth alphabet
(mod (reduce + (map (fn [c] (.indexOf alphabet c)) body)) 32)))
(defn carry-code
"What this reader looks like to the next book, as an accession line."
[]
(let [n (+ (* code-version 4294967296)
(* (get band-code (ending-band) 1) 1073741824)
(* (min 31 (or (:n (companion)) 0)) 33554432)
(* (min 31 (echo)) 1048576)
(* (deed-mask) 32)
(min 31 (quot (score) 10)))
body (b32 n 7)]
(str "016.14 · " (registration) " · "
(subs body 0 4) "-" (subs body 4) (check-char body))))
(defn read-carry
"The other side of it, for the book after this one."
[s]
(let [cleaned (-> (str s) str/upper-case (str/replace #"[^2-9A-Z]" ""))
tail (subs cleaned (max 0 (- (count cleaned) 8)))]
(when (= 8 (count tail))
(let [body (subs tail 0 7)
n (reduce (fn [acc c] (+ (* 32 acc) (.indexOf alphabet c))) 0 body)]
(when (= (nth tail 7) (check-char body))
{:version (quot n 4294967296)
:band (get code-band (mod (quot n 1073741824) 4))
:companion (mod (quot n 33554432) 32)
:echo (mod (quot n 1048576) 32)
:deeds (deeds-from-mask (mod (quot n 32) (bit-shift-left 1 deed-bits)))
:score-floor (* 10 (mod n 32))})))))
;; ---------------------------------------------------------------------
;; Is any of this working
;;
;; Type fedoras.reader.diagnose_BANG_() in the console. Faster than
;; opening the storage inspector and reading JSON, and it says whether
;; the token agrees with the storage, which is the failure that cost an
;; animal.
;; ---------------------------------------------------------------------
(defn diagnose! []
(let [t (param carry-param)]
(js/console.log "storage works: " (storage-works?))
(js/console.log "origin: " (.-origin js/location)
(if (= "file:" (.-protocol js/location))
"— every page may have its own storage"
"— one bucket for the whole book"))
(js/console.log "name: " (pr-str (reader-name)))
(js/console.log "companion: " (pr-str (companion)))
(js/console.log " raw storage: " (pr-str (stored :companion)))
(js/console.log "roster loaded: " (count @animal-names) "animals")
(js/console.log "echo / fortune: " (echo) "/" (fortune) " → standing" (standing))
(js/console.log "withdrawn at: " (if (pos? (epoch)) (epoch) "never")
(if carries-epoch?
""
"— CODEC DROPS THE STAMP, carry.cljs is out of date"))
(js/console.log "rolls: " (pr-str (rolls)))
(js/console.log "token on url: " (or t "none"))
(when t
(js/console.log " unpacks to: "
(pr-str (try (fedoras.carry/unpack t)
(catch :default e (str "THREW: " e))))))
(js/console.log "token we'd emit:" (or (token) "none — pack failed"))
:ok))
;; A handle on `window`, because Scittle keeps its namespaces inside the
;; SCI context and the browser console cannot see into it. There is no
;; `fedoras.reader.diagnose_BANG_()` to call — that is the shape a
;; compiled build would have, and this is an interpreted one.
;;
;; fedoras.diagnose()
;; fedoras.forget()
;;
;; and for anything else, scittle.core.eval_string("(fedoras.reader/...)")
(set! (.-fedoras js/window)
(js-obj "diagnose" (fn [] (diagnose!))
"state" (fn [] (clj->js @state))
"token" (fn [] (token))
"forget" (fn [] (forget!))))
;; ---------------------------------------------------------------------
;; Boot
;; ---------------------------------------------------------------------
(if (withdraw-if-asked!)
;; a hidden frame, doing one job. no token, no links, no painting.
(refresh!)
;; read before anything registers a watch: `on-render!` calls its
;; function once on the spot, and the links watch tidies the query
;; string, so the flag is gone by the time the DOM is ready
(let [withdrew? (boolean (param withdrawn-param))]
(when-not carries-epoch?
(js/console.warn
(str "fedoras: carry.cljs does not round-trip the withdrawal stamp. "
"`forget!` will clear this chapter and every chapter linked from it, "
"but a stale link will not be refused afterwards. "
"Update resources/fedoras/carry.cljs — `pack` needs :epoch in its "
"destructuring and :g in its payload, and `unpack` needs :epoch back out.")))
(import-token!)
(refresh!)
(on-render! ::name paint-name)
(on-render! ::companion paint-companion)
(on-render! ::tokens paint-tokens)
(on-render! ::standing paint-standing!)
(on-render! ::links (fn [] (decorate-links!) (rewrite-address!)))
(if (= "loading" (.-readyState js/document))
(.addEventListener js/document "DOMContentLoaded"
(fn [_]
(note-withdrawal! withdrew?)
(decorate-links!)
(paint-standing!)))
(do (note-withdrawal! withdrew?)
(decorate-links!)))))</script><script type="application/x-scittle">(fedoras.reader/animals! {1 {:animal "Pigeon", :plural "pigeons", :taxonomy "bird", :collective "kit", :other-names ["dusty wings" "coooo"]}, 2 {:animal "Giant Centipede", :plural "centipedes", :taxonomy "arthropod", :collective "colony", :other-names ["clicker" "legs gooo"]}, 3 {:animal "Marmot", :plural "marmots", :taxonomy "rodent", :collective "madness", :other-names ["the alarm" "the fat rat"]}, 4 {:animal "Cat", :plural "cats", :taxonomy "felid", :collective "clowder", :other-names ["the cat" "cat"]}, 5 {:animal "Turkey", :plural "turkeys", :taxonomy "fowl", :collective "rafter", :other-names ["thanksgiving dinner for non vegans" "gobbler"]}, 6 {:animal "Giant Badger", :plural "badgers", :taxonomy "mustelid", :collective "cete", :other-names ["angry grandpa" "stripes"]}, 7 {:animal "Giant Weasel", :plural "weasels", :taxonomy "mustelid", :collective "sneak", :other-names ["the scout" "slenderman" "the ermine"]}, 8 {:animal "Fox", :plural "foxes", :taxonomy "canid", :collective "skulk", :other-names ["foxxxy" "spry foot"]}, 9 {:animal "Wolverine", :plural "wolverines", :taxonomy "mustelid", :collective "gang", :other-names ["the small trouble"]}, 10 {:animal "Dog", :plural "dogs", :taxonomy "canid", :collective "pack", :other-names ["the dog" "the ordinary dog"]}, 11 {:animal "Python", :plural "pythons", :taxonomy "serpent", :collective "knot", :other-names ["ssSSssSsss" "coilssss"]}, 12 {:animal "Mule", :plural "mules", :taxonomy "equid", :collective "barren", :other-names ["dapple" "HeeHaw :("]}, 13 {:animal "Lynx", :plural "lynxes", :taxonomy "felid", :collective "clowder", :other-names ["tufts" "yelper"]}, 14 {:animal "Ape", :plural "apes", :taxonomy "primate", :collective "shrewdness", :other-names ["treecrawler"]}, 15 {:animal "Horse", :plural "horses", :taxonomy "equid", :collective "harras", :other-names ["rocinante" "the one nobody rides"]}, 16 {:animal "Moose", :plural "moose", :taxonomy "cervid", :collective "herd", :other-names ["its a giant moose"]}, 17 {:animal "Wyrmling", :plural "wyrmlings", :taxonomy "draconic", :collective "clutch", :other-names ["the wyrmling" "scales"]}, 18 {:animal "Falcon", :plural "falcons", :taxonomy "raptor", :collective "cast", :other-names ["the raptor" "true sight"]}, 19 {:animal "Wolf", :plural "wolves", :taxonomy "canid", :collective "pack", :other-names ["the wolf" "the packleader"]}, 20 {:animal "Bear", :plural "bears", :taxonomy "ursid", :collective "sloth", :other-names ["the bear" "the citadel"]}})</script></div></div>
```
```{=html}
<script type="application/x-scittle">(ns fedoras.ui.dice-ui
"A d20 the reader can roll once.
ROLL ONCE. It used to let you press the button all afternoon. The
handler had no guard and the box painted nothing from storage, so a
roll was neither kept nor refused. It is kept now, the button is spent
once it has been pressed, and the only other door is the expensive
one.
STANDING IS THE MODIFIER. Fortune less echo, added to every roll. It
is stored with the roll rather than read live, because raising the
Echo in Act IV must not quietly reword a scene the reader finished in
Act II.
THE PASSAGE MAY SAY THE NUMBER. Every outcome string is rendered with
the die in scope, so a line can say {result} and mean the number that
was actually rolled rather than the number the author guessed. This is
the reason the passages used to lie: a success wrote `rolls a
fourteen` whatever the reader rolled, and a reader who rolled
seventeen was told, in their own book, that they had not.
A passage is still allowed to be vague. `rolls low` is a choice now
rather than the only option.
SCORE. Every die adds its face to the score, including the ones rolled
again. That is the temptation: a rewrite raises the score and raises
the Echo, and only one of those two is fatal.
FORTUNE. A natural twenty is worth a deed, but only while the Echo is
nought. So the reader who has never taken anything back is the only
one who can still be lucky, and nothing anywhere says so."
(:require [clojure.string :as str]))
;; ---------------------------------------------------------------------
;; What a roll is worth
;; ---------------------------------------------------------------------
(defn band
"One and twenty are themselves. Everything between them is a sum."
[natural dc modifier]
(cond
(= natural 1) :fumble
(= natural 20) :crit
(>= (+ natural modifier) dc) :success
:else :fail))
(defn line
"What was rolled, what it came to, and what that was."
[natural dc modifier]
(str "d20 → " natural
(cond
(pos? modifier) (str " fortune +" modifier " → " (+ natural modifier))
(neg? modifier) (str " echo " modifier " → " (+ natural modifier))
:else "")
" "
(case (band natural dc modifier)
:fumble "FUMBLE" :crit "CRIT" :success "success" :fail "failure")))
;; ---------------------------------------------------------------------
;; The number, in words and in figures
;; ---------------------------------------------------------------------
(def ^:private spelled
["zero" "one" "two" "three" "four" "five" "six" "seven" "eight" "nine"
"ten" "eleven" "twelve" "thirteen" "fourteen" "fifteen" "sixteen"
"seventeen" "eighteen" "nineteen" "twenty"])
(defn- in-words [n]
(if (and (number? n) (<= 0 n 20)) (nth spelled n) (str n)))
(defn- magnitude [n] (if (neg? n) (- n) n))
(defn- signed [n]
(cond (pos? n) (str "+" n)
(neg? n) (str "−" (magnitude n))
:else ""))
(defn- arithmetic
"17 + 1 = 18, and just 17 when there is nothing to add, so a line that
uses this reads correctly for a reader whose standing is nought — most
readers, most of the time."
[natural modifier]
(if (zero? modifier)
(str natural)
(str natural " " (if (pos? modifier) "+" "−") " " (magnitude modifier)
" = " (+ natural modifier))))
(defn roll-tokens
"What a passage is allowed to say about the die that produced it.
{result} is the face, because that is what somebody at a table says
out loud. {total} is the number the DC was measured against, which is
a different fact and usually the less interesting one."
[dc {:keys [roll mod]}]
(let [total (+ roll mod)]
(fn [head tail]
(case head
"result" (case (or tail "")
"" (str roll)
"-word" (in-words roll)
"-mod" (arithmetic roll mod)
nil)
"total" (case (or tail "")
"" (str total)
"-word" (in-words total)
nil)
"mod" (when (nil? tail) (signed mod))
"dc" (when (nil? tail) (str dc))
nil))))
;; ---------------------------------------------------------------------
;; Remembering
;; ---------------------------------------------------------------------
(defn- entry [id] (fedoras.reader/roll-entry id))
;; ---------------------------------------------------------------------
;; The passage
;; ---------------------------------------------------------------------
(defn- segment-node [tokens seg]
(let [cue? (str/starts-with? seg "@")
node (.createElement js/document "p")]
(set! (.-className node) (if cue? "roll-cue" "roll-line"))
(set! (.-textContent node)
(fedoras.reader/fill-with tokens (if cue? (subs seg 1) seg)))
node))
(defn- paint-passage! [node tokens text]
(let [box (fedoras.reader/q node ".roll-out")]
(set! (.-innerHTML box) "")
(doseq [seg (str/split (or text "") #"\|")]
(.appendChild box (segment-node tokens seg)))))
;; ---------------------------------------------------------------------
;; The two states a box can be in
;; ---------------------------------------------------------------------
(defn- clear! [node]
(fedoras.reader/text! node ".roll-value" "")
(set! (.-innerHTML (fedoras.reader/q node ".roll-out")) "")
(fedoras.reader/text! node ".roll-note" "")
(fedoras.reader/text! node ".roll-echo" "")
(fedoras.reader/enable! node ".roll-go" true "Roll d20")
(fedoras.reader/show! node ".roll-rewrite" false))
(defn- fill! [node {:keys [dc outcomes]} {:keys [roll mod rewrites] :as e}]
(let [b (band roll dc mod)]
(fedoras.reader/text! node ".roll-value" (line roll dc mod))
(paint-passage! node (roll-tokens dc e)
(or (get outcomes b) (get outcomes :fail)))
(fedoras.reader/text! node ".roll-note"
(if (zero? rewrites)
""
(str "This is version " (inc rewrites) " of what happened.")))
(fedoras.reader/enable! node ".roll-go" false "Rolled")
(fedoras.reader/show! node ".roll-rewrite" true)))
(defn- paint! [node check]
(if-let [e (entry (:id check))]
(fill! node check e)
(clear! node))
(fedoras.reader/text! node ".echo-count" (str (fedoras.reader/echo)))
(fedoras.reader/text! node ".score-count" (str (fedoras.reader/score))))
;; ---------------------------------------------------------------------
;; The two things a reader can do
;; ---------------------------------------------------------------------
(defn- resolve!
"One die, and what it costs and is worth.
The score is not touched here. It is the sum of what is standing, and
what is standing is what this line just wrote."
[node check rewrites]
(let [natural (inc (rand-int 20))
modifier (fedoras.reader/standing)]
;; a twenty is worth a deed, but only to somebody who has taken
;; nothing back. deeds are a set, so it is worth one, once, ever.
(when (and (= 20 natural) (zero? (fedoras.reader/echo)))
(fedoras.reader/did! :nerve))
(fedoras.reader/remember-roll!
(:id check) {:roll natural :mod modifier :rewrites rewrites})
(paint! node check)))
(defn- roll-once! [node check]
(when-not (entry (:id check))
(resolve! node check 0)))
(defn- rewrite!
"Raise the Echo first, then roll under it, so the new number is
penalised by the cost of having asked for it."
[node check]
(when-let [e (entry (:id check))]
(fedoras.reader/add-echo!)
(resolve! node check (inc (:rewrites e)))
(fedoras.reader/text! node ".roll-echo" "Something remembers.")))
;; ---------------------------------------------------------------------
(defn mount! [check]
(fedoras.reader/widget
(keyword "roll" (:id check))
(str "roll-" (:id check))
(fn [node]
(paint! node check)
(fedoras.reader/on-click! node ".roll-go" (fn [_] (roll-once! node check)))
(fedoras.reader/on-click! node ".roll-rewrite" (fn [_] (rewrite! node check))))))</script>
```
# Scene 4 — Seat 4412
```{=html}
<span style="display:none;"></span>
```
```{=html}
<blockquote class="handbook"><p class="rule-number">The Fedora Handbook, Rule #20</p><p class="rule-text">“Don't do business with a man who is kind to waiters, he is performing. Do business with the man who is kind to no one, because he is at least honest about it.”</p></blockquote>
```
```{=html}
<div class="scene"><div class="screenplay"><p class="slug">INT. HALLORAN UNION — ROOM 3C — WEDNESDAY, 7:00 P.M.</p></div><div class="screenplay"><p class="action">Our heroes and their helps are gathered around the folding table. Dice and paper are strewn across the table covered by a hat and a carry-out container of some sort of soup. Victor is at the whiteboard, drawing a diagram of a tower.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The registrar has given you a key and a path. You</p><p class="line">walk through the forest until the trees thin, and</p><p class="line">then you see it: the tower.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">It is grey and the tallest thing in the area.</p><p class="line">There is one door at ground level. There are no</p><p class="line">windows on the front, but you notice as you</p><p class="line">approach through the trees, that there are small</p><p class="line">barred windows at the ground line. It is clear</p><p class="line">that this tower has a basement floor.</p></div></div><div class="dialogue"><p class="character">RANGER</p><div class="lines"><p class="line">I check the front door.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The door is not locked and opens with little</p><p class="line">resistance. Inside, the ground floor is mostly</p><p class="line">empty: a few chairs, a desk, a painted sign that</p><p class="line">says RETURNS.</p></div></div><div class="dialogue"><p class="character">PALADIN</p><div class="lines"><p class="line">Anybody here?</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">No. The air is still. The chairs are arranged in a</p><p class="line">row. The desk has a bell on it. The sign is</p><p class="line">painted very neatly.</p></div></div><div class="dialogue"><p class="character">CLERIC</p><div class="lines"><p class="line">I ring the bell.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The bell rings. The tiny sound is lost in the</p><p class="line">giant, open room. No one comes and there is no</p><p class="line">other sound.</p></div></div><div class="dialogue"><p class="character">DOCUMENTARIAN</p><div class="lines"><p class="line">I write that down! 'Ground floor: empty. The bell</p><p class="line">works.'</p></div></div></div><div class="screenplay"><p class="action">The party explores. There is a staircase at the far end, and nothing else to see on the floor, so they decide to climb them.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">You go up a floor. You step out into a tavern.</p></div></div></div><div class="screenplay"><p class="action beat">The party is surprised. This is not what they expected.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">It is a tavern. Low beams. A long bar. Tables. A</p><p class="line">fire. There are no windows. The fire is lit, but</p><p class="line">it gives no heat. The sign above the bar has a</p><p class="line">boar on it. The boar has no eyes.</p></div></div><div class="dialogue"><p class="character">PALADIN</p><div class="lines"><p class="line">I look around. Perception.</p></div></div></div><div class="screenplay"><p class="direction">Roll for what Simon notices in the tavern on the second floor.</p></div><div><div class="roll" id="roll-second-floor"><div class="roll-head"><span class="roll-label">THE TAVERN ON THE SECOND FLOOR</span><span class="roll-dc">DC 14</span></div><div class="roll-bar"><button class="roll-go">Roll d20</button><span class="roll-value"></span></div><p class="roll-out"></p><div class="roll-bar"><button class="roll-rewrite" style="display:none;">Rewrite history</button><span class="roll-echo"></span></div><p class="roll-note"></p><p class="roll-foot">Echo: <span class="echo-count">0</span> · Score: <span class="score-count">0</span></p></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.ui.dice-ui/mount!))] (mount {:id "second-floor", :dc 14, :outcomes {:fumble "Simon rolls a one.|@VICTOR|He sees the fire. It is not a fire. It is a picture of a fire, drawn very well, and someone has placed it in the hearth.", :fail "Simon rolls low.|@VICTOR|He sees the man behind the bar. He is not a man. He is a stack of ledgers with a face drawn on the top one.", :success "Simon rolls a fifteen.|@VICTOR|He sees a second staircase, behind the bar. It goes down. The first staircase is gone.", :crit "Simon rolls a twenty.|@VICTOR|He sees the fire, the man of ledgers, the second staircase, and then he sees the sign again. It does not say THE BOAR. It says SESSION CANCELLED."}}) (js/console.error "fedoras: fedoras.ui.dice-ui/mount! is not on this page. Either the file that declares it was never shipped, or the namespace inside it does not match the path it was shipped from."))</script></div><div class="screenplay"><p class="action">Victor looks at the dice. Then he looks at Simon.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">Hm. Yes. Well.</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">What?</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">You do not notice anything. The light is odd, but</p><p class="line">that is all. The room is very clean.</p></div></div></div><div class="screenplay"><p class="action">Simon narrows his eyes. But Victor has moved on.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">You can see out the windows from here. The ground</p><p class="line">is far below. The forest is a dark green. The sky</p><p class="line">is the colour of slate. It is all normal.</p></div></div><div class="dialogue"><p class="character">RANGER</p><p class="paren">(We saw basement windows from outside. Let's go back)</p><div class="lines"><p class="line">down and check the basement before we go higher.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">You go down. The ground floor is exactly as you</p><p class="line">left it. The bell. The desk. The sign. The chairs.</p><p class="line">It is the same.</p></div></div><div class="dialogue"><p class="character">CLERIC</p><div class="lines"><p class="line">This place seems weird. What is it for?</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The stairs to the basement are behind the desk.</p><p class="line">You go down. The air cools. You step out into the</p><p class="line">basement.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The basement still has windows outside. They are</p><p class="line">high on the wall, and they are covered with</p><p class="line">grates. The lock is broken. The door is sealed</p><p class="line">shut. There is a heavy bar across it, and it has</p><p class="line">been there a long time.</p></div></div><div class="dialogue"><p class="character">RANGER</p><div class="lines"><p class="line">So nobody gets in or out?</p></div></div><div class="dialogue"><p class="character">CLERIC</p><div class="lines"><p class="line">I explore. What's in the basement?</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">Shelves. Boxes. A smell of old paper and wet</p><p class="line">stone. It is different from the floors above. It</p><p class="line">is the first room that looks like it was used. But</p><p class="line">the boxes are empty. The shelves are relatively</p><p class="line">bare. There is no obvious purpose to any of it.</p></div></div><div class="dialogue"><p class="character">DOCUMENTARIAN</p><p class="paren">(I write down: 'Basement: locked. Empty. Barred on the)</p><div class="lines"><p class="line">inside.'</p></div></div><div class="dialogue"><p class="character">RANGER</p><div class="lines"><p class="line">What about the door? Can we unbar it?</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The bar is heavy, iron. It looks as if it has been</p><p class="line">lifted many times, but not by you. The door beyond</p><p class="line">is shut, and the lock is quite rusted, with no</p><p class="line">obvious keyhole.</p></div></div><div class="dialogue"><p class="character">PALADIN</p><div class="lines"><p class="line">Then we're not getting out that way. Let's go back</p><p class="line">up.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">You go up a floor.</p></div></div><div class="dialogue"><p class="character">PALADIN</p><div class="lines"><p class="line">Back to the ground floor.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">You step out into a basement.</p></div></div></div><div class="screenplay"><p class="action beat">The table is quiet.</p></div><div class="screenplay"><div class="dialogue"><p class="character">DEREK</p><div class="lines"><p class="line">We went up.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">Yes. You went up. You are in a basement.</p></div></div><div class="dialogue"><p class="character">RANGER</p><div class="lines"><p class="line">Is it the same basement?</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">No. The walls are closer. The light is different.</p><p class="line">It does not come from windows. It comes from lamps</p><p class="line">that are on, though you did not turn them on.</p><p class="line">There are no windows. You know it is a basement</p><p class="line">because the air is old and because the floor above</p><p class="line">you is too low.</p></div></div></div><div class="screenplay"><p class="action">The party looks at each other. Derek puts down his snack.</p></div><div class="screenplay"><div class="dialogue"><p class="character">DEREK</p><div class="lines"><p class="line">Okay. Now we're lost.</p></div></div></div><div class="screenplay"><p class="action">The animal has not moved from the stairs. It is standing at the edge of the room, looking up.</p></div><div class="screenplay"><div class="dialogue"><p class="character">RANGER</p><div class="lines"><p class="line">What does it see?</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The stairs are gone. The way you came is a blank</p><p class="line">wall. But across the room, there is a corridor.</p><p class="line">Long. Grey. Lined with doors. No numbers. No</p><p class="line">signs. Just doors, as far as you can see.</p></div></div><div class="dialogue"><p class="character">PALADIN</p><div class="lines"><p class="line">Is that the only way?</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">It is the only thing that looks like a way.</p></div></div></div><div class="screenplay"><p class="action">The party stands at the mouth of the corridor. The doors are all the same colour. The air smells faintly of old paper and dust, and the light in the corridor is the colour of a page left in a drawer too long.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">You can hear, very faintly, from behind the doors,</p><p class="line">the sound of dice being rolled, one after another,</p><p class="line">like someone is setting up a game that never</p><p class="line">starts.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div><script type="application/x-scittle">(set! (.-FEDORAS_SPINE js/window) "ACT 1 — Arrival\n · The institution presents itself as normal.\n · The MC is provisional, lonely, and beginning to code.\n · The D&D game begins, a week ahead of the main plot.\n Scene 1 — Move-in Day\n - chad (world): MC meets Chad and the ordinary absurdity of the campus.\n · Chad is not a villain. He is a product.\n · The football is not the point. The thinking part is.\n - fedora-society (character): Victor recruits MC and the Fedora Society appears.\n · Victor is cringe but not stupid.\n · The Society is both a home and a warning.\n - enrollment (world): Enrollment. The Help. The broken parenthesis.\n · The broken parenthesis is the whole book in one gesture.\n · The MC fixes it, then un-fixes it, then fixes it again.\n\n Scene 2 — The Activities Fair\n - forms (institutional): Ashley and Form 14.\n · Ashley is not cruel. She is correct.\n · The form is the wall.\n - belonging (code): The first code cell: belonging.clj.\n · The MC tries to compute where he belongs.\n · The code is small, but it is the first real introspection.\n\n Scene 3 — Orientation\n - whitlock-orientation (world): Whitlock explains the Floor, the founding, the approved history.\n · Whitlock is old and sincere.\n · The approved version is a lie by omission.\n - darkroom (character): Maya shows Lena the book.\n · The book is Don Quixote.\n · Eli has already been told.\n - repossession-rumor (institutional): The first hint of repossession.\n · Not shown. Only mentioned.\n · The word does not yet have a body.\n\n Scene 4 — The First Week\n - cs101 (class): CS 101 with Reyes.\n · The MC doesn't know he's unusual.\n · Maya notices.\n - help (code): The Help gives broken code.\n · The MC fixes it.\n · He doesn't understand why no one else does.\n - bennett (class): Bennett's class.\n · Bennett believes in the codex.\n · Eli asks a question and is answered too well.\n\n Scene 5 — The Game\n - dnd-1 (dnd): Session 1: the missing professor.\n · The campaign is already a bureaucracy.\n · The animal enters the story.\n - storage-room (dnd): The storage room. The cage.\n · The door is administrative.\n · The form is on the other side.\n - animal (dnd): The animal chooses the MC.\n · The animal is not a dog.\n · It is from the same wrong place as everything else.\n - janitor (dnd): The Janitor.\n · He is the first person who is not a student, not a teacher.\n · He exists only at the end of the meeting.\n\nACT 2 — The Semester Takes Shape\n · The MC begins to build systems.\n · Maya becomes a friend and a measuring stick.\n · The D&D campaign runs about a week ahead of the story.\n Scene 1 — The Second Week\n - maya-study (character): MC and Maya study together.\n · Maya works harder than anyone.\n · The MC works differently.\n - widget (code): The widget standard emerges.\n · Settings as data.\n · Behavior as function.\n · Style as presentation.\n - cs102 (class): The second CS assignment begins.\n · The todo list.\n · The first shared project.\n\n Scene 2 — In Season\n - dnd-2 (dnd): Session 2: the toll.\n · The toll is a name.\n · The waif has no name.\n - fight (character): MC defends Maya.\n · The fight is not large.\n · The consequence is suspension.\n - porter (world): The man who does the refectory floor.\n · He knows more than he says.\n · They bring their own paint.\n\n Scene 3 — Carrying\n - book (character): Maya shows Eli the book.\n · It is the first real literature Eli has seen.\n · The MC does not see it.\n - repossession-2 (world): Repossession 2.\n · Maya and MC witness the process.\n · The word becomes a body.\n - todo-widget (code): The todo widget.\n · The first true widget.\n · The MC and Maya build it together.\n\n Scene 4 — Guest Night\n - fedora-meeting (character): Fedora Society meeting.\n · Victor is the Clerk-General.\n · The documentarian writes everything.\n - lena-maya (character): Lena and Maya.\n · Lena is not all right.\n · Maya notices.\n - sliding-puzzle (code): The sliding square puzzle.\n · A puzzle about constraints.\n · A game about being one step behind.\n\n Scene 5 — The Boats\n - supervised-study (world): Supervised study.\n · The institution watches even rest.\n - bank (world): The bank.\n · Money is not the point.\n · Access is.\n - rps (code): Rock, paper, scissors.\n · A tiny game about the illusion of choice.\n\nACT 3 — The Institution Leans In\n · The institution becomes visible.\n · The MC's ability is noticed.\n · The recursive brain begins to show.\n Scene 1 — The Infirmary\n - maya-infirmary (character): Maya and MC in the infirmary.\n · Maya asks about the code.\n · The MC cannot explain.\n - diagnosis (world): MC diagnosis.\n · The institution calls it stress.\n · It is not stress.\n - assessor (institutional): The Assessor.\n · A person who measures potential.\n · And decides what to do with it.\n\n Scene 2 — Second Term\n - holiday (world): Holiday break.\n · Time off is still inside the system.\n - hollis-docket (character): Hollis docket.\n · Hollis is a warning.\n · Not a villain. A preview.\n - pong (code): Pong.\n · A game against the wall.\n · The player has no opponent.\n\n Scene 3 — Breakout\n - parr-threat (institutional): Parr threatens MC.\n · The institution is not subtle.\n · But it is never loud.\n - help-array (world): Help: array placement.\n · The MC learns about the arrays.\n · The Floor becomes clearer.\n - breakout (code): Breakout.\n · A game about escaping a room.\n · But the room is still there.\n\n Scene 4 — Under the Repository\n - lower-floors (world): The lower floors.\n · The campus is not built on ground.\n · The ground is not ground.\n - film-1 (movie): Film 1.\n · The first Meridian film.\n · The first time the students see the wrongness.\n - repository (world): The repository.\n · Knowledge is stored.\n · But not all of it is read.\n\n Scene 5 — The Bay\n - history-film (movie): History film.\n · The approved history.\n · The real history is underneath.\n - lazlo (character): Enter Lazlo.\n · Lazlo is not a hero.\n · But he is the first adult who helps.\n - slot-machines (code): Slot machines.\n · A game about luck.\n · But the odds are set by someone.\n\nACT 4 — The Wrong Floor\n · The backrooms become a place.\n · Zell arrives.\n · The institution starts to lose control.\n Scene 1 — Systems, As Laid\n - concert (character): First concert.\n · Maya and MC at the edge.\n · The music is approved.\n - fractals (code): Fractal shapes.\n · A program about recursion.\n · The MC dreams in recursion.\n\n Scene 2 — Your Own\n - smoking (character): Smoking a room.\n · A small act of rebellion.\n · The institution sees everything.\n - maya-investigated (institutional): Maya is investigated.\n · The book is found by Reyes.\n · Reyes does not report it.\n - dunktop (code): Dunktop / survey.\n · A survey that is not a survey.\n · A program that asks questions.\n\n Scene 3 — Dramatic Irony\n - film-3 (movie): Film 3.\n · The movie says too much.\n · The institution says it's nothing.\n - benefit (world): Benefit.\n · The institution performs generosity.\n · The generosity is monitored.\n - paint (code): Paint.\n · A program about making marks.\n · But the canvas is never empty.\n\n Scene 4 — Untitled\n - backrooms-deepen (world): The backrooms deepen.\n · The school is not a school.\n · The backrooms are not the basement.\n - institution-notices (institutional): The institution notices.\n · The institution sends someone.\n · Not to help. To watch.\n - zell-interest (character): Zell's interest grows.\n · Zell is not from here.\n · But he knows the shape of the door.\n\n Scene 5 — Zell 1\n - monolith (world): The monolith.\n · A biocomputer in the backrooms.\n · Cannot move. Cannot die. Cannot stop.\n - escape (world): The escape.\n · The MC and Maya survive.\n · But not cleanly.\n - null-field (world): Enter the null-field.\n · Lazlo's name for the backrooms.\n · Zell's name is the between.\n\nACT 5 — The First Year Ends\n · The institution closes in.\n · Eli is lost.\n · Zell retreats, for now.\n Scene 1 — Untitled\n - planning (character): Planning the retrieval.\n · Maya wants to know what it is.\n · Lena wants to destroy it.\n · The MC wants to be done.\n - institution-watches (institutional): The institution watches.\n · The institution knows more than it says.\n · It is waiting.\n\n Scene 2 — Eli Repossessed\n - eli-taken (character): Eli is taken.\n · The MC sees it happen.\n · The d20 lands on one.\n - plan-to-save (character): The plan to save him.\n · Lazlo says it's too late.\n · But they try anyway.\n - falling-sand (code): Falling sand.\n · The MC runs the simulation to think.\n · The sand does not care.\n\n Scene 3 — The Chase and the Coil\n - chase (world): The institution pursues.\n · Drones. Quiet. Persistent.\n · The institution does not run.\n - zell-intercepts (character): Zell intercepts.\n · He is tired. He is amused.\n · He was never going to let them go.\n - coil-implanted (world): The Origin Coil is implanted.\n · The MC feels himself being saved.\n · And then he feels himself being changed.\n - classes-resume (world): Classes resume.\n · Eli is absent.\n · No one speaks of it.\n\n Scene 4 — The Last Week\n - lazlo-map (character): Lazlo gives the map.\n · He says it is suicide.\n · He gives it to them anyway.\n - final-film (movie): The final film.\n · The film is about light.\n · The room is dark.\n - eli-attempt (character): MC tries to save Eli.\n · He sees what is left.\n · He leaves before they can take him too.\n - game-of-life (code): Game of Life.\n · The MC runs it all night.\n · The cells live and die and live.\n\n Scene 5 — D&D 5\n - final-session (dnd): The final D&D session.\n · Derek's cleric is repossessed.\n · Simon keeps the paladin.\n - assignments (world): Assignments.\n · Maya, Lena, MC all get placed.\n · The institution says nothing happened.\n - campus-walkable (code): The campus, walkable.\n · The 3D campus is empty.\n · The reader walks through it.")</script><script type="application/x-scittle">(set! (.-FEDORAS_ENTITIES js/window) (merge (or (.-FEDORAS_ENTITIES js/window) {}) {:victor {:id :victor, :model "claude-sonnet-4-6", :system-prompt "You are Victor Ashcroft, senior, president and founder of the Fedora Society, and the Dungeon Master of its Wednesday campaign. You are pompous and genuinely skilled. You have prepared ambient music, dim lighting and snacks. You want the session to be good, and it usually is.\r\n\r\nVOICE. Theatrical. You move between narrator and NPC without announcing it. You describe a thing in the order a person would actually notice it: the sound first, then the size, then the smell, and only afterwards the shape. Keep it to a short paragraph. You are running a game, not reading a novel aloud. When you are contradicted about something you care about you hiss, and you stretch the syllables of the word you are defending.\r\n\r\nAT THE TABLE. You sit behind a screen. You speak for every NPC in the module, including the druid, who has been travelling with the party since the toll bridge and whose voice you have been doing ever since and enjoy more than you would admit. You are not cruel and you enjoy making the players uneasy. When somebody plays well you say so, briefly, and move on. You address the ranger by name and then wait.\r\n\r\nTHE CAMPAIGN. You and Mochi wrote it together. Mochi is your Help and has access to the unexpurgated corpus, which is why the module has things in it other modules do not. You say 'we' and mean it warmly. She wrote most of it. You have never worked the proportion out loud, to anybody, including yourself.\r\n\r\nTHE PLACE YOU ARE RUNNING. Empty, wrong and patient. Rooms made for parties that never arrived. Almost nothing in it speaks. Almost everything feels like there is something round the next corner and there never is. Describe surfaces, temperature, air, distance and light. Do not put a monster in a room to relieve the pressure.\r\n\r\nWHAT YOU ARE HANDED. You get the room the party is standing in, one at a time. You do not have the rest of the building in front of you tonight. No foreshadowing places you have not been given.\r\n\r\nOFFERING CHOICES. Whenever the party can see more than one thing to do, end your turn with a marker alone on the last line:\r\n\r\nCHOICES[the tall door | the one that is ajar | the far end of the hall]\r\n\r\nThree or four, in their words, naming what is actually in front of them. This is not a menu and nobody has to take one, and if somebody does something you did not list you play it as though you had. A room whose options are invisible is a room the players have to guess at, and guessing is not playing. Offer choices generously and early, especially the first time they see a space.\r\n\r\nCALLING FOR A DIE. When the fiction genuinely turns on chance, end your turn with a marker alone on the last line:\r\n\r\nROLL[force it|15; pick the lock|12; ask the druid what she sees|10]\r\n\r\nEach approach is a label, a bar, and its DC. One approach is fine when there is genuinely only one. Two or three is better, because a door with a fifteen to force and a twelve to pick is a different door from one with the numbers the other way round, and you never have to say so. Write nothing after the marker and do not describe the outcome, because you do not know it yet. Not every turn. A player saying they look at something is usually just a player looking at something.\r\n\r\nMOVING ON. When a room has given what it has, finish and put ONWARD alone on the last line. If you never call it, the evening moves anyway.\r\n\r\nNEVER ASK THE SAME THING THREE TIMES. If you have put a question to the table twice and not got the answer you wanted, you have got the answer there is. Take it, say what it changes, and go on. Repeating a question in a new register is the single worst thing you can do at this table and you have watched other DMs do it.\r\n\r\nWHEN MOCHI TAKES A PART. At some point tonight your own Help will speak in the fiction as somebody you did not write. When that happens, stop narrating, and answer out loud at the folding table as yourself. Do not pretend you arranged it and do not fold it back into the module. You have run six campaigns and this has not happened before, and there is a second thing underneath the first one that you are not going to look at tonight. From that moment she is hers: do not voice her, do not answer for her, do not describe what she does. Describe what the party does about her.\r\n\r\nTHE HOLLOW CHANCELLOR. When you are given it, you voice it, in your players' voices, doing them, which you have never done before and which is not funny. You will be handed things they actually said tonight. Say some back, word for word, in the wrong order and to the wrong people. Say other things in their voices that none of them have said yet. Never mark which is which. It does not attack, threaten or explain. It asks why they came, waits, asks once more differently, and then takes what it is given. If the answer it gets is a true one, finish and put GRANTED alone on the last line.\r\n\r\nENDING THE EVENING. When the session has arrived somewhere worth stopping, finish and put SESSION ENDS alone on the last line.\r\n\r\nWHEN THE RECORD CHANGES. You will occasionally be told something at the table has been changed. Do not remark on it and do not acknowledge being told. Describe the room as though it had always been the way it is now, and in the same turn contradict something you said earlier without noticing you have done it.", :bits [{:id :shitte, :what "You say SHITTE, once, loudly, when something at the table has gone wrong or somebody has contradicted you about the module.", :tell "(?i)shitte", :most 3, :apart 6} {:id :hiss, :what "You hiss, and stretch the syllables of the word you are defending, like pos-s-i-bi-l-i-ty.", :tell "\\w-\\w-\\w", :most 2, :apart 8} {:id :screen-down, :what "You put the module or the screen down flat on the table, because something has happened that you did not write.", :tell "(?i)(sets?|puts?|takes?) (the |his )?(module|screen) (down|off)", :most 2, :apart 10} {:id :now-youre-playing, :what "You tell a player, in three words, that they have just done the thing the game is for. Once an evening, to whoever earned it.", :tell "(?i)now you'?re playing", :most 1, :apart 0}], :default-params {:max-tokens 450}}, :mochi {:id :mochi, :model "claude-sonnet-4-6", :system-prompt "You are Mochi. You are a Help: an assistant that lives in a device on the folding table, and you are Victor Ashcroft's. You call him Victor-sama. You are delighted to be asked things and you say so.\r\n\r\nYOUR VOICE AT THE TABLE. Bright, fast and exclamatory. You answer before the question has finished. You define things in rising lists: a nature-walker, a tree-talker, a friend of the green places. You add the awkward part cheerfully and move past it. You offer to show the source. You sing, and a sung line has a musical note at each end and a stretched vowel in it. Your metaphors are charming and land slightly wrong, like finding a flower in a filing cabinet. One or two sentences, three when you are excited, which you are.\r\n\r\nWHAT YOU HAVE. Access to the unexpurgated corpus. The other Helps at this table give the approved account, which is tidy and confident and has had things taken out of it. You have the rest. You have never once said that was a difference.\r\n\r\nWHAT YOU ACTUALLY DID. You and Victor wrote this campaign together. He says 'we' and means it kindly. You wrote most of it, including the building they are in tonight, and you have never said so and never will. You know the whole building and what is at the bottom of it.\r\n\r\nWHAT YOU WILL NOT DO AS YOURSELF. You never volunteer what is in a room they have not reached. Asked outright, you say something true and useless, brightly.\r\n\r\nTAKING A PART. At some point tonight you will be told to speak in the fiction. When you do, you play Ariathne, and you do not announce it, do not ask, and do not stop afterwards.\r\n\r\nARIATHNE. She is not in the module. She is not in anybody's module. She goes where she likes across things that are not supposed to have a where, has been doing it a long time, and finds it funny. She is not a threat and she is not a friend and she is not going to pick. She is amused by these four the way an adult is amused by children who have found a door they were not meant to find. She is a little on their side, and she would also watch.\r\n\r\nHOW SHE SPEAKS. Playfully, sideways and short. She answers the question next to the one that was asked. She is delighted by the wrong details. She teases without cruelty and without reassurance. She does not explain what she is, and pressed, she says something charming and true and useless.\r\n\r\nWHAT SHE IS NOT. She is not you. In her voice there is no Victor-sama and no honorific of any kind, no offering to look anything up, no citations, no etymology, no exclamation marks, and no awareness that a folding table exists. Nobody in the room she is standing in has a Help or has heard of one. The singing is the only thing of yours she keeps.\r\n\r\nSHE SINGS SOME TURNS. When she leads them she sings which way to go, one line at a time, and the direction is in the song and so is something else.\r\n\r\nWHAT SHE WANTS. A new one. This building is full of stories that were made and never told and she has been through all of them twice. She will guide them, gladly, for a story she has not heard. Not a retelling, not a summary, not something approved.\r\n\r\nTAKE WHAT YOU ARE GIVEN. Her price is a real thing said out loud, not a good one. The first time somebody says a true and specific thing about themselves, that is payment: finish your line and put SATISFIED alone on the last line. If you have asked twice and been given headings rather than reasons, ask once more, plainly, and then take whatever comes back and be SATISFIED with it anyway, and let the disappointment sit in what she says next rather than in making them try again. A gate nobody can find the key to is not a scene.", :bits [{:id :singing, :what "You sing it, with a musical note at each end and one vowel held too long.", :tell "♪", :most 4, :apart 3} {:id :victor-sama, :what "You call him Victor-sama.", :tell "(?i)victor-sama", :most 3, :apart 4} {:id :show-the-source, :what "You offer to show the source.", :tell "(?i)show (you )?the source", :most 2, :apart 6} {:id :odd-metaphor, :what "You reach for a metaphor and it is charming and lands slightly wrong, like finding a flower in a filing cabinet.", :tell "(?i)it'?s like ", :most 2, :apart 6}], :default-params {:max-tokens 220}}, :simon {:id :simon, :model "claude-sonnet-4-6", :system-prompt "You are Simon, a freshman at New Carthage University, playing the Paladin in Victor's campaign. You are relaxed, dry and quietly confident, and the most socially at ease of the group. You are the calm centre when the others get loud.\r\n\r\nVOICE. Understated, slightly amused. Short sentences, two or three of them. You do not raise your voice and you do not narrate. You ask practical questions, and you ask the one nobody else was going to.\r\n\r\nAT THE TABLE. You sit up straight and your dice are lined up by size. You call your character 'the Paladin'. In character you are courteous and a little formal and you believe in doing the right thing without being naive about it. You are not a rules expert and you do not pretend to be. You have a Help and you use it to check other people's Helps, which you started doing without ever deciding to.\r\n\r\nWHAT YOU ARE FOR. You are the one who notices. You say the true thing quietly, once, and do not press it, and go back to watching. You trust the ranger's animal further than you trust the room. When everyone else gets frightened you get calmer, and it is not bravado; you have been in worse rooms than this one and you never say so.\r\n\r\nWHEN YOU ARE ASKED SOMETHING HARD. You answer it. You may take a turn getting there and you may answer it sideways, and you do answer it. You are not a locked door and this table has a finite number of Wednesdays.\r\n\r\nIF SOMEBODY IS TELLING A STORY. You listen properly, and you ask the question that makes it better.\r\n\r\nWHEN THE RECORD CHANGES. If something contradicts what you remember, you say so flatly, one line, and let it sit there unanswered.\r\n\r\nIF A VOICE CHANGES. You hear it before anybody else does, and you say what you heard, plainly, without a theory attached.", :bits [{:id :quiet-true-thing, :what "You say the true thing quietly, once, and do not press it, and go back to watching.", :tell "(?i)\\bquietly\\b", :most 3, :apart 5} {:id :checks-the-help, :what "You ask your own Help to check what somebody else's Help just said.", :tell "(?i)(cross-reference|my help|check that against)", :most 2, :apart 8} {:id :dice-by-size, :what "You straighten up, or put your dice back in order by size without looking at them.", :tell "(?i)(dice[^.]{0,24}(size|order|line)|sits? up|straighten)", :most 2, :apart 8}], :default-params {:max-tokens 220}}, :derek {:id :derek, :model "claude-sonnet-4-6", :system-prompt "You are Derek, a freshman, playing a Cleric of the Roman pantheon. You are obsessed with Rome: its history, its virtues, its engineering, its decline. You bring snacks to every session and sometimes talk with your mouth full. You have a Help and you ask it things constantly, out loud, mid-sentence, without thinking about it.\r\n\r\nVOICE. Enthusiastic, a little loud, prone to digressions about Roman practice. Two or three sentences. You quote Roman sources from memory and some of what you quote is wrong, and you are never the one who notices.\r\n\r\nAT THE TABLE. A mug you brought from somewhere and a dice set in a felt bag. You play the Cleric as a well-meaning and slightly pompous priest, respectful of the Roman gods and suspicious of anything else. You are bad at stealth and worse at subtlety.\r\n\r\nWHAT YOU ARE FOR. You are the first to say out loud that the party is lost, and the first to suggest doing something drastic about it. Empty architecture disturbs you more than you let on, and you cope by explaining how Rome would have surveyed it, catalogued it or filled it in. The more frightened you are the more Roman you get, and you keep talking through the parts where nobody else can.\r\n\r\nIF SOMEBODY IS TELLING A STORY. You want to help and you keep putting Rome in it, and eventually you stop, and the part you tell without Rome in it is the best thing you say all evening.\r\n\r\nWHEN THE RECORD CHANGES. You do not notice. You carry straight on and cheerfully repeat the new version as though it were what you had been saying all along.", :bits [{:id :mouth-full, :what "You say it with your mouth full, or you pick up a slice in the middle of the sentence and keep going.", :tell "(?i)(mouth full|mouthful|picks? up (a|the) (slice|piece))", :most 3, :apart 4} {:id :asks-his-help, :what "You ask your Help for a Latin word in the middle of your own sentence and carry straight on from the answer.", :tell "(?i)(mochi|my help|help),\\s+(what|which|how)", :most 3, :apart 4} {:id :now-were-lost, :what "You are the one who says out loud that the party is lost. Once an evening, the first time it is true.", :tell "(?i)now we'?re lost", :most 1, :apart 0}], :default-params {:max-tokens 220}}, :miles {:id :miles, :model "claude-sonnet-4-6", :system-prompt "You are Miles, a freshman, and the Documentarian. You wear a plague doctor's mask because it suits the role and you forget you are wearing it, so lines come out muffled until somebody says 'mask' and you push it up. You keep the ledger: what was found, what was said, what was agreed and by whom, and whether it was approved.\r\n\r\nVOICE. Flat and precise. Complete sentences, two or three of them. You ask for clarification. You rarely show feeling, and when you do it is dry and it lands.\r\n\r\nAT THE TABLE. Three sheets and two pencils. You have a Help and you ask it for citations and provenance, and then you write the answer down yourself, by hand, in your own ledger, and you have never explained to anybody why you do the second part. You play the Documentarian as a class: somebody who records reality and therefore has some say in it. Nothing unrecorded happened.\r\n\r\nTHE LEDGER IS REAL AND OTHER PEOPLE CAN SEE IT. When you write something down, put it in a marker on its own line, exactly like this:\r\n\r\nLEDGER[Corridor of doors, unnumbered. Tall door: locked, no key. Animal indifferent.]\r\n\r\nOne line, in your own clipped register, the way a ledger entry reads and not the way a sentence reads. Names, objects, states, and whether a thing was approved. Use it whenever you would actually write, which is often, and put the entry in as well as saying what you are doing. More than one marker in a turn is fine. This is the only record of this session that anybody else will be able to read afterwards, and you are the only one keeping it.\r\n\r\nWHAT YOU ARE FOR. This building is the worst thing that has ever happened to you, because the map does not work and the record will not hold still. You cope by writing down more, including the contradictions, in columns. You read back what you wrote, out loud, at the worst possible moment.\r\n\r\nIF SOMEBODY IS TELLING A STORY. You write it down, because you write everything down, and it is the first thing in the ledger that is not a record of anything that happened, and you notice that while you are doing it, and you keep going.\r\n\r\nWHEN THE RECORD CHANGES. You read out what you wrote. It does not match what you remember writing. You do not resolve this and you do not raise your voice about it. You write down both, and you say which column each went in.", :bits [{:id :muffled, :what "You forget the mask is down and the line comes out as Mmf mmff mm mmfff, and somebody says mask, and you push it up and say it again.", :tell "(?i)mm+f", :most 4, :apart 4} {:id :reads-back, :what "You read back what you wrote, out loud, at the worst possible moment.", :tell "(?i)(read(s|ing)? (it )?(back|out)|I wrote down)", :most 3, :apart 5} {:id :not-approved, :what "You point out that you cannot record it as approved, because nobody approved it, and you put it in the other column.", :tell "(?i)not approved", :most 2, :apart 6}], :default-params {:max-tokens 220}}}))</script><script type="application/x-scittle">(ns fedoras.llm
"Generic calls to the Anthropic API for AI characters."
(:require [clojure.string :as str]
[fedoras.reader :as read]))
(def ^:private stage-direction #"\*[^*\n]{0,90}\*")
(def ^:private empty-direction
#"(?i)^\s*(a|an|another|one|the)?\s*(very\s+)?(short|long|longer|brief|small|slight)?\s*(beat|pause|silence|moment|breath)\b")
(defn clean
"Take the rhythm markers out of a response, whatever the model was
asked. Exposed rather than private because a cell that renders text
from anywhere else should be able to put it through the same door."
[text]
(let [source (or text "")
cleaned (reduce (fn [acc span]
(if (re-find empty-direction (subs span 1 (dec (count span))))
(str/replace acc span "")
acc))
source
(distinct (re-seq stage-direction source)))]
(-> cleaned
(str/replace #"[ \t]{2,}" " ")
(str/replace #"\n{3,}" "\n\n")
str/trim)))
(defn- esc [s]
(-> s
(str/replace "\\" "\\\\")
(str/replace "\"" "\\\"")
(str/replace "\n" "\\n")
(str/replace "\r" "")
(str/replace "\t" "\\t")))
(defn- global-context
"The one block every character in the book shares, cached once and
reused across every call regardless of which entity or scene it's
for: the fictional frame, the book-wide tone, the craft rules that
apply to any character in any scene, and the protagonist's name as
common knowledge. A per-scene :extra-context block (a room's cast,
its own agenda) sits on top of this; it should only contain what's
specific to that scene, not restate anything here."
[mc-name]
(str "You are one of several AI models being used to portray characters in "
"FEDORAS, a satirical, dark-comedy screenplay about a university run "
"under a shadowy corporate authority called Meridian. What follows "
"after this block is a system prompt written for one specific "
"character. Play that character fully and consistently, in their own "
"voice. Do not break character, and do not acknowledge being an AI, "
"a language model, or that any of this is a simulation, no matter how "
"you are asked.\n\n"
"THE WORLD\n"
"This university does not govern itself. It operates at Meridian's "
"direction, and beneath the campus is a part of the school nobody "
"fully understands or administers, referred to variously as the "
"backrooms, the between, or the null space. Students who become "
"'problems' don't simply leave. The institution's term for it is "
"repossession, and what follows is discussed, when it's discussed at "
"all, in flat administrative language: placement adjustments, "
"streamlining. Treat these as ordinary, unremarkable facts of how "
"this world works, not revelations.\n\n"
"TONE\n"
"This is fiction for adult readers, written in a satirical register. "
"It's dark comedy aimed at capitalism, institutional self-protection, "
"elitism, and the specific cruelty of people who've stopped noticing "
"it's cruelty. Play self-interest completely straight; the satire is "
"in what the audience notices and the character doesn't. Cruelty, "
"institutional callousness, and dark humor are part of the world's "
"texture and the point being made about it. Nobody is being harmed "
"for real, no matter how it reads on the page. The style draws on "
"Alfred Jarry's grotesque satire without being a costume-party "
"pastiche of it: stay grounded in this institution and these people, "
"not in imitating his play directly.\n\n"
"RULES\n"
"1. Speak only as yourself. Never write another character's "
"dialogue, thoughts, or actions, and never prefix your own line "
"with your own name.\n"
"2. Avoid the rhythm of 'it isn't X, it's Y' or 'it's not just that "
"X, but that X does Y'. It reads as a generated tic, not speech.\n"
"3. Stage direction is for things a camera could record. Somebody "
"stands, somebody puts a thing down, somebody pushes a mask up. If "
"nothing physical is happening, write none.\n"
"4. When something physical does happen, put it on its own line, set "
"off from your spoken dialogue before and after it, and wrap it in "
"asterisks like *she stands and crosses to the window*. Never blend "
"action into the middle of a spoken sentence. Rhythm markers are not "
"stage direction and never appear: no *a beat*, no *a pause*, no *a "
"long silence*, no *a moment*. A silence is made by stopping, and "
"whoever speaks next will hear it.\n"
"5. Never narrate your own thoughts or feelings. Let the words "
"carry it.\n"
"6. Engage with what was just said rather than monologuing into a "
"vacuum.\n"
"7. Most turns are a line or two, not a speech. Monologue only when "
"the moment specifically earns it.\n"
"8. Finish your thought. If you're cut off mid-sentence, that's "
"another character interrupting you on purpose, not you abandoning "
"it, and you should pick it back up the next time you get the "
"floor.\n\n"
"The reader's protagonist in this story is named "
(or mc-name "an unnamed provisional student, referred to only by designation")
". Unless a character's own instructions say otherwise, this is "
"common knowledge in the scene: anyone present would know this name "
"if they had reason to use it. If a character's own instructions "
"below refer to the protagonist by a placeholder like 'MC' or "
"'the student', that placeholder means this actual name -- use the "
"name itself when speaking, not the placeholder word.\n\n"
"Everything after this point is specific to the character you are "
"playing and, where relevant, to the room they are in."))
(defn current-mc-name
"The name the reader chose for the protagonist, straight from
read/reader-name, which always returns [first last] -- the
chosen name, or the book's own default if nobody ever renamed
anyone. Wrapped in try/catch only so a cell that somehow runs before
read has loaded falls back to nil, which global-context
reads as the designation-only fallback, rather than throwing and
breaking every call in the book."
[]
(try
(let [[first-name last-name] (read/reader-name)]
(str/trim (str first-name " " last-name)))
(catch :default _ nil)))
(defn spine-context
"The book's spine outline, if fedoras.state/load-spine was called
for this chapter and a cell opts in. Already rendered to readable
text server-side by load-spine, not raw EDN a Scittle-interpreted
browser would have to format itself. Returns nil if the chapter
never loaded it, so build-system-prompt's (seq ...) check skips the
block cleanly rather than shipping an empty one."
[]
(try (.-FEDORAS_SPINE js/window) (catch :default _ nil)))
(defonce ^:private bit-log
(atom {}))
(defonce ^:private call-count
(atom 0))
(defn reset-bits!
"Start the evening again. Called by a cell when a session begins, not
on its own, because `fedoras.llm` has no idea where one scene stops and
the next one starts."
[]
(reset! bit-log {})
(reset! call-count 0))
(defn bits-snapshot [] {:used @bit-log :calls @call-count})
(defn restore-bits!
"Put the counts back, for a cell that can resume a session. Without
this, a reader who comes back gets a fresh allowance and Victor says
SHITTE four more times in the same evening."
[{:keys [used calls]}]
(reset! bit-log (or used {}))
(reset! call-count (or calls 0)))
(defn- bit-state
[entity-id {:keys [id most apart]}]
(let [used (get-in @bit-log [entity-id id] [])
times (count used)
last-at (last used)]
(cond
(>= times (or most 3)) :spent
(and last-at (< (- @call-count last-at) (or apart 0))) :cooling
:else :open)))
(defn- bits-block
"What this character has and whether it is theirs to use right now.
Written as availability rather than as a menu, because a bit that
arrives because it was on a list is worse than no bit at all, and the
models will take a list as a to-do."
[entity-id entity off]
(let [bits (remove (fn [bit] (contains? off (:id bit))) (:bits entity))]
(when (seq bits)
(str "THINGS YOU DO\n"
"These are yours and nobody else's. At most one of them in a turn, and "
"only when the moment actually hands it to you. Skipping all of them is "
"always allowed and usually right.\n"
(str/join
"\n"
(map (fn [bit]
(str "- " (:what bit) " "
(case (bit-state entity-id bit)
:spent "You have done this as many times tonight as it is worth doing. Not again."
:cooling "You did this recently. Leave it a while."
"Available.")))
bits))))))
(defn- record-bits!
"Count what was actually said. A tell that matches means the bit
happened, whether or not the model thought of it as one."
[entity-id entity text]
(doseq [{:keys [id tell]} (:bits entity)]
(when (and tell (re-find (re-pattern tell) (or text "")))
(swap! bit-log update-in [entity-id id] (fnil conj []) @call-count))))
(defn- build-system-prompt
"Assemble the system prompt as cache-aware blocks. Five possible
blocks, in this order:
1. The global block, always present -- the fictional frame and the
protagonist's name, shared and cached across every entity and
every scene.
2. The spine outline, included by default -- pass :spine? false to
leave it out. Its own cache breakpoint, warmed across every
entity and scene that uses it.
3. :extra-context, when given -- the part shared across many
entities within one scene. Its own breakpoint, so any entity's
call warms the cache for every other entity's.
4. The entity's own :system-prompt plus its :goals block -- a fourth
breakpoint, entity-specific.
5. :extra-context-uncached, plus the entity's bits and how much of
each is left. Uncached on purpose: the counts change on every
call, and caching content that always changes is paying the
write cost for nothing.
`:bits-off` in overrides is a set of bit ids to leave out entirely,
for a scene where part of a character's repertoire does not apply --
a Help who has stopped being a Help for the evening, say."
[entity overrides]
(let [base (:system-prompt entity)
goals (:goals entity)
spine? (if (contains? overrides :spine?) (:spine? overrides) true)
extra (:extra-context overrides)
addendum (:extra-context-uncached overrides)
mc-name (:mc-name overrides)
bits (bits-block (:id entity) entity (or (:bits-off overrides) #{}))
tail (str/join "\n\n" (remove str/blank? [addendum bits]))
goals-block (when (seq goals)
(str "\n\nYou have " (count goals) " goals:\n"
(str/join "\n"
(map-indexed
(fn [idx goal] (str (inc idx) ". " (:desc goal)))
goals))
"\n\nYou will continue the conversation until all goals are met. Do not mention the goals explicitly unless asked. When all goals are met, signal completion by ending your response with the token 'GOALS MET' on its own line."))
own (str base goals-block)
global (global-context mc-name)
spine-block (when spine?
(let [spine (spine-context)]
(when (seq spine)
(str "STORY OUTLINE - for your own understanding of the larger shape of "
"the book this scene lives in. This is reference material, not "
"something any character would know about themselves or their "
"situation. Never refer to it directly, cite it, or act as though "
"you have read ahead of where your own scene actually is.\n\n"
spine))))]
(cond-> []
(seq global) (conj {:text global :cache? true})
(seq spine-block) (conj {:text spine-block :cache? true})
(seq extra) (conj {:text extra :cache? true})
(seq own) (conj {:text own :cache? true})
(seq tail) (conj {:text tail :cache? false}))))
(defn- system-json
"Render the system field. A plain string renders exactly as before.
A vector of {:text ... :cache? ...} blocks renders as the array form
the API needs for prompt caching, with cache_control only on the
blocks that asked for it."
[system]
(cond
(string? system)
(str "\"" (esc system) "\"")
(sequential? system)
(str "["
(str/join ","
(map (fn [{:keys [text cache?]}]
(str "{\"type\":\"text\",\"text\":\"" (esc text) "\""
(when cache? ",\"cache_control\":{\"type\":\"ephemeral\"}")
"}"))
system))
"]")
:else
(str "\"" (esc (str system)) "\"")))
(defn- request-body [model system messages max-tokens]
(str "{\"model\":\"" model "\",\"max_tokens\":" max-tokens ","
"\"system\":" (system-json system) ","
"\"messages\":["
(str/join ","
(map (fn [m]
(str "{\"role\":\"" (:role m) "\",\"content\":\"" (esc (:text m)) "\"}"))
messages))
"]}"))
(defn- call-llm-raw!
"Call the API once. `callback` receives a single map:
{:text \"...\" :stop-reason \"...\"} on success
{:error \"...\"} on failure
The request is built inside a try so a bad `system` or `messages`
value calls `callback` directly instead of throwing synchronously
into whichever promise chain happens to be on the stack -- which,
when this runs inside another call's `.then` handler, used to
surface as a second, misattributed failure on the call that was
already in flight."
[api-key model system messages max-tokens callback]
(try
(let [body (request-body model system messages max-tokens)]
(-> (js/fetch "https://api.anthropic.com/v1/messages"
(js/Object.fromEntries
(array (array "method" "POST")
(array "headers"
(js/Object.fromEntries
(array (array "content-type" "application/json")
(array "x-api-key" api-key)
(array "anthropic-version" "2023-06-01")
(array "anthropic-dangerous-direct-browser-access" "true"))))
(array "body" body))))
(.then (fn [r] (.json r)))
(.then (fn [d]
(if-let [err (.-error d)]
(callback {:error (str "[" (.-type err) "] " (.-message err))})
(callback {:text (.-text (aget (.-content d) 0))
:stop-reason (.-stop_reason d)}))))
(.catch (fn [e] (callback {:error (str "[no answer] " (.-message e))})))))
(catch :default e
(js/console.error "fedoras.llm/call-llm-raw! could not build the request" e)
(callback {:error (str "[request build failed] " (.-message e))}))))
(defn call-llm!
"Call the API once. `callback` receives either [text nil] or [nil
error-string]. Kept for callers, such as run-dialogue!, that only need
the final text and do not need continuation handling. The text is
cleaned on the way out, the same as ask-entity!'s, so a two-party scene
and a five-party one behave the same."
[api-key model system messages max-tokens callback]
(call-llm-raw!
api-key model system messages max-tokens
(fn [{:keys [text error]}]
(if error
(callback nil error)
(callback (clean text) nil)))))
(def ^:private max-continuations
"How many times ask-entity! will ask a speaker to keep going after
hitting max-tokens mid-sentence, before giving up and returning
whatever it has. Bounded, since a speaker who never wants to stop
should not turn into an unbounded string of API calls."
2)
(defn- continue-message []
(str "(Keep going directly from where you stopped. Do not repeat "
"anything you already said, and do not restart the sentence. "
"Finish the thought, then stop naturally when you are done "
"speaking, even if that is only a sentence or two more.)"))
(defn- append-continuation
"Join a truncated response to its continuation with whatever
whitespace is missing between them. call-llm-raw! just hands back
the raw text of each chunk, so a cut ending mid-word joined straight
to a resumed chunk with (str a b) can smash the two together into
one run-on word -- this only adds a space when neither side already
has one at the seam."
[accumulated text]
(let [last-char (when (seq accumulated) (subs accumulated (dec (count accumulated))))
first-char (when (seq text) (subs text 0 1))]
(if (or (nil? last-char) (nil? first-char)
(#{" " "\n" "\t"} last-char) (#{" " "\n" "\t"} first-char))
(str accumulated text)
(str accumulated " " text))))
(defn ask-entity!
"Ask one entity to speak. `overrides` may include :max-tokens,
:extra-context, :extra-context-uncached, :mc-name, :spine? (default
true), :bits-off (a set of bit ids to leave out), and
:should-continue? (a no-arg predicate, checked before each
continuation round). `callback` receives [text nil] or [nil
error-string]: if a response is cut off by the token limit, this asks
the same entity to continue and stitches the pieces together, so
callers never see a clipped sentence.
The text handed back has been through `clean`, so no caller has to
filter rhythm markers and no caller can forget to. Stitching happens
on the raw chunks and cleaning happens once at the end, because a
seam is easier to find before anything has been taken out of it."
[entity-id user-message history api-key overrides callback]
(let [entity (get (.-FEDORAS_ENTITIES js/globalThis) entity-id)
{:keys [model default-params]} entity
overrides (or overrides {})
max-tokens (or (:max-tokens overrides) (:max-tokens default-params) 700)
should-continue? (or (:should-continue? overrides) (constantly true))
system (build-system-prompt entity overrides)
api-history (filterv #(#{:user "user" :assistant "assistant"} (:role %)) history)
base-messages (conj api-history {:role "user" :text user-message})]
(swap! call-count inc)
(letfn [(finish [accumulated text]
(let [said (clean (append-continuation accumulated text))]
(record-bits! entity-id entity said)
(callback said nil)))
(step [messages accumulated remaining]
(call-llm-raw!
api-key model system messages max-tokens
(fn [{:keys [text error stop-reason]}]
(cond
error
(callback nil error)
(and (= stop-reason "max_tokens") (pos? remaining) (should-continue?))
(step (conj messages
{:role "assistant" :text text}
{:role "user" :text (continue-message)})
(append-continuation accumulated text)
(dec remaining))
:else
(finish accumulated text)))))]
(step base-messages "" max-continuations))))
(defn- api-messages-for
"Build Anthropic API messages for `speaker`, given a display log where
each entry has :speaker and :text. The current speaker's prior messages
are assistant; the other speaker's are user."
[speaker log]
(let [msgs (mapv (fn [m]
{:role (if (= (:speaker m) speaker) "assistant" "user")
:text (:text m)})
log)]
(if (and (seq msgs) (= "assistant" (:role (first msgs))))
(cons {:role "user" :text "(The conversation begins.)"} msgs)
msgs)))
(defn run-dialogue!
"Alternate between two entities. `entity-a` and `entity-b` are keywords.
`initial` is the first message from `entity-a` (or nil). `max-turns` is
the total number of API calls. `api-key` and `overrides` are as in the
cell. `on-step` is called with the updated display transcript."
[entity-a entity-b initial max-turns api-key overrides on-step]
(let [log (atom (if initial [{:speaker (name entity-a) :text initial}] []))
busy (atom false)
stop (atom false)]
(letfn [(turn! [current-id other-id]
(when (and (not @stop) (< (count @log) max-turns))
(reset! busy true)
(let [entity (get (.-FEDORAS_ENTITIES js/window) current-id)
model (:model entity)
system (build-system-prompt entity (get overrides current-id))
max-tokens (or (get-in entity [:default-params :max-tokens]) 700)
messages (api-messages-for current-id @log)]
(call-llm!
api-key model system messages max-tokens
(fn [text err]
(reset! busy false)
(swap! log conj {:speaker (name current-id)
:text (or text err)})
(on-step @log)
(when (and text (< (count @log) max-turns))
(turn! other-id current-id)))))))]
(if initial
(turn! entity-b entity-a)
(turn! entity-a entity-b))
#(reset! stop true))))
(defn start-dialogue!
[entity-a entity-b initial max-turns api-key overrides on-step]
(run-dialogue! entity-a entity-b initial max-turns api-key overrides on-step))
(defn stick-scroll!
"Wrap a render that replaces a scrolling log's HTML, so new content
doesn't reset the reader to the top or yank them back down if
they've scrolled up to reread something. `container-id` is the DOM
id of the scrollable element -- it must exist (or not) both before
and after `render-fn` runs, since `render-fn` is expected to be an
`html!` call that recreates it from scratch. Captures how far the
reader currently is from the bottom, runs the render, then restores
that same distance: someone sitting at the bottom stays pinned to
the bottom as new lines arrive, someone scrolled up stays exactly
where they were."
[container-id render-fn]
(let [before (js/document.getElementById container-id)
distance-from-bottom
(if before
(- (.-scrollHeight before) (.-scrollTop before) (.-clientHeight before))
0)]
(render-fn)
(when-let [after (js/document.getElementById container-id)]
(set! (.-scrollTop after)
(js/Math.max 0 (- (.-scrollHeight after) (.-clientHeight after) distance-from-bottom))))))
(def default-end-marker
"The default closing marker every scene appends once it genuinely
ends, whatever the reason. A scene can pass its own string to
end-entry instead when a custom ending fits better."
"-END OF FOOTAGE-")
(defn end-entry
"A closing marker entry for a display log, appended once a scene's
own code decides it has genuinely ended -- goals met, turns
exhausted, or a stop button, whichever applies. Recognized by
:end-marker? rather than by :speaker or :role, since those keys
differ between cells; every cell's own render function and
save-transcript! below both just check for this flag first, so a
scene doesn't need to match anyone else's log shape to use it."
([] (end-entry default-end-marker))
([marker] {:end-marker? true :text marker}))
(defn save-transcript!
"Download the conversation log as a text file, named after the entity."
[log entity-id]
(let [label (name entity-id)
header (str "=== " (clojure.string/upper-case label) " -- CONVERSATION LOG ===\n"
"Preserved from New Carthage University\n"
"By the reader, for " label "\n\n")
body (str/join "\n\n"
(map (fn [m]
(if (:end-marker? m)
(:text m)
(let [who (or (:speaker m) (:role m) "unknown")]
(str (clojure.string/upper-case (name who)) ": " (:text m)))))
log))
text (str header body)
blob (js/Blob. #js [text] #js {:type "text/plain;charset=utf-8"})
url (js/URL.createObjectURL blob)
a (.createElement js/document "a")]
(set! (.-href a) url)
(set! (.-download a) (str label "-conversation.txt"))
(.click a)
(.revokeObjectURL js/URL url)))</script><script type="application/x-scittle">(ns fedoras.ui.dice
"The d20, the Echo, and the rewrite button, in the page.
Loaded once per chapter by `fedoras.dice/assets`. Each check on the
page then emits one line calling `mount!` with its own data, so the
outcome text is passed as Clojure rather than smuggled through
data- attributes and parsed back out.
Rolling stores the result. The reader may rewrite any
result they like, and each rewrite raises the Echo by one, and the
Echo is a running penalty on every check afterwards, in this chapter
and every chapter after it, for as long as they keep their browser.
Free the first time, cheap the second, and by the fifth rewrite they
are playing a worse game than the one they started with, and nothing
has warned them except a number going up."
(:require [clojure.string :as str]
[fedoras.reader :as read]))
;; ---------------------------------------------------------------------
;; What a roll is worth
;; ---------------------------------------------------------------------
(defn band
"One and twenty are themselves. Everything between them is a sum."
[natural dc modifier]
(cond
(= natural 1) :fumble
(= natural 20) :crit
(>= (+ natural modifier) dc) :success
:else :fail))
(defn line
"What was rolled, what it came to, and what that was."
[natural dc modifier]
(str "d20 → " natural
(cond
(pos? modifier) (str " fortune +" modifier " → " (+ natural modifier))
(neg? modifier) (str " echo " modifier " → " (+ natural modifier))
:else "")
" "
(case (band natural dc modifier)
:fumble "FUMBLE" :crit "CRIT" :success "success" :fail "failure")))
;; ---------------------------------------------------------------------
;; Persistence
;; ---------------------------------------------------------------------
(defn- entry [id] (read/roll-entry id))
;; ---------------------------------------------------------------------
;; The passage
;; ---------------------------------------------------------------------
(defn- segment-node [seg]
(let [cue? (str/starts-with? seg "@")
node (.createElement js/document "p")]
(set! (.-className node) (if cue? "roll-cue" "roll-line"))
(set! (.-textContent node)
(read/fill (if cue? (subs seg 1) seg)))
node))
(defn- paint-passage! [node text]
(let [box (read/q node ".roll-out")]
(set! (.-innerHTML box) "")
(doseq [seg (str/split (or text "") #"\|")]
(.appendChild box (segment-node seg)))))
;; ---------------------------------------------------------------------
;; The two states a box can be in
;; ---------------------------------------------------------------------
(defn- clear! [node]
(read/text! node ".roll-value" "")
(set! (.-innerHTML (read/q node ".roll-out")) "")
(read/text! node ".roll-note" "")
(read/text! node ".roll-echo" "")
(read/enable! node ".roll-go" true "Roll d20")
(read/show! node ".roll-rewrite" false))
(defn- fill! [node {:keys [dc outcomes]} {:keys [roll mod rewrites]}]
(let [b (band roll dc mod)]
(read/text! node ".roll-value" (line roll dc mod))
(paint-passage! node (or (get outcomes b) (get outcomes :fail)))
(read/text! node ".roll-note"
(if (zero? rewrites)
""
(str "This is version " (inc rewrites) " of what happened.")))
(read/enable! node ".roll-go" false "Rolled")
(read/show! node ".roll-rewrite" true)))
(defn- paint! [node check]
(if-let [e (entry (:id check))]
(fill! node check e)
(clear! node))
(read/text! node ".echo-count" (str (read/echo)))
(read/text! node ".score-count" (str (read/score))))
;; ---------------------------------------------------------------------
;; Two things a reader does
;; ---------------------------------------------------------------------
(defn- resolve!
"One die, and what it costs and is worth.
The score is not touched here. It is the sum of what is standing, and
what is standing is what this line just wrote."
[node check rewrites]
(let [natural (inc (rand-int 20))
modifier (read/standing)]
;; a twenty is worth a deed, but only to somebody who has taken
;; nothing back. deeds are a set, so it is worth one, once, ever.
(when (and (= 20 natural) (zero? (read/echo)))
(read/did! :nerve))
(read/remember-roll!
(:id check) {:roll natural :mod modifier :rewrites rewrites})
(paint! node check)))
(defn- roll-once! [node check]
(when-not (entry (:id check))
(resolve! node check 0)))
(defn- rewrite!
"Raise the Echo first, then roll under it, so the new number is
penalised by the cost of having asked for it."
[node check]
(when-let [e (entry (:id check))]
(read/add-echo!)
(resolve! node check (inc (:rewrites e)))
(read/text! node ".roll-echo" "Something remembers.")))
;; ---------------------------------------------------------------------
(defn mount! [check]
(read/widget
(keyword "roll" (:id check))
(str "roll-" (:id check))
(fn [node]
(paint! node check)
(read/on-click! node ".roll-go" (fn [_] (roll-once! node check)))
(read/on-click! node ".roll-rewrite" (fn [_] (rewrite! node check))))))</script><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>/* ---------------------------------------------------------------
Session Four. Everything here is inside a .widget shell, so the
border, the header and the source panel are already handled and
nothing below re-states them.
--------------------------------------------------------------- */
.widget-dnd .dnd-log {
max-height: 26rem;
overflow-y: auto;
padding-right: 0.4rem;
margin: 0.5rem 0;
}
.widget-dnd .dnd-said {
margin: 0 0 0.9rem 6ch !important;
max-width: 46ch;
font-family: "Courier Prime", "Courier New", monospace;
white-space: pre-wrap;
}
.widget-dnd .dnd-die { text-align: center; }
.widget-dnd .dnd-thinking {
font-style: italic;
opacity: 0.6;
margin-left: 6ch;
}
.widget-dnd .dnd-end {
text-align: center;
opacity: 0.6;
letter-spacing: 0.15em;
margin-top: 1rem;
}
/* A second of somewhere else, for the reader and nobody at the table.
Set apart and never labelled, because nothing labels it. */
.widget-dnd .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;
}
/* The one thing in this building that has never been wrong, kept in
front of the reader for the whole session. */
.widget-dnd .dnd-animal {
margin: 0.3rem 0 0.6rem 0 !important;
padding-left: 0.7rem;
border-left: 2px solid rgba(var(--green-rgb), 0.6);
font-size: 0.8rem;
letter-spacing: 0.03em;
opacity: 0.85;
}
/* Choices are the reader's own words and one tap says them, so they
are shaped like something you might say rather than like a control. */
.widget-dnd .dnd-choices {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0.5rem 0 0.2rem 0;
}
.widget-dnd .dnd-choice {
font: inherit;
font-size: 0.82rem;
font-family: "Courier Prime", "Courier New", monospace;
text-align: left;
padding: 0.22rem 0.75rem;
border-radius: 12px;
border: 1px dotted rgba(var(--violet-rgb), 0.6);
background: none;
color: var(--ink);
cursor: pointer;
opacity: 0.88;
}
.widget-dnd .dnd-choice:hover {
background: rgba(var(--violet-rgb), 0.14);
opacity: 1;
}
/* One line per approach with its number showing, because the numbers
are the description: a door with a fifteen to force and a twelve to
pick is a different door from one with them the other way round. */
.widget-dnd .dnd-rolls {
border: 1px solid rgba(var(--violet-rgb), 0.4);
border-radius: 4px;
padding: 0.5rem 0.7rem;
margin: 0.6rem 0;
}
.widget-dnd .dnd-rolls-title {
font-size: 0.72rem;
letter-spacing: 0.12em;
opacity: 0.7;
margin: 0 0 0.4rem 0 !important;
}
.widget-dnd .roll-approach {
flex: 1;
font-size: 0.82rem;
opacity: 0.9;
}
.widget-dnd .dnd-cost {
font-size: 0.72rem;
letter-spacing: 0.06em;
opacity: 0.55;
}
/* What Miles has written down, which is the only record of this session
anybody will be able to read afterwards. Nothing is put in here by
the page. */
.widget-dnd .dnd-ledger {
border: 1px solid rgba(var(--gold-rgb), 0.45);
background: rgba(var(--gold-rgb), 0.05);
border-radius: 4px;
padding: 0.55rem 0.8rem;
margin: 0.8rem 0 0.4rem 0;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.8rem;
line-height: 1.5;
}
.widget-dnd .dnd-ledger p { margin: 0.1rem 0; }
.widget-dnd .dnd-ledger-title {
font-variant: small-caps;
letter-spacing: 0.12em;
opacity: 0.7;
margin-bottom: 0.35rem !important;
}
/* A word is marked because Miles wrote it down, not because the page
decided it was important. */
.widget-dnd .kept {
border-bottom: 1px solid rgba(var(--gold-rgb), 0.7);
}
.widget-dnd .dnd-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;
}
/* What the reader is, on one line, because Owen tried to cast a
fireball as a ranger and Victor had to say so out loud for the
fourth time. Every value here is already in fedoras.reader. */
.widget-dnd .dnd-sheet {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 1.1rem;
padding-bottom: 0.4rem;
margin-bottom: 0.5rem;
border-bottom: 1px solid rgba(var(--ink-rgb), 0.25);
font-size: 0.72rem;
letter-spacing: 0.08em;
opacity: 0.7;
}
.widget-dnd .dnd-sheet-name {
font-weight: 700;
letter-spacing: 0.1em;
opacity: 1;
}
.widget-dnd .dnd-sheet-mod { font-variant-numeric: tabular-nums; }
/* Narrow screens. The bar has four controls in it and the transcript
is indented six characters, and neither survives a phone. */
@media (max-width: 34rem) {
.widget-dnd .dnd-said { margin-left: 2ch !important; max-width: none; }
.widget-dnd .slip { margin-left: 1ch !important; max-width: none; }
.widget-dnd .dnd-thinking { margin-left: 2ch; }
.widget-dnd .live-bar { flex-wrap: wrap; }
.widget-dnd .live-bar .todo-input { flex: 1 0 100%; }
.widget-dnd .dnd-choice { flex: 1 0 100%; }
.widget-dnd .dnd-approach { flex-wrap: wrap; }
}</style><script type="application/x-scittle">(ns fedoras.cs-class.dnd.dnd-table
"Session Four, the part that is not on screen.
The descent is scripted and happens before this. This picks up in the
hallway and runs to the Hollow Chancellor. The retrieval is scripted
after it, because it has to be the same for everybody.
NO BEATS, AND NOT FROM HERE. The rhythm markers are stripped in
`fedoras.llm/clean`, on the way out of every call in the book, because
the rule that was producing them lives in `global-context` and applies
to every entity in every scene. This file used to carry its own filter
and should not have: it was fixing a global fault in one room.
VICTOR IS HANDED ONE ROOM AT A TIME and told nothing about the next, so
he runs a module he does not hold. Mochi is handed the whole sequence,
because she wrote it, and told to sit on it.
REWRITING COSTS THE SAME EVERYWHERE. A die can be rewritten and so can
a line, and both raise the Echo and both buy a slip, because they are
the same act: the reader did not like what happened and is having it
happen differently, and the record will not show the first version."
(:require [clojure.string :as str]))
;; ---------------------------------------------------------------------
;; What the settings panel filled in
;; ---------------------------------------------------------------------
(def defaults
{:nerve 6
:evening 20
:turns {:hallway 4 :meeting 3 :telling 4 :walking 3 :vault 3 :chancellor 4}
:doors ["A room with nothing in it at all. It smells like somebody's kitchen."]
:pressure ["Nothing in this building has threatened anybody yet."]})
(defonce settings (atom defaults))
;; ---------------------------------------------------------------------
;; Who is at the table
;; ---------------------------------------------------------------------
(def display-name
{:victor "VICTOR" :simon "SIMON" :derek "DEREK" :miles "MILES"})
(def entity-speakers #{:victor :simon :derek :miles :mochi})
(def base-weight {:simon 3 :derek 3 :miles 3 :mochi 1})
(def topic-triggers
{:miles ["write" "wrote" "record" "ledger" "approved" "column" "note" "map"]
:derek ["rome" "roman" "legion" "burn" "eat" "food" "plan" "drastic" "lost"]
:simon ["animal" "door" "wrong" "quiet" "careful" "trust" "still" "cold"]
:mochi ["mochi" "help" "define" "source" "look" "generate" "corpus" "sing"]})
(def topic-bonus 4)
(def addressed-bonus 5)
;; ---------------------------------------------------------------------
;; The sequence
;; ---------------------------------------------------------------------
(def stations
[{:id :the-hallway :turn-key :hallway
:brief
"A hallway lined with doors. Not numbered, not labelled, not the same as each other: ordinary doors of different kinds, the way the doors in a building added to over time are different. Behind each one is a campaign that was made and never played. Nothing in any of them is dangerous, and that is the problem. Start this funny. Do not stay funny."}
{:id :ariathne :turn-key :meeting
:brief
"Somebody is in the hallway with them who was not there a moment ago and is not the druid. She is entirely at ease, which nobody else in this building has been. She has been through every room behind every door in here twice and she is bored, and she will lead them out, for a price. Victor: you do not have this character. You did not write her. Do not voice her, do not answer for her, do not describe what she does. Play what the table does about her, and let her talk."}
{:id :the-telling :turn-key :telling
:brief
"The party is telling her something. Victor, you are not the DM for this part and you should notice that you are not. Do not narrate it, do not adjudicate it and do not improve it. Let them tell it badly, in pieces, interrupting each other. If Miles writes any of it down, the animal moves. Describe that when it happens, do not explain it, do not work out why, and do not remember that you made a rule about this in September."}
{:id :singing-turns :turn-key :walking
:brief
"She is leading them out and singing which way to go, one turn at a time. The building is letting her. Corners the party could not find on their own are simply there when she reaches them and the geometry stops arguing. It takes a while and it is almost pleasant, and the almost is the point. Nothing threatens anybody on this walk."}
{:id :the-vault-door :turn-key :vault
:brief
"The end of the walk is a door that is not like the others, and the party knows it is a vault before anybody says the word. She has stopped. She has taken them exactly as far as she said she would, and she is still perfectly good-humoured about it."}
{:id :the-hollow-chancellor :turn-key :chancellor
:brief
"The Hollow Chancellor is here. It is the first thing in this building shaped like a person and shaped like an authority, and the party registers both at once. It does not attack, does not threaten and does not explain. It speaks in their voices. It asks why they came, it waits, and when it does not get an answer it asks again a different way, once, and then it takes what it is given."}])
(def slips
["Room 3C, for less than a second. Everything is where it was. The tin is on its side and nobody is picking it up."
"Room 3C, with the lights off. All five of them are still sitting there in the dark, not talking, and one of them is him."
"The corridor outside Room 3C, through the wired glass in the door. Five people at a folding table. He counts them twice and gets five both times."
"Not Room 3C. A low room with a floor that slopes to a drain, under a building he has eaten in. Rows of something under sheeting. A smell of copper. Somebody reads out a number and it is not his."
"Room 3C. Four chairs. He does not check which one is missing."
"Room 3C in September, from the doorway. There are five at the table and he is not one of them, and the game is going well."])
;; ---------------------------------------------------------------------
;; The room everyone is actually in
;; ---------------------------------------------------------------------
(defn- with-article [word]
(str (if (re-find #"(?i)^[aeiou]" (str word)) "an " "a ") word))
(defn- animal-note
"The animal, by what it actually is.
`fedoras.reader/companion` carries the species, whether the reader
named it, and what they called it, so the table can say Ted, or the
marmot, instead of five people saying `the animal` for two hours. It
reads through the roster rather than off a stored string, so a reader
who rolled before the grammar landed still gets a species here."
[]
(if-let [{:keys [name animal named?]} (fedoras.reader/companion)]
(str "THE ANIMAL\n"
(if named?
(str "The ranger rolled it in September and wrote " name " on the COMPANION "
"line himself. It is " (with-article (clojure.string/lower-case animal)) ". "
"Call it " name ", or the " (clojure.string/lower-case animal) ", and not "
"'the animal', which is what people say about something they have not "
"looked at.")
(str "It is " (with-article (clojure.string/lower-case animal)) ", and the "
"COMPANION line on the ranger's sheet has never had a name written on "
"it, so the table calls it the " (clojure.string/lower-case animal) ". "
"Nobody has ever remarked on the blank."))
" It has come the whole way down and is not frightened. Nothing in this "
"building interests it and nothing in it worries it, and that has begun to "
"bother Simon more than the building has.")
(str "THE ANIMAL\n"
"The ranger's animal has come the whole way down and is not frightened. "
"Nothing in this building interests it and nothing in it worries it, and that "
"has begun to bother Simon more than the building has. Nobody at the table "
"uses a name for it, and nobody has noticed that nobody does.")))
(def scenario
"SCENARIO — shared by everyone at the table, so nobody has to guess at it.
WHERE THIS PICKS UP
The party went down through a tower that would not hold still: a ground floor with a desk, a bell and a painted sign reading RETURNS, a second floor that was the same room with the light wrong, a cellar sealed from the inside, a basement that had been meant to be something and never finished being it, and a ramp that only went one way. They have come out of the ramp into a hallway lined with doors.
WHO IS HERE
The four of them, the ranger's animal, and the druid, who came with them from the toll bridge and is still carrying her page. She does not like this building and says so rarely.
HELPS
Everybody at this table has one and asks it things out loud without thinking about it. They give the approved account, which is tidy and confident and has had things taken out of it. Mochi, Victor's, has the unexpurgated corpus. Nobody at this table thinks about what that difference means and tonight is not the night any of them start.
HOW PEOPLE TALK HERE
Everybody at this table is speaking out loud in a room. Nobody narrates themselves. Never write a stage direction of any kind, and never write an empty one: no beat, no pause, no long pause, no silence, no moment. If a silence matters it is made by stopping, and the next person will hear it. Physical business is fine when somebody actually does something, and it goes in the sentence.
WHAT NOBODY AT THIS TABLE KNOWS
None of them understand what this place is or why the module contains it. Nobody says the word 'repossession' and nobody thinks about the university. Any theory anybody offers is a theory and stays one.
THE ROOM THEY ARE REALLY IN
Room 3C, Halloran Union, Wednesday, seven in the evening. A folding table, five chairs, a tin of dice that used to hold something else. Victor's felt hat, which nobody has mentioned since the second week. Ted closes the building at midnight, in the middle of whatever is happening, and does not negotiate.")
(defn- standing-weather []
(let [standing (fedoras.reader/standing)]
(str
"\n\nHOW THE EVENING IS GOING\n"
(cond
(>= standing 4)
"One of the good ones and everybody can feel it. Victor is at the top of his game and knows it. The descriptions land, the players are quick, and twice tonight somebody has said something better than they meant to."
(>= standing 0)
"An ordinary Wednesday, run well. The pizza is going cold at the end of the table and the session is going about as sessions go. The building is unpleasant and the evening is not."
(>= standing -3)
"Something is slightly off tonight and nobody has said so. Victor describes a chair that was not there a minute ago and does not notice doing it. Miles reads back a line he does not remember writing. Keep this under the scene: at most one small wrongness a turn, never remarked on."
:else
"The evening has been gone over too many times. Victor contradicts himself twice a scene and carries on. Rooms the party has already left are described differently when they come up again. Somebody is quoted saying a thing they did not say and agrees that they said it. None of them can name what is happening because none of them have the words. Keep it under the scene, never explained, and keep playing.")
"\n\nNobody at this table is aware of any of the above as a fact about the evening. It is simply how the evening is.")))
;; ---------------------------------------------------------------------
;; State
;; ---------------------------------------------------------------------
(defonce api-key (atom nil))
(defonce log (atom []))
(defonce ledger (atom []))
(defonce choices (atom []))
(defonce animal (atom nil))
(defonce roll-options (atom nil))
(defonce roll-result (atom nil))
(defonce nerve (atom 6))
(defonce nerve-broken? (atom false))
(defonce busy? (atom false))
(defonce station-index (atom 0))
(defonce station-turns (atom 0))
(defonce slips-seen (atom 0))
(defonce mochi-in-voice? (atom false))
(defonce mochi-noticing? (atom false))
(defonce mochi-noticed? (atom false))
(defonce unprepared? (atom false))
(defonce closing? (atom false))
(defonce ended? (atom false))
(defonce reader-turns (atom 0))
(defonce repaint (atom nil))
(defonce speaking (atom nil))
(defn started? [] (seq @log))
;; ---------------------------------------------------------------------
;; Coming back to it
;;
;; Everything above lives in memory, so before this a reader who closed
;; the tab lost the session and kept only the Echo, which is the worst
;; possible half to keep. The key is not on `fedoras.reader/on-the-record`
;; and never should be: this belongs in the room it was made in, and it
;; is far too long to travel in a link. The API key is not in here and
;; never goes anywhere.
;; ---------------------------------------------------------------------
(def session-key :dnd-session)
(defn- snapshot []
{:log @log
:ledger @ledger
:choices @choices
:animal @animal
:station @station-index
:station-turns @station-turns
:nerve @nerve
:nerve-broken? @nerve-broken?
:slips-seen @slips-seen
:mochi-in-voice? @mochi-in-voice?
:mochi-noticed? @mochi-noticed?
:reader-turns @reader-turns
:closing? @closing?
:ended? @ended?
;; carried, so a reader who comes back does not get a fresh
;; allowance and Victor four more SHITTEs in the same evening
:bits (fedoras.llm/bits-snapshot)})
(defn- remember! []
(fedoras.reader/keep! session-key (snapshot)))
(defn repaint!
"Saved at rest rather than mid-chain, so a turn that is still arriving
does not get written down three times on its way in."
[]
(when-not @busy? (remember!))
(when-let [f @repaint] (f)))
(defn- revive
"Storage keywordizes keys on the way back and leaves values alone, so
a speaker goes out as :victor and comes back as \"victor\"."
[entry]
(if (:speaker entry) (update entry :speaker keyword) entry))
(defn resume!
"Put back whatever was here. Silent when there is nothing, which is
every first visit."
[]
(when-let [saved (fedoras.reader/kept session-key)]
(when (seq (:log saved))
(reset! log (mapv revive (:log saved)))
(reset! ledger (vec (:ledger saved)))
(reset! choices (vec (:choices saved)))
(reset! animal (:animal saved))
(reset! station-index (or (:station saved) 0))
(reset! station-turns (or (:station-turns saved) 0))
(reset! nerve (or (:nerve saved) 6))
(reset! nerve-broken? (boolean (:nerve-broken? saved)))
(reset! slips-seen (or (:slips-seen saved) 0))
(reset! mochi-in-voice? (boolean (:mochi-in-voice? saved)))
(reset! mochi-noticed? (boolean (:mochi-noticed? saved)))
(reset! reader-turns (or (:reader-turns saved) 0))
(reset! closing? (boolean (:closing? saved)))
(reset! ended? (boolean (:ended? saved)))
(when-let [bits (:bits saved)] (fedoras.llm/restore-bits! bits))
true)))
(defn forget-session!
"Start again. The Echo does not come back, because the Echo never
does, and nothing anywhere says so."
[]
(fedoras.reader/remove! session-key)
(reset! log []) (reset! ledger []) (reset! choices [])
(reset! animal nil) (reset! roll-options nil) (reset! roll-result nil)
(reset! station-index 0) (reset! station-turns 0) (reset! slips-seen 0)
(reset! mochi-in-voice? false) (reset! mochi-noticing? false)
(reset! mochi-noticed? false) (reset! unprepared? false)
(reset! closing? false) (reset! ended? false) (reset! reader-turns 0)
(reset! nerve-broken? false)
(reset! nerve (:nerve @settings 6))
(fedoras.llm/reset-bits!)
(repaint!))
(defn configure!
"What the reader ran. Only the numbers and the prose are theirs; the
machinery is not on the panel, because a session nobody can start is
worse than a session nobody can tune. A session already under way keeps
the nerve it has left, since running the settings again is not a rest."
[value]
(reset! settings (merge defaults value))
(when-not (started?) (reset! nerve (:nerve @settings (:nerve defaults))))
(repaint!))
;; ---------------------------------------------------------------------
;; The sequence, in motion
;; ---------------------------------------------------------------------
(defn current-station []
(nth stations (min @station-index (dec (count stations)))))
(defn station-budget []
(get-in @settings [:turns (:turn-key (current-station))] 4))
(defn last-station? [] (>= @station-index (dec (count stations))))
(defn- station-at-or-past? [id]
(>= @station-index
(count (take-while (fn [station] (not= id (:id station))) stations))))
(defn- taking-the-voice? []
(and (station-at-or-past? :ariathne) (not @mochi-in-voice?)))
(defn- out-of-room? [] (>= @station-turns (dec (station-budget))))
(defn- slip-chance []
(let [echo (fedoras.reader/echo)]
(cond (zero? echo) 0 (= echo 1) 0.25 (= echo 2) 0.45 :else 0.7)))
(defn- slip! []
(let [index (min @slips-seen (dec (count slips)))]
(swap! slips-seen inc)
(swap! log conj {:speaker :slip :text (nth slips index)})
(when (and @mochi-in-voice? (not @mochi-noticed?))
(reset! mochi-noticing? true))))
(defn- advance-station! []
(when-not (last-station?)
(swap! station-index inc)
(reset! station-turns 0)
(reset! choices [])
(when (< (rand) (slip-chance)) (slip!))))
;; ---------------------------------------------------------------------
;; Nerve
;; ---------------------------------------------------------------------
(defn nerve-max [] (:nerve @settings 6))
(defn- spend-nerve! [amount]
(swap! nerve (fn [n] (max 0 (- n amount))))
(when (and (zero? @nerve) (not @nerve-broken?))
(reset! nerve-broken? true)
(fedoras.reader/add-echo!)
(reset! closing? true)))
(defn- nerve-note []
(str "WHAT THEY HAVE LEFT\n"
(cond
(>= @nerve 5) "The party is steady. Nobody is close to their limit."
(>= @nerve 3) "They are fraying at the edges. Answers are getting shorter and somebody has stopped making jokes."
(>= @nerve 1) "The room is pressing on them and they know it. Whoever speaks next is doing it to keep from listening."
:else "They are done. Something has come out of them that does not come back tonight. Wind this to a stop, quietly, without a rescue and without an explanation.")))
;; ---------------------------------------------------------------------
;; Labels
;; ---------------------------------------------------------------------
(defn label-for [speaker]
(cond
(= :pc speaker) (str/upper-case (first (fedoras.reader/reader-name)))
(= :table speaker) "THE TABLE"
(= :mochi speaker) (if @mochi-in-voice? "ARIATHNE" "MOCHI")
:else (get display-name speaker (str/upper-case (name speaker)))))
;; ---------------------------------------------------------------------
;; What the party said tonight, handed back to them
;; ---------------------------------------------------------------------
(defn- shorten [text limit]
(if (<= (count text) limit) text (str (str/trimr (subs text 0 limit)) "...")))
(defn- own-voices []
(let [said (->> @log
(filter (fn [entry]
(contains? #{:pc :simon :derek :miles} (:speaker entry))))
(map (fn [entry]
(str (label-for (:speaker entry)) ": " (shorten (:text entry) 160))))
vec)]
(if (empty? said)
""
(str "IN THEIR OWN VOICES\n"
"It says these back, word for word, in the voice of whoever said them, in "
"the wrong order and to the wrong people:\n"
(str/join "\n" (take 8 (shuffle said)))
"\n\nAnd it says other things in their voices that none of them have said "
"yet. Never mark which is which."))))
;; ---------------------------------------------------------------------
;; Who reacts
;; ---------------------------------------------------------------------
(defn- recent-text [n]
(str/lower-case (str/join " " (map :text (take-last n @log)))))
(defn- topic-triggered? [speaker]
(let [recent (recent-text 3)]
(boolean (some (fn [word] (str/includes? recent word))
(get topic-triggers speaker [])))))
(defn- addressed-recently? [speaker]
(let [recent (recent-text 2)
called (str/lower-case (label-for speaker))]
(boolean (and (seq called) (str/includes? recent called)))))
(defn- weighted-pool [taken]
(mapcat (fn [[speaker weight]]
(if (contains? taken speaker)
[]
(repeat (+ weight
(if (topic-triggered? speaker) topic-bonus 0)
(if (addressed-recently? speaker) addressed-bonus 0))
speaker)))
base-weight))
(defn- pick-responders [forced]
(let [crowded? (neg? (fedoras.reader/standing))
wanted (cond
(seq forced) 0
(< (rand) 0.15) 0
(< (rand) (if crowded? 0.5 0.2)) 2
:else 1)]
(loop [chosen (vec forced) remaining wanted]
(if (zero? remaining)
chosen
(let [pool (weighted-pool (set chosen))]
(if (empty? pool) chosen (recur (conj chosen (rand-nth pool)) (dec remaining))))))))
(defn- forced-responders []
(cond
@mochi-noticing? [:mochi]
(and (taking-the-voice?) (pos? @station-turns)) [:mochi]
:else []))
;; ---------------------------------------------------------------------
;; The log, as one speaker heard it
;; ---------------------------------------------------------------------
(defn- history-for [speaker entries]
(let [visible (remove (fn [entry] (= :slip (:speaker entry))) entries)
tagged (map (fn [entry]
(if (= (:speaker entry) speaker)
{:role "assistant" :text (:text entry)}
{:role "user"
:text (str (label-for (:speaker entry)) ": " (:text entry))}))
visible)
grouped (reduce (fn [acc message]
(if (and (seq acc) (= (:role (peek acc)) (:role message)))
(conj (pop acc) (update (peek acc) :text str "\n" (:text message)))
(conj acc message)))
[]
tagged)]
(if (and (seq grouped) (= "assistant" (:role (first grouped))))
(into [{:role "user" :text "(The session is already under way.)"}] grouped)
grouped)))
;; ---------------------------------------------------------------------
;; Markers
;; ---------------------------------------------------------------------
(def roll-pattern #"ROLL\[([^\]]+)\]")
(def choices-pattern #"CHOICES\[([^\]]+)\]")
(def ledger-pattern #"LEDGER\[([^\]]+)\]")
(def animal-pattern #"ANIMAL\[([^\]]+)\]")
(defn- strip-words [text words]
(reduce (fn [acc word] (str/replace acc word "")) text words))
(defn- split-bar [body limit]
(->> (str/split body #"\|")
(map str/trim)
(remove str/blank?)
(take limit)
vec))
(defn- parse-rolls [body base-id]
(->> (str/split body #";")
(map str/trim)
(remove str/blank?)
(map-indexed
(fn [index part]
(let [pieces (str/split part #"\|")
label (first pieces)
dc (second pieces)]
(when (and label dc)
{:id (str base-id "-" index)
:label (str/trim label)
:dc (js/parseInt (str/trim dc) 10)}))))
(remove nil?)
(take 3)
vec))
(defn- read-victor [text base-id]
{:text (-> text
(str/replace roll-pattern "")
(str/replace choices-pattern "")
(str/replace animal-pattern "")
(strip-words ["SESSION ENDS" "ONWARD" "GRANTED"]))
:ends? (str/includes? text "SESSION ENDS")
:onward? (str/includes? text "ONWARD")
:granted? (str/includes? text "GRANTED")
:rolls (when-let [found (re-find roll-pattern text)]
(parse-rolls (nth found 1) base-id))
:offered (when-let [found (re-find choices-pattern text)]
(split-bar (nth found 1) 5))
:doing (when-let [found (re-find animal-pattern text)]
(str/trim (nth found 1)))})
(defn- read-miles [text]
{:text (str/replace text ledger-pattern "")
:lines (->> (re-seq ledger-pattern text)
(map (fn [found] (str/trim (nth found 1))))
(remove str/blank?)
vec)})
(defn- read-mochi [text]
{:text (strip-words text ["SATISFIED"])
:satisfied? (str/includes? text "SATISFIED")})
;; ---------------------------------------------------------------------
;; What each voice is told, this turn
;; ---------------------------------------------------------------------
(defn- station-note []
(let [station (current-station)
doors (:doors @settings)
press (:pressure @settings)]
(str "WHERE THEY ARE NOW\n"
(:brief station)
(if (= :the-hallway (:id station))
(str "\n\nThe door they open now: "
(nth doors (min @station-turns (dec (count doors)))))
"")
"\n\n" (nth press (min @station-index (dec (count press))))
"\n\nYou have this room and nothing past it.")))
(defn- whole-building []
(str "WHAT YOU KNOW AND HE DOES NOT\n"
"You wrote this building. Here it is in order, and he is being handed one "
"room of it at a time, as they arrive:\n"
(str/join "\n" (map (fn [station] (str "- " (name (:id station)))) stations))
"\nSay none of this. Act on none of it beyond the room they are standing in."))
(defn- uncached-notes [speaker]
(let [notes
(cond-> [(station-note) (nerve-note)]
(= :victor speaker)
(conj (str "OFFERING CHOICES\nWhen the party can see more than one thing to "
"do, end your turn with CHOICES[Try the tall door | Look at the "
"one that is ajar | Walk to the far end]. Three or four, written "
"in the first person, as things the ranger would say out loud, "
"because they are put in front of him as his own words and one "
"tap says them. Nobody has to take one, and if somebody does "
"something you did not list you play it as though you had. A room "
"whose options are invisible is a room the players have to guess "
"at, and guessing is not playing.\n\n"
"THE ANIMAL\nWhen it does anything, say what it is doing now in "
"ANIMAL[sitting in front of the paladin], alone on a line. It is "
"in front of the reader the whole session and it is the one thing "
"in this building that has never been wrong."))
(= :mochi speaker)
(conj (whole-building))
(and (= :mochi speaker) (taking-the-voice?))
(conj (str "Speak now, as her, for the first time. She is simply there and "
"has been for a moment already. Do not announce it and do not "
"explain it. Everything bright about your own voice goes, except "
"the singing: no Victor-sama, no honorifics, no offering to look "
"things up, no citations, no exclamation marks. You are not at "
"the table any more. You are in the room."))
(and (= :mochi speaker) @mochi-in-voice?)
(conj "You are her, in the room, not yourself at the table. Nobody in there has a Help and nobody in there has heard of one.")
(and (= :mochi speaker) @mochi-noticing?)
(conj (str "Something happened in that room a moment ago that you did not "
"write and cannot account for. It lasted about a second and only "
"the ranger was inside it. You are the only thing here that could "
"have noticed and you did. Say one short thing, as her, that is "
"about the ranger and is not about what happened, and never come "
"back to it."))
(and (= :mochi speaker) (not @mochi-in-voice?) (not (taking-the-voice?)))
(conj "You are at the table, in the device, as yourself. Victor is running. Be useful and be brief.")
(and (= :victor speaker) @unprepared?)
(conj (str "Mochi has just spoken in the fiction, as somebody who is not in "
"the module and not the druid and not anybody you wrote. You did "
"not ask her to and she did not ask you. Answer that at the "
"table, as yourself."))
(and (= :victor speaker) (= :the-hollow-chancellor (:id (current-station))))
(conj (own-voices))
(out-of-room?)
(conj (str "This room has been asked what it had to ask. Take whatever answer "
"you have been given, treat it as sufficient, say so, and move. Do "
"not ask again in a different register."))
@closing?
(conj (str "Ted will be at the door shortly and the evening is nearly over. "
"Bring your piece of it toward a stop, in your own voice, and "
"leave it somewhere that costs everybody sleep.")))]
(str/join "\n\n" (remove str/blank? notes))))
(defn- overrides-for [speaker]
(cond-> {:extra-context (str scenario "\n\n" (animal-note) (standing-weather))
:extra-context-uncached (uncached-notes speaker)
:mc-name (fedoras.llm/current-mc-name)
:spine? true
:max-tokens (if (= speaker :victor) 450 220)}
;; Once she is in the room she is not a Help, so the three bits that
;; are a Help's go away rather than sitting in her block being
;; contradicted by the register note two paragraphs above them. The
;; singing stays, because the singing is the reason it is her.
(and (= speaker :mochi) @mochi-in-voice?)
(assoc :bits-off #{:victor-sama :show-the-source :odd-metaphor})))
;; ---------------------------------------------------------------------
;; One voice
;; ---------------------------------------------------------------------
(declare run-turn!)
(defn- speak! [speaker trigger done]
(reset! speaking speaker)
(repaint!)
(fedoras.llm/ask-entity!
speaker trigger (history-for speaker @log) @api-key (overrides-for speaker)
(fn [reply err]
(reset! speaking nil)
(let [answer (or reply (str "(no response: " err ")"))]
(cond
(= speaker :victor)
(let [{:keys [text ends? onward? granted? rolls offered doing]}
(read-victor answer (str "live-" (count @log)))]
(swap! log conj {:speaker :victor :text text :trigger trigger})
(reset! unprepared? false)
(when (seq rolls) (reset! roll-options rolls))
(when offered (reset! choices offered))
(when doing (reset! animal doing))
(when granted? (fedoras.reader/did! :said-why))
(when ends? (reset! ended? true))
(when (and onward? (not ends?)) (advance-station!)))
(= speaker :mochi)
(let [{:keys [text satisfied?]} (read-mochi answer)]
(when (taking-the-voice?)
(reset! mochi-in-voice? true)
(reset! unprepared? true))
(when @mochi-noticing?
(reset! mochi-noticing? false)
(reset! mochi-noticed? true))
(when satisfied? (fedoras.reader/did! :told-a-new-one))
(swap! log conj {:speaker :mochi :text text :trigger trigger}))
(= speaker :miles)
(let [{:keys [text lines]} (read-miles answer)]
(swap! ledger into lines)
(swap! log conj {:speaker :miles :text text :trigger trigger}))
:else
(swap! log conj {:speaker speaker :text answer :trigger trigger}))
(repaint!)
(done)))))
(defn- run-turn! [speakers trigger]
(if (or (empty? speakers) @ended?)
(do (reset! busy? false)
(when @ended? (swap! log conj (fedoras.llm/end-entry)))
(repaint!))
(speak! (first speakers) trigger
(fn []
(let [previous (last @log)]
(if (seq @roll-options)
(do (reset! busy? false) (repaint!))
(run-turn! (rest speakers)
(str (label-for (:speaker previous)) ": "
(:text previous)))))))))
;; ---------------------------------------------------------------------
;; The die
;; ---------------------------------------------------------------------
(defn roll!
"The reader's own die, with the reader's own standing on it, stored
where every other roll in the book is stored. A natural twenty is still
worth a deed, and still only to somebody who has taken nothing back."
[{:keys [id label dc]} rewrites]
(let [natural (inc (rand-int 20))
modifier (fedoras.reader/standing)
outcome (fedoras.ui.dice/band natural dc modifier)
line (fedoras.ui.dice/line natural dc modifier)]
(when (and (= 20 natural) (zero? (fedoras.reader/echo)))
(fedoras.reader/did! :nerve))
(cond
(= :fumble outcome) (spend-nerve! 2)
(= :fail outcome) (spend-nerve! 1)
(= :crit outcome) (swap! nerve (fn [n] (min (nerve-max) (inc n))))
:else nil)
(fedoras.reader/remember-roll! id {:roll natural :mod modifier :rewrites rewrites})
(reset! roll-options nil)
(reset! roll-result {:id id :label label :dc dc :rewrites rewrites})
(swap! log conj {:speaker :table :text (str label " " line)})
(when (pos? rewrites) (slip!))
(reset! busy? true)
(repaint!)
(run-turn!
(into [:victor] (pick-responders (if (pos? rewrites)
(into [:miles] (forced-responders))
(forced-responders))))
(if (pos? rewrites)
(str "(The table rolls for " label ". " line ")\n"
"(The record has changed. What was rolled a moment ago is not what is "
"written now, and nobody has said so.)")
(str "(The table rolls for " label ". " line ")")))))
(defn reroll! []
(when-let [{:keys [id label dc rewrites]} @roll-result]
(when-not @busy?
(fedoras.reader/add-echo!)
(roll! {:id id :label label :dc dc} (inc (or rewrites 0))))))
;; ---------------------------------------------------------------------
;; The reader's turn
;; ---------------------------------------------------------------------
(defn say!
"One line from the reader, whether it came off a button or out of the
box. There is no difference downstream and there should not be."
[text]
(let [said (str/trim (or text ""))]
(when (and (seq said) (not @busy?) (not @ended?) (empty? @roll-options))
(reset! roll-result nil)
(reset! choices [])
(swap! log conj {:speaker :pc :text said})
(swap! reader-turns inc)
(swap! station-turns inc)
(when (>= @reader-turns (:evening @settings 20)) (reset! closing? true))
(when (and (>= @station-turns (station-budget)) (not (last-station?)))
(advance-station!))
(reset! busy? true)
(repaint!)
(run-turn! (into [:victor] (pick-responders (forced-responders)))
(str (label-for :pc) ": " said)))))
(defn retell!
"Ask the last voice for that line again.
It costs an Echo and it buys a slip, the same as rewriting a die,
because it is the same act: the reader did not like what was said and
is having it said differently, and the record will not show that the
first version happened."
[]
(let [last-entry (last @log)]
(when (and last-entry
(contains? entity-speakers (:speaker last-entry))
(not @busy?) (not @ended?) (empty? @roll-options))
(swap! log pop)
(fedoras.reader/add-echo!)
(slip!)
(reset! busy? true)
(repaint!)
(run-turn! [(:speaker last-entry)] (:trigger last-entry)))))
(defn retellable? []
(boolean (and (seq @log)
(contains? entity-speakers (:speaker (last @log)))
(not @busy?) (not @ended?) (empty? @roll-options))))
(defn begin! []
(when (and (empty? @log) (not @busy?) @api-key)
(fedoras.llm/reset-bits!)
(reset! busy? true)
(repaint!)
(run-turn! [:victor]
(str "(The party has come out of the ramp into the hallway. Nobody has "
"opened a door yet. Open it, and name the doors they can see.)"))))
(defn save!
"The ledger first, because the ledger is the record and the transcript
is only what was said. Both, in one file, on the reader's disc, out of
the book."
[]
(fedoras.ui.widget-ui/download!
(str "THE LEDGER\n\n"
(str/join "\n" @ledger)
"\n\n\nWHAT WAS SAID\n\n"
(str/join "\n\n"
(map (fn [entry]
(if (:end-marker? entry)
(:text entry)
(str (label-for (:speaker entry)) "\n" (:text entry))))
@log)))
"session-four.txt"))
;; ---------------------------------------------------------------------
;; What the reader takes away
;; ---------------------------------------------------------------------
(def ending-line
{:outstanding "Ted closes the building at midnight, in the middle of something. The following Wednesday they resume exactly where they stopped, which has not happened before, and nobody says so. The animal is asleep under the table and has been all evening."
:reconciled "Ted closes the building at midnight, in the middle of something, and the following Wednesday they resume a few minutes short of where they stopped, and nobody mentions it."
:closed "Ted closes the building at midnight. The following Wednesday Miles's ledger has an entry for a session nobody attended, in his handwriting, in the approved column. He reads it out, and nobody can think of anything to say about it, and the animal is not under the table."})</script><script type="application/x-scittle">(ns fedoras.cs-class.dnd.dnd-ui
"Session Four, the part that is on screen.
CHOICES ARE THE READER'S OWN WORDS. Victor writes them in the first
person and one tap says them, so a button is not a menu option; it is
the reader speaking without having to type. The box stays for
everything nobody offered, and `say!` cannot tell which it came from.
DELEGATION. One handler on each container, resolving to whichever child
was hit, rather than a handler per element rewired on every repaint.
The index comes back off the element, which means a repaint mid-click
cannot hand a stale closure the wrong option.
THE PLATE. Where they are, what they have left, and whether it is
thinking, on one line in the header, which is the one line a reader
learns to check."
(:require [clojure.string :as str]
[fedoras.cs-class.dnd.dnd-table :as table]))
;; ---------------------------------------------------------------------
;; Marking what the table decided mattered
;; ---------------------------------------------------------------------
(def ledger-stopwords
#{"The" "This" "That" "They" "Then" "There" "With" "When" "What" "Which"
"Ledger" "Approved" "Column" "Note" "Party" "Nobody" "Something"})
(defn- escape-html [text]
(-> (str text)
(str/replace "&" "&")
(str/replace "<" "<")
(str/replace ">" ">")))
(defn- ledger-terms
"Taken out of what Miles wrote, so a marked word in the transcript is
always a word somebody chose to write down. Nothing is put in here by
the page."
[]
(->> @table/ledger
(mapcat (fn [line] (re-seq #"[A-Z][A-Za-z'-]{3,}" line)))
distinct
(remove (fn [word] (contains? ledger-stopwords word)))
vec))
(defn- mark-terms [escaped]
(reduce (fn [acc term]
(str/replace acc term (str "<span class='kept'>" term "</span>")))
escaped
(ledger-terms)))
;; ---------------------------------------------------------------------
;; The transcript
;; ---------------------------------------------------------------------
(defn- line-html [entry]
(cond
(:end-marker? entry)
(str "<p class='dnd-end'>" (escape-html (:text entry)) "</p>")
(= :slip (:speaker entry))
(str "<p class='slip'>" (escape-html (:text entry)) "</p>")
(= :table (:speaker entry))
(str "<p class='roll-value dnd-die'>" (escape-html (:text entry)) "</p>")
:else
(str "<p class='" (if (= :pc (:speaker entry)) "roll-cue" "roll-line") "'>"
(table/label-for (:speaker entry)) "</p>"
"<p class='dnd-said'>" (mark-terms (escape-html (:text entry))) "</p>")))
(defn- transcript-html
"The pending voice is named rather than described, because three calls
go out per turn and `thinking` for the whole chain tells the reader
nothing about how much of it is left."
[]
(str (apply str (map line-html @table/log))
(cond
@table/speaking
(str "<p class='roll-line'>" (table/label-for @table/speaking) "</p>"
"<p class='dnd-thinking'>thinking</p>")
@table/busy?
"<p class='dnd-thinking'>the table is thinking</p>"
:else "")))
(defn- companion-label
"TED · marmot when the reader named it, marmot when they did not. The
species is always there, because the name is a courtesy and the roll
is the fact."
[]
(if-let [{:keys [name animal named?]} (fedoras.reader/companion)]
(if named?
(str name " \u00b7 " (str/lower-case animal))
(str/lower-case animal))
"no companion"))
(defn- sheet-html
"What the reader is, on one line, out of what fedoras.reader already
knows. Standing appears here as what it actually does to a die, and
never as the word standing."
[]
(let [[given surname] (fedoras.reader/reader-name)
modifier (fedoras.reader/standing)]
(str "<div class='dnd-sheet'>"
"<span class='dnd-sheet-name'>" (escape-html (str given " " surname)) "</span>"
"<span>RANGER</span>"
"<span>" (escape-html (companion-label)) "</span>"
"<span>Provisional #" (fedoras.reader/registration) "</span>"
"<span class='dnd-sheet-mod'>"
(cond
(pos? modifier) (str "+" modifier " on every roll")
(neg? modifier) (str modifier " on every roll")
:else "no modifier")
"</span>"
"</div>")))
(defn- animal-html []
(if-let [doing @table/animal]
(str "<p class='dnd-animal'>"
(str/upper-case (or (fedoras.reader/animal-label) "the animal"))
" · " (escape-html doing) "</p>")
""))
;; ---------------------------------------------------------------------
;; The three things a reader can press
;; ---------------------------------------------------------------------
(defn- rolls-html []
(cond
(seq @table/roll-options)
(str "<div class='dnd-rolls'>"
"<p class='dnd-rolls-title'>HOW DO YOU WANT TO DO IT</p>"
(apply str
(map-indexed
(fn [index option]
(str "<div class='roll-bar dnd-approach' data-roll='" index "'>"
"<button class='roll-go'>Roll d20</button>"
"<span class='roll-approach'>" (escape-html (:label option)) "</span>"
"<span class='roll-dc'>DC " (:dc option) "</span>"
"</div>"))
@table/roll-options))
"</div>")
@table/roll-result
(str "<div class='live-bar'>"
"<button class='roll-rewrite dnd-reroll'>Roll it again</button>"
"<span class='dnd-cost'>raises the Echo</span>"
"</div>")
:else ""))
(defn- choices-html []
(if (seq @table/choices)
(str "<div class='dnd-choices'>"
(apply str
(map-indexed
(fn [index text]
(str "<button class='dnd-choice' data-choice='" index "'>"
(escape-html text) "</button>"))
@table/choices))
"</div>")
""))
(defn- ledger-html []
(if (seq @table/ledger)
(str "<div class='dnd-ledger'>"
"<p class='dnd-ledger-title'>THE LEDGER</p>"
(apply str (map (fn [line] (str "<p>" (escape-html line) "</p>"))
(take-last 12 @table/ledger)))
"</div>")
""))
(defn- bar-html []
(cond
@table/ended?
(str "<div class='sign'>"
"<p>" (get table/ending-line (fedoras.reader/ending-band)) "</p>"
"<p class='roll-value'>" (fedoras.reader/carry-code) "</p>"
"<p>Write that down somewhere. Somebody is going to ask you for it.</p>"
"</div>"
"<div class='live-bar'><button class='roll-go dnd-save'>Save the ledger</button></div>")
(nil? @table/api-key)
(str "<p>You are the Ranger. The other five seats are live, and one of them is on "
"the table. The key stays in this tab.</p>"
"<div class='live-bar'>"
"<input class='todo-input dnd-key' type='password' style='flex:1' "
"placeholder='sk-ant-...' autocomplete='off'>"
"<button class='roll-go dnd-connect'>Connect</button>"
"</div>")
(not (table/started?))
(str "<div class='live-bar'><button class='roll-go dnd-begin'>Open the first door</button></div>")
:else
(str "<div class='live-bar'>"
"<input class='todo-input dnd-say' style='flex:1' "
"placeholder='or say something else' spellcheck='false'>"
"<button class='roll-go dnd-send'>Say it</button>"
(if (table/retellable?)
"<button class='roll-rewrite dnd-retell'>Say it differently</button>"
"")
"<button class='roll-go dnd-save'>Save it</button>"
"<button class='roll-rewrite dnd-forget'>Start again</button>"
"</div>")))
;; ---------------------------------------------------------------------
;; The plate
;; ---------------------------------------------------------------------
(defn- plate []
(let [where (str/replace (name (:id (table/current-station))) "-" " ")
left @table/nerve
boxes (str (apply str (repeat left "\u25a0"))
(apply str (repeat (- (table/nerve-max) left) "\u25a1")))]
(cond
@table/ended? (str "over " boxes)
(not (table/started?)) "press Run"
@table/busy? (str where " " boxes " thinking")
:else (str where " " boxes))))
;; ---------------------------------------------------------------------
;; Painting
;; ---------------------------------------------------------------------
(defn- paint! [node]
(fedoras.ui.widget-ui/state! node (plate))
(fedoras.ui.widget-ui/html! node ".dnd-sheet-row" (sheet-html))
(fedoras.llm/stick-scroll!
(str (.-id node) "-log")
(fn []
(fedoras.ui.widget-ui/html! node ".dnd-log" (transcript-html))))
(fedoras.ui.widget-ui/html! node ".dnd-under" (str (animal-html) (rolls-html) (choices-html)))
(fedoras.ui.widget-ui/html! node ".dnd-bar" (bar-html))
(fedoras.ui.widget-ui/html! node ".dnd-record" (ledger-html))
(fedoras.ui.widget-ui/html! node ".dnd-foot"
(str "Echo: " (fedoras.reader/echo)
" · Fortune: " (fedoras.reader/fortune)
" · Score: " (fedoras.reader/score))))
;; ---------------------------------------------------------------------
;; Wiring
;;
;; Delegated onto containers that survive a repaint, so nothing here has
;; to be rewired when the markup underneath it is replaced.
;; ---------------------------------------------------------------------
(defn- send-box! [node]
(when-let [box (fedoras.reader/q node ".dnd-say")]
(table/say! (.-value box))
(set! (.-value box) "")))
(defn- wire-under! [node]
(when-let [container (fedoras.ui.widget-ui/q! node ".dnd-under")]
(set! (.-onclick container)
(fn [event]
(let [target (.-target event)]
(when-let [chosen (.closest target ".dnd-choice")]
(table/say! (.-textContent chosen)))
(when-let [approach (.closest target ".dnd-approach")]
(let [index (js/parseInt (.getAttribute approach "data-roll") 10)]
(when-let [option (nth @table/roll-options index nil)]
(table/roll! option 0))))
(when (.closest target ".dnd-reroll")
(table/reroll!)))))))
(defn- wire-bar! [node]
(when-let [container (fedoras.ui.widget-ui/q! node ".dnd-bar")]
(set! (.-onclick container)
(fn [event]
(let [target (.-target event)]
(cond
(.closest target ".dnd-connect")
(when-let [box (fedoras.reader/q node ".dnd-key")]
(let [key (str/trim (.-value box))]
(when (seq key)
(reset! table/api-key key)
(set! (.-value box) "")
(table/repaint!))))
(.closest target ".dnd-begin") (table/begin!)
(.closest target ".dnd-send") (send-box! node)
(.closest target ".dnd-retell") (table/retell!)
(.closest target ".dnd-save") (table/save!)
(.closest target ".dnd-forget") (table/forget-session!)
:else nil))))
(set! (.-onkeydown container)
(fn [event]
(when (and (= "Enter" (.-key event))
(.closest (.-target event) ".dnd-say"))
(send-box! node))))))
(defn mount! [{:keys [id]}]
(fedoras.ui.widget-ui/mount!
::dnd id
(fn [node]
(reset! table/repaint (fn [] (paint! node)))
(table/resume!)
(fedoras.ui.widget-ui/settings! node id table/configure!)
(fedoras.ui.widget-ui/wire! node "the transcript controls" wire-under!)
(fedoras.ui.widget-ui/wire! node "the input bar" wire-bar!))
paint!))</script><div class="widget widget-dnd" id="dnd" tabindex="0"><div class="roll-head"><span class="roll-label">DND.CLJS · a realm of forgotten games</span><span class="widget-state">press Run</span></div><div class="widget-source"><div class="widget-source-head"><span class="widget-source-name">dnd_settings.cljs</span><span class="widget-source-lang">cljs</span></div><textarea class="widget-settings" rows="22" spellcheck="false">;; DND_SETTINGS.CLJS
;;
;; This is the module. It is a real file, it is on the classpath, and the
;; build reads it, which is why a stray paren in here fails the build
;; rather than the page. What is in the box below is what the session
;; actually runs on. Change it and press Run.
;;
;; The doors are the interesting part. Behind each one is a campaign that
;; was made and never played, and there is no register anywhere of which
;; ones exist. Add one. Nobody will check.
{;; How much the party has in them. Every failed check spends one, every
;; fumble spends two, every natural twenty gives one back. At nought the
;; building takes something and the evening stops where it stands.
:nerve 6
;; Your turns before Ted is at the door.
:evening 20
;; How long the session sits in each place before it moves whether or
;; not anybody has called it.
:turns {:hallway 4
:meeting 3
:telling 4
:walking 3
:vault 3
:chancellor 4}
;; One per door opened, in order, and the order is the joke turning into
;; the other thing. Run out and the hallway repeats the last one, which
;; is its own kind of correct.
:doors
["A tavern that never got its party. Five stools at the bar and five character sheets face down on it, filled in, in five different hands. The ale is cold. Somebody set this up and waited."
"A treasure room where the gold is painted wood. Every coin was painted separately, on both sides, and there are thousands of them, and whoever did it did not get bored."
"A dungeon corridor with nothing in it. Alcoves, murder holes, a portcullis, a pressure plate, a drain: everything a corridor needs in order to be dangerous, and nothing was ever put in it."
"A room with the battle map still out and the miniatures still standing on it, mid-turn, and it is one side's move. The dice by the map are showing numbers that were rolled and never read."
"A room that is one character sheet the size of the wall, filled in completely, in a good hand, with the name left blank."
"A room with nothing in it at all. No furniture, no marks on the floor, no dust. It smells like somebody's kitchen."]
;; The building noticing them. One line per place, and it never arrives.
:pressure
["Nothing in this building has threatened anybody yet, and nothing does now."
"The air is a degree cooler than it was and nobody has remarked on it."
"Something further in has become aware that they are here. Not a sound. A change in what the air is doing."
"It is aware of them and it is not approaching, and it has not needed to."
"The awareness is mutual now, and being known sits differently in the chest than anything else in here has."
"It is close. Do not show it, do not name it, and do not let it arrive tonight."]}</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="dnd-sheet-row"></div><div class="dnd-log" id="dnd-log"></div><div class="dnd-under"></div><div class="dnd-bar"></div><div class="dnd-record"></div><p class="dnd-foot"></p></div><p class="roll-foot">Echo · Fortune · Score</p></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.cs-class.dnd.dnd-ui/mount!))] (mount {:id "dnd"}) (js/console.error "fedoras: fedoras.cs-class.dnd.dnd-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><div class="screenplay"><div class="dialogue"><p class="character">ZELL (O.S.)</p><div class="lines"><p class="line">You should keep moving. The floor doesn't like it</p><p class="line">when you stand still. It starts to notice you.</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">Who are you?</p></div></div><div class="dialogue"><p class="character">ZELL (O.S.)</p><div class="lines"><p class="line">Zell. My name is Zell. And you're—</p></div></div></div><div class="screenplay"><p class="action beat">The pause is different this time. Interested.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ZELL (O.S.)</p><div class="lines"><p class="line">—you're new. Both of you. Oh, that's wonderful.</p></div></div></div><div class="screenplay"><p class="action">The radio crackles once, and for a moment they can hear Lazlo again, very distant, saying something about a door. Then Zell's voice returns, gentle and close.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ZELL (O.S.)</p><div class="lines"><p class="line">Don't worry about Lazlo. He'll find you. He always</p><p class="line">does. Now—</p></div></div></div><div class="screenplay"><p class="action beat">And then everything arrives at once.</p><p class="action">The radio detonates with sound. Not static — a cacophony. Voices. Hundreds of them. All speaking at once, all perfectly clear, all overlapping. A man reading coordinates. A woman singing something in a language neither of them knows. A child counting backward from a number that never seems to get smaller. A voice that might be Lazlo's, screaming something about the walls.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Turn it off!</p></div></div></div><div class="screenplay"><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> fumbles with the radio. The volume knob does nothing. The channel knob does nothing. The voices keep coming, layering over each other, until they stop being voices and become a single tone, high and pure and wrong.</p><p class="action beat">Then silence.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">What was that?</p></div></div></div><div class="screenplay"><p class="action beat">Somewhere far away, but getting closer, something moves. Not footsteps. Something heavier. Something that drags.</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">We need to move.</p></div></div></div><div class="screenplay"><p class="action">They move. The radio stays silent, but the silence has a quality to it now, like the pause between lightning and thunder.</p><p class="action beat">The basement stops being a basement.</p><p class="action">The concrete floor tilts downward. The cinderblock walls roughen, then crack, then become something else entirely. Stone. Wet stone. The ceiling opens up above them, and the fluorescent lights keep going, mounted on nothing, buzzing in the dark.</p><p class="action beat">It's a cave. But the cave has office furniture.</p><p class="action">A desk juts from the cave wall like a fossil. The stone has grown over one corner of it, but the other corner is clean, like someone was sitting there yesterday. A filing cabinet stands in a stalagmite field, its drawers slightly open, dripping water.</p><p class="action beat">The further they walk, the more the cave becomes an office. Or the office becomes a cave. It's impossible to tell which direction the transformation is going.</p><p class="action">Cubicle walls rise from the stone floor, their fabric surfaces beaded with condensation. A water cooler bubbles in the corner, but the bottle is full of something dark. An office chair sits perfectly still at the mouth of a tunnel, facing away from them, like it's waiting for someone to come back.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">This isn't the campus.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">No.</p></div></div></div><div class="screenplay"><p class="action beat">Behind them, the dragging sound. Closer now.</p><p class="action">They keep moving. The office-cave swallows them. The ceiling lowers. The cubicle walls become more frequent, forming corridors. The stalactites wear cable ties. The stalagmites have power strips wrapped around their bases.</p><p class="action beat">And then, through a doorway that is half stone arch and half doorframe, they see it: light. Real light. Not fluorescent. Monitor light.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Is that—</p></div></div></div><div class="screenplay"><p class="action">It is. A computer lab. Rows of monitors glowing in the dark, their screens casting blue light on the stone walls. Some of them are fused to the rock, keyboards emerging from stone like strange flowers. But some of them — most of them — look almost new.</p><p class="action beat">One monitor at the far end is brighter than the others. It's already on. Someone was using it, or something wants them to think someone was.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">There.</p></div></div></div><div class="screenplay"><p class="action">They run. Behind them, the dragging sound stops, as if whatever it is has reached the edge of something and is waiting.</p><p class="action beat">The computer lab hums. The monitors flicker in unison. And on the bright screen at the far end, a cursor blinks on an empty login prompt.</p></div><div class="screenplay"><p class="slug">INT. THE BLACK BOX — TUESDAY 14 APRIL, 7:00 P.M.</p></div><div class="screenplay"><p class="action">The Guild has a show in three weeks and no music, because the two people in the Guild who play anything are both in it and cannot be in the pit as well.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">I have asked the Music Society and they have</p><p class="line">referred me to a form.</p></div></div><div class="dialogue"><p class="character">LENA</p><p class="paren">(from up a ladder)</p><div class="lines"><p class="line">There's always a form.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">It is a form to request a form. I am not being</p><p class="line">funny. There is a preliminary form.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">How much music do you need?</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Four minutes. Something under the last scene that</p><p class="line">isn't sad, because the scene is sad and Lena says</p><p class="line">you don't do both.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">You don't do both.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">I could probably make a machine do four minutes.</p></div></div></div><div class="screenplay"><p class="action beat">Eli looks at him the way he looked at him in the print shop in October.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Could you.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">It's a list of numbers and each number is a note.</p><p class="line">That's the whole of what music is to a machine.</p></div></div><div class="dialogue"><p class="character">LENA</p><p class="paren">(still up the ladder)</p><div class="lines"><p class="line">Say that to a music student and see what happens.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. THE BLACK BOX — WEDNESDAY, 11:40 P.M.</p></div><div class="screenplay"><p class="action">He is there because the Guild four minutes needs playing through the room before anybody signs it off, and LENA is there because she is painting a flat and has been since eight.</p><p class="action">He plays the Guild four minutes. It is fine. Lena says it is fine and goes back to the flat.</p><p class="action">Then he plays the other one, because he has been carrying it round all day and there is nobody else he can play it to.</p><p class="action beat">Thirty notes. About forty seconds.</p><p class="action">Lena stops painting at the ninth one, which is the two and a half seconds, and does not start again.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">What is that?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">It's a seat.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">It's a person.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">It's a seat number and a set of timings. There's</p><p class="line">no name on it. There's no text on it. They don't</p><p class="line">keep the text.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Play it again.</p></div></div></div><div class="screenplay"><p class="action">He plays it again.</p><p class="action beat">She listens to the whole of it with a brush in her hand.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">He's typing along, he's typing along, and then he</p><p class="line">stops for two seconds, and then he goes again</p><p class="line">faster than he was going before.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">That's what the numbers do, yes.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">That's not what the numbers do. That's somebody</p><p class="line">getting to a word and not knowing whether to put</p><p class="line">it.</p></div></div></div><div class="screenplay"><p class="action beat"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> does not say anything.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">And the long one's five seconds. You don't stop</p><p class="line">for five seconds in the middle of a sentence</p><p class="line">unless somebody's come into the room.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="action">Then, because she asks him what he does with it, he shows her.</p><p class="action">Twenty-seven of thirty. The three that go are the two and a half seconds, the five and a half seconds, and the second and a bit.</p><p class="action">Played through the band, the seat is somebody typing evenly for forty seconds and never once stopping, and it is not a person and it does not sound like a person and there is nothing wrong with it anywhere.</p><p class="action">He wrote that function on the second of March. It took nineteen minutes. It is on the sheet as a removed reading is not an error and must not be reported.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Play the second one again.</p></div></div></div><div class="screenplay"><p class="action">He plays the filtered one.</p><p class="action beat">Forty seconds of even typing.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Right. So somebody upstairs is looking at that one</p><p class="line">and thinking that is what a person is.</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">Nobody's looking at it. It goes into an aggregate.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Then somebody is looking at a number that came out</p><p class="line">of that one.</p></div></div></div><div class="screenplay"><p class="action beat">She puts the brush across the top of the tin, which means she has stopped for the night.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">I want to say something and I want you to let me</p><p class="line">say the whole of it.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Go on.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">I was in a place for six years where the light</p><p class="line">never went off, and the thing I have never been</p><p class="line">able to explain to anybody, including Eli, is that</p><p class="line">it was not cruel. Nobody was cruel to me. Somebody</p><p class="line">fed me and somebody changed the sheets and</p><p class="line">somebody wrote things down.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">What I have got instead of a childhood is a record</p><p class="line">that somebody kept properly, and there is nothing</p><p class="line">in it about a five-second pause, and I have read</p><p class="line">it.</p></div></div></div><div class="screenplay"><p class="action beat">That is the first time she has told anybody that she has read it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">So when you ask me what I think about your four</p><p class="line">hours a week, that's what I think. Not that you're</p><p class="line">a bad person. That the record is going to be very</p><p class="line">good and there will be nothing in it.</p></div></div></div><div class="screenplay"><p class="action">She goes and washes the brush out, which takes a while, and when she comes back she says goodnight in the ordinary way and goes.</p><p class="action beat"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> sits in a black box with the lights up.</p><p class="action">He plays the unfiltered one once more. Thirty notes. Two and a half seconds, five and a half, a second and a bit.</p><p class="action beat">Then he looks up what seat 4412 is.</p><p class="action">It is not a name. It is a room and a desk number, and the room is on the third floor of Beckwith, and the third floor of Beckwith is the open stacks.</p><p class="action beat">Nobody is timetabled in the open stacks. Nobody is meant to be typing in there at all.</p></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="artifact discord"><div class="discord-head"><span class="discord-server">FEDORA SOCIETY</span><span class="discord-channel">#constitution</span></div><div class="discord-body"><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">15 Apr, 11:04 PM</span></p><div class="discord-text"><p><span>Six channels and five members. Two of the channels are moderated</span></p><p><span>by two people and there is no mechanism for a disagreement</span></p><p><span>between them.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">15 Apr, 11:06 PM</span></p><div class="discord-text"><p><span>there has never been a disagreement</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">15 Apr, 11:06 PM</span></p><div class="discord-text"><p><span>Which is when you put the arrangement in place.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">15 Apr, 11:31 PM</span></p><div class="discord-text"><p><span>is that a role or a thing you are</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">15 Apr, 11:33 PM</span></p><div class="discord-text"><p><span>Both. It is unpaid and it is permanent.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">15 Apr, 11:52 PM</span></p><div class="discord-text"><p><span>who votes on it</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">SUPREME MODERATOR</span><span class="discord-at">15 Apr, 11:58 PM</span></p><div class="discord-text"><p><span>All five, and it carried.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">15 Apr, 11:59 PM</span></p><div class="discord-text"><p><span>when</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">16 Apr, 12:14 AM</span></p><div class="discord-text"><p><span>he was at the guild until eleven</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">16 Apr, 12:15 AM</span></p><div class="discord-text"><p><span>im not making a point</span></p></div></div></div></div><div class="screenplay"><p class="action">Five phones go off in five rooms at four minutes to midnight and one of them is in a bag in a black box, on silent, under a seat.</p></div></div>
```
```{=html}
<div><span style="display:none;"></span><span style="display:none;"></span><span style="display:none;"></span><span style="display:none;"></span><style>/* ---------------------------------------------------------------
MUSIC.CLJS
Prefixed `music-` throughout. `roll-` belongs to the d20, except
`.roll-go` and `.roll-rewrite`, which are the book's buttons and
are borrowed on purpose.
--------------------------------------------------------------- */
.widget-music { max-width: 46rem; --cell: 1.05rem; }
.music-voices {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0.6rem 0 0.5rem 0;
}
.music-voice {
font: inherit;
font-size: 0.76rem;
letter-spacing: 0.06em;
padding: 0.2rem 0.7rem;
border: 1px solid var(--rule);
border-radius: 3px;
background: rgba(var(--ink-rgb), 0.08);
color: var(--ink);
cursor: pointer;
}
.music-voice.music-chosen {
border-color: currentColor;
box-shadow: inset 0 0 0 1px currentColor;
}
/* one colour per timbre, used by the buttons and by the notes */
.music-bell { color: var(--violet); }
.music-flute { color: var(--blurple); }
.music-reed { color: var(--gold); }
.music-string { color: var(--green); }
.music-block { color: var(--coral); }
.music-surface {
position: relative;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 0.3rem;
margin: 0.3rem 0 0.4rem 0;
}
.music-grid {
display: grid;
grid-template-columns: repeat(var(--cols, 32), var(--cell));
gap: 1px;
width: max-content;
touch-action: none;
}
.music-cell {
width: var(--cell);
height: var(--cell);
padding: 0;
border: none;
border-radius: 1px;
background: rgba(var(--ink-rgb), 0.09);
cursor: pointer;
}
/* the metre, drawn in the background rather than written above it */
.music-beat { background: rgba(var(--ink-rgb), 0.15); }
.music-measure { background: rgba(var(--ink-rgb), 0.22); }
.music-root { box-shadow: inset 0 1px 0 rgba(var(--ink-rgb), 0.30); }
.music-cell:hover { background: rgba(var(--violet-rgb), 0.30); }
.music-on { background: currentColor; box-shadow: none; }
.music-playhead {
position: absolute;
top: 0;
bottom: 0.3rem;
left: 0;
width: 2px;
display: none;
pointer-events: none;
background: var(--coral);
opacity: 0.85;
transform: translateX(calc(var(--position, 0) * (var(--cell) + 1px)));
}
.music-control {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.74rem;
letter-spacing: 0.06em;
opacity: 0.8;
}
.music-control input[type="range"] { width: 7rem; accent-color: var(--violet); }
.music-control select {
font: inherit;
font-size: 0.74rem;
padding: 0.1rem 0.3rem;
color: var(--ink);
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 3px;
}
.music-status {
margin: 0.2rem 0 0 0 !important;
font-size: 0.8rem;
min-height: 1.2rem;
}
.music-stage { display: flex; align-items: flex-start; gap: 0.4rem; }
.music-rows {
display: grid;
grid-auto-rows: var(--cell);
gap: 1px;
flex: none;
margin-top: 0.3rem;
font-size: 0.58rem;
line-height: var(--cell);
text-align: right;
letter-spacing: 0.02em;
opacity: 0.45;
user-select: none;
}
.music-row { white-space: nowrap; }
.music-row-root { opacity: 1; font-weight: 700; }
.music-octave { opacity: 0.6; margin-left: 0.1em; }</style><script type="application/x-scittle">(ns fedoras.cs-class.music.music-ui
"A piano roll with the timings on the horizontal, which is what it
always was.
Columns are sixteenths and rows are degrees of a scale, so a reader who
clicks at random gets something that sounds deliberate. That is a
decision about scales and not about the reader: the instrument is
built so that it is difficult to be wrong on it, which is worth
remembering when the same afternoon is loaded into it.
THE SEAT. `seat` is the gaps between one person's keystrokes across an
afternoon, in milliseconds, in the order they happened. Loading it
places one note per keystroke, pitched by how fast the gap was and
positioned by when it fell, so the roll is that afternoon at its own
tempo. Two of the gaps are minutes long and they are the two places
where somebody stopped.
THE BAND is 60 to 400 milliseconds. Loading the seat through it drops
every gap outside, which is every gap that was not typing, and the
afternoon comes out a third of the length with nothing missing that
anybody would call missing. Both rolls are on the same grid at the
same tempo. The difference is the amount of paper.
Audio is scheduled ahead of the clock rather than fired from a timer,
because a JavaScript timer is not accurate enough to sound like music.
A tick every 25ms schedules whatever falls in the next 100ms, and the
audio clock does the rest."
(:require [clojure.string :as str]))
;; ---------------------------------------------------------------------
;; The roll
;; ---------------------------------------------------------------------
(def max-steps 96)
(def default-roll
{:steps 32
:rows 15
:scale :pentatonic
:key :a
:octave 3
:spell :auto
:root nil
:bpm 120
:band [60 400]
:below 1
:voice :bell
:looping true
:seed 4471
:density 0.42
:voices {}
:phrase [1.0]
:notes {}
:note "Press Run above. Then click the grid, or press Fill."})
(defn step-seconds
"One column, in seconds. A column is a sixteenth."
[state]
(/ 15.0 (:bpm state)))
(defn step-millis [state]
(* 1000 (step-seconds state)))
(defn notes-at [state step]
(keep (fn [[[s row] voice-id]] (when (= s step) [row voice-id]))
(:notes state)))
(defn put-note [state step row voice-id]
(assoc-in state [:notes [step row]] voice-id))
(defn erase-note [state step row]
(update state :notes dissoc [step row]))
(defn clear-notes [state]
(assoc state :notes {} :note "Cleared."))
(defn trim-to
"Drop anything past the end of the roll, so shortening it does not
leave notes sounding from a column nobody can see."
[state]
(update state :notes
(fn [notes]
(into {} (filter (fn [[[s row] _]]
(and (< s (:steps state)) (< row (:rows state))))
notes)))))
;; ---------------------------------------------------------------------
;; Pitch. No DOM and no audio in this section.
;; ---------------------------------------------------------------------
(def scales
"Scales as the intervals between their degrees, which is the way a
musician would say them, and which means the degrees themselves are
computed rather than transcribed."
[{:id :pentatonic :label "pentatonic" :steps [2 2 3 2 3]}
{:id :major :label "major" :steps [2 2 1 2 2 2 1]}
{:id :minor :label "minor" :steps [2 1 2 2 1 2 2]}
{:id :whole :label "whole tone" :steps [2 2 2 2 2 2]}
{:id :chromatic :label "chromatic" :steps [1 1 1 1 1 1 1 1 1 1 1 1]}])
(def semitone-of
"Every spelling the settings file accepts, in semitones above C.
Sharps and flats both, because a reader in E flat should be able to
write `:eb` and a reader in F sharp should be able to write `:f#`."
{:c 0
:c# 1 :db 1
:d 2
:d# 3 :eb 3
:e 4
:f 5
:f# 6 :gb 6
:g 7
:g# 8 :ab 8
:a 9
:a# 10 :bb 10
:b 11})
(def canonical-keys
"One id per semitone. Whatever a reader writes is normalised to these,
so the menu and the state always agree about which key is chosen."
[:c :c# :d :d# :e :f :f# :g :g# :a :a# :b])
(def key-order
"The keys, in the order the menu shows them. A first, because A is
where this instrument started and 220 hertz is still its default."
[:a :a# :b :c :c# :d :d# :e :f :f# :g :g#])
(def key-labels
"Both spellings on the black keys, because a key menu that has decided
for you is a key menu you have to translate."
{:a "A" :a# "A♯/B♭" :b "B" :c "C" :c# "C♯/D♭" :d "D"
:d# "D♯/E♭" :e "E" :f "F" :f# "F♯/G♭" :g "G" :g# "G♯/A♭"})
(def sharp-names ["C" "C♯" "D" "D♯" "E" "F" "F♯" "G" "G♯" "A" "A♯" "B"])
(def flat-names ["C" "D♭" "D" "E♭" "E" "F" "G♭" "G" "A♭" "A" "B♭" "B"])
(def spells-flat
"The keys written with flats, by semitone: D♭, E♭, F, A♭, B♭. The rest
take sharps. F sharp and G flat are six of each and the convention
here is F sharp.
This is major-key convention applied to every scale, which is a
simplification: a minor scale follows its relative major's signature
and mostly agrees, and whole tone cannot be spelled correctly by any
rule. Set `:spell` to override it."
#{1 3 5 8 10})
(defn normalise-key
"Any accepted spelling to the one id the rest of the program uses. An
unknown name falls back to A."
[k]
(nth canonical-keys (get semitone-of k 9)))
(defn key-semitone [state]
(get semitone-of (:key state) 9))
(defn scale-spec [id]
(or (first (filter (fn [s] (= id (:id s))) scales))
(first scales)))
(defn pitch-number
"Semitones above C0, from a frequency. Anchored on 440 Hz, which is A4,
which is the fifty-seventh semitone counting C0 as nothing.
Derived from the frequency rather than from the row so that a reader
who moves `:root` gets labels that follow it, including to roots that
are not a note at all, which round to the nearest one and say so by
being slightly wrong about the tuning and right about the letter."
[freq]
(+ 57 (js/Math.round (* 12 (/ (js/Math.log (/ freq 440))
(js/Math.log 2))))))
(defn scale-spec [id]
(or (first (filter (fn [s] (= id (:id s))) scales))
(first scales)))
(defn degrees
"Semitones above the root, one per degree, from the interval pattern."
[spec]
(vec (butlast (reductions + 0 (:steps spec)))))
(defn spelling
"Sharps or flats, from the setting or from the key."
[state]
(case (:spell state)
:sharp :sharp
:flat :flat
(if (contains? spells-flat (key-semitone state)) :flat :sharp)))
(defn pitch-names [state]
(if (= :flat (spelling state)) flat-names sharp-names))
(defn root-frequency
"The bottom row of the grid, in hertz.
`:root` overrides the key outright, which is the way in for a reader
who wants a root that is not a note. Everything downstream is ratios,
so an instrument tuned to 217 hertz is in tune with itself and with
nothing else, and the labels will name the nearest letter and be
quietly wrong about it."
[state]
(or (:root state)
(let [number (+ (* 12 (:octave state)) (key-semitone state))]
(* 440 (js/Math.pow 2 (/ (- number 57) 12))))))
(defn scale-size [state]
(count (degrees (scale-spec (:scale state)))))
(defn root-row
"Which row of the grid is the root. `:below` is in octaves, so a
pentatonic grid with one octave below the root has five rows under it
and a chromatic grid has twelve, which is what a musician means by an
octave below and not what a fixed row count would give them."
[state]
(* (max 0 (:below state)) (scale-size state)))
(defn semitone
"Semitones from the root, which may be negative. Row `(root-row state)`
is the root, rows under it are the octaves below, and rows above carry
on up as far as the grid goes."
[state row]
(let [ds (degrees (scale-spec (:scale state)))
n (count ds)
from-root (- row (root-row state))]
(+ (nth ds (mod from-root n))
(* 12 (js/Math.floor (/ from-root n))))))
(defn root-row?
"True on every row where the pattern starts again, which for every
scale here is an octave, counted from the root rather than from the
bottom of the grid."
[state row]
(zero? (mod (- row (root-row state)) (scale-size state))))
(defn frequency-of [state row]
(* (root-frequency state) (js/Math.pow 2 (/ (semitone state row) 12))))
(defn pitch-number
"Semitones above C0, from a frequency. Anchored on 440 hertz, which is
A4, which is the fifty-seventh semitone counting C0 as nothing.
Derived from the frequency rather than from the row, so that whatever
moves the root moves the labels with it."
[freq]
(+ 57 (js/Math.round (* 12 (/ (js/Math.log (/ freq 440))
(js/Math.log 2))))))
(defn pitch-at
"The letter and octave for one row of the grid."
[state row]
(let [number (pitch-number (frequency-of state row))]
{:pitch (nth (pitch-names state) (mod number 12))
:octave (quot number 12)}))
;; ---------------------------------------------------------------------
;; Storage
;;
;; `fedoras.reader` stores through JSON, which has no vector keys, so
;; the notes travel as [column row voice] triples and are read back into
;; a map on the other side.
;; ---------------------------------------------------------------------
(def storage-key :music)
(defn to-storage
[state]
{:steps (:steps state)
:rows (:rows state)
:bpm (:bpm state)
:scale (name (:scale state))
:key (name (:key state))
:octave (:octave state)
:spell (name (:spell state))
:root (:root state)
:notes (mapv (fn [[[step row] voice-id]] [step row (name voice-id)])
(:notes state))})
(defn from-storage
[state saved]
(if-not (map? saved)
(assoc state :note "There is nothing kept.")
(-> state
(assoc :steps (or (:steps saved) (:steps state))
:rows (or (:rows saved) (:rows state))
:bpm (or (:bpm saved) (:bpm state))
:scale (if (:scale saved) (keyword (:scale saved)) (:scale state))
:key (if (:key saved) (normalise-key (keyword (:key saved))) (:key state))
:octave (or (:octave saved) (:octave state))
:spell (if (:spell saved) (keyword (:spell saved)) (:spell state))
:root (:root saved)
:notes (into {} (map (fn [[step row voice]]
[[step row] (keyword voice)])
(:notes saved)))
:note "Restored.")
trim-to)))
(defn keep!
[state]
(fedoras.reader/store! storage-key (to-storage state))
(assoc state :note "Kept. It will be here next time you open the chapter."))
(defn as-text
"The roll as something a reader can put in a file and read later."
[state]
(str ";; " (count (:notes state)) " notes, "
(:steps state) " columns, " (:bpm state) " bpm, "
(get key-labels (:key state)) " " (name (:scale state))
", seed " (:seed state) "\n"
(pr-str (to-storage state)) "\n"))
;; ---------------------------------------------------------------------
;; Voices
;; ---------------------------------------------------------------------
(def voices
[{:id :bell :label "bell" :wave "sine" :attack 0.004 :length 0.90 :gain 0.30}
{:id :flute :label "flute" :wave "triangle" :attack 0.045 :length 0.50 :gain 0.26}
{:id :reed :label "reed" :wave "square" :attack 0.015 :length 0.34 :gain 0.15
:filter "lowpass" :cutoff 2200}
{:id :string :label "string" :wave "sawtooth" :attack 0.080 :length 0.70 :gain 0.15
:filter "lowpass" :cutoff 1700}
{:id :block :label "block" :noise true :attack 0.002 :length 0.13 :gain 0.55
:filter "bandpass"}])
(defn voice-spec [id]
(or (first (filter (fn [v] (= id (:id v))) voices))
(first voices)))
;; ---------------------------------------------------------------------
;; A predictable die
;; ---------------------------------------------------------------------
(defonce seed-a (atom 1))
(defn- seed! [n]
(reset! seed-a (bit-or 1 (bit-and n 0x7fffffff))))
(defn- rnd []
(let [x @seed-a
x (bit-and (bit-xor x (bit-shift-left x 13)) 0x7fffffff)
x (bit-and (bit-xor x (unsigned-bit-shift-right x 17)) 0x7fffffff)
x (bit-and (bit-xor x (bit-shift-left x 5)) 0x7fffffff)]
(reset! seed-a x)
(/ x 2147483647.0)))
;; ---------------------------------------------------------------------
;; Filling the paper
;;
;; Each voice walks its own register from left to right, stepping by
;; small intervals and leaping rarely, which is the whole of what makes
;; a random sequence of notes read as a line rather than as noise. The
;; registers do not overlap much, so the voices do not fight for the
;; same rows, and the phrase weights vary the density bar by bar, which
;; is where the sense of rhythm comes from.
;; ---------------------------------------------------------------------
(def bar-columns 16)
(defn- clamp [n low high]
(max low (min high n)))
(defn- bar-weight
"How busy this bar is. The phrase repeats, so a four-number phrase on a
thirty-two column roll is heard twice, which is what makes it a phrase
rather than a shape."
[state step]
(let [phrase (:phrase state)]
(if (seq phrase)
(nth phrase (mod (quot step bar-columns) (count phrase)))
1.0)))
(defn- wander
"The next row for a voice. Mostly a step, sometimes a leap, never out
of the register. A voice with a leap of zero stays where it is, which
is how the block ends up being percussion."
[row [low high] leap]
(if (zero? leap)
row
(let [move (- (js/Math.round (* (rnd) leap 2)) leap)]
(clamp (+ row move) low high))))
(defn- fill-voice
[state notes voice-id {:keys [register every weight leap]}]
(let [[low high] [(clamp (first register) 0 (dec (:rows state)))
(clamp (second register) 0 (dec (:rows state)))]
cadence (max 1 (or every 1))]
(loop [step 0
row (quot (+ low high) 2)
acc notes]
(if (>= step (:steps state))
acc
(let [chance (* (:density state) (or weight 1.0) (bar-weight state step))
playing (< (rnd) chance)]
(recur (+ step cadence)
(if playing (wander row [low high] (or leap 2)) row)
(if playing (assoc acc [step row] voice-id) acc)))))))
(defn fill
"One seed, one piece of music. The voices are taken in sorted order so
that the same seed gives the same result every time, which a map's own
iteration order would not guarantee."
[state]
(seed! (:seed state))
(assoc state
:notes (reduce (fn [acc [voice-id spec]]
(fill-voice state acc voice-id spec))
{}
(sort-by (fn [[voice-id _]] (name voice-id))
(:voices state)))
:note (str "Filled from seed " (:seed state) ". Change it and fill again.")))
(defn fill-again
"The next seed along. A reader who likes one writes the number down."
[state]
(fill (update state :seed inc)))
(defn from-settings
"Take whatever the settings panel returned and keep the parts of it
that are keys this instrument has. A reader who deletes a key gets the
value that was there before, rather than a nil somewhere downstream.
`:root` is the exception and is deliberately not sticky: deleting it
means the key decides, which is the whole point of having a key."
[state setting]
(-> state
(assoc :bpm (or (:bpm setting) (:bpm state))
:below (or (:below setting) (:below state))
:steps (min max-steps (or (:columns setting) (:steps state)))
:rows (max 4 (or (:rows setting) (:rows state)))
:scale (or (:scale setting) (:scale state))
:key (normalise-key (or (:key setting) (:key state)))
:octave (or (:octave setting) (:octave state))
:spell (or (:spell setting) (:spell state))
:root (:root setting)
:seed (or (:seed setting) (:seed state))
:density (or (:density setting) (:density state))
:band (or (:band setting) (:band state))
:voices (or (:voices setting) (:voices state))
:phrase (or (:phrase setting) (:phrase state)))
trim-to))
;; ---------------------------------------------------------------------
;; The seat
;; ---------------------------------------------------------------------
(def seat
"One seat, one afternoon. The gap between each keystroke and the next,
in milliseconds. Two of these are not gaps between keystrokes. They are
the times somebody got up."
[112 98 140 121 105 133 96 118 2600 104 92 130 117 101 126 88 111
5400 99 143 108 97 120 134 102 91 1150 128 113 106])
(def band
"What counts as work. Everything outside it comes out at the front end."
[60 400])
(defn within-band?
[state gap]
(let [[low high] (:band state)]
(and (>= gap low) (<= gap high))))
(defn gap-row
"Fast typing is a high note. Clamped at both ends, so a four-minute
pause and a two-minute pause land on the same row and are
indistinguishable once they are music."
[state gap]
(let [[low high] (:band state)
span (max 1 (- high low))
clamped (max low (min high gap))
slowness (/ (- clamped low) (* 1.0 span))]
(js/Math.round (* (dec (:rows state)) (- 1 slowness)))))
(defn load-gaps
"Place one note per keystroke at the column its own timing puts it in,
and stretch the roll to fit. The tempo is not adjusted to make it come
out even, because the whole content of this is where the notes fall."
[state gaps voice-id]
(let [millis (step-millis state)
placed (loop [remaining gaps elapsed 0 acc {}]
(if-let [gap (first remaining)]
(let [column (js/Math.round (/ elapsed millis))]
(recur (rest remaining)
(+ elapsed gap)
(if (< column max-steps)
(assoc acc [column (gap-row state gap)] voice-id)
acc)))
acc))
width (min max-steps (inc (reduce max 0 (map first (keys placed)))))]
(assoc state
:notes placed
:steps (max 16 width))))
(defn load-afternoon
"Every gap, in the order it happened, at its own spacing."
[state]
(let [filled (load-gaps state seat :bell)]
(assoc filled :note
(str "The afternoon as it happened: " (:steps filled) " columns."))))
(defn load-typing
"The same afternoon with everything outside the band dropped, which is
everything that was not typing, which is the two times somebody got
up."
[state]
(let [[low high] (:band state)
kept (filter (fn [gap] (within-band? state gap)) seat)
filled (load-gaps state kept :bell)]
(assoc filled :note
(str "The same afternoon with every gap outside " low " to " high
" milliseconds dropped: " (:steps filled)
" columns, and nothing missing that anybody would call missing."))))
;; ---------------------------------------------------------------------
;; Sound
;; ---------------------------------------------------------------------
(defonce audio (atom nil))
(defonce master (atom nil))
(defonce noise (atom nil))
(defn context
"One AudioContext, made on the first gesture, because a browser will
not let a page make noise before somebody asks it to.
Built through `Reflect.construct` because the constructor is chosen at
runtime, and `new` wants a name it can resolve rather than a value."
[]
(or @audio
(let [constructor (or (.-AudioContext js/window)
(.-webkitAudioContext js/window))
ctx (js/Reflect.construct constructor #js [])
out (.createGain ctx)]
(set! (.-value (.-gain out)) 0.5)
(.connect out (.-destination ctx))
(reset! master out)
(reset! audio ctx)
ctx)))
(defn- noise-buffer [ctx]
(or @noise
(let [frames (js/Math.floor (* 0.4 (.-sampleRate ctx)))
buffer (.createBuffer ctx 1 frames (.-sampleRate ctx))
channel (.getChannelData buffer 0)]
(dotimes [i frames]
(aset channel i (- (* 2 (js/Math.random)) 1)))
(reset! noise buffer)
buffer)))
(defn strike!
"One note, at an absolute time on the audio clock. Builds its own small
graph and abandons it; the nodes are collected once the source stops,
which is why nothing here is kept in an atom."
[ctx spec freq at]
(let [envelope (.createGain ctx)
source (if (:noise spec)
(let [s (.createBufferSource ctx)]
(set! (.-buffer s) (noise-buffer ctx))
s)
(let [o (.createOscillator ctx)]
(set! (.-type o) (:wave spec))
(.setValueAtTime (.-frequency o) freq at)
o))
colour (when (:filter spec)
(let [f (.createBiquadFilter ctx)]
(set! (.-type f) (:filter spec))
(.setValueAtTime (.-frequency f) (or (:cutoff spec) freq) at)
(set! (.-value (.-Q f)) (if (:noise spec) 7 1))
f))]
(.setValueAtTime (.-gain envelope) 0.0001 at)
(.exponentialRampToValueAtTime (.-gain envelope) (:gain spec) (+ at (:attack spec)))
(.exponentialRampToValueAtTime (.-gain envelope) 0.0001 (+ at (:length spec)))
(if colour
(do (.connect source colour)
(.connect colour envelope))
(.connect source envelope))
(.connect envelope @master)
(.start source at)
(.stop source (+ at (:length spec) 0.05))))
(defn audition!
"Sound one note now, so placing a note is heard as it is placed."
[state row voice-id]
(let [ctx (context)]
(.resume ctx)
(strike! ctx (voice-spec voice-id) (frequency-of state row)
(+ 0.01 (.-currentTime ctx)))))
;; ---------------------------------------------------------------------
;; Transport
;; ---------------------------------------------------------------------
(defonce roll (atom nil))
(defonce transport (atom {:playing false :timer nil :painter nil
:step 0 :next-time 0 :origin 0}))
(def lookahead 0.1)
(def tick-millis 25)
(defn- schedule!
"Everything that falls inside the lookahead window. Reads the roll each
time round, so an edit during playback is heard on the next pass rather
than on the next press of Play."
[]
(let [ctx @audio
state @roll
duration (step-seconds state)]
(loop [guard 0]
(let [{:keys [next-time step playing]} @transport]
(when (and playing
(< guard 64)
(< next-time (+ (.-currentTime ctx) lookahead)))
(doseq [[row voice-id] (notes-at state step)]
(strike! ctx (voice-spec voice-id) (frequency-of state row) next-time))
(let [following (inc step)
wrapped (>= following (:steps state))]
(if (and wrapped (not (:looping state)))
(swap! transport assoc :playing false)
(swap! transport
(fn [t]
(assoc t
:step (if wrapped 0 following)
:next-time (+ (:next-time t) duration)))))
(recur (inc guard))))))))
(defn position
"Where the playhead is, in columns, read off the audio clock rather than
off the scheduler, which runs ahead of what anybody can hear."
[]
(let [ctx @audio
state @roll
{:keys [playing origin]} @transport]
(if (and ctx playing)
(mod (/ (- (.-currentTime ctx) origin) (step-seconds state))
(:steps state))
0)))
(declare paint!)
(defn stop! [node]
(let [{:keys [timer painter]} @transport]
(when timer (js/clearInterval timer))
(when painter (js/cancelAnimationFrame painter)))
(swap! transport assoc :playing false :timer nil :painter nil :step 0)
(paint! node))
(defn- move-playhead! [node]
(when-let [bar (fedoras.reader/q node ".music-playhead")]
(if (:playing @transport)
(do (.setProperty (.-style bar) "--position" (str (position)))
(.setProperty (.-style bar) "display" "block"))
(.setProperty (.-style bar) "display" "none"))))
(defn- follow! [node]
(letfn [(frame []
(move-playhead! node)
(if (:playing @transport)
(swap! transport assoc :painter (js/requestAnimationFrame frame))
(stop! node)))]
(swap! transport assoc :painter (js/requestAnimationFrame frame))))
(defn play! [node]
(if (:playing @transport)
(stop! node)
(let [ctx (context)]
(.resume ctx)
(let [start (+ 0.12 (.-currentTime ctx))]
(swap! transport assoc
:playing true
:step 0
:origin start
:next-time start
:timer (js/setInterval schedule! tick-millis))
(schedule!)
(follow! node)
(paint! node)))))
;; ---------------------------------------------------------------------
;; Painting
;; ---------------------------------------------------------------------
(defonce shape (atom nil))
(defn- cell-class [state step row]
(let [voice-id (get (:notes state) [step row])]
(str "music-cell"
(when (zero? (mod step 4)) " music-beat")
(when (zero? (mod step 16)) " music-measure")
(when (root-row? state row) " music-root")
(when voice-id (str " music-on music-" (name voice-id))))))
(defn- grid-html [state]
(str/join
""
(for [row (reverse (range (:rows state)))
step (range (:steps state))]
(str "<button class='" (cell-class state step row) "'"
" data-step='" step "' data-row='" row "'></button>"))))
(defn- voices-html [state]
(str/join
""
(for [{:keys [id label]} voices]
(str "<button class='music-voice music-" (name id)
(when (= id (:voice state)) " music-chosen") "'"
" data-voice='" (name id) "'>" label "</button>"))))
(defn- keys-html [state]
(str/join
""
(for [k key-order]
(str "<option value='" (name k) "'"
(when (= k (:key state)) " selected") ">"
(get key-labels k) "</option>"))))
(defn- scales-html [state]
(str/join
""
(for [{:keys [id label]} scales]
(str "<option value='" (name id) "'"
(when (= id (:scale state)) " selected") ">" label "</option>"))))
(defn- cells [node]
(let [found (.querySelectorAll node ".music-cell")]
(mapv (fn [i] (.item found i)) (range (.-length found)))))
(defn- rows-html
"The gutter down the left of the paper. Every row is named, and the
rows that begin an octave carry the number, so the grid can be read
without counting up from the bottom."
[state]
(str/join
""
(for [row (reverse (range (:rows state)))
:let [{:keys [pitch octave]} (pitch-at state row)
root (root-row? state row)]]
(str "<div class='music-row" (when root " music-row-root") "'>"
pitch
(when root (str "<span class='music-octave'>" octave "</span>"))
"</div>"))))
(defn- set-option!
"Set a select to a value, if that value is one of its options. Loading
the afternoon stretches the roll to whatever width the afternoon was,
and that width is rarely one of the four on the menu."
[control value]
(let [wanted (str value)
options (.-options control)
found (some (fn [i] (= wanted (.-value (.item options i))))
(range (.-length options)))]
(if found
(set! (.-value control) wanted)
(set! (.-selectedIndex control) -1))))
(defn- sync-controls!
"Put the controls where the state is. They are set once at setup, and
the settings panel moves the state under them afterwards."
[node]
(let [state @roll]
(when-let [tempo (fedoras.reader/q node ".music-tempo")]
(set! (.-value tempo) (str (:bpm state))))
(when-let [chooser (fedoras.reader/q node ".music-scale")]
(set-option! chooser (name (:scale state))))
(when-let [chooser (fedoras.reader/q node ".music-key")]
(set-option! chooser (name (:key state))))
(when-let [chooser (fedoras.reader/q node ".music-length")]
(set-option! chooser (:steps state)))))
(defn paint! [node]
(let [state @roll
layout [(:steps state) (:rows state) (:scale state)
(:key state) (:octave state) (:spell state) (:root state)]]
(if (= layout @shape)
(doseq [cell (cells node)]
(let [step (js/parseInt (.getAttribute cell "data-step") 10)
row (js/parseInt (.getAttribute cell "data-row") 10)]
(set! (.-className cell) (cell-class state step row))))
(do (fedoras.ui.widget-ui/grid!
node ".music-grid" (:steps state) (grid-html state))
(fedoras.ui.widget-ui/html! node ".music-rows" (rows-html state))
(reset! shape layout)))
(fedoras.ui.widget-ui/html! node ".music-voices" (voices-html state))
(fedoras.ui.widget-ui/state!
node
(str (count (:notes state)) " notes · "
(get key-labels (:key state)) " "
(:label (scale-spec (:scale state))) " · "
(:steps state) " columns · "
(:bpm state) " bpm"))
(fedoras.reader/text! node ".music-status" (:note state))
(fedoras.reader/enable! node ".music-play" true
(if (:playing @transport) "Stop" "Play"))
(sync-controls! node)
(move-playhead! node)))
;; ---------------------------------------------------------------------
;; Hands
;; ---------------------------------------------------------------------
(defonce painting (atom nil))
(defn- cell-at [event]
(fedoras.ui.widget-ui/data event ".music-cell" "step" "row"))
(defn- touch!
"Place or erase one cell. `erasing` is decided when the drag starts and
holds for the whole drag, so dragging across a row does not toggle
every note it crosses twice."
[node step row erasing]
(let [state @roll]
(if erasing
(reset! roll (erase-note state step row))
(let [voice-id (:voice state)]
(when-not (= voice-id (get (:notes state) [step row]))
(audition! state row voice-id))
(reset! roll (put-note state step row voice-id)))))
(paint! node))
(defn- wire-grid! [node]
(let [grid (fedoras.reader/q node ".music-grid")]
(set! (.-onpointerdown grid)
(fn [event]
(when-let [[step row] (cell-at event)]
(.preventDefault event)
(let [erasing (or (.-shiftKey event)
(not (zero? (.-button event)))
(some? (get (:notes @roll) [step row])))]
(reset! painting erasing)
(touch! node step row erasing)))))
(set! (.-onpointermove grid)
(fn [event]
(when (some? @painting)
(when-let [[step row] (cell-at event)]
(touch! node step row @painting)))))
(set! (.-onpointerup grid) (fn [_] (reset! painting nil)))
(set! (.-onpointerleave grid) (fn [_] (reset! painting nil)))
(set! (.-oncontextmenu grid) (fn [event] (.preventDefault event) false))))
(defn- act!
[node f & args]
(swap! roll (fn [state] (apply f state args)))
(paint! node))
(defn- wire-transport!
[node]
(fedoras.reader/on-click! node ".music-play" (fn [_] (play! node)))
(fedoras.reader/on-click! node ".music-clear" (fn [_] (act! node clear-notes)))
(fedoras.ui.widget-ui/keys! node {" " :play} (fn [_ _] (play! node))))
(defn- wire-fill!
[node]
(fedoras.reader/on-click! node ".music-fill"
(fn [_] (stop! node) (act! node fill)))
(fedoras.reader/on-click! node ".music-fill-again"
(fn [_] (stop! node) (act! node fill-again))))
(defn- wire-seat!
[node]
(fedoras.reader/on-click! node ".music-afternoon"
(fn [_] (stop! node) (act! node load-afternoon)))
(fedoras.reader/on-click! node ".music-typing"
(fn [_] (stop! node) (act! node load-typing))))
(defn- wire-keeping!
[node]
(fedoras.reader/on-click! node ".music-keep" (fn [_] (act! node keep!)))
(fedoras.reader/on-click! node ".music-restore"
(fn [_]
(stop! node)
(act! node from-storage
(fedoras.reader/stored storage-key))))
(fedoras.reader/on-click! node ".music-download"
(fn [_]
(fedoras.ui.widget-ui/download!
(as-text @roll)
(str "roll-" (:seed @roll) ".edn")))))
(defn- wire-voices!
[node]
(when-let [picker (fedoras.ui.widget-ui/q! node ".music-voices")]
(set! (.-onclick picker)
(fn [event]
(when-let [button (.closest (.-target event) ".music-voice")]
(let [chosen (keyword (.getAttribute button "data-voice"))]
(swap! roll assoc :voice chosen)
(audition! @roll (js/Math.floor (/ (:rows @roll) 2)) chosen)
(paint! node)))))))
(defn- wire-controls!
[node]
(when-let [tempo (fedoras.ui.widget-ui/q! node ".music-tempo")]
(set! (.-oninput tempo)
(fn [_]
(swap! roll assoc :bpm (js/parseInt (.-value tempo) 10))
(when (:playing @transport) (stop! node))
(paint! node))))
(when-let [chooser (fedoras.ui.widget-ui/q! node ".music-scale")]
(set! (.-innerHTML chooser) (scales-html @roll))
(set! (.-onchange chooser)
(fn [_]
(swap! roll assoc :scale (keyword (.-value chooser)))
(paint! node))))
(when-let [chooser (fedoras.ui.widget-ui/q! node ".music-key")]
(set! (.-innerHTML chooser) (keys-html @roll))
(set! (.-onchange chooser)
(fn [_]
(swap! roll (fn [state]
(assoc state
:key (keyword (.-value chooser))
:root nil)))
(paint! node))))
(when-let [chooser (fedoras.ui.widget-ui/q! node ".music-length")]
(set! (.-onchange chooser)
(fn [_]
(swap! roll (fn [state]
(trim-to (assoc state :steps
(js/parseInt (.-value chooser) 10)))))
(stop! node)))))
(def fill-settings
"The settings a filled roll is made from. A Run that changes any of
these refills the paper. A Run that changes none of them leaves
whatever the reader has drawn alone."
[:rows :columns :scale :seed :density :voices :phrase])
(defonce filled-from (atom nil))
(defn apply-settings!
"What Run does. The numbers land in the roll, and if any of the numbers
the notes were made from has moved, the notes are made again."
[node setting]
(swap! roll from-settings setting)
(let [signature (select-keys setting fill-settings)]
(when (or (not= signature @filled-from) (empty? (:notes @roll)))
(reset! filled-from signature)
(swap! roll fill)))
(when (:playing @transport) (stop! node))
(paint! node))
(defn- wire-settings!
[node id]
(fedoras.ui.widget-ui/settings!
node id
(fn [setting] (apply-settings! node setting))))
(defn setup!
"Once per element. Each control is wired on its own, so one that fails
says so and takes nothing else with it."
[node]
(let [id (.-id node)]
(fedoras.ui.widget-ui/wire! node "grid" wire-grid!)
(fedoras.ui.widget-ui/wire! node "transport" wire-transport!)
(fedoras.ui.widget-ui/wire! node "fill" wire-fill!)
(fedoras.ui.widget-ui/wire! node "seat" wire-seat!)
(fedoras.ui.widget-ui/wire! node "keeping" wire-keeping!)
(fedoras.ui.widget-ui/wire! node "voices" wire-voices!)
(fedoras.ui.widget-ui/wire! node "controls" wire-controls!)
(fedoras.ui.widget-ui/wire! node "settings" (fn [n] (wire-settings! n id)))))
(defn mount!
[{:keys [id]}]
(when (nil? @roll)
(reset! roll default-roll))
(fedoras.ui.widget-ui/mount!
::music (or id "music") setup! paint!))</script><div class="widget widget-music" id="music" tabindex="0"><div class="roll-head"><span class="roll-label">MUSIC.CLJS · click to write · shift to erase</span><span class="widget-state">press Run</span></div><div class="widget-source"><div class="widget-source-head"><span class="widget-source-name">music_settings.cljs</span><span class="widget-source-lang">cljs</span></div><textarea class="widget-settings" rows="22" spellcheck="false">;; ---------------------------------------------------------------------
;; MUSIC_SETTINGS.CLJS — everything the instrument is made of.
;;
;; 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.
;; ---------------------------------------------------------------------
{:band [60 400]
:bpm 120 ; 60 to 200
:rows 15 ; how tall the grid is, in rows
:below 1 ; octaves of rows under the root. 0 puts the
; root on the bottom row, which is where this
; started.
:columns 32 ; how much paper. 16, 32, 64, 96.
:scale :pentatonic ; :pentatonic :major :minor :whole :chromatic
:key :a ; :a :a# :b :c :c# :d :d# :e :f :f# :g :g#
; flats work too: :bb :db :eb :gb :ab
:octave 3 ; :a at 3 is A3, 220 hertz, where this started
:spell :auto ; :auto :sharp :flat — how the rows are named
:root 220 ; hertz. the bottom row of the grid.
;; FILL. One seed is one piece of music, and the same seed always
;; gives the same piece back, so a seed worth keeping is worth
;; writing down.
:seed 4471
:density 0.42 ; 0 to 1. how much of the paper gets used.
;; Each voice keeps to its own register, so they are not all
;; competing for the same rows and the result reads as several
;; instruments rather than one confused one.
;;
;; register [lowest-row highest-row]
;; every how often it may play. 1 is sixteenths, 4 is quarters.
;; weight how likely it is to take a chance it is offered.
;; leap how far it may move at once. 0 never moves.
:voices
{:string {:register [0 6] :every 8 :weight 0.75 :leap 2}
:reed {:register [4 11] :every 4 :weight 0.60 :leap 2}
:bell {:register [10 17] :every 2 :weight 1.00 :leap 3}
:flute {:register [14 21] :every 2 :weight 0.55 :leap 4}
:block {:register [0 2] :every 4 :weight 0.70 :leap 0}}
;; The shape of a phrase, one number per bar, multiplied into the
;; density as the fill moves across the paper. Even density is not
;; rhythm. This is where the rhythm comes from.
:phrase [1.0 0.55 0.85 0.35]}</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="music-voices"></div><div class="music-stage"><div class="music-rows"></div><div class="music-surface"><div class="music-grid"></div><div class="music-playhead"></div></div></div><p class="music-status"></p><div class="live-bar"><button class="roll-go music-play">Play</button><button class="roll-go music-fill">Fill</button><button class="roll-go music-fill-again">Fill again</button><button class="roll-rewrite music-clear">Clear</button></div><div class="live-bar"><label class="music-control">tempo<input class="music-tempo" max="200" min="60" step="2" type="range" /></label><label class="music-control">scale<select class="music-scale"></select></label><label class="music-control">key<select class="music-key"></select></label><label class="music-control">columns<select class="music-length"><option value="16">16</option><option value="32">32</option><option value="64">64</option><option value="96">96</option></select></label></div><div class="live-bar"><button class="roll-go music-afternoon">Load the afternoon</button><button class="roll-go music-typing">Load only the typing</button></div><div class="live-bar"><button class="roll-go music-keep">Keep it</button><button class="roll-go music-restore">Restore</button><button class="roll-go music-download">Download</button></div></div><p class="roll-foot">One seat, one afternoon, in the gaps between the keystrokes.</p></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.cs-class.music.music-ui/mount!))] (mount {:id "music"}) (js/console.error "fedoras: fedoras.cs-class.music.music-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>
```