---
format:
html:
toc: true
toc-depth: 2
theme: {dark: darkly, light: cosmo}
number-sections: false
number-depth: 0
code-fold: false
code-tools: true
highlight-style: nord
---
<style></style><style>.printedClojure .sourceCode {
background-color: transparent;
border-style: none;
}
</style><style>.clay-limit-image-width .clay-image {max-width: 100%}
.clay-side-by-side .sourceCode {margin: 0}
.clay-side-by-side {margin: 1em 0}
</style>
<script src="https://code.jquery.com/jquery-3.6.0.min.js" type="text/javascript"></script><script src="https://code.jquery.com/ui/1.13.1/jquery-ui.min.js" type="text/javascript"></script>
```{=html}
<div style="display:none;"><style>/* ---------------------------------------------------------------
Fedoras — one palette, two schemes.
Dark is the default. Quarto puts .quarto-light on the body when the
reader flips the toggle, and everything below is written against
these variables, so a scheme change is nine lines rather than three
hundred.
--------------------------------------------------------------- */
:root {
--paper: #1b1d21; /* background */
--ink: #e6e4df; /* text */
--ink-soft: #a9a49b; /* secondary text */
--rule: rgba(230, 228, 223, 0.22);
--panel: rgba(230, 228, 223, 0.05);
--violet: #9d8dff; /* apparatus, epigraphs */
--gold: #d0a44c; /* beats, commits */
--coral: #e2707c; /* moderation, rewrites */
--green: #6fcf9b; /* the Helps */
--blurple: #8b95f0; /* Discord */
--violet-rgb: 157, 141, 255;
--gold-rgb: 208, 164, 76;
--coral-rgb: 226, 112, 124;
--green-rgb: 111, 207, 155;
--blurple-rgb: 139, 149, 240;
--ink-rgb: 168, 164, 156;
--panel-hard: #101215;
}
body.quarto-light {
--paper: #faf8f4;
--ink: #1c1f24;
--ink-soft: #6b665e;
--rule: rgba(28, 31, 36, 0.22);
--panel: rgba(28, 31, 36, 0.05);
--violet: #6c5ce7;
--gold: #b0863a;
--coral: #e04f5f;
--green: #2ea078;
--blurple: #5865f2;
--violet-rgb: 108, 92, 231;
--gold-rgb: 176, 134, 58;
--coral-rgb: 224, 79, 95;
--green-rgb: 46, 160, 120;
--blurple-rgb: 88, 101, 242;
--ink-rgb: 128, 128, 128;
--panel-hard: #14161a;
}
/* ---------------------------------------------------------------
Fedoras — screenplay typography for the HTML edition.
Loaded by fedoras.screenplay/assets. Edit here, rebuild, done.
PDF styling lives in notebooks/tex/fedoras-preamble.tex instead.
--------------------------------------------------------------- */
.screenplay {
font-family: "Courier Prime", "Courier New", ui-monospace, monospace;
font-size: 1rem;
line-height: 1.35;
max-width: 62ch;
margin: 1.6rem 0 2.2rem 0;
}
.screenplay .slug {
font-weight: 700;
letter-spacing: 0.04em;
margin: 1.6rem 0 0.8rem 0;
}
.screenplay .action {
margin: 0 0 0.85rem 0;
}
.action{
white-space: pre-line;
}
.screenplay .action.beat {
margin-bottom: 0.35rem;
}
.screenplay .dialogue {
margin: 0 0 0.95rem 0;
}
.screenplay .character {
margin: 0 0 0 12ch;
font-weight: 700;
letter-spacing: 0.06em;
}
.screenplay .paren {
margin: 0 0 0 9ch;
font-style: italic;
opacity: 0.85;
}
.screenplay .lines {
margin-left: 6ch;
max-width: 50ch;
}
.screenplay .line {
margin: 0;
}
.screenplay .transition {
margin: 1.1rem 0 1.1rem 0;
text-align: right;
font-weight: 700;
letter-spacing: 0.08em;
}
.screenplay .direction {
font-style: italic;
opacity: 0.85;
margin: 0 0 0.9rem 0;
}
/* In-world text: banners, signs, whiteboards. */
.screenplay .sign {
border: 1px solid currentColor;
padding: 0.7rem 1rem;
margin: 0.9rem 0 1.1rem 4ch;
max-width: 46ch;
text-align: center;
letter-spacing: 0.05em;
}
.screenplay .sign p {
margin: 0.15rem 0;
}
.screenplay .whiteboard {
text-align: left;
}
.screenplay .whiteboard-title {
font-weight: 700;
text-transform: uppercase;
margin-bottom: 0.4rem !important;
}
.screenplay .whiteboard ol {
margin: 0;
padding-left: 2.2ch;
}
/* --- The Fedora Handbook epigraphs ------------------------------- */
.handbook {
max-width: 54ch;
margin: 1.4rem 0 2rem 2ch;
padding-left: 1.1rem;
border-left: 3px solid var(--violet);
font-style: italic;
}
.handbook .rule-number {
font-style: normal;
font-variant: small-caps;
letter-spacing: 0.08em;
opacity: 0.75;
margin-bottom: 0.25rem;
}
.handbook .rule-text {
margin: 0;
}
/* --- Drafting apparatus (hidden in the reading copy) -------------- */
.beats {
border: 1px dashed var(--gold);
background: rgba(var(--gold-rgb), 0.07);
padding: 0.6rem 0.9rem;
margin: 1.1rem 0;
max-width: 62ch;
font-size: 0.88rem;
}
.beats-title {
font-variant: small-caps;
letter-spacing: 0.1em;
font-weight: 700;
margin: 0 0 0.35rem 0;
opacity: 0.8;
}
.beats ul {
margin: 0;
padding-left: 1.4rem;
}
.beats.note {
border-color: var(--blurple);
background: rgba(var(--blurple-rgb), 0.07);
}
.status {
display: inline-block;
font-family: ui-monospace, monospace;
font-size: 0.7rem;
letter-spacing: 0.14em;
padding: 0.15rem 0.5rem;
border-radius: 3px;
margin: 0 0 0.8rem 0;
}
.status-outline { background: var(--ink-soft); color: #fff; }
.status-draft { background: var(--gold); color: #fff; }
.status-locked { background: var(--green); color: #fff; }
/* --- Live (editable) code cells ---------------------------------- */
.live-cell {
border: 1px solid rgba(var(--ink-rgb), 0.4);
border-radius: 5px;
padding: 0.7rem 0.8rem;
margin: 1.2rem 0;
max-width: 68ch;
}
.live-label {
font-size: 0.78rem;
letter-spacing: 0.09em;
text-transform: uppercase;
opacity: 0.7;
margin-bottom: 0.45rem;
}
.live-src {
width: 100%;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.9rem;
line-height: 1.4;
padding: 0.5rem;
border: 1px solid rgba(var(--ink-rgb), 0.35);
border-radius: 4px;
background: rgba(var(--ink-rgb), 0.06);
resize: vertical;
}
.live-bar {
margin-top: 0.5rem;
display: flex;
gap: 0.5rem;
align-items: center;
}
.live-run {
font: inherit;
font-size: 0.85rem;
padding: 0.25rem 0.9rem;
border-radius: 4px;
border: 1px solid rgba(var(--ink-rgb), 0.5);
background: rgba(var(--violet-rgb), 0.15);
cursor: pointer;
}
.live-run:hover { background: rgba(var(--violet-rgb), 0.3); }
.live-out {
margin: 0.5rem 0 0 0;
padding: 0.45rem 0.6rem;
min-height: 1.6rem;
font-size: 0.88rem;
white-space: pre-wrap;
border-left: 3px solid rgba(var(--violet-rgb), 0.6);
background: rgba(var(--ink-rgb), 0.06);
}
/* --- Front matter ------------------------------------------------- */
.title-page {
text-align: center;
margin: 3rem 0 2rem 0;
}
.title-page .title {
font-family: "Courier Prime", "Courier New", monospace;
font-size: 3.2rem;
letter-spacing: 0.22em;
margin: 0;
}
.title-page .subtitle {
font-family: "Courier Prime", "Courier New", monospace;
letter-spacing: 0.1em;
margin-top: 0.8rem;
opacity: 0.85;
}
h1.sidebar-title,
h2.sidebar-title {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.personae {
max-width: 66ch;
}
.personae h3 {
font-variant: small-caps;
letter-spacing: 0.1em;
margin-top: 2rem;
border-bottom: 1px solid rgba(var(--ink-rgb), 0.35);
padding-bottom: 0.2rem;
}
.personae .who {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.06em;
margin: 1rem 0 0.1rem 0;
}
.personae .what {
margin: 0 0 0.2rem 0;
font-style: italic;
opacity: 0.8;
}
.personae .desc {
margin: 0.15rem 0 0 0;
max-width: 60ch;
}
/* --- The reader's protagonist ------------------------------------ */
.pc-name {
font-weight: inherit;
border-bottom: 1px dotted rgba(var(--violet-rgb), 0.55);
}
.screenplay .slug,
.screenplay .transition,
.screenplay .character { text-transform: uppercase; }
.name-field {
border: 1px solid rgba(var(--violet-rgb), 0.55);
border-radius: 5px;
padding: 0.9rem 1rem;
margin: 1.6rem auto;
max-width: 60ch;
}
.name-label { margin: 0 0 0.6rem 0; }
.name-inputs { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.name-inputs input {
font: inherit;
padding: 0.3rem 0.6rem;
border: 1px solid rgba(var(--ink-rgb), 0.45);
border-radius: 4px;
background: rgba(var(--ink-rgb), 0.06);
max-width: 14ch;
}
.name-note {
font-size: 0.82rem;
opacity: 0.7;
margin: 0.6rem 0 0 0;
}
.name-label,
.name-confirm {
font-size: 1.2rem;
}
.name-inputs input,
.name-inputs button {
font: inherit;
font-size: 1.2rem;
}
/* --- Artifacts ---------------------------------------------------- */
.artifact {
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.88rem;
line-height: 1.45;
max-width: 66ch;
margin: 1.4rem 0;
border-radius: 5px;
overflow: hidden;
}
.artifact p { margin: 0.1rem 0; }
/* Discord */
.discord { border: 1px solid rgba(var(--blurple-rgb), 0.5); }
.discord-head {
background: rgba(var(--blurple-rgb), 0.14);
padding: 0.4rem 0.8rem;
display: flex; gap: 0.8rem;
}
.discord-server { font-weight: 700; letter-spacing: 0.06em; }
.discord-channel { opacity: 0.75; }
.discord-body { padding: 0.7rem 0.8rem; }
.discord-msg { margin-bottom: 0.7rem; }
.discord-who { font-weight: 700; }
.discord-role {
font-size: 0.7rem; letter-spacing: 0.08em;
background: rgba(var(--violet-rgb), 0.25);
padding: 0.05rem 0.4rem; border-radius: 3px; margin-left: 0.4rem;
}
.discord-at { opacity: 0.55; margin-left: 0.5rem; font-size: 0.78rem; }
.discord-reacts { margin-top: 0.2rem !important; }
.react {
display: inline-block; font-size: 0.75rem;
border: 1px solid rgba(var(--ink-rgb),0.35); border-radius: 10px;
padding: 0 0.45rem; margin-right: 0.3rem;
}
/* Moderation log */
.modlog {
border-left: 3px solid var(--coral);
background: rgba(var(--coral-rgb), 0.08);
padding: 0.6rem 0.9rem;
font-size: 0.82rem;
letter-spacing: 0.03em;
}
/* Git */
.commit {
border: 1px solid rgba(var(--ink-rgb), 0.4);
padding: 0.7rem 0.9rem;
background: rgba(var(--ink-rgb), 0.05);
}
.commit-hash { color: var(--gold); font-weight: 700; }
.commit-meta { opacity: 0.7; }
.commit-msg { margin-top: 0.5rem; padding-left: 1.5rem; }
/* Terminal */
.terminal {
background: var(--panel-hard);
color: var(--ink);
padding: 0.7rem 0.9rem;
border: 1px solid rgba(var(--ink-rgb),0.35);
}
.term-in { color: var(--green); }
.term-in::before { content: ""; }
.term-out { opacity: 0.9; }
/* AI */
.aichat { border: 1px solid rgba(var(--green-rgb), 0.5); }
.ai-model {
background: rgba(var(--green-rgb), 0.16);
padding: 0.35rem 0.8rem !important;
font-weight: 700; letter-spacing: 0.1em; font-size: 0.75rem;
}
.ai-system { padding: 0.4rem 0.8rem; font-size: 0.8rem; opacity: 0.85; }
.ai-system pre { white-space: pre-wrap; margin: 0.4rem 0 0 0; }
.ai-body { padding: 0.6rem 0.8rem; }
.ai-turn { margin-bottom: 0.6rem; }
.ai-role { font-size: 0.68rem; letter-spacing: 0.12em; opacity: 0.6; }
.ai-assistant .ai-text { border-left: 2px solid rgba(46,160,120,0.6); padding-left: 0.6rem; }
/* Email */
.email { border: 1px solid rgba(var(--ink-rgb),0.45); padding: 0.7rem 0.9rem; }
.email-head td { padding: 0 0.6rem 0.1rem 0; vertical-align: top; font-size: 0.82rem; }
.email-head td:first-child { opacity: 0.6; }
.email-body { margin-top: 0.6rem; }
/* Handouts */
.handout {
border: 1px solid currentColor;
padding: 1rem 1.2rem;
font-family: "Courier Prime", "Courier New", monospace;
}
.handout-title {
font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
border-bottom: 1px solid currentColor; padding-bottom: 0.4rem;
margin-bottom: 0.7rem !important;
}
.handout-table { width: 100%; font-size: 0.85rem; margin: 0.5rem 0; }
.handout-table td.k { opacity: 0.65; width: 34%; padding-right: 1rem; vertical-align: top; }
/* --- Prose between scenes ---------------------------------------- */
.prose {
max-width: 64ch;
margin: 1.5rem 0 2rem 0;
font-family: "Iowan Old Style", "Palatino Linotype", Palatino,
"Book Antiqua", Georgia, serif;
font-size: 1.06rem;
line-height: 1.65;
color: var(--ink);
}
.prose p {
margin: 0 0 1.05rem 0;
}
.prose p:first-child { margin-top: 0; }
.prose p:last-child { margin-bottom: 0; }
blockquote.turn {
max-width: 64ch;
border-left: 3px solid rgba(var(--violet-rgb), 0.6);
padding-left: 1rem;
margin: 1.4rem 0 0.6rem 0;
}
.name-confirm {
margin: 0.7rem 0 0 0;
font-weight: 600;
}
/* --- The d20 ------------------------------------------------------ */
.roll {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 60ch;
font-family: ui-monospace, "Courier New", monospace;
}
.roll-head {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
letter-spacing: 0.12em;
opacity: 0.75;
margin-bottom: 0.5rem;
}
.roll-bar { display: flex; gap: 0.7rem; align-items: center; margin-top: 0.3rem; }
.roll-go, .roll-rewrite {
font: inherit;
font-size: 0.82rem;
padding: 0.25rem 0.9rem;
border-radius: 4px;
border: 1px solid rgba(var(--ink-rgb), 0.5);
background: rgba(var(--violet-rgb), 0.15);
cursor: pointer;
}
.roll-rewrite { background: rgba(var(--coral-rgb), 0.12); }
.roll-go:hover { background: rgba(var(--violet-rgb), 0.3); }
.roll-rewrite:hover { background: rgba(var(--coral-rgb), 0.25); }
.roll-value { font-size: 0.9rem; letter-spacing: 0.05em; }
.roll-out {
margin: 0.6rem 0 0 0 !important;
font-family: "Courier Prime", "Courier New", monospace;
min-height: 1.2rem;
}
.roll-echo { font-size: 0.8rem; opacity: 0.8; }
.roll-note { font-size: 0.8rem; opacity: 0.6; margin: 0.4rem 0 0 0 !important; }
.roll-foot {
font-size: 0.72rem;
letter-spacing: 0.1em;
opacity: 0.55;
margin: 0.5rem 0 0 0 !important;
border-top: 1px solid rgba(var(--ink-rgb),0.25);
padding-top: 0.35rem;
}
.roll-cue {
margin: 0.6rem 0 0 4ch !important;
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.06em;
}
.roll-line {
margin: 0 0 0 6ch !important;
max-width: 40ch;
font-family: "Courier Prime", "Courier New", monospace;
}
.roll-out .roll-line:first-child { margin-left: 0 !important; max-width: none; }
.companion-name {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.14em;
font-size: 1.05rem;
margin: 0.7rem 0 0.3rem 0 !important;
}
.companion-intro { max-width: 58ch; margin: 0 0 0.3rem 0 !important; }
.session { max-width: 60ch; margin: 1.1rem 0; }
.session-who {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700; letter-spacing: 0.12em; margin: 0 0 0.3rem 0 !important;
}
.session-text { margin: 0 !important; }
.ac-name { border-bottom: 1px dotted rgba(var(--violet-rgb), 0.55); }
.companion-naming { margin-top: 0.6rem; }
/* --- Plots -------------------------------------------------------- */
.plot {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
}
.plot-controls {
display: flex; gap: 1rem; flex-wrap: wrap;
font-size: 0.8rem; opacity: 0.85; margin: 0.4rem 0;
}
.plot-controls input {
font: inherit; font-size: 0.8rem; width: 7ch;
padding: 0.15rem 0.35rem;
border: 1px solid rgba(var(--ink-rgb),0.45);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.06);
}
.plot-status { font-size: 0.75rem; letter-spacing: 0.1em; opacity: 0.7; }
.plot canvas {
display: block;
margin-top: 0.6rem;
background: var(--paper);
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
}
.roll-go:disabled, .roll-rewrite:disabled {
opacity: 0.4;
cursor: default;
}
.sketch {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
}
.sketch canvas {
display: block;
margin-top: 0.7rem;
background: var(--paper);
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
max-width: 100%;
}
.sketch-page {
margin-top: 0.7rem;
padding: 0.5rem 0.6rem;
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.04);
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.9rem;
}
.sketch-page button {
font: inherit; font-size: 0.85rem;
border: 1px solid rgba(var(--ink-rgb),0.45);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.08);
cursor: pointer;
min-width: 2ch;
}
/* --- The page itself, when Quarto is not doing it for us ---------- */
.screenplay, .artifact, .prose, .handout, .beats, .roll, .plot, .sketch,
.todo, .puzzle-box, .personae, .name-field, .live-cell, .session {
color: var(--ink);
}
.handout, .puzzle-cell, .sign { border-color: var(--rule); }
.terminal { background: var(--panel-hard); color: var(--ink); }
.title-page .title, .title-page .subtitle { color: var(--ink); }
/* --- Form controls -------------------------------------------------
Inputs, textareas and buttons do not inherit colour from the page,
so every one of them needs telling, or the reader types black on
black and cannot see what they are writing.
------------------------------------------------------------------- */
.name-inputs input,
.companion-input,
.todo-input,
.plot-controls input,
.live-src,
.sketch textarea,
.sketch-page input {
color: var(--ink);
background: var(--panel);
border-color: var(--rule);
caret-color: var(--ink);
}
.name-inputs input::placeholder,
.companion-input::placeholder,
.todo-input::placeholder,
.sketch-page input::placeholder,
.live-src::placeholder {
color: var(--ink-soft);
opacity: 1;
}
.roll-go, .roll-rewrite, .live-run, .todo-del,
.sketch-page button, .puzzle-cell {
color: var(--ink);
}
.sketch-page button {
background: var(--panel);
border-color: var(--rule);
}
/* Quarto's own search box does not follow the book's palette on every
theme, so it is told here as well. */
#quarto-search input,
#quarto-search .aa-Input,
.aa-Form,
.aa-InputWrapper input,
.quarto-search input {
background: var(--panel) !important;
color: var(--ink) !important;
border-color: var(--rule) !important;
}
.aa-Form { border: 1px solid var(--rule) !important; }
#quarto-search input::placeholder,
.aa-Input::placeholder {
color: var(--ink-soft) !important;
opacity: 1;
}
.aa-DetachedSearchButton, .quarto-search-button {
background: var(--panel) !important;
color: var(--ink) !important;
border-color: var(--rule) !important;
}
/* Canvases inherit nothing, so the sketch cells read their ink off the
element's own resolved colour. This is the line that gives them one. */
.sketch canvas, .plot canvas {
color: var(--ink);
background: transparent;
}
.tribute-heading {
margin: 2rem 0 0.5rem;
font-variant: small-caps;
font-weight: 600;
letter-spacing: 0.04em;
}
.tribute p {
margin: 0.6rem 0;
}
.tribute-meta {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--bs-border-color);
text-align: right;
font-size: 0.85rem;
}
.tribute-meta p {
margin: 0.25rem 0;
}
.tribute-date {
font-style: italic;
}
.tribute-attribution {
font-size: 1rem;
}
.tribute-source {
margin-top: 0.75rem !important;
}
.footnote {
margin-top: 1.5rem;
font-size: 0.9rem;
color: var(--bs-secondary);
}
/* ---------------------------------------------------------------
The three endings.
All three are in the HTML. Which one is visible is a class on
<body>, written once by fedoras.reader/paint-standing! from the
reader's standing, so a chapter that forks does it here rather than
in nine lines of DOM work per passage.
A page with no class on it shows the middle one. That is what the
print edition gets, and it is what the table actually got, and the
PDF reads straight through with no gap and no note.
--------------------------------------------------------------- */
.ending { display: none; }
.ending-reconciled { display: block; }
body.standing-closed .ending-reconciled { display: none; }
body.standing-outstanding .ending-reconciled { display: none; }
body.standing-closed .ending-closed { display: block; }
body.standing-outstanding .ending-outstanding { display: block; }
@media print {
.ending { display: none !important; }
.ending-reconciled { display: block !important; }
}
/* ---------------------------------------------------------------
The slips.
--------------------------------------------------------------- */
.slip {
margin: 1.2rem 0 1.2rem 4ch !important;
padding-left: 1rem;
max-width: 46ch;
border-left: 2px solid rgba(var(--coral-rgb), 0.55);
font-family: "Courier Prime", "Courier New", monospace;
font-style: italic;
opacity: 0.8;
white-space: pre-wrap;
}
#quarto-margin-sidebar { display: none; }
.scene-code {
border: 1px solid var(--rule);
border-radius: 3px;
padding: 0.6rem 0.8rem;
margin: 0.9rem 0 1.1rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.88rem;
line-height: 1.45;
background: var(--panel);
color: var(--ink);
overflow-x: auto;
}
.scene-code pre { margin: 0; }
.scene-code code { background: none; padding: 0; }
/* Pressing Enroll flashes the name it confirms, the same name or a new
one, so the press can be seen to have done something. */
.name-confirm .pc-name.pc-flash {
animation: pc-flash 1.4s ease-out;
}
@keyframes pc-flash {
0%, 30% { color: var(--green); }
}</style><script src="scittle.js" type="application/javascript"></script><div style="display:none;"><script type="application/x-scittle">(ns fedoras.carry
"What the reader carries between chapters."
(:require [clojure.string :as str]))
(def ^:private book-key
"The registration on the front of the file. It is not a secret and it
is not pretending to be one — it is the number this is keyed to, and
it is the same number a reader has been looking at since August."
3812)
;; ---------------------------------------------------------------------
;; Bytes
;; ---------------------------------------------------------------------
(defn- to-bytes [s] (.encode (js/TextEncoder.) s))
(defn- from-bytes [b] (.decode (js/TextDecoder. "utf-8") b))
(defn- bytes->str [b]
(loop [i 0 acc ""]
(if (>= i (.-length b))
acc
(recur (inc i) (str acc (js/String.fromCharCode (aget b i)))))))
(defn- str->bytes [s]
(let [out (js/Uint8Array. (count s))]
(dotimes [i (count s)] (aset out i (.charCodeAt s i)))
out))
;; ---------------------------------------------------------------------
;; The keystream
;;
;; The same small xorshift the dice and the floors use, so there is one
;; generator in this project and not four.
;; ---------------------------------------------------------------------
(defn- keystream [n seed]
(let [out (js/Uint8Array. n)]
(loop [i 0 x (bit-or 1 (bit-and seed 0x7fffffff))]
(if (>= i n)
out
(let [x (bit-and (bit-xor x (bit-shift-left x 13)) 0x7fffffff)
x (bit-and (bit-xor x (unsigned-bit-shift-right x 17)) 0x7fffffff)
x (bit-and (bit-xor x (bit-shift-left x 5)) 0x7fffffff)]
(aset out i (bit-and x 0xff))
(recur (inc i) x))))))
(defn- mask [b seed]
(let [k (keystream (.-length b) seed)
out (js/Uint8Array. (.-length b))]
(dotimes [i (.-length b)] (aset out i (bit-xor (aget b i) (aget k i))))
out))
;; ---------------------------------------------------------------------
;; Base64, url-safe
;; ---------------------------------------------------------------------
(defn- b64 [b]
(-> (js/btoa (bytes->str b))
(str/replace "+" "-")
(str/replace "/" "_")
(str/replace "=" "")))
(defn- un-b64 [s]
(let [t (-> s (str/replace "-" "+") (str/replace "_" "/"))
pad (case (mod (count t) 4) 2 "==" 3 "=" "")]
(str->bytes (js/atob (str t pad)))))
;; ---------------------------------------------------------------------
;; Rolls, short
;; ---------------------------------------------------------------------
(defn- pack-rolls
"[roll mod rewrites], shortest form. A roll with no modifier and no
rewrites is one number."
[rolls]
(into {}
(for [[id {:keys [roll mod rewrites]}] rolls
:when roll]
[id (cond
(pos? (or rewrites 0)) [roll (or mod 0) rewrites]
(not (zero? (or mod 0))) [roll mod]
:else [roll])])))
(defn- unpack-rolls
"Every shape this has ever emitted: a bare number, the short vector,
and the verbose map that went out while `pack-rolls` was sitting here
unused. Tokens from before this change still read."
[m]
(into {}
(for [[id v] m]
[(clojure.core/name id)
(cond
(number? v) {:roll v :mod 0 :rewrites 0}
(map? v) {:roll (:roll v)
:mod (or (:mod v) 0)
:rewrites (or (:rewrites v) 0)}
:else {:roll (nth v 0)
:mod (nth v 1 0)
:rewrites (nth v 2 0)})])))
;; ---------------------------------------------------------------------
;; The token
;; ---------------------------------------------------------------------
(defn- checksum [b]
(loop [i 0 s 7]
(if (>= i (.-length b))
(mod s 251)
(recur (inc i) (mod (+ (* s 31) (aget b i)) 251)))))
(defn pack
"A map to a token. The keys are one letter each because this goes in
a query string thirty times a page."
[{:keys [name companion echo rolls deeds epoch todo]}]
(try
(let [payload (js/JSON.stringify
(clj->js
(cond-> {}
name (assoc :n name)
companion (assoc :a [(:n companion) (:given companion)])
(pos? (or echo 0)) (assoc :e echo)
(seq rolls) (assoc :r (pack-rolls rolls))
(seq deeds) (assoc :d (mapv clojure.core/name deeds))
;; when the reader last withdrew the whole record.
;; it travels because the alternative is that only
;; the state travels, and then a link from before
;; the withdrawal quietly reinstates it.
(pos? (or epoch 0)) (assoc :g epoch)
(seq todo) (assoc :t [(:seq todo 0) (:items todo)]))))
body (mask (to-bytes payload) book-key)
out (js/Uint8Array. (inc (.-length body)))]
(aset out 0 (checksum body))
(dotimes [i (.-length body)] (aset out (inc i) (aget body i)))
(b64 out))
(catch :default _ nil)))
(defn unpack
"A token back to a map, or nil if anybody has been at it.
An absent field comes back empty rather than missing, which is fine
and is not a licence: the caller merges this forward and knows that an
empty Echo means the token is old, not that the Echo is nought."
[s]
(try
(when-not (str/blank? s)
(let [raw (un-b64 s)
want (aget raw 0)
body (.slice raw 1)]
(when (= want (checksum body))
(let [m (js->clj (js/JSON.parse (from-bytes (mask body book-key)))
:keywordize-keys true)]
{:name (:n m)
:companion (when-let [a (:a m)] {:n (first a) :given (or (second a) "")})
:echo (or (:e m) 0)
:rolls (unpack-rolls (or (:r m) {}))
:deeds (mapv keyword (or (:d m) []))
:epoch (or (:g m) 0)
:todo (when-let [t (:t m)] {:seq (first t) :items (or (second t) [])})}))))
(catch :default _ nil)))
;; ---------------------------------------------------------------------
;; When it does not reconcile
;; ---------------------------------------------------------------------
(defn complain! []
(js/console.warn
"016.09 — entry does not reconcile and has not been carried forward. Retained."))</script><script type="application/x-scittle">(ns fedoras.reader
"Everything the reader owns.
One atom: containing Name, animal, echo, fortune, score, deeds. A widget
reads it and never touches storage directly, and every change goes through
one of the setters, which writes, re-reads, and lets the watches fire."
(:require [clojure.string :as str]
[fedoras.carry :as carry]))
;; ---------------------------------------------------------------------
;; Storage
;; ---------------------------------------------------------------------
(def prefix "fedoras.")
(def carry-param :f)
(def forget-param
"A page that loads with this on it withdraws its own record and stops.
It is a separate field from the token because it must work on a page
that has never seen a token."
:forget)
(def withdrawn-param
"Survives exactly one reload, to say the thing was done. The state it
would otherwise have been recorded in has just been deleted, which is
the one place a confirmation cannot live."
:withdrawn)
(def legacy-params
"The fields the query string used to carry, one per fact. Stripped off
any link that still has them. There was a second copy of this list
called `carried-params` and the two had drifted."
[:pc :pcl :ac :acn :echo :fortune :score])
(defn- read-json [s]
(js->clj (js/JSON.parse s) :keywordize-keys true))
(defn- write-json [v]
(js/JSON.stringify (clj->js v)))
(defn stored [key]
(try
(when-let [s (.getItem js/localStorage (str prefix (name key)))]
(read-json s))
(catch :default _ nil)))
(defn store! [key val]
(try (.setItem js/localStorage (str prefix (name key)) (write-json val))
(catch :default _ nil))
val)
(defn remove!
"Take something out rather than writing an empty one over it, because
a stored nil and a missing key read back the same and only one of them
is what was meant."
[key]
(try (.removeItem js/localStorage (str prefix (clojure.core/name key)))
(catch :default _ nil)))
(defn storage-works?
"Whether anything at all is going to be persisted.
On a file:// path some browsers give every page its own opaque origin,
which means localStorage throws and the whole book forgets the reader
the moment they click anything. Worth knowing rather than guessing."
[]
(try
(.setItem js/localStorage (str prefix "probe") "1")
(let [ok (= "1" (.getItem js/localStorage (str prefix "probe")))]
(.removeItem js/localStorage (str prefix "probe"))
ok)
(catch :default _ false)))
;; ---------------------------------------------------------------------
;; The query string, which is a transport and nothing else
;; ---------------------------------------------------------------------
(defn- current-url [] (js/URL. (.-href js/location)))
(defn param [key]
(try
(let [v (.get (.-searchParams (current-url)) (name key))]
(when-not (str/blank? v) v))
(catch :default _ nil)))
(defn set-params! [m]
(try
(let [u (current-url) sp (.-searchParams u)]
(doseq [[k v] m]
(if (or (nil? v) (= "" v))
(.delete sp (name k))
(.set sp (name k) (str v))))
(.replaceState js/history nil "" (.toString u)))
(catch :default _ nil)))
(defn- int-or [v d]
(let [n (js/parseInt (str v) 10)] (if (js/isNaN n) d n)))
;; ---------------------------------------------------------------------
;; The atom
;; ---------------------------------------------------------------------
(def default-name ["Owen" "Mercer"])
(def state (atom nil))
(def animal-names
"Number to the animal's grammar: what it is called, what more than one
of them is called, what a ledger would file it under. Filled in by
`fedoras.state/assets` from a later script tag, so it is empty for the
moment between this file being read and the roster arriving, and
nothing here may treat an empty roster as a missing roll."
(atom {}))
(defn- as-grammar
"One roster entry, whatever shape it turned up in.
The page used to be handed {1 \"Pigeon\"} and is now handed
{1 {:animal \"Pigeon\" :plural \"pigeons\" ...}}, and a book whose
`names-form` has not caught up with its roster hands over one of each.
A bare string is an animal with no grammar, which is precisely what it
was before, so it becomes one here.
This is normalised at the boundary rather than at each use because the
alternative was a `merge` three functions away receiving a string,
throwing `conj on a map takes map entries`, and taking down every
render watch on the page — a build that is one function out of step
should read a little worse, not stop."
[v]
(cond
(map? v) v
(string? v) {:animal v}
:else {}))
(defn- read-companion []
(let [s (stored :companion)
n (int-or (first s) nil)]
(when (and n (<= 1 n 20))
(let [given (or (nth s 2 nil) "")
g (as-grammar (get @animal-names n))
;; the animal's name is a convenience and the roster may not
;; have landed yet. the number is the fact.
animal (or (:animal g) (nth s 1 nil) "")
named? (not (str/blank? given))]
(merge {:plural "" :taxonomy "" :collective "" :other-names []}
g
{:n n
:animal animal
:given given
:named? named?
:name (cond named? (str/trim given)
(not (str/blank? animal)) animal
:else (str "d20 " n))})))))
(defn- read-name []
(let [s (stored :pc)]
[(or (first s) (first default-name))
(or (second s) (second default-name))]))
(defn refresh!
"Total on purpose. Every render watch in the book hangs off this atom,
so anything that throws in here blanks the whole page, and what it
reads is JSON that a previous version of this file wrote, which is not
a thing to be optimistic about. A key that is the wrong shape reads as
absent."
[]
(reset! state
{:name (read-name)
:companion (try (read-companion) (catch :default _ nil))
:echo (int-or (stored :echo) 0)
:rolls (let [r (stored :rolls)] (if (map? r) r {}))
:deeds (let [d (stored :deeds)]
(set (map keyword (if (sequential? d) d []))))
:epoch (int-or (stored :epoch) 0)
:todo (let [t (stored :todo)] (if (sequential? t) t []))
:todo-seq (int-or (stored :todo-seq) 0)}))
;; ---------------------------------------------------------------------
;; What a chapter may ask for
;; ---------------------------------------------------------------------
(defn reader-name [] (:name @state))
(defn companion [] (:companion @state))
(defn echo [] (:echo @state))
(defn deeds [] (:deeds @state))
(defn fortune [] (count (:deeds @state)))
(defn epoch
"When this reader last withdrew the whole record, in seconds, or
nought if they never have.
It is not a clock and nothing displays it. It exists so that a
withdrawal can be compared against a link: a token stamped earlier
than this was written by somebody who has since been forgotten, and it
is not allowed to put them back."
[]
(:epoch @state))
(defn standing
"Fortune less echo. Added to every roll for the rest of the book, and
the number the last chapter reads to decide how it ends."
[]
(- (fortune) (echo)))
(defn registration [] "3812")
;; ---------------------------------------------------------------------
;; And what it may change
;; ---------------------------------------------------------------------
(defn set-name! [f l]
(store! :pc [(if (str/blank? f) (first default-name) (str/trim f))
(if (str/blank? l) (second default-name) (str/trim l))])
(refresh!))
(defn set-companion!
"Three cells: the number, the animal's own name as a courtesy, and
whatever the reader called it. The number is the only one that has to
be right, because the other two are recovered from the roster."
[n given]
(store! :companion [n (get-in @animal-names [n :animal] "") (or given "")])
(refresh!))
(defn set-echo! [n] (store! :echo (max 0 n)) (refresh!))
(defn add-echo!
"One more thing taken back."
[]
(set-echo! (inc (echo))))
(defn did!
"Record a deed. A set, so doing the same thing twice is doing it once,
and nothing anywhere says that it happened."
[k]
(store! :deeds (mapv name (conj (deeds) (keyword k))))
(refresh!))
(defn animals!
"The roster's grammar, from the page. Normalised on the way in, so the
shape `fedoras.state/names-form` happens to be emitting this week is
something only this function has an opinion about.
Refreshes, because a companion read before this arrived is holding an
empty animal name."
[m]
(reset! animal-names
(if (map? m)
(into {} (map (fn [[k v]] [k (as-grammar v)])) m)
{}))
(refresh!))
(defn rolls
"Every check the reader has resolved, by id.
Normalised on the way out. Storage keywordizes keys on read, and a
check id is an arbitrary string rather than a name, so the keys come
back as keywords and nothing matches them. And anything that is not a
map at all becomes an empty one, because assoc on a vector with a
string key takes the page down."
[]
(let [r (:rolls @state)]
(if (map? r)
(into {} (map (fn [[k v]] [(name k) v])) r)
{})))
(defn roll-entry [id]
(when-let [e (get (rolls) id)]
{:roll (or (:roll e) (get e "roll"))
:mod (or (:mod e) (get e "mod") 0)
:rewrites (or (:rewrites e) (get e "rewrites") 0)}))
(defn remember-roll! [id e]
(store! :rolls (assoc (rolls) id e))
(refresh!))
(defn score []
(+ (reduce + 0 (keep :roll (vals (rolls))))
(or (:n (companion)) 0)))
;; ---------------------------------------------------------------------
;; Import, once, on arrival
;; ---------------------------------------------------------------------
(def carries-epoch?
"Whether the codec on this page round-trips a withdrawal stamp."
(try
(= 12345 (:epoch (carry/unpack
(carry/pack {:name ["Owen" "Mercer"] :epoch 12345}))))
(catch :default _ false)))
(defn- wipe!
"Everything under the prefix, and then one thing: when that happened."
[at]
(try
(let [ks (vec (for [i (range (.-length js/localStorage))
:let [k (.key js/localStorage i)]
:when (and k (str/starts-with? k prefix))]
k))]
(doseq [k ks] (.removeItem js/localStorage k)))
(catch :default _ nil))
(store! :epoch at))
(defn- merge-token!
[{:keys [name companion echo rolls deeds epoch todo]}]
(let [ours (int-or (stored :epoch) 0)
theirs (or epoch 0)]
(cond
;; A link from before the reader withdrew the record. It is a true
;; account of somebody who does not exist any more, which is not
;; the same thing as an instruction to rebuild them. This is the
;; case that made `forget!` a lie: it emptied one page's storage
;; and every other page, and every entry in the back history, was
;; still holding a complete copy and handed it straight back.
;; A link from before the reader withdrew the record. It is a true
;; account of somebody who does not exist any more, which is not
;; the same thing as an instruction to rebuild them.
;;
;; Only when the codec can say so. A page that cannot read a stamp
;; off a token must not conclude from its absence that every link
;; in the book is stale.
(and carries-epoch? (< theirs ours))
(js/console.info
(str "016.09 — the link this page was opened from was written before the record "
"was withdrawn. Nothing carried forward. Reach this chapter from a page you "
"have opened since, and whatever is current will arrive with you."))
:else
(do
;; They withdrew it somewhere this page has not heard about yet.
;; The erasure travels the way the state travels, which is the
;; only way anything travels in a book read off a disk.
(when (and carries-epoch? (> theirs ours))
(wipe! theirs)
(js/console.info
"016.09 — prior record withdrawn at the reader's request. Nothing carried forward."))
;; the name is the one thing a reader can retype in five seconds,
;; so the token is allowed to have an opinion about it
(when (seq name)
(store! :pc name))
;; The animal:
;;
;; This was write-once — take the token's only if there is
;; nothing here — which is right on one origin and wrong on a
;; folder of files, where every page has its own storage and the
;; token is the only thing that crosses. A reader who opened Act
;; II after rolling and before naming taught that page the horse,
;; permanently, and Act II could never afterwards learn that the
;; horse was called Horace.
;;
;; The Echo is the recency stamp. It only ever goes up, and a
;; reroll raises it, so a token carrying an Echo at least as
;; large as this page's is at least as new as this page and is
;; allowed to speak. One older, and it is a link from before the
;; reroll and is not.
(let [have (read-companion)
here-echo (int-or (stored :echo) 0)
there-echo (or echo 0)]
(when (and (:n companion)
(or (nil? have) (>= there-echo here-echo)))
(store! :companion
[(:n companion) ""
;; a blank name in a token is a reader who had not
;; named it yet, not a reader who unnamed it
(if (str/blank? (:given companion))
(or (:given have) "")
(:given companion))])))
;; upward only, always. after the companion, which reads it.
(store! :echo (max (int-or (stored :echo) 0) (or echo 0)))
;; a check is resolved once, and what is already in this browser
;; was rolled by this reader, so it wins every collision
(when (seq rolls)
(let [named (fn [m] (if (map? m)
(into {} (map (fn [[k v]] [(clojure.core/name k) v])) m)
{}))]
(store! :rolls (merge (named rolls) (named (stored :rolls))))))
;; deeds are a set, and a set does not get smaller by being described
(when (seq deeds)
(let [held (let [d (stored :deeds)] (if (sequential? d) d []))]
(store! :deeds (vec (into (set (map clojure.core/name held))
(map clojure.core/name deeds))))))
(when-let [t todo]
(when (>= (:seq t 0) (int-or (stored :todo-seq) 0))
(store! :todo (:items t))
(store! :todo-seq (:seq t))))))))
(defn- import-token! []
(if-let [t (param carry-param)]
(if-let [m (try (fedoras.carry/unpack t) (catch :default _ nil))]
(merge-token! m)
(try (fedoras.carry/complain!) (catch :default _ nil)))
;; nothing to do, unless somebody has a link from before the token
(do
(when-let [pc (param :pc)]
(store! :pc [pc (or (param :pcl) (second default-name))]))
(when-let [ac (param :ac)]
(let [n (int-or ac nil)]
(when (and n (<= 1 n 20) (nil? (read-companion)))
(store! :companion [n "" (or (param :acn) "")]))))
(when-let [e (param :echo)]
(store! :echo (max (int-or (stored :echo) 0) (int-or e 0)))))))
(defn- withdraw-if-asked!
"A page loaded with a withdrawal stamp on it clears itself and does
nothing else.
This is how one button reaches twelve documents that cannot see each
other. The page the reader clicked opens each of the others in a
hidden frame with the stamp in the query string, and each one runs
this on the way up. Nothing is scripted across the boundary — every
file is its own origin and none of them can touch another's storage —
but a document can always clear its own, and a query string is the one
thing that crosses.
Returns true if this load was a withdrawal, so boot knows to skip the
rest of its work."
[]
(when-let [at (some-> (param forget-param) (int-or nil))]
(when (> at (int-or (stored :epoch) 0))
(wipe! at))
true))
;; ---------------------------------------------------------------------
;; Rendering
;; ---------------------------------------------------------------------
(defn on-render! [k f] (add-watch state k (fn [_ _ _ _] (f))) (f))
(defn el [id] (.getElementById js/document id))
(defn q [node selector] (when node (.querySelector node selector)))
(defn q-all
"Every match, as a vector. One argument searches the document; two
searches inside a node.
Written the long way because a NodeList is not a Clojure collection
and array-seq is not in Scittle's bindings."
([selector] (q-all js/document selector))
([node selector]
(if-not node
[]
(let [nodes (.querySelectorAll node selector)]
(mapv (fn [i] (.item nodes i)) (range (.-length nodes)))))))
(defn text! [node selector s]
(when-let [t (q node selector)] (set! (.-textContent t) s)))
(defn on-click! [node selector f]
(when-let [b (q node selector)] (set! (.-onclick b) f)))
(defn enable! [node selector on? label]
(when-let [b (q node selector)]
(set! (.-disabled b) (not on?))
(when label (set! (.-textContent b) label))))
(defn show! [node selector on?]
(when-let [t (q node selector)]
(set! (.-display (.-style t)) (if on? "flex" "none"))))
(defn widget [k id f]
(on-render! k (fn [] (when-let [node (el id)] (f node)))))
;; ---------------------------------------------------------------------
;; Withdrawing the record, everywhere
;;
;; Twelve documents, each its own origin, none able to read another's
;; storage. Every shared channel is shut: localStorage and IndexedDB are
;; origin-scoped, window.name is cleared on the way across, cookies do
;; not apply to a file, and an iframe gets its own origin so the parent
;; cannot script it.
;;
;; It can still LOAD it. A hidden frame pointed at a sibling chapter,
;; with a stamp in the query string, runs that chapter's own copy of
;; this file, which clears that chapter's own storage. Nothing crosses
;; the boundary except the URL, which is the one thing that was ever
;; going to.
;; ---------------------------------------------------------------------
(defn- sibling-pages
"Every other chapter this page links to.
Read off the sidebar rather than configured, because Quarto already
puts the whole book in it and a hand-kept list of filenames is a list
that goes stale the first time a chapter is renamed."
[]
(let [here (.-pathname js/location)
dir (subs here 0 (inc (.lastIndexOf here "/")))]
(->> (q-all "a[href]")
(keep (fn [a]
(let [h (.getAttribute a "href")]
(when (and h
(not (str/starts-with? h "#"))
(not (re-find #"(?i)^(https?:|mailto:|javascript:)" h)))
(try
(let [u (js/URL. (.-href a))
p (.-pathname u)]
(when (and (str/starts-with? p dir)
(str/ends-with? (str/lower-case p) ".html")
(not= p here))
(str (.-protocol u) "//" (.-host u) p)))
(catch :default _ nil))))))
distinct
vec)))
(defn- sweep!
"Tell every other chapter to withdraw, then call `done` once.
Once, whatever happens: a frame that never fires must not leave the
reader looking at a button that has stopped meaning anything, so there
is a deadline as well as a count."
[at done]
(let [pages (sibling-pages)
fired (atom false)
finish (fn [] (when-not @fired (reset! fired true) (done)))]
(if (empty? pages)
(finish)
(let [box (.createElement js/document "div")
left (atom (count pages))
one (fn [] (when (zero? (swap! left dec)) (finish)))]
(js/console.info "016.09 — withdrawing from" (count pages) "other chapters")
(set! (.-style box)
"position:absolute;width:0;height:0;overflow:hidden;border:0;visibility:hidden")
(.appendChild (.-body js/document) box)
(doseq [p pages]
(let [f (.createElement js/document "iframe")]
(set! (.-onload f) (fn [_] (one)))
(set! (.-onerror f) (fn [_] (one)))
(set! (.-src f) (str p "?" (clojure.core/name forget-param) "=" at))
(.appendChild box f)))
(js/setTimeout finish 5000)))))
(defn forget!
"Forget everything, not the six things somebody remembered to list,
and not only on this page.
Emptying this document's storage was never the whole job: the reader
was also sitting, complete, in the query string of every link on every
other chapter and in every entry of the back history, and the next
navigation put them back. So this clears here, stamps when, and then
visits every other chapter to say so. The stamp is what makes a link
written before the withdrawal refuse to be believed afterwards.
It also says so while it is happening. It used to do all of this in
silence and then reload, which is indistinguishable from a button that
does nothing except reload."
[]
(let [at (quot (.now js/Date) 1000)
btn (q js/document ".pc-forget")]
(when btn
(set! (.-disabled btn) true)
(set! (.-textContent btn) "Withdrawing the record…"))
(wipe! at)
(sweep! at
(fn []
(let [u (current-url)]
(doseq [k (concat [carry-param forget-param] legacy-params)]
(.delete (.-searchParams u) (clojure.core/name k)))
(.set (.-searchParams u) (clojure.core/name withdrawn-param) "1")
(set! (.-href js/location) (.toString u)))))))
(defn- note-withdrawal!
"One line, on the way back up, and then the button goes back to
offering. The confirmation cannot be stored, because storing things is
what has just been undone — so it is read off the query string once,
before anything else in boot is allowed to tidy the query string away."
[withdrew?]
(when withdrew?
(js/console.info
"016.09 — record withdrawn from this chapter and from every chapter linked from it.")
(when-let [b (q js/document ".pc-forget")]
(set! (.-textContent b) "Record withdrawn")
(js/setTimeout (fn [] (set! (.-textContent b) "Forget everything")) 5000))))
;; ---------------------------------------------------------------------
;; Export
;; ---------------------------------------------------------------------
(defn token
"What this reader looks like from outside. Recomputed rather than
stored, so it cannot disagree with the state it is describing.
Guarded, because this runs on every render and inside `decorate-links!`,
and a codec that throws on one unexpected key should cost the reader a
shareable link and nothing else."
[]
(try
(let [{:keys [name companion echo rolls deeds todo todo-seq]} @state]
(fedoras.carry/pack
{:name name
:companion (when companion (select-keys companion [:n :given]))
:echo echo
:rolls rolls
:deeds deeds
:epoch (epoch)
:todo (when (seq todo) {:seq (or todo-seq 0) :items todo})}))
(catch :default e
(js/console.warn "fedoras: could not pack the carry token —" e)
nil)))
(defn decorate-links!
"Every internal link carries one field.
Two things were wrong before this and both of them mattered. It ran
inline, at parse time, so any link below the script tag did not exist
yet — which is every prev-and-next arrow Quarto puts in the footer.
And it copied the query string rather than the state, so a page opened
from storage handed on nothing."
[]
(when-let [t (token)]
(doseq [a (q-all "a[href]")]
(let [h (.getAttribute a "href")]
(when (and h
(not (str/starts-with? h "#"))
(not (re-find #"(?i)^(https?:|mailto:|javascript:)" h)))
(try
(let [lu (js/URL. (.-href a))
sp (.-searchParams lu)]
(doseq [k legacy-params] (.delete sp (clojure.core/name k)))
(.set sp (clojure.core/name carry-param) t)
(set! (.-href a) (.toString lu)))
(catch :default _ nil)))))))
(defn- rewrite-address!
"Put the token in this page's own address bar and take the old fields
off it, so a reader who arrived on a legacy link stops looking at
their own Echo."
[]
(when-let [t (token)]
(set-params! (into {(clojure.core/name carry-param) t}
(map (fn [k] [k nil])
(concat [forget-param withdrawn-param] legacy-params))))))
;; ---------------------------------------------------------------------
;; Tokens
;;
;; It used to be a chain of str/replace calls, which is why {pc-last}
;; worked in a scene and rendered as the literal characters {pc-last}
;; inside a dice passage.
;;
;; Case follows the token: {ac} is `ted`, {Ac} is `Ted`, {AC} is `TED`,
;; so a passage can start a sentence or write a cue line without the
;; author reaching for a second token. Note that this makes {PC} in an
;; already-written scene render as OWEN rather than Owen; the old regex
;; was case-insensitive and threw the information away.
;;
;; An unkown token is passed over: A brace in a passage that is not
;; something this understands comes out exactly as it went in, so this
;; is safe to run over arbitrary prose.
;; ---------------------------------------------------------------------
(def ^:private token-re #"\{([A-Za-z]+)(-[A-Za-z-]+)?\}")
(defn- apply-case [head s]
(cond
(str/blank? s) s
(= head (str/upper-case head)) (str/upper-case s)
(= (subs head 0 1) (str/upper-case (subs head 0 1)))
(str (str/upper-case (subs s 0 1)) (subs s 1))
:else s))
(defn render
"Replace every {token} the resolver knows. `resolve` is called with the
lower-cased head and the lower-cased tail — \"ac\" and \"-plural\" for
{ac-plural} — and returns a string or nil."
[resolve s]
(str/replace (or s "") token-re
(fn [m]
(let [[whole head tail] (if (string? m) [m m nil] m)]
(or (some->> (resolve (str/lower-case head)
(some-> tail str/lower-case))
(apply-case head))
whole)))))
(defn- string-hash
"Stable across loads, which is the point: the same sentence picks the
same alternative name every time the reader opens the page, so nothing
reads as though it were rolled twice."
[s]
(reduce (fn [a i] (mod (+ (* 31 a) (.charCodeAt s i)) 1000003))
7 (range (count s))))
(defn- an [s]
(str (if (re-find #"(?i)^[aeiou]" (str s)) "an " "a ") s))
(defn- animal-tokens
"What a passage may say about the animal. The reader's own name for it
is a name; everything else is the species, because a reader who called
a marmot Ted did not thereby decide that two of them are Teds."
[source tail]
(when-let [{:keys [animal plural taxonomy collective other-names name named?]}
(companion)]
(let [kind (str/lower-case (or animal ""))]
(case (or tail "-name")
"-name" (if named? name (str "the " kind))
"-kind" kind
"-a" (an kind)
"-plural" (if (str/blank? plural) (str kind "s") (str/lower-case plural))
"-many" (if (str/blank? collective)
(str "several " (if (str/blank? plural) (str kind "s") plural))
(str (an collective) " of "
(if (str/blank? plural) (str kind "s") plural)))
"-taxonomy" (str/lower-case (or taxonomy kind))
"-given" (if named? name "")
"-other" (if (seq other-names)
(nth other-names (mod (string-hash source) (count other-names)))
(str "the " kind))
nil))))
(defn- reader-tokens [source]
(let [[f l] (reader-name)]
(fn [head tail]
(case head
"pc" (case (or tail "-first")
"-first" f
"-full" (str f " " l)
"-last" l
nil)
"ac" (or (animal-tokens source tail)
;; no roll yet, and a passage still has to read
(case (or tail "-name")
("-name" "-kind" "-other") "the animal"
"-a" "an animal"
"-plural" "animals"
"-many" "several animals"
"-taxonomy" "animal"
"-given" ""
nil))
nil))))
(defn fill-with
"Put the reader into a passage, plus whatever else the caller knows.
The dice box passes the die in this way."
[extra s]
(let [own (reader-tokens s)]
(render (fn [h t] (or (extra h t) (own h t))) s)))
(defn fill
"Put the reader into a passage. The only place any of these are
resolved."
[s]
(fill-with (fn [_ _] nil) s))
(defn animal-label
"Kept for anything still asking. The display name, as stored, not
shouted — the old one upper-cased and every caller lower-cased it
again somewhere else."
[]
(when-let [c (companion)] (:name c)))
;; ---------------------------------------------------------------------
;; The carry-over
;;
;; What a reader takes to the next book.
;; ---------------------------------------------------------------------
(def on-the-record
"What goes in the token. Everything else is furniture.
A key is on this list because it is small and because losing it would
lose the reader's year. Adding one is a decision about how long every
link on every page is going to be, so it is a list and not a
convention."
#{:pc :companion :echo :rolls :deeds :todo})
(defn kept
"Read something the reader left in a room. No widget that calls this
needs to know anything about tokens."
[k]
(stored k))
(defn keep!
"Leave something in a room. It stays where it was made."
[k v]
(store! k v)
(when (contains? on-the-record k) (refresh!))
v)
(def ^:private alphabet "23456789ABCDEFGHJKLMNPQRSTUVWXYZ")
(defn- b32 [n len]
(loop [n n i 0 out ""]
(if (= i len)
out
(recur (quot n 32) (inc i)
(str (nth alphabet (mod n 32)) out)))))
;; ---------------------------------------------------------------------
;; The name and the animal, on every page
;; ---------------------------------------------------------------------
(defn- paint-name []
(let [[f l] (reader-name)]
(doseq [node (q-all ".pc-name")]
(set! (.-textContent node)
(apply-case (or (.getAttribute node "data-case") "pc")
(case (.getAttribute node "data-pc")
"-full" (str f " " l) "-last" l f))))
(when-let [i (el "pc-first")] (when (str/blank? (.-value i)) (set! (.-value i) f)))
(when-let [i (el "pc-last")] (when (str/blank? (.-value i)) (set! (.-value i) l)))))
(defn- paint-companion
"Each span carries the token that made it, so {ac-plural} written in a
scene at build time still says `marmots` after the reader rolls a
marmot. It used to carry nothing and every span got the same word."
[]
(doseq [node (q-all ".ac-name")]
(let [tail (or (.getAttribute node "data-ac") "-name")
head (or (.getAttribute node "data-case") "ac")]
(set! (.-textContent node)
(or (some->> ((reader-tokens (or (.-textContent node) "")) "ac" tail)
(apply-case head))
"the animal")))))
(defn- paint-tokens []
(doseq [node (q-all ".tok")]
(set! (.-textContent node) (fill (.getAttribute node "data-tok")))))
;; ---------------------------------------------------------------------
;; Ending / Standing
;; ---------------------------------------------------------------------
(def deed-roll
[:nerve ;; a natural twenty with the Echo at nought
:named-it ;; the animal was given a name and kept it
:kept-the-programmes ;; both sheets went in the bag and stayed there
:no-name-for-the-box ;; nothing was spoken into the undertaker's box
:read-it-back ;; the ledger was read against the room out loud
:opened-the-cage
:held-the-door
:went-back-for-it
:signed-nothing
:walked-out
:told-a-new-one ;; Ariathne was paid in something that was theirs
:said-why]) ;; the chancellor asked, and got a true answer
(def ^:private deed-bits 15)
(defn- deed-mask []
(let [held (deeds)]
(reduce (fn [acc [index deed]]
(if (contains? held deed)
(+ acc (bit-shift-left 1 index))
acc))
0
(map-indexed vector deed-roll))))
(defn- deeds-from-mask [mask]
(set (keep-indexed (fn [index deed]
(when (pos? (bit-and mask (bit-shift-left 1 index)))
deed))
deed-roll)))
(def band-code {:closed 0 :reconciled 1 :outstanding 2})
(def code-band {0 :closed 1 :reconciled 2 :outstanding})
(defn ending-band []
(let [reader-standing (standing)]
(cond
(>= reader-standing 4) :outstanding
(>= reader-standing 0) :reconciled
:else :closed)))
(defn- paint-standing!
"One class on the body, so a chapter that forks three ways does it in
the stylesheet instead of in nine lines of DOM work per passage. A page
with no class on it shows :reconciled, which is what the print edition
gets and what the table actually got.
This existed and was never wired to anything, which meant every
`sp/by-standing` on every page was rendering all three endings at
once, or none."
[]
(when-let [body (.-body js/document)]
(let [classes (.-classList body)]
(doseq [old ["standing-closed" "standing-reconciled" "standing-outstanding"]]
(.remove classes old))
(.add classes (str "standing-" (clojure.core/name (ending-band)))))))
(def code-version 3)
(defn- check-char [body]
(nth alphabet
(mod (reduce + (map (fn [c] (.indexOf alphabet c)) body)) 32)))
(defn carry-code
"What this reader looks like to the next book, as an accession line."
[]
(let [n (+ (* code-version 4294967296)
(* (get band-code (ending-band) 1) 1073741824)
(* (min 31 (or (:n (companion)) 0)) 33554432)
(* (min 31 (echo)) 1048576)
(* (deed-mask) 32)
(min 31 (quot (score) 10)))
body (b32 n 7)]
(str "016.14 · " (registration) " · "
(subs body 0 4) "-" (subs body 4) (check-char body))))
(defn read-carry
"The other side of it, for the book after this one."
[s]
(let [cleaned (-> (str s) str/upper-case (str/replace #"[^2-9A-Z]" ""))
tail (subs cleaned (max 0 (- (count cleaned) 8)))]
(when (= 8 (count tail))
(let [body (subs tail 0 7)
n (reduce (fn [acc c] (+ (* 32 acc) (.indexOf alphabet c))) 0 body)]
(when (= (nth tail 7) (check-char body))
{:version (quot n 4294967296)
:band (get code-band (mod (quot n 1073741824) 4))
:companion (mod (quot n 33554432) 32)
:echo (mod (quot n 1048576) 32)
:deeds (deeds-from-mask (mod (quot n 32) (bit-shift-left 1 deed-bits)))
:score-floor (* 10 (mod n 32))})))))
;; ---------------------------------------------------------------------
;; Is any of this working
;;
;; Type fedoras.reader.diagnose_BANG_() in the console. Faster than
;; opening the storage inspector and reading JSON, and it says whether
;; the token agrees with the storage, which is the failure that cost an
;; animal.
;; ---------------------------------------------------------------------
(defn diagnose! []
(let [t (param carry-param)]
(js/console.log "storage works: " (storage-works?))
(js/console.log "origin: " (.-origin js/location)
(if (= "file:" (.-protocol js/location))
"— every page may have its own storage"
"— one bucket for the whole book"))
(js/console.log "name: " (pr-str (reader-name)))
(js/console.log "companion: " (pr-str (companion)))
(js/console.log " raw storage: " (pr-str (stored :companion)))
(js/console.log "roster loaded: " (count @animal-names) "animals")
(js/console.log "echo / fortune: " (echo) "/" (fortune) " → standing" (standing))
(js/console.log "withdrawn at: " (if (pos? (epoch)) (epoch) "never")
(if carries-epoch?
""
"— CODEC DROPS THE STAMP, carry.cljs is out of date"))
(js/console.log "rolls: " (pr-str (rolls)))
(js/console.log "token on url: " (or t "none"))
(when t
(js/console.log " unpacks to: "
(pr-str (try (fedoras.carry/unpack t)
(catch :default e (str "THREW: " e))))))
(js/console.log "token we'd emit:" (or (token) "none — pack failed"))
:ok))
;; A handle on `window`, because Scittle keeps its namespaces inside the
;; SCI context and the browser console cannot see into it. There is no
;; `fedoras.reader.diagnose_BANG_()` to call — that is the shape a
;; compiled build would have, and this is an interpreted one.
;;
;; fedoras.diagnose()
;; fedoras.forget()
;;
;; and for anything else, scittle.core.eval_string("(fedoras.reader/...)")
(set! (.-fedoras js/window)
(js-obj "diagnose" (fn [] (diagnose!))
"state" (fn [] (clj->js @state))
"token" (fn [] (token))
"forget" (fn [] (forget!))))
;; ---------------------------------------------------------------------
;; Boot
;; ---------------------------------------------------------------------
(if (withdraw-if-asked!)
;; a hidden frame, doing one job. no token, no links, no painting.
(refresh!)
;; read before anything registers a watch: `on-render!` calls its
;; function once on the spot, and the links watch tidies the query
;; string, so the flag is gone by the time the DOM is ready
(let [withdrew? (boolean (param withdrawn-param))]
(when-not carries-epoch?
(js/console.warn
(str "fedoras: carry.cljs does not round-trip the withdrawal stamp. "
"`forget!` will clear this chapter and every chapter linked from it, "
"but a stale link will not be refused afterwards. "
"Update resources/fedoras/carry.cljs — `pack` needs :epoch in its "
"destructuring and :g in its payload, and `unpack` needs :epoch back out.")))
(import-token!)
(refresh!)
(on-render! ::name paint-name)
(on-render! ::companion paint-companion)
(on-render! ::tokens paint-tokens)
(on-render! ::standing paint-standing!)
(on-render! ::links (fn [] (decorate-links!) (rewrite-address!)))
(if (= "loading" (.-readyState js/document))
(.addEventListener js/document "DOMContentLoaded"
(fn [_]
(note-withdrawal! withdrew?)
(decorate-links!)
(paint-standing!)))
(do (note-withdrawal! withdrew?)
(decorate-links!)))))</script><script type="application/x-scittle">(fedoras.reader/animals! {1 {:animal "Pigeon", :plural "pigeons", :taxonomy "bird", :collective "kit", :other-names ["dusty wings" "coooo"]}, 2 {:animal "Giant Centipede", :plural "centipedes", :taxonomy "arthropod", :collective "colony", :other-names ["clicker" "legs gooo"]}, 3 {:animal "Marmot", :plural "marmots", :taxonomy "rodent", :collective "madness", :other-names ["the alarm" "the fat rat"]}, 4 {:animal "Cat", :plural "cats", :taxonomy "felid", :collective "clowder", :other-names ["the cat" "cat"]}, 5 {:animal "Turkey", :plural "turkeys", :taxonomy "fowl", :collective "rafter", :other-names ["thanksgiving dinner for non vegans" "gobbler"]}, 6 {:animal "Giant Badger", :plural "badgers", :taxonomy "mustelid", :collective "cete", :other-names ["angry grandpa" "stripes"]}, 7 {:animal "Giant Weasel", :plural "weasels", :taxonomy "mustelid", :collective "sneak", :other-names ["the scout" "slenderman" "the ermine"]}, 8 {:animal "Fox", :plural "foxes", :taxonomy "canid", :collective "skulk", :other-names ["foxxxy" "spry foot"]}, 9 {:animal "Wolverine", :plural "wolverines", :taxonomy "mustelid", :collective "gang", :other-names ["the small trouble"]}, 10 {:animal "Dog", :plural "dogs", :taxonomy "canid", :collective "pack", :other-names ["the dog" "the ordinary dog"]}, 11 {:animal "Python", :plural "pythons", :taxonomy "serpent", :collective "knot", :other-names ["ssSSssSsss" "coilssss"]}, 12 {:animal "Mule", :plural "mules", :taxonomy "equid", :collective "barren", :other-names ["dapple" "HeeHaw :("]}, 13 {:animal "Lynx", :plural "lynxes", :taxonomy "felid", :collective "clowder", :other-names ["tufts" "yelper"]}, 14 {:animal "Ape", :plural "apes", :taxonomy "primate", :collective "shrewdness", :other-names ["treecrawler"]}, 15 {:animal "Horse", :plural "horses", :taxonomy "equid", :collective "harras", :other-names ["rocinante" "the one nobody rides"]}, 16 {:animal "Moose", :plural "moose", :taxonomy "cervid", :collective "herd", :other-names ["its a giant moose"]}, 17 {:animal "Wyrmling", :plural "wyrmlings", :taxonomy "draconic", :collective "clutch", :other-names ["the wyrmling" "scales"]}, 18 {:animal "Falcon", :plural "falcons", :taxonomy "raptor", :collective "cast", :other-names ["the raptor" "true sight"]}, 19 {:animal "Wolf", :plural "wolves", :taxonomy "canid", :collective "pack", :other-names ["the wolf" "the packleader"]}, 20 {:animal "Bear", :plural "bears", :taxonomy "ursid", :collective "sloth", :other-names ["the bear" "the citadel"]}})</script></div></div>
```
# Scene 4 — Made Good
```{=html}
<span style="display:none;"></span>
```
```{=html}
<blockquote class="handbook"><p class="rule-number">The Fedora Handbook, Rule #15</p><p class="rule-text">“The fastest way to end a conversation is to win it.”</p></blockquote>
```
---
```{=html}
<div class="scene"><div class="screenplay"><p class="slug">INT. PLANT SERVICES — TUESDAY 14 APRIL, 6:10 A.M.</p></div><div class="screenplay"><p class="action">One mug. Hollis has been in since five because the sheet says one day a week and Hollis has never in his ` been in for part of a day.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">I want to take the tray out.</p></div></div></div><div class="screenplay"><p class="action beat">Hollis does not look up.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">That's a fitting on a works order.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">I know.</p></div></div><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">Then you know it isn't yours to take out and it</p><p class="line">isn't mine either, and I've been here since before</p><p class="line">your mother and it still isn't mine.</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">The water's going into the fill.</p></div></div><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">I know where the water's going.</p></div></div></div><div class="screenplay"><p class="action beat">He drinks.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">How long have you known?</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">April.</p></div></div><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">Right. I've known since February and I put a note</p><p class="line">in and the note went where the notes go, and I</p><p class="line">have been sat here since about the middle of March</p><p class="line">deciding whether I was going to say anything else</p><p class="line">about it to anybody, and I hadn't decided.</p></div></div></div><div class="screenplay"><p class="action">He gets up. It takes a moment.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">Now I have.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. PLANT SERVICES — 6:25 A.M.</p></div><div class="screenplay"><p class="action">He does two things and does them in this order.</p><p class="action">First he takes the defect book off the shelf, which is a carbon book with a soft cover, and writes a defect notice, in full, in the hand he has used for forty-one years, with the fitting described and the works order quoted and the reason set out in four lines.</p><p class="action">He tears out the top copy and puts it in the tray by the door, which is emptied on Thursdays, and which he has been putting notes in since before the tray by the door was that tray.</p><p class="action beat">He knows exactly what will happen to it.</p><p class="action">Then he goes to the bench and gets a spanner and puts it in <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>'s hand.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">Nineteen. It'll be a nineteen and it'll be seized,</p><p class="line">and you'll want the long one and you'll want a bar</p><p class="line">on the end of the long one, and the bar's under</p><p class="line">the bench in a bit of pipe.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">You're not coming?</p></div></div><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">I'm signing you in. That's the part I've got.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div><style>/* ---------------------------------------------------------------
The widget shell. Every cs_class widget sits in one of these, so
anything here is a decision about all of them at once.
--------------------------------------------------------------- */
.widget {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
color: var(--ink);
}
.widget:focus { outline: 1px solid rgba(var(--violet-rgb), 0.7); }
.widget:focus-visible { outline: 1px solid rgba(var(--violet-rgb), 0.9); }
/* the plate on the right of the header. every widget writes to it and
nothing else does, so it is the one line a reader learns to check. */
.widget-state {
font-variant-numeric: tabular-nums;
text-align: right;
}
.widget-source {
margin: 0.9rem 0 0.3rem 0;
border: 1px solid rgba(var(--ink-rgb), 0.35);
border-radius: 4px;
overflow: hidden;
}
.widget-source-head {
display: flex;
justify-content: space-between;
padding: 0.3rem 0.6rem;
background: rgba(var(--ink-rgb), 0.10);
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
opacity: 0.75;
}
.widget-settings {
display: block;
width: 100%;
box-sizing: border-box;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.82rem;
line-height: 1.5;
padding: 0.6rem 0.7rem;
border: none;
border-top: 1px solid rgba(var(--ink-rgb), 0.25);
background: rgba(var(--ink-rgb), 0.05);
color: var(--ink);
caret-color: var(--ink);
resize: vertical;
}
.widget-source .live-bar { padding: 0 0.7rem 0.6rem 0.7rem; }
.widget-settings-said {
margin: 0 0.7rem 0.5rem 0.7rem !important;
font-size: 0.76rem;
min-height: 1rem;
opacity: 0.7;
}
.widget-settings-broke {
opacity: 1;
color: var(--coral);
}
.widget-body { display: none; }
.widget-body-shown { display: block; }
.widget-source { margin: 0.7rem 0 0.6rem 0; }</style><style>/* fedoras/legend/legend.css
*
* One key, one palette, every board.
*
* THE ALIGNMENT. Three columns per item — glyph, label, note — with the
* first two a fixed width, so every note in the key begins at the same
* x whether it is in the first column of the key or the third. The old
* one was flex, which sizes each item to its own text and lets them
* pair up wherever they fit, and that is why the right-hand column
* wandered about.
*
* --legend-cols is set per board. --legend-label widens the name column
* for a program with long names in it. */
.legend {
display: grid;
grid-template-columns: repeat(var(--legend-cols, 2), minmax(0, 1fr));
gap: 0.12rem 1.4rem;
align-items: start;
font-size: 0.72rem;
line-height: 1.7;
margin: 0.5rem 0 0.2rem;
}
.legend-item {
display: grid;
grid-template-columns: 1.15rem var(--legend-label, 5.6rem) minmax(0, 1fr);
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.legend-glyph {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.15rem;
height: 1.15rem;
border: 1px solid currentColor;
background: rgba(128, 128, 128, 0.14);
flex: none;
}
.legend-glyph svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
/* the two that are filled rather than drawn */
.legend-glyph.tone-you svg circle,
.legend-glyph.tone-entity svg path,
.legend-glyph.tone-entity svg circle { fill: currentColor; }
.legend-glyph.tone-you svg path { fill: none; }
.legend-letter {
font-size: 0.62rem;
font-weight: 600;
line-height: 1;
color: currentColor;
}
.legend-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.legend-note {
opacity: 0.5;
font-style: italic;
min-width: 0;
}
/* a bad one is red and its box is dashed, because somebody is reading
this on paper and somebody else cannot see the red at all */
.legend-item:has(.tone-bad) .legend-note { opacity: 0.72; }
.legend-glyph.tone-bad { border-style: dashed; }
@media (max-width: 34rem) {
.legend { grid-template-columns: 1fr; }
}
/* ---------------------------------------------------------------------
* THE PALETTE
*
* Set on any ancestor. A board that wants its own look overrides one
* property rather than touching a renderer.
* ------------------------------------------------------------------ */
.legend,
.floor,
.brk,
.widget-rogue,
.walk {
--tone-ink: var(--ink);
--tone-bad: #c0453a;
--tone-good: #6f9f5e;
--tone-aim: #d8a657;
--tone-box: #a5794a;
--tone-shield: #5a86b8;
--tone-you: #f2e8c4;
--tone-entity: #b8503f;
}
.tone-ink { color: var(--tone-ink); }
.tone-bad { color: var(--tone-bad); }
.tone-good { color: var(--tone-good); }
.tone-aim { color: var(--tone-aim); }
.tone-box { color: var(--tone-box); }
.tone-shield { color: var(--tone-shield); }
.tone-you { color: var(--tone-you); }
.tone-entity { color: var(--tone-entity); }</style><script type="application/x-scittle">(ns fedoras.ui.widget-ui
"What every widget's page side does the same way.
Called fully qualified from the widgets, the way `fedoras.reader` is,
because the frame emits this in an earlier Scittle tag and Scittle
evaluates tags in document order. Nothing here depends on a require
crossing that boundary."
(:require [clojure.string :as str]))
(defonce mounted
(atom #{}))
(defonce running
(atom #{}))
(defonce shipped
(atom {}))
(defn running? [id] (contains? @running id))
(defn reveal!
"Show the widget's body. Called on the first successful run, and never
undone: a reader who breaks the setting afterwards keeps the thing that
was already working."
[node id]
(when-let [body (fedoras.reader/q node ".widget-body")]
(.add (.-classList body) "widget-body-shown"))
(swap! running conj id))
(defn state!
"Write the plate in the header."
[node text]
(fedoras.reader/text! node ".widget-state" text))
(defn kept
"What this widget left here last time, or nil."
[k]
(fedoras.reader/kept k))
(defn keep!
"Leave it here. Whether it also travels is the reader's business and
not this widget's."
[k v]
(fedoras.reader/keep! k v))
(defn did!
"Award the reader a point of fortune, once, silently.
A widget calls this when somebody has actually done the thing —
finished the conversation, raked the garden, written on the card. It
is a set, so it cannot be given twice, and nothing anywhere tells the
reader it happened.
Keep the keys in fedoras.endings.standing/deeds so there is one place
to see how much fortune a reader can actually get. Six to ten is the
ceiling: past that the modifier swamps the die."
[k]
(fedoras.reader/did! k))
(defn q!
"An element inside a widget, or a complaint in the header.
`fedoras.reader/q` returns nil for a selector that matches nothing,
which then fails as a null dereference three functions away from the
typo that caused it. This says which selector was wrong, in the one
place a reader is already looking."
[node selector]
(or (fedoras.reader/q node selector)
(do (state! node (str "missing: " selector)) nil)))
(defn wire!
"One control, wired in isolation.
Widgets used to set up every handler in one function, so the first
thing that threw took every handler after it with it, silently. Each
call here stands alone and says so if it fails."
[node what f]
(try
(f node)
(catch :default e
(js/console.error (str "widget: " what " did not wire") e)
(state! node (str what " did not wire")))))
(defn evaluate
"Evaluate ClojureScript source, in the page, and hand back what it
returned. This is Scittle evaluating the same way it evaluates the
book's own script tags, so what a reader types is not a lesser kind of
code than what shipped."
[source]
(if-let [core (some-> (.-scittle js/window) (.-core))]
(.eval_string core source)
(throw (js/Error. "the ClojureScript runtime is not on this page"))))
(defn- say!
[node message ok?]
(when-let [line (fedoras.reader/q node ".widget-settings-said")]
(set! (.-textContent line) message)
(.toggle (.-classList line) "widget-settings-broke" (not ok?))))
(defn reveal!
"Show the widget's body. Called on the first successful run, and never
undone: a reader who breaks the settings afterwards keeps the thing
that was already working."
[node id]
(when-let [body (fedoras.reader/q node ".widget-body")]
(.add (.-classList body) "widget-body-shown"))
(swap! running conj id))
(defn run-settings!
"Evaluate the panel and hand the result to `f`. A reader who breaks it
gets the message the runtime gave, under the box, and the widget goes
on running on the last settings that worked."
[node id f]
(when-let [box (fedoras.reader/q node ".widget-settings")]
(try
(let [value (evaluate (.-value box))]
(if (map? value)
(do (reveal! node id)
(f value)
(say! node (str "Running on " (count value) " settings.") true)
(fedoras.reader/enable! node ".widget-settings-run" true "Run again"))
(say! node "That did not come out as a map, so nothing was changed." false)))
(catch :default e
(say! node (str "It says: " (.-message e)) false)))))
(defn settings!
"Wire the source panel. Nothing runs until the reader presses Run,
which is the whole point of putting the file above the program."
[node id f]
(when-let [box (fedoras.reader/q node ".widget-settings")]
(swap! shipped assoc id (.-value box))
(fedoras.reader/on-click! node ".widget-settings-run"
(fn [_] (run-settings! node id f)))
(fedoras.reader/on-click! node ".widget-settings-reset"
(fn [_]
(set! (.-value box) (get @shipped id))
(run-settings! node id f)
(say! node "Put back as it shipped, and run again." true)))))
(defn mount!
"Register a widget. `setup!` runs once for a given element, `paint!`
runs on every render once the widget is running.
The node is checked because `getElementById` returns the first match in
the document, so a stale cell earlier in a chapter carrying the same id
silently hands a widget somebody else's element. Every selector inside
it then misses, nothing throws, and the widget reads as a Run button
that does nothing."
[k id setup! paint!]
(fedoras.reader/widget
k id
(fn [node]
(if-not (.contains (.-classList node) "widget")
(js/console.error "widget:" id "is not a widget element. Another"
"element on this page claims that id." node)
(do
(when-not (contains? @mounted [k id])
(swap! mounted conj [k id])
(when-not (fedoras.reader/q node ".widget-settings")
(reveal! node id))
(setup! node))
(when (running? id) (paint! node)))))))
(defn download!
"Hand the reader a file. Theirs, on their disc, out of the book."
[text filename]
(let [blob (js/Blob. #js [text] #js {:type "text/plain;charset=utf-8"})
url (js/URL.createObjectURL blob)
anchor (.createElement js/document "a")]
(set! (.-href anchor) url)
(set! (.-download anchor) filename)
(.click anchor)
(.revokeObjectURL js/URL url)))
(defn html!
"Replace the contents of one element inside a widget. Silent if it is
not there, because a renderer runs on every chapter and most chapters
do not have most widgets."
[node selector markup]
(when-let [target (fedoras.reader/q node selector)]
(set! (.-innerHTML target) markup)))
(defn grid!
"Repaint a grid: the column count as a custom property, the cells as
markup. Two lines that every grid widget in the book was writing out."
[node selector cols markup]
(when-let [target (fedoras.reader/q node selector)]
(.setProperty (.-style target) "--cols" (str cols))
(set! (.-innerHTML target) markup)))
(defn data
"Integer data attributes off the nearest element matching `selector`,
or nil if the event did not land on one.
(data event \".floor-room\" \"r\" \"c\") => [3 7]
This is how a grid gets its clicks: one handler on the container that
reads the coordinates back off the element, rather than a handler per
cell that has to be rewired every time the grid is repainted."
[event selector & names]
(when-let [element (.closest (.-target event) selector)]
(mapv (fn [n] (js/parseInt (.getAttribute element (str "data-" n)) 10))
names)))
(defn delegate!
"One click handler on a container, resolving to whichever child was
hit. `f` is called with the event and the vector `data` returned."
[node container-selector child-selector names f]
(when-let [container (fedoras.reader/q node container-selector)]
(set! (.-onclick container)
(fn [event]
(when-let [found (apply data event child-selector names)]
(f event found))))))
(defn keys!
"Wire a keymap onto the widget itself. The shell carries the tabindex,
so the keys work when the widget has focus and nowhere else, and the
page still scrolls when it does not."
[node keymap f]
(set! (.-onkeydown node)
(fn [event]
(when-let [what (get keymap (.-key event))]
(.preventDefault event)
(f event what)))))</script><script type="application/x-scittle">(ns fedoras.ui.legend
"The key, built in the page.
Both boards build their own key at runtime rather than taking a
rendered one from the JVM, because Breakout's changes the moment
somebody catches a bad drop. So the renderer lives here and both of
them call it with data.
An entry is {:svg or :letter, :label, :note, :tone}. Nothing here
knows what any of the tones look like — that is the stylesheet's
business and it is the reason a board never contains a hex code."
(:require [clojure.string :as str]))
(defn- glyph [{:keys [svg letter tone] :or {tone :ink}}]
(str "<span class='legend-glyph tone-" (name tone) "'>"
(if svg
(str "<svg viewBox='0 0 24 24'>" svg "</svg>")
(str "<span class='legend-letter'>" letter "</span>"))
"</span>"))
(defn html
"The whole key as one string, ready for innerHTML."
[entries]
(str/join
""
(for [{:keys [label note] :as e} entries]
(str "<div class='legend-item'>"
(glyph e)
"<span class='legend-label'>" label "</span>"
"<span class='legend-note'>" (or note "") "</span>"
"</div>"))))
(defn paint!
"Put a key into an element. `cols` is how many across.
(legend/paint! (reader/q node \".floor-legend\") entries 2)"
([el entries] (paint! el entries 2))
([el entries cols]
(when el
(set! (.-className el) "legend")
(.setProperty (.-style el) "--legend-cols" (str cols))
(set! (.-innerHTML el) (html entries)))))
(defn tone-class
"For the board itself, so a box on the grid is the same brown as the
box in the key."
[tone]
(str "tone-" (name (or tone :ink))))</script><style>/* ---------------------------------------------------------------
ROGUE.CLJS
Everything is a box with a border, because that is what these
games look like and pretending otherwise would be a different
joke.
--------------------------------------------------------------- */
.widget-rogue { max-width: 44rem; }
.rogue-status {
margin: 0.6rem 0 0.5rem 0;
font-size: 0.76rem;
font-variant-numeric: tabular-nums;
}
.rogue-line { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
.rogue-line + .rogue-line { margin-top: 0.4rem; }
.rogue-key { opacity: 0.5; letter-spacing: 0.1em; }
.rogue-bar {
position: relative;
display: inline-block;
flex: 1;
min-width: 7rem;
height: 1.1rem;
border: 1px solid var(--rule);
border-radius: 2px;
overflow: hidden;
}
.rogue-bar-fill { position: absolute; inset: 0 auto 0 0; }
.rogue-hp .rogue-bar-fill { background: rgba(var(--coral-rgb), 0.5); }
.rogue-energy .rogue-bar-fill { background: rgba(var(--violet-rgb), 0.5); }
.rogue-xp .rogue-bar-fill { background: rgba(var(--gold-rgb), 0.45); }
.rogue-bar-text {
position: relative;
display: block;
text-align: center;
line-height: 1.1rem;
font-size: 0.68rem;
}
.rogue-stage { margin: 0.5rem 0; }
.rogue-doors { display: flex; gap: 0.6rem; flex-wrap: wrap; }
.rogue-door {
flex: 1;
min-width: 12rem;
padding: 1.4rem 0.8rem !important;
font-size: 0.78rem;
letter-spacing: 0.1em;
}
.rogue-door-boss { background: rgba(var(--coral-rgb), 0.2); }
.rogue-foes { display: flex; gap: 0.6rem; flex-wrap: wrap; margin-bottom: 0.5rem; }
.rogue-foe {
flex: 1;
min-width: 9rem;
border: 1px solid var(--rule);
border-radius: 3px;
padding: 0.5rem 0.6rem;
}
.rogue-boss { border-color: rgba(var(--coral-rgb), 0.7); }
/* down, and still on the list, because the fight is not over */
.rogue-down { opacity: 0.35; text-decoration: line-through; }
.rogue-foe-name { font-size: 0.74rem; letter-spacing: 0.06em; margin-bottom: 0.3rem; }
.rogue-foe-note { font-size: 0.68rem; opacity: 0.55; margin-top: 0.2rem; }
.rogue-shelf { display: flex; flex-direction: column; gap: 0.4rem; }
.rogue-stock {
display: flex;
align-items: center;
gap: 0.6rem;
border: 1px solid var(--rule);
border-radius: 3px;
padding: 0.4rem 0.6rem;
}
.rogue-thing { flex: 1; font-size: 0.8rem; }
.rogue-offer {
border: 1px solid var(--rule);
border-radius: 3px;
padding: 0.7rem 0.8rem;
}
.rogue-dark {
border-color: rgba(var(--coral-rgb), 0.6);
background: rgba(var(--coral-rgb), 0.07);
}
.rogue-price { font-size: 0.76rem; opacity: 0.7; margin: 0.3rem 0 0 0 !important; }
.rogue-log {
margin: 0.5rem 0;
font-size: 0.74rem;
line-height: 1.6;
opacity: 0.75;
min-height: 6rem;
max-height: 9rem;
overflow-y: auto;
}
.rogue-log p { margin: 0 !important; }
.rogue-kit {
border-top: 1px solid var(--rule);
padding-top: 0.45rem;
font-size: 0.72rem;
line-height: 1.7;
}
.rogue-kit-row { opacity: 0.85; }
.rogue-quit {
margin-top: 0.7rem;
padding-top: 0.5rem;
border-top: 1px dashed rgba(var(--ink-rgb), 0.3);
}
/* the same size and colour as everything else, because a button that
looked important would be doing the reader's thinking for them */
.rogue-stop { opacity: 0.7; }
.rogue-stop:hover { opacity: 1; }
.rogue-foe-acts {
display: flex;
gap: 0.3rem;
margin-top: 0.4rem;
}
.rogue-hit {
flex: 1;
font-size: 0.7rem !important;
padding: 0.15rem 0.4rem !important;
}
.rogue-status-line { gap: 0.5rem; }
.rogue-shield {
font-size: 0.7rem;
letter-spacing: 0.08em;
padding: 0.05rem 0.4rem;
border: 1px solid rgba(var(--blurple-rgb), 0.6);
border-radius: 2px;
}
.rogue-poison {
font-size: 0.7rem;
letter-spacing: 0.08em;
padding: 0.05rem 0.4rem;
border: 1px solid rgba(var(--green-rgb), 0.6);
border-radius: 2px;
color: var(--green);
}
.rogue-from {
opacity: 0.45;
font-size: 0.68rem;
letter-spacing: 0.08em;
margin-left: 0.4rem;
}
/* --- Damage Flashes ------------------------------------------------------
One frame of the exchange lasts about 140ms, so these have to be
quick. A transition would smear across two frames and read as a
glow rather than a hit.
------------------------------------------------------------------- */
.rogue-lit-hurt {
background: rgba(var(--coral-rgb), 0.3);
border-color: rgba(var(--coral-rgb), 0.9) !important;
}
.rogue-lit-shield {
background: rgba(var(--blurple-rgb), 0.3);
border-color: rgba(var(--blurple-rgb), 0.9) !important;
}
.rogue-lit-poison {
background: rgba(var(--green-rgb), 0.3);
border-color: rgba(var(--green-rgb), 0.9) !important;
}
/* a miss is the absence of a thing happening, so it goes grey and
flat rather than lighting up */
.rogue-lit-missed {
opacity: 0.45;
background: rgba(var(--ink-rgb), 0.18);
}
.rogue-lit .rogue-foe-name { font-weight: 700; }
/* the whole widget, when it is you who was hit */
.widget-rogue { transition: box-shadow 80ms linear; }
.rogue-you-hurt { box-shadow: inset 0 0 0 3px rgba(var(--coral-rgb), 0.55); }
.rogue-you-shield { box-shadow: inset 0 0 0 3px rgba(var(--blurple-rgb), 0.55); }
.rogue-you-poison { box-shadow: inset 0 0 0 3px rgba(var(--green-rgb), 0.55); }
.rogue-foe-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
margin-bottom: 0.3rem;
}
.rogue-foe-name { font-size: 0.74rem; letter-spacing: 0.06em; }
.rogue-marks { display: flex; gap: 0.2rem; flex: none; }
/* the same glyph as the key, a size down */
.rogue-mark {
width: auto;
min-width: 1rem;
height: 1rem;
padding: 0 0.15rem;
border-radius: 2px;
}
.rogue-mark .legend-letter {
font-size: 0.58rem;
letter-spacing: 0.02em;
}
.rogue-legend {
border-top: 1px solid var(--rule);
padding-top: 0.4rem;
margin-top: 0.4rem;
}
.rogue-down {
opacity: 0.35;
text-decoration: line-through;
}
/* a thing that has stopped does not flash. whatever lit it, it is
over. */
.rogue-down.rogue-lit-hurt,
.rogue-down.rogue-lit-shield,
.rogue-down.rogue-lit-poison,
.rogue-down.rogue-lit-missed {
background: none;
border-color: var(--rule) !important;
}</style><script type="application/x-scittle">(ns fedoras.cs-class.rogue.rogue-ui
"A roguelike with the map taken out.
WHAT IS LEFT WHEN YOU TAKE THE MAP OUT. Two doors, and behind each one
a room from a bag that was packed before you got here. The bag holds
the same things every floor: three fights, a shop, a treasure, some
quests, and rather more empty rooms than anybody would put in a
building on purpose. You draw two, you take one, and the other is
never spoken of again.
It is a large table and a small interpreter. Every item, upgrade,
enemy, boss, sacrifice and quest is data in `rogue_settings.cljs`, and
the fight reads that data as flags, so a reader who wants a new item
writes a map and does not touch this file. That is the whole design,
and it is also why the file is short and the settings file is not.
THE SACRIFICES ARE NOT BALANCED and are not meant to be. They are what
you remember about a run, and the price is whatever you can least
afford, which is a design decision that games make and then describe
as difficulty."
(:require [clojure.string :as str]))
;; ---------------------------------------------------------------------
;; 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)))
(defn- roll
"An integer from 0 up to n, not including n."
[n]
(min (dec n) (int (* (rnd) n))))
(defn- pick [coll]
(when (seq coll)
(let [v (vec coll)] (nth v (roll (count v))))))
(defn- shuffled [coll]
(let [v (js/Array.from (into-array coll))]
(loop [i (dec (.-length v))]
(if (pos? i)
(let [j (roll (inc i))
held (aget v i)]
(aset v i (aget v j))
(aset v j held)
(recur (dec i)))
(vec v)))))
(defn- chance? [percent] (< (* 100 (rnd)) percent))
;; ---------------------------------------------------------------------
;; State
;; ---------------------------------------------------------------------
(defonce book (atom nil))
(defonce game (atom nil))
(defonce reel (atom nil))
(def storage-key :rogue)
(defn living [state] (filterv (fn [e] (pos? (:hp e))) (:enemies state)))
(defn refresh-price
"What the next look out the back costs. It doubles, and the shop does
not say that it doubles."
[n]
(let [{:keys [refresh-price refresh-price-mult]} (:tuning @book)]
(js/Math.round (* (or refresh-price 10)
(js/Math.pow (or refresh-price-mult 2) n)))))
(defn refresh-chance
"How likely it is that there is anything out the back. It falls, and
the shop does not say that it falls either."
[n]
(let [{:keys [refresh-chance refresh-chance-drop]} (:tuning @book)]
(max 0 (- (or refresh-chance 85) (* (or refresh-chance-drop 20) n)))))
(def default-book
"Enough to run on before the settings are read, so nothing downstream
destructures a nil."
{:start {:hp 300 :strength 4 :speed 10 :dodge 0 :crit 5}
:tuning {:energy-gain 5 :strong-at 30 :strong-mult 2 :crit-mult 2
:item-price 25 :heal-price 10 :heal-amount 12
:paid-heal [14 20] :free-heal [3 7] :scavenge [4 11]
:defend-reduce 0.5 :enemy-gold 2
:xp-curve [5 15 30 75 120 200 320]
:level-reach 2 :max-party 3 :min-enemy-growth 2
:superboss-every 7 :fortune-level 5
:sacrifice-chance 50
:refresh-price 10 :refresh-price-mult 2
:refresh-chance 85 :refresh-chance-drop 20
:poison-tick 1 :poison-fade 1 :summon-cap 5
:near-miss 0.25}
:floor {:steps 6 :encounters 3 :shops 1 :treasures 1 :quests 2 :empties 4}
:items [] :upgrades [] :sacrifices []
:minors [] :bosses [] :superbosses [] :quests []
:quest-rewards {:gold 30 :xp 4}})
;; ---------------------------------------------------------------------
;; Reading the kit
;;
;; Every item, upgrade and sacrifice you hold is one map, and what they
;; do is the sum of them. There is no ordering and no interaction: two
;; things that both say +3 Strength give you six, and that is the whole
;; of the rules engine.
;; ---------------------------------------------------------------------
(defn kit
"Everything the player is carrying, as one seq of effect maps."
[state]
(concat (:items state) (:upgrades state) (:sacrifices state)))
(defn total
"Add up one numeric effect across the kit."
[state k]
(reduce + 0 (keep k (kit state))))
(defn flag?
"True when anything in the kit sets a flag."
[state k]
(boolean (some k (kit state))))
(defn best
"The largest value of one effect across the kit, or `floor` if nothing
sets it. For things that replace rather than accumulate."
[state k floor]
(reduce max floor (keep k (kit state))))
(defn least
"The smallest value, for thresholds that things lower."
[state k ceiling]
(reduce min ceiling (keep k (kit state))))
(defn max-hp [state]
(let [base (+ (get-in @book [:start :hp])
(total state :max-hp)
(* (total state :sacrifice-hp) (count (:sacrifices state))))
mult (reduce * 1 (keep :hp-mult (kit state)))]
(js/Math.round (* base mult))))
(defn strength [state]
(+ (get-in @book [:start :strength])
(total state :strength)
(* (total state :gold-strength) (quot (:gold state) 10))
(* (total state :item-strength) (count (:items state)))
(* (total state :level-strength) (:level state))
(* (total state :sacrifice-strength) (count (:sacrifices state)))))
(defn speed [state]
(+ (get-in @book [:start :speed]) (total state :speed)))
(defn dodge [state]
(min 75 (+ (get-in @book [:start :dodge]) (total state :dodge))))
(defn crit [state]
(min 100 (+ (get-in @book [:start :crit]) (total state :crit))))
(defn crit-mult [state]
(best state :crit-mult (get-in @book [:tuning :crit-mult])))
(defn strong-at [state]
(least state :strong-at (get-in @book [:tuning :strong-at])))
(defn energy-gain [state]
(+ (get-in @book [:tuning :energy-gain]) (total state :energy-gain)))
;; ---------------------------------------------------------------------
;; Levelling
;; ---------------------------------------------------------------------
(defn xp-needed
"What the next level costs. Past the end of the curve it doubles, which
is a thing games do so that the number can keep going up without
anybody having to decide what should happen."
[level]
(let [curve (get-in @book [:tuning :xp-curve])]
(if (<= level (count curve))
(nth curve (dec level))
(* 2 (last curve)))))
(defn xp-for
"What one kill is worth. A minor gives its level, a boss gives twice
its level and one more, so the numbers grow and the ratio does not."
[state boss?]
(let [level (:level state)
base (if boss? (+ (* 2 level) 1) level)]
(js/Math.round
(* (+ base (total state :xp-flat))
(reduce * 1 (keep :xp-mult (kit state)))))))
;; ---------------------------------------------------------------------
;; The log
;; ---------------------------------------------------------------------
(defn say
"Add a line to the log. The log is short on purpose: a roguelike that
tells you everything it did is a spreadsheet."
[state & parts]
(update state :log
(fn [lines] (vec (take-last 9 (conj (or lines []) (str/join "" parts)))))))
;; ---------------------------------------------------------------------
;; The floor, as a bag
;; ---------------------------------------------------------------------
(def never-together
"Pairs the game will not offer you at the same time, because a choice
between two things you want is a choice and a choice between a thing
you want and a thing you want is theatre."
#{#{:treasure :quest} #{:treasure :shop}})
(defn pack-floor
"The bag for one floor. Everything that is guaranteed goes in first and
the rest is empty rooms, and there are always more rooms than steps,
because you have to be able to leave one behind."
[state]
(let [{:keys [steps encounters shops treasures quests empties]} (:floor @book)]
(shuffled
(concat (repeat encounters :encounter)
(repeat shops :shop)
(repeat treasures :treasure)
(repeat quests :quest)
(repeat empties :empty)
(repeat (max 0 (- (* 2 steps)
encounters shops treasures quests empties))
:empty)))))
(defn- not-together?
"Whether these two rooms may be offered as a pair.
`hash-set` rather than the `#{}` literal, because a set literal checks
for duplicates at runtime and throws, and two doors drawing the same
room is ordinary. That crash lands inside `draw-doors`, which every
room exit goes through, so it reads as a button that does nothing."
[a b]
(contains? never-together (hash-set a b)))
(defn draw-doors
"Two rooms from the bag, avoiding the pairs that are not really a
choice. The bag is rotated rather than redrawn, and given up on after
a few tries, because a bag that has run down to two treasures is a bag
that has earned it."
[state]
(loop [bag (vec (:bag state)) tries 0]
(let [a (first bag)
b (second bag)]
(cond
(nil? a) (assoc state :doors [:empty :empty] :bag [])
(nil? b) (assoc state :doors [a :empty] :bag [])
(and (< tries 4) (not-together? a b))
(recur (vec (concat (rest bag) [a])) (inc tries))
:else (assoc state :doors [a b] :bag (vec (drop 2 bag)))))))
(defn next-step
"Move to the next door, or to the boss if the floor is spent."
[state]
(let [step (inc (:step state))
steps (get-in @book [:floor :steps])]
(cond
(> step steps)
(assoc state :scene :boss-door :step step)
(= step steps)
(-> state (assoc :step step) (assoc :doors [:heal :empty]) (assoc :scene :doors))
:else
(-> state (assoc :step step) draw-doors (assoc :scene :doors)))))
;; ---------------------------------------------------------------------
;; Enemies
;; ---------------------------------------------------------------------
(def marks
"What a card can be carrying, as legend entries.
The same shapes go in the key, so a reader meets each one twice: once
where it is explained and once where it matters. Which is the whole
reason the key is a shared thing rather than a paragraph."
[{:id :poisoned :letter "P" :tone :good :label "poisoned"
:note "loses health at the end of each round"}
{:id :shield :letter "S" :tone :shield :label "shielded"
:note "the number in front of its health"}
{:id :summons :letter "+" :tone :bad :label "summons"
:note "brings more of them, every few rounds"}
{:id :multi :letter "X" :tone :bad :label "strikes twice"
:note "or more than twice"}
{:id :turned :letter "T" :tone :aim :label "reassigned"
:note "fights for you until it dies"}])
(defn marks-on
"Which marks one enemy is carrying, in the order they are listed."
[enemy]
(filterv (fn [{:keys [id]}]
(case id
:poisoned (pos? (or (:poisoned enemy) 0))
:shield (pos? (or (:shield enemy) 0))
:summons (some? (:summons enemy))
:multi (pos? (or (:extra-strikes enemy) 0))
:turned (:turned enemy)
false))
marks))
(defn- mark-html
[{:keys [letter tone]} value]
(str "<span class='rogue-mark legend-glyph tone-" (name tone) "'>"
"<span class='legend-letter'>" letter (when value value) "</span>"
"</span>"))
(defn make-enemy
[state template boss?]
(let [weaken (min 0.8 (total state :enemy-weaken))
floor (:floor state)
scale (+ 1 (* 0.25 (dec floor)))
hp (max 1 (js/Math.round (* (:hp template) scale (- 1 weaken))))]
(assoc template
:hp hp
:max-hp hp
:shield (js/Math.round (* (or (:shield template) 0) scale))
:poisoned 0
:strength (js/Math.round (* (:strength template) scale))
:boss boss?)))
(defn- between
"An integer in an inclusive range, given as [low high] in the settings.
A bare number is a range of one, so a reader can write either."
[range fallback]
(let [[low high] (cond
(and (vector? range) (= 2 (count range))) range
(number? range) [range range]
:else fallback)]
(+ low (roll (inc (max 0 (- high low)))))))
(defn encounter-budget
"How much room this encounter has, in CR.
A budget rather than a party size, because a room with one heavy thing
in it and a room with six light ones should both be a fair fight, and
which you get should be a matter of what came out of the bag rather
than a rule anybody could state."
[state]
(let [{:keys [encounter-cr cr-per-floor cr-per-level]} (:tuning @book)]
(+ (between encounter-cr [4 7])
(* (or cr-per-floor 1.6) (dec (:floor state)))
(* (or cr-per-level 0.9) (dec (:level state))))))
(defn eligible
"Who you can meet.
Anything whose `:min-level` your level plus the reach has caught up
with, so there is always something above you out there and it is
always the thing worth the most. A minor with no `:min-level` is
available from the start.
Falls back to the whole pool rather than returning nothing, because a
settings file where every minor is gated above level one should give
you a strange fight rather than an empty room."
[state]
(let [ceiling (+ (:level state) (or (get-in @book [:tuning :level-reach]) 2))
pool (filterv (fn [e] (<= (or (:min-level e) 1) ceiling)) (:minors @book))]
(if (seq pool) pool (:minors @book))))
(defn make-encounter
"One encounter, drawn against a budget.
Anything whose CR fits in what is left may be drawn, so a large budget
spent on the first draw is one heavy thing and the same budget spent
evenly is a crowd. The shape of a room is a consequence of arithmetic
rather than a rule, which is why no two floors give the same rooms and
why a summoner sometimes arrives with company and sometimes alone."
[state]
(let [most (or (get-in @book [:tuning :max-party]) 6)
pool (eligible state)]
(loop [left (encounter-budget state)
picked []]
(let [affordable (filterv (fn [e] (<= (or (:cr e) 1) left)) pool)]
(if (or (>= (count picked) most) (empty? affordable))
(vec (map-indexed (fn [i template]
(assoc (make-enemy state template false) :slot i))
(if (seq picked) picked [(pick pool)])))
(let [chosen (pick affordable)]
(recur (- left (or (:cr chosen) 1))
(conj picked chosen))))))))
(defn make-boss [state]
(let [pool (:bosses @book)
which (nth pool (mod (dec (:floor state)) (max 1 (count pool))))]
[(assoc (make-enemy state which true) :slot 0)]))
(defn superboss-floor?
"Whether this floor's boss is something else. Every seventh, and then
every seventh after that, because the game does not end and saying so
is more honest than a credits screen."
[state]
(let [every (get-in @book [:tuning :superboss-every])]
(and (pos? every) (zero? (mod (:floor state) every)))))
(defn make-superboss
[state]
(let [pool (:superbosses @book)
every (max 1 (get-in @book [:tuning :superboss-every]))
times (quot (:floor state) every)
which (if (seq pool)
(nth pool (mod (dec times) (count pool)))
(pick (:bosses @book)))]
[(assoc (make-enemy state which true) :slot 0 :superboss true)]))
;; ---------------------------------------------------------------------
;; The fight
;; ---------------------------------------------------------------------
(defn start-fight
[state enemies]
(let [heal (total state :fight-heal)]
(-> state
(assoc :enemies enemies)
(assoc :energy (total state :start-energy))
(assoc :shield (total state :shield))
(assoc :poisoned 0)
(assoc :round 1)
(assoc :untouched true)
(assoc :defending false)
(assoc :hp (min (max-hp state) (+ (:hp state) heal)))
(assoc :scene :fight)
(say "A fight. " (str/join ", " (map :name enemies)) "."))))
(defn flash
"Mark something as having just been hit, so the paint can show it.
A frame of the exchange is a state, and states have no memory of what
changed between them. Rather than diffing two states to work out what
moved, the fight says so on the way past. Cleared at the start of each
step, so a flash lasts exactly one frame."
[state who kind]
(assoc-in state [:flash who] kind))
(defn unflash
[state]
(dissoc state :flash))
(defn take-damage
"Damage against a thing with a shield in front of it.
One function because shield has to come off before health does, and
before this there were four places that subtracted from health and
would each have needed the same three lines. Returns the thing."
[who amount]
(let [shield (or (:shield who) 0)
stopped (min shield amount)
through (- amount stopped)]
(-> who
(assoc :shield (- shield stopped))
(update :hp - through))))
(defn poison-of
"How much poison one of your hits lays on. Zero unless something in the
kit says otherwise, which is most of the time, which is what makes the
items that say otherwise worth having."
[state]
(total state :poison))
(defn hurt-enemy
"Damage one enemy by slot, through its shield, and say which of the two
took it so the card can flash the right colour."
[state slot amount]
(let [target (first (filter (fn [e] (= (:slot e) slot)) (:enemies state)))
shield (or (:shield target) 0)
on-shield (pos? (min shield amount))]
(-> state
(update :enemies
(fn [es]
(mapv (fn [e]
(if (= slot (:slot e))
(let [hit (take-damage e amount)]
(assoc hit :hp (max 0 (:hp hit))))
e))
es)))
(flash slot (if on-shield :shield :hurt)))))
(defn poison-enemy
[state slot amount]
(if-not (pos? amount)
state
(update state :enemies
(fn [es]
(mapv (fn [e]
(if (= slot (:slot e))
(update e :poisoned (fnil + 0) amount)
e))
es)))))
(defn damage-to
"What one attack does to one enemy, and whether it crits."
[state enemy strong?]
(let [base (strength state)
low (if (< (:hp enemy) (/ (:max-hp enemy) 2))
(+ 1 (total state :low-bonus))
1)
first-round (if (= 1 (:round state)) (+ 1 (total state :first-bonus)) 1)
mult (reduce * 1 (keep :damage-mult (kit state)))
strong (if strong? (get-in @book [:tuning :strong-mult]) 1)
crit? (chance? (crit state))
crit-x (if crit? (crit-mult state) 1)]
[(max 1 (js/Math.round (* base low first-round mult strong crit-x)))
crit?]))
(defn land-hit
"One landed attack on one enemy, with everything that follows: the
shield taking it first, execution, lifesteal, and the poison that
comes off your hands whether or not the hit killed."
[state enemy amount]
(let [execute (total state :execute)
after-shield (max 0 (- amount (or (:shield enemy) 0)))
finished (and (pos? execute)
(<= (- (:hp enemy) after-shield)
(* execute (:max-hp enemy))))
dealt (if finished (+ (:hp enemy) (or (:shield enemy) 0)) amount)
steal (+ (total state :flat-steal)
(js/Math.round (* dealt (total state :lifesteal))))
venom (poison-of state)
spreads (flag? state :poison-cleave)]
(-> state
(hurt-enemy (:slot enemy) dealt)
(as-> s (if spreads
(reduce (fn [acc e] (poison-enemy acc (:slot e) venom))
s (living s))
(poison-enemy s (:slot enemy) venom)))
(update :hp (fn [hp] (min (max-hp state) (+ hp steal))))
(as-> s (if finished (say s (:name enemy) " is finished off.") s)))))
(defn swing
"One attack, at one target or at all of them."
[state target strong?]
(let [targets (if (flag? state :cleave) (living state) [target])]
(reduce (fn [acc enemy]
(let [fresh (first (filter (fn [e] (= (:slot e) (:slot enemy)))
(:enemies acc)))]
(if (and fresh (pos? (:hp fresh)))
(let [[amount crit?] (damage-to acc fresh strong?)]
(-> acc
(land-hit fresh amount)
(say (:name fresh) " takes " amount
(cond strong? " (strong)" crit? " (crit)" :else ""))))
acc)))
state
targets)))
(defn extra-strikes
"How many swings past the first. Summed, so two of a thing that gives
you an extra swing gives you two extra swings, which is what anybody
would guess."
[state]
(total state :extra-strikes))
(defn strike-power
"What the extra swings hit for, as a fraction. Multiplied together, so
two forks at 60% give you two extra swings at 36% each rather than one
free upgrade to full power."
[state]
(reduce * 1 (keep :double-power (kit state))))
(defn ability
"The one active thing you are carrying, or nil.
One at a time, because an ability you have to choose between is a
decision and a row of them is a toolbar."
[state]
(:ability state))
(defn ready?
"Whether it can be used this round."
[state]
(and (some? (ability state))
(zero? (or (:cooldown-left state) 0))))
(defn cool-down
"One round off the cooldown. Called at the end of a round, so an
ability with a cooldown of one is usable every other turn rather than
every turn."
[state]
(update state :cooldown-left (fn [n] (max 0 (dec (or n 0))))))
(defn use-ability
"What the one ability you are carrying does.
`keep!` is threaded through so that anything hitting more than one
thing shows as more than one beat. A chain that lands three times in a
single frame is a number changing; three frames is lightning."
[state keep!]
(let [{:keys [effect amount cooldown bounces falloff max-cr name]}
(ability state)
alive (living state)]
(-> (case effect
:heal
(keep! (-> state
(update :hp (fn [hp] (min (max-hp state) (+ hp amount))))
(assoc :poisoned 0)
(flash :you :poison)
(say name ". " amount " back, and the poison with it.")))
:shield
(keep! (-> state
(update :shield (fnil + 0) amount)
(flash :you :shield)
(say name ". " amount " shield.")))
:convert
(let [takeable (filterv (fn [e]
(and (not (:turned e))
(<= (or (:cr e) 1) (or max-cr 3))))
alive)
target (first (sort-by (fn [e] (or (:cr e) 1)) takeable))]
(if-not target
(say state "Nothing here is junior enough to reassign.")
(keep! (-> state
(update :enemies
(fn [es]
(mapv (fn [e]
(if (= (:slot e) (:slot target))
(assoc e :turned true)
e))
es)))
(flash (:slot target) :shield)
(say name ". " (:name target) " is on your side now.")))))
:chain
(let [order (take (or bounces 3) (cycle (sort-by :hp alive)))]
(first
(reduce
(fn [[acc power] enemy]
(let [fresh (first (filter (fn [e] (= (:slot e) (:slot enemy)))
(living acc)))]
(if-not fresh
[acc power]
(let [hit (max 1 (js/Math.round (* (strength acc) power)))]
[(keep! (-> (unflash acc)
(hurt-enemy (:slot fresh) hit)
(say (:name fresh) " takes " hit " from the arc")))
(* power (or falloff 0.65))]))))
[(say state name ".") 1.5]
order)))
:weaken
(keep! (-> state
(update :enemies
(fn [es]
(mapv (fn [e]
(update e :strength
(fn [s]
(max 1 (js/Math.round (* s (- 1 amount)))))))
es)))
(as-> s (reduce (fn [acc e] (flash acc (:slot e) :shield)) s alive))
(say name ". Everything here is suddenly less sure of itself.")))
:plague
(keep! (-> (reduce (fn [acc e] (poison-enemy acc (:slot e) amount)) state alive)
(as-> s (reduce (fn [acc e] (flash acc (:slot e) :poison)) s alive))
(say name ". Poison, on all of it.")))
:extra-turn
(-> state (assoc :free-swing true) (say name "."))
state)
(assoc :cooldown-left cooldown))))
(defn player-attack
"One turn's attacking: the first swing, then however many extras the
kit has bought you, each at the compounded power."
[state slot strong?]
(let [alive (living state)
target (or (first (filter (fn [e] (= (:slot e) slot)) alive))
(first alive))]
(if-not target
state
(let [extras (extra-strikes state)
power (strike-power state)
first-swing (swing state target strong?)
after (reduce
(fn [acc _]
(let [again (or (first (filter (fn [e] (= (:slot e) slot))
(living acc)))
(first (living acc)))]
(if-not again
acc
(let [[amount crit?] (damage-to acc again strong?)]
(-> acc
(land-hit again
(max 1 (js/Math.round (* amount power))))
(say (:name again) " again"
(if crit? " (crit)" "")))))))
first-swing
(range extras))]
(if strong?
(assoc after :energy 0)
(update after :energy + (energy-gain state)))))))
(defn player-attack-framed
"One turn's attacking, with a frame kept after each strike.
The same as `player-attack` except that it hands each landed blow to
`keep!` before the next one, so a reader with two extra strikes sees
three beats rather than one."
[state slot strong? keep!]
(let [alive (living state)
target (or (first (filter (fn [e] (= (:slot e) slot)) alive))
(first alive))]
(if-not target
state
(let [extras (extra-strikes state)
power (strike-power state)
first-swing (keep! (swing (unflash state) target strong?))
after (reduce
(fn [acc _]
(let [again (or (first (filter (fn [e] (= (:slot e) slot))
(living acc)))
(first (living acc)))]
(if-not again
acc
(let [[amount crit?] (damage-to acc again strong?)]
(keep!
(-> (unflash acc)
(land-hit again
(max 1 (js/Math.round (* amount power))))
(say (:name again) " again"
(if crit? " (crit)" ""))))))))
first-swing
(range extras))]
(if strong?
(assoc after :energy 0)
(update after :energy + (energy-gain state)))))))
(defn enemy-swing
"One enemy hit landing on you, through dodge and shield.
`power` is a fraction, so the extra swings of a multi-striker hit for
less than its first, exactly as yours do. A single-striker is called
with 1 and nothing changes for it."
[state enemy power]
(let [fresh (first (filter (fn [e] (= (:slot e) (:slot enemy))) (:enemies state)))]
(if-not (and fresh (pos? (:hp fresh)))
state
(if (chance? (dodge state))
(-> state
(update :hp (fn [hp] (min (max-hp state) (+ hp (total state :dodge-heal)))))
(flash (:slot fresh) :missed)
(say "You slip past " (:name fresh) "."))
(let [raw (* (:strength fresh) power)
defended (if (:defending state)
(* raw (get-in @book [:tuning :defend-reduce]))
raw)
reduced (* defended (- 1 (min 0.8 (total state :reduce))))
soaked (max 1 (js/Math.round (- reduced (total state :soak))))
shield (or (:shield state) 0)
stopped (min shield soaked)
through (- soaked stopped)
venom (or (:poison fresh) 0)
thorns (total state :thorns)]
(-> state
(assoc :shield (- shield stopped))
(update :hp - through)
(assoc :untouched false)
(flash :you (if (pos? stopped) :shield :hurt))
(say (:name fresh) " hits for " soaked
(when (pos? stopped) (str " (" stopped " on the shield)")))
(as-> s (if (pos? venom)
(-> s (update :poisoned (fnil + 0) venom) (say "Poisoned."))
s))
(as-> s (if (pos? thorns)
(-> s
(hurt-enemy (:slot fresh) thorns)
(say (:name fresh) " takes " thorns " back"))
s))))))))
(defn enemy-turn-one
"One enemy's whole turn: its swing, and however many extras it has.
The same arithmetic as yours, so NIGHT PORTER hitting twice for half is
the thing a reader already understands from carrying the fork."
[state enemy]
(let [fresh (first (filter (fn [e] (= (:slot e) (:slot enemy))) (:enemies state)))]
(if-not (and fresh (pos? (:hp fresh)))
state
(let [extras (or (:extra-strikes fresh) 0)
power (or (:double-power fresh) 1)]
(reduce (fn [acc _] (enemy-swing acc fresh power))
(enemy-swing state fresh 1)
(range extras))))))
(defn enemy-turn
"Every living enemy takes one swing at you, through your shield, and
some of them leave something behind."
[state]
(reduce
(fn [acc enemy]
(let [fresh (first (filter (fn [e] (= (:slot e) (:slot enemy))) (:enemies acc)))]
(if-not (and fresh (pos? (:hp fresh)))
acc
(if (chance? (dodge acc))
(-> acc
(update :hp (fn [hp] (min (max-hp acc) (+ hp (total acc :dodge-heal)))))
(flash (:slot fresh) :missed)
(say "You slip past " (:name fresh) "."))
(let [raw (:strength fresh)
defended (if (:defending acc)
(* raw (get-in @book [:tuning :defend-reduce]))
raw)
reduced (* defended (- 1 (min 0.8 (total acc :reduce))))
soaked (max 1 (js/Math.round (- reduced (total acc :soak))))
shield (or (:shield acc) 0)
stopped (min shield soaked)
through (- soaked stopped)
venom (or (:poison fresh) 0)
thorns (total acc :thorns)]
(-> acc
(assoc :shield (- shield stopped))
(update :hp - through)
(assoc :untouched false)
(flash :you (if (pos? stopped) :shield :hurt))
(say (:name fresh) " hits for " soaked
(when (pos? stopped) (str " (" stopped " on the shield)")))
(as-> s (if (pos? venom)
(-> s
(update :poisoned (fnil + 0) venom)
(say "Poisoned."))
s))
(as-> s (if (pos? thorns)
(-> s
(hurt-enemy (:slot fresh) thorns)
(say (:name fresh) " takes " thorns " back"))
s))))))))
state
(living state)))
(defn summon!
"Whatever the room is producing this round.
The cap is a headcount, not a budget: the budget was spent when the
room was built and a summoner is a thing that spends past it. Which is
the point of a summoner and the reason it is worth its own CR — you are
paying for the room it will become, not the room it is.
Counted against the living, so a summoner whose spawn you have killed
can produce again. A summoner that could only ever produce five things
in total would be a timer rather than a threat."
[state]
(let [cap (or (get-in @book [:tuning :summon-cap]) 6)
round (:round state)
pool (:minors @book)
summoners (filter (fn [e]
(and (pos? (:hp e))
(:summons e)
(zero? (mod round (max 1 (:summon-every e))))))
(:enemies state))]
(reduce
(fn [acc summoner]
(let [alive (count (living acc))
room (max 0 (- cap alive))
how-many (min room (or (:summon-count summoner) 1))
template (first (filter (fn [m] (= (:id m) (:summons summoner))) pool))]
(if (or (zero? how-many) (nil? template))
acc
(let [next-slot (inc (reduce max -1 (map :slot (:enemies acc))))
fresh (mapv (fn [i]
(assoc (make-enemy acc template false)
:slot (+ next-slot i)))
(range how-many))]
(-> acc
(update :enemies into fresh)
(as-> s (reduce (fn [st e] (flash st (:slot e) :shield)) s fresh))
(say (:name summoner) " produces "
(str/join ", " (map :name fresh)) "."))))))
state
summoners)))
(defn tick-poison
"Poison, at the end of a round, on everything that has it.
It goes through shields, because a shield is a thing you hold in front
of you and poison is already inside.
Yours fades at the rate the settings give, less whatever you carry to
slow it. Theirs fades at the flat rate, because the enemies are not
carrying anything."
[state]
(let [per (+ (get-in @book [:tuning :poison-tick]) (total state :poison-power))
base-fade (get-in @book [:tuning :poison-fade])
my-fade (max 1 (- base-fade (total state :poison-fade)))
their-fade (max 0 (- base-fade (total state :poison-linger)))
mine (or (:poisoned state) 0)
bitten (keep (fn [e] (when (pos? (or (:poisoned e) 0)) (:slot e)))
(:enemies state))]
(-> state
(as-> s
(if (pos? mine)
(-> s
(update :hp - (* (get-in @book [:tuning :poison-tick]) mine))
(update :poisoned (fn [n] (max 0 (- n my-fade))))
(flash :you :poison)
(say "Poison takes "
(* (get-in @book [:tuning :poison-tick]) mine) "."))
s))
(as-> s (reduce (fn [acc slot] (flash acc slot :poison)) s bitten))
(update :enemies
(fn [es]
(mapv (fn [e]
(let [stacks (or (:poisoned e) 0)]
(if (pos? stacks)
(-> e
(update :hp (fn [hp] (max 0 (- hp (* per stacks)))))
(update :poisoned (fn [n] (max 0 (- n their-fade)))))
e)))
es))))))
(defn end-round
[state]
(-> state
(update :hp (fn [hp] (min (max-hp state) (+ hp (total state :regen)))))
(update :round inc)
(assoc :defending false)))
(defn count-kills
"Tally what died this round, for the quests."
[state before]
(let [dead (remove (fn [e]
(some (fn [f] (and (= (:slot f) (:slot e)) (pos? (:hp f))))
(:enemies state)))
(filter (fn [e] (pos? (:hp e))) before))]
(reduce (fn [acc enemy]
(-> acc
(update-in [:kills (:kind enemy)] (fnil inc 0))
(update :xp + (xp-for acc (:boss enemy)))
(update :gold + (js/Math.round
(* (get-in @book [:tuning :enemy-gold])
(+ 1 (total acc :gold-mult)))))))
state
dead)))
(defn win-fight
"The end of a fight, and the bookkeeping that follows it.
Unflashed, because a flash is a thing that happened on one frame and
the final state is the one that stays on screen. Without this the
widget keeps the colour of whatever landed last, for as long as the
reader is looking at it."
[state]
(let [boss? (boolean (some :boss (:enemies state)))
super? (boolean (some :superboss (:enemies state)))
hp (:hp state)
most (max-hp state)
near (* most (get-in @book [:tuning :near-miss]))]
(-> state
unflash
(update :hp (fn [h] (min most (+ h (total state :won-heal)))))
(update :bosses-beaten (fn [n] (if boss? (inc (or n 0)) (or n 0))))
(update :clean-wins (fn [n] (if (:untouched state) (inc (or n 0)) (or n 0))))
(assoc :beat-superboss super?)
(assoc :enemies [])
(say "The room is quiet.")
(as-> s (if (< hp near)
(say s "You finished that on " hp ". You will not remember it that way.")
s))
(assoc :scene (if boss? :after-boss :advance)))))
(defn- worth-showing?
"A frame with nothing lit on it is a frame with nothing to see. The
poison tick and the summon step usually change nothing, and holding
them for a beat each turns one exchange into four."
[frame]
(seq (:flash frame)))
;; ---------------------------------------------------------------------
;; Quests
;; ---------------------------------------------------------------------
(defn quest-done?
[state quest]
(let [{:keys [goal kind count]} quest]
(case goal
:kill (>= (get-in state [:kills kind] 0) count)
:boss (>= (or (:bosses-beaten state) 0) count)
:items (>= (clojure.core/count (:items state)) count)
:upgrades (>= (clojure.core/count (:upgrades state)) count)
:gold (>= (:gold state) count)
:clean (>= (or (:clean-wins state) 0) count)
false)))
(defn finished-quests [state]
(filterv (fn [q] (quest-done? state q)) (:quests state)))
;; ---------------------------------------------------------------------
;; The score
;; ---------------------------------------------------------------------
(defn score
"What a run is worth, which is a number, which is the point."
[state]
(+ (* 100 (dec (:floor state)))
(* 25 (:level state))
(* 5 (+ (count (:items state)) (count (:upgrades state))))
(* 40 (count (:sacrifices state)))
(:gold state)))
(defn keep-score!
[state]
(let [now (score state)
best-yet (or (fedoras.reader/stored storage-key) 0)]
(when (> now best-yet)
(fedoras.reader/store! storage-key now))
now))
(defn bank!
"Stop, and keep it.
This button has been on the screen since the first room. It is the
other half of the loop and the half nobody uses, which is the whole of
what this game has to say."
[state]
(let [now (keep-score! state)]
(-> state
(assoc :banked now)
(assoc :final-score now)
(say "You stop, and it counts.")
(assoc :scene :banked))))
(defn award-fortune! []
(let [held (or (fedoras.reader/stored :fortune) 0)]
(fedoras.reader/store! :fortune (inc held))
(inc held)))
;; ---------------------------------------------------------------------
;; Rooms
;; ---------------------------------------------------------------------
(defn sacrifice-cost
"What this one asks for. An item or an upgrade if you have one, then
life, then gold, so the thing it takes is always the thing you were
going to use."
[state offer]
(let [{:keys [item upgrade life gold]} (:cost offer)]
(cond
(and item (>= (count (:items state)) item)) [:item item]
(and upgrade (>= (count (:upgrades state)) upgrade)) [:upgrade upgrade]
(and life (> (:hp state) life)) [:life life]
(and gold (>= (:gold state) gold)) [:gold gold]
:else nil)))
(defn spoils
"Three things after a boss, from both pools at once.
Mixed rather than one or the other, because choosing between an item
and an upgrade is a choice about what kind of run this is, and choosing
between three items is a choice about arithmetic."
[state]
(let [pool (concat (map (fn [thing] (assoc thing :from :items)) (:items @book))
(map (fn [thing] (assoc thing :from :upgrades)) (:upgrades @book)))]
(vec (take 3 (shuffled pool)))))
(defn advance
"Past the room you were in, and on to the next pair of doors, or to the
next floor if the boss is done.
After a boss it is a sacrifice or it is the spoils, and the spoils are
what you get when there is no sacrifice you could pay for. Which means
a reader with nothing is never offered nothing: the game only takes
from people who have something to take."
[state]
(cond
(= :after-boss (:scene state))
(let [needed (get-in @book [:tuning :fortune-level])
earned (and (:beat-superboss state) (>= (:level state) needed))
now (score state)
best (if (> now (get (:best-bank state) :score 0))
{:score now :floor (:floor state)}
(:best-bank state))
state (-> state
(assoc :best-bank best)
(dissoc :beat-superboss))]
(if earned
(assoc state :scene :fortune)
(let [offer (when (< (roll 100) (get-in @book [:tuning :sacrifice-chance]))
(pick (:sacrifices @book)))]
(if (and offer (sacrifice-cost state offer))
(-> state (assoc :offer offer) (assoc :scene :sacrifice))
(-> state
(assoc :spoils (spoils state))
(assoc :scene :spoils))))))
(= :next-floor (:scene state))
(let [state (-> state (update :floor inc) (assoc :step 0))]
(-> state
(assoc :bag (pack-floor state))
(say "Floor " (:floor state) ".")
draw-doors
(assoc :scene :doors)))
:else (next-step state)))
(def room-plural
{:encounter "fights" :shop "shops" :treasure "treasures"
:quest "noticeboards" :empty "quiet rooms" :heal "places to sit down"})
(defn- left-behind
"What is on the other side of every door you did not take. Counted
because it is counted anyway, and never mentioned until it is too late
to do anything about it."
[state]
(let [skipped (:skipped state)]
(if (empty? skipped)
"There was nothing you turned down."
(str "You left behind "
(str/join ", "
(map (fn [[room n]]
(str n " " (get room-plural room (name room))))
(sort-by (fn [[_ n]] (- n)) skipped)))
"."))))
(defn- could-have
[state]
(if-let [{:keys [score floor]} (:best-bank state)]
(str "You could have stopped on floor " floor " with " score ".")
"You never got as far as a boss."))
(defn open
"What is behind the door you took."
[state room]
(case room
:encounter (start-fight state (make-encounter state))
:empty
(-> state
(say "Nothing here. Somebody has been through already.")
(assoc :scene :advance))
:shop
(-> state
(assoc :stock (vec (take 3 (shuffled (:items @book)))))
(assoc :restocks 0)
(dissoc :shop-shut)
(assoc :scene :shop))
:treasure
(let [what (roll 3)]
(case what
0 (let [found (pick (:items @book))]
(-> state
(update :items conj found)
(say "A " (or (:name found) (:desc found)) ".")
(assoc :scene :advance)))
1 (let [amount (+ 20 (roll 20))]
(-> state
(update :gold + amount)
(say amount " gold, unattended.")
(assoc :scene :advance)))
(-> state
(assoc :owed 1)
(assoc :held-scene :advance)
(assoc :offers (vec (take 3 (shuffled (:upgrades @book)))))
(assoc :scene :upgrade)
(say "Something here teaches you a thing."))))
:quest
(let [done (finished-quests state)]
(if (seq done)
(let [{:keys [gold xp]} (:quest-rewards @book)
quest (first done)]
(-> state
(update :quests (fn [qs] (vec (remove (fn [q] (= (:id q) (:id quest))) qs))))
(update :gold + gold)
(update :xp + xp)
(say "Turned in: " (:desc quest) ". " gold " gold.")
(assoc :scene :advance)))
(if (>= (count (:quests state)) 2)
(-> state
(say "The board is full. Nothing for you.")
(assoc :scene :advance))
(let [held (set (map :id (:quests state)))
available (remove (fn [q] (contains? held (:id q))) (:quests @book))]
(if-let [offer (pick available)]
(-> state (assoc :offer offer) (assoc :scene :quest))
(assoc state :scene :advance))))))
:heal (assoc state :scene :heal)
(assoc state :scene :advance)))
;; ---------------------------------------------------------------------
;; A run
;; ---------------------------------------------------------------------
(defn fresh-run
[now]
(seed! now)
(let [state {:scene :doors
:floor 1
:step 0
:level 1
:xp 0
:gold 0
:owed 0
:items []
:upgrades []
:sacrifices []
:ability nil
:cooldown-left 0
:quests []
:kills {}
:skipped {}
:bosses-beaten 0
:clean-wins 0
:banked 0
:best-bank nil
:restocks 0
:poisoned 0
:shield 0
:enemies []
:energy 0
:log ["You are here. There are two doors."]}
state (assoc state :hp (max-hp state))]
(-> state
(assoc :bag (pack-floor state))
draw-doors)))
(defn check-level
"Levelling is checked after anything that gives XP, and it can happen
more than once at a stroke, which is a thing that feels good and costs
nothing to allow."
[state]
(if (< (:xp state) (xp-needed (:level state)))
state
(-> state
(update :xp - (xp-needed (:level state)))
(update :level inc)
(assoc :offers (vec (take 3 (shuffled (:upgrades @book)))))
(assoc :held-scene (:scene state))
(assoc :scene :upgrade)
(say "Level " (inc (:level state)) "."))))
;; ---------------------------------------------------------------------
;; Painting
;; ---------------------------------------------------------------------
(defn- label-of [thing]
(or (:name thing) (:desc thing)))
(defn- button
[act label & {:keys [class disabled]}]
(str "<button class='roll-go rogue-act" (when class (str " " class)) "'"
" data-act='" act "'" (when disabled " disabled") ">" label "</button>"))
(defn- bar
[now most class]
(let [pct (max 0 (min 100 (js/Math.round (* 100 (/ now (max 1 most))))))]
(str "<span class='rogue-bar " class "'>"
"<span class='rogue-bar-fill' style='width:" pct "%'></span>"
"<span class='rogue-bar-text'>" now " / " most "</span></span>")))
(def room-names
{:encounter "SOMETHING IS IN THERE"
:shop "A SHOP"
:treasure "TREASURE"
:quest "A NOTICEBOARD"
:empty "A ROOM"
:heal "A QUIET ROOM · 10 gold"})
(defn- status-html [state]
(str "<div class='rogue-line'>"
"<span class='rogue-key'>FLOOR</span> " (:floor state)
" · <span class='rogue-key'>LEVEL</span> " (:level state)
" · <span class='rogue-key'>GOLD</span> " (:gold state)
" · <span class='rogue-key'>STR</span> " (strength state)
" · <span class='rogue-key'>SPD</span> " (speed state)
" · <span class='rogue-key'>DODGE</span> " (dodge state) "%"
" · <span class='rogue-key'>CRIT</span> " (crit state) "%"
(when (pos? (poison-of state))
(str " · <span class='rogue-key'>VENOM</span> " (poison-of state)))
"</div>"
(when (or (pos? (or (:shield state) 0)) (pos? (or (:poisoned state) 0)))
(str "<div class='rogue-line rogue-status-line'>"
(when (pos? (or (:shield state) 0))
(str "<span class='rogue-shield'>SHIELD " (:shield state) "</span>"))
(when (pos? (or (:poisoned state) 0))
(str "<span class='rogue-poison'>POISONED " (:poisoned state) "</span>"))
"</div>"))
"<div class='rogue-line'>"
(bar (:hp state) (max-hp state) "rogue-hp") " "
(bar (:energy state) (strong-at state) "rogue-energy") " "
(bar (:xp state) (xp-needed (:level state)) "rogue-xp")
"</div>"))
(defn- enemies-html [state]
(str "<div class='rogue-foes'>"
(str/join
""
(map (fn [e]
(let [down (not (pos? (:hp e)))
lit (get (:flash state) (:slot e))
carried (marks-on e)]
(str "<div class='rogue-foe"
(when down " rogue-down")
(when (:boss e) " rogue-boss")
(when (:turned e) " rogue-turned")
(when lit (str " rogue-lit rogue-lit-" (name lit)))
"'>"
"<div class='rogue-foe-head'>"
"<span class='rogue-foe-name'>" (:name e) "</span>"
(when (seq carried)
(str "<span class='rogue-marks'>"
(str/join ""
(map (fn [m]
(mark-html
m
(case (:id m)
:poisoned (:poisoned e)
:shield (:shield e)
nil)))
carried))
"</span>"))
"</div>"
(bar (:hp e) (:max-hp e) "rogue-hp")
"<div class='rogue-foe-note'>str " (:strength e) "</div>"
(when-not down
(str "<div class='rogue-foe-acts'>"
(button (str "attack-" (:slot e)) "Hit"
:class "rogue-hit")
(button (str "strong-" (:slot e)) "Strong"
:class "rogue-hit"
:disabled (< (:energy state) (strong-at state)))
"</div>"))
"</div>")))
(:enemies state)))
"</div>"))
(def ^:private no-stopping
"Scenes with no way out. A fight is the one place the button is not
offered, which is the only fair thing in here."
#{:fight :dead :banked})
(defn- scene-html
"The room, as markup.
The default clause goes last. A `case` with a stray form in the middle
shifts every pair after it, so a default written halfway down does not
merely sit in the wrong place: it becomes the test value for whatever
follows, and two scenes below it stop matching at all."
[state]
(case (:scene state)
:doors
(let [[a b] (:doors state)]
(str "<div class='rogue-doors'>"
(button "door-0" (get room-names a "A ROOM") :class "rogue-door")
(button "door-1" (get room-names b "A ROOM") :class "rogue-door")
"</div>"))
:boss-door
(str "<div class='rogue-doors'>"
(button "boss"
(if (superboss-floor? state)
"THE DOOR AT THE END"
"THE ONLY DOOR LEFT")
:class "rogue-door rogue-door-boss")
"</div>")
:fight
(str (enemies-html state)
"<div class='live-bar'>"
(button "defend" "Brace")
"</div>")
:shop
(let [price (js/Math.round (* (get-in @book [:tuning :item-price])
(- 1 (min 0.8 (total state :discount)))))
n (or (:restocks state) 0)
shut (:shop-shut state)
again (refresh-price n)]
(str "<div class='rogue-shelf'>"
(str/join ""
(map-indexed
(fn [i thing]
(str "<div class='rogue-stock'>"
"<span class='rogue-thing'>" (label-of thing) "</span>"
(button (str "buy-" i) (str "Buy · " price " gold")
:disabled (< (:gold state) price))
"</div>"))
(:stock state)))
(when (and (empty? (:stock state)) shut)
"<p class='rogue-price'>Shelves are bare. Somebody will be along.</p>")
"</div>"
"<div class='live-bar'>"
(button "restock"
(str "Restock · " again " gold · " (refresh-chance n) "%")
:disabled (or shut (< (:gold state) again)))
(button "leave" "Leave")
"</div>"))
:upgrade
(str (when (> (or (:owed state) 1) 1)
(str "<p class='rogue-price'>" (:owed state) " to choose.</p>"))
"<div class='rogue-shelf'>"
(str/join ""
(map-indexed
(fn [i thing]
(str "<div class='rogue-stock'>"
"<span class='rogue-thing'>" (label-of thing) "</span>"
(button (str "take-" i) "Take")
"</div>"))
(:offers state)))
"</div>")
:spoils
(str "<div class='rogue-offer'>"
"<p class='rogue-thing'>Whatever it was carrying.</p>"
(if (seq (:spoils state))
(str "<div class='rogue-shelf'>"
(str/join ""
(map-indexed
(fn [i thing]
(str "<div class='rogue-stock'>"
"<span class='rogue-thing'>" (label-of thing)
"<span class='rogue-from'>"
(if (= :upgrades (:from thing)) " upgrade" " item")
"</span></span>"
(button (str "spoil-" i) "Take")
"</div>"))
(:spoils state)))
"</div>")
(str "<p class='rogue-price'>Nothing, as it turns out.</p>"
"<div class='live-bar'>" (button "on" "On") "</div>"))
"</div>")
:quest
(str "<div class='rogue-offer'>"
"<p class='rogue-thing'>" (:desc (:offer state)) "</p>"
"<div class='live-bar'>"
(button "accept" "Accept")
(button "decline" "Walk on")
"</div></div>")
:sacrifice
(let [offer (:offer state)
[what amount] (sacrifice-cost state offer)]
(str "<div class='rogue-offer rogue-dark'>"
"<p class='rogue-thing'>" (:desc offer) "</p>"
"<p class='rogue-price'>It wants "
(case what
:item (str amount " item" (when (> amount 1) "s"))
:upgrade (str amount " upgrade" (when (> amount 1) "s"))
:life (str amount " maximum health")
:gold (str amount " gold")
"nothing")
".</p>"
"<div class='live-bar'>"
(button "pay" "Pay it")
(button "refuse" "Refuse")
"</div></div>"))
:heal
(let [{:keys [heal-price paid-heal free-heal scavenge]} (:tuning @book)
span (fn [range fallback]
(let [[low high] (cond
(and (vector? range) (= 2 (count range))) range
(number? range) [range range]
:else fallback)]
(if (= low high) (str low) (str low "–" high))))
hurt (- (max-hp state) (:hp state))]
(str "<div class='rogue-offer'>"
"<p class='rogue-thing'>Somewhere to sit down. Nobody comes in.</p>"
(when (zero? hurt)
"<p class='rogue-price'>There is nothing wrong with you.</p>")
"<div class='live-bar'>"
(button "rest" (str "Pay to rest · " (or heal-price 10)
" gold · +" (span paid-heal [14 20]))
:disabled (< (:gold state) (or heal-price 10)))
(button "sit" (str "Sit a while · +" (span free-heal [3 7])))
(button "scavenge" (str "Scavenge · +" (span scavenge [4 11]) " gold"))
"</div></div>"))
:dead
(str "<div class='rogue-offer rogue-dark'>"
"<p class='rogue-thing'>You stop.</p>"
"<p class='rogue-price'>Banked 0. " (could-have state) "</p>"
"<p class='rogue-price'>" (left-behind state) "</p>"
"<p class='rogue-price'>Best kept: "
(or (fedoras.reader/stored storage-key) 0) "</p>"
"<div class='live-bar'>" (button "again" "Again") "</div></div>")
:banked
(str "<div class='rogue-offer'>"
"<p class='rogue-thing'>Banked " (:banked state)
" on floor " (:floor state) ".</p>"
"<p class='rogue-price'>" (left-behind state) "</p>"
"<p class='rogue-price'>Best kept: "
(or (fedoras.reader/stored storage-key) 0) "</p>"
"<div class='live-bar'>" (button "again" "Again") "</div></div>")
:fortune
(str "<div class='rogue-offer'>"
"<p class='rogue-thing'>You got past it. A point of fortune.</p>"
"<p class='rogue-price'>Fortune held: " (:fortune-held state)
" · there is another one seven floors from here.</p>"
"<div class='live-bar'>"
(button "carry-on" "Keep going")
"</div></div>")
(str "<div class='rogue-offer rogue-dark'>"
"<p class='rogue-thing'>Nothing is here. Scene: "
(name (:scene state)) "</p>"
"<div class='live-bar'>" (button "on" "On") "</div></div>")))
(defn- stage-html
"The room, and under it the way out.
The way out is under every room that is not a fight, at every moment,
saying exactly what stopping would be worth. It is the cheapest thing
in the file and the only part of the game that is about anything."
[state]
(str (scene-html state)
(when-not (contains? no-stopping (:scene state))
(str "<div class='live-bar rogue-quit'>"
(button "stop" (str "Stop here · bank " (score state))
:class "rogue-stop")
"</div>"))))
(defn- kit-html [state]
(let [line (fn [title things]
(when (seq things)
(str "<div class='rogue-kit-row'>"
"<span class='rogue-key'>" title "</span> "
(str/join " · " (map label-of things))
"</div>")))]
(str (or (line "ITEMS" (:items state)) "")
(or (line "UPGRADES" (:upgrades state)) "")
(or (line "SACRIFICES" (:sacrifices state)) "")
(when (seq (:quests state))
(str "<div class='rogue-kit-row'>"
"<span class='rogue-key'>QUESTS</span> "
(str/join " · "
(map (fn [q]
(str (:desc q)
(when (quest-done? state q) " ✓")))
(:quests state)))
"</div>")))))
(defn paint! [node]
(let [state @game
lit (get (:flash state) :you)]
(doseq [kind ["hurt" "shield" "poison"]]
(.remove (.-classList node) (str "rogue-you-" kind)))
(when lit (.add (.-classList node) (str "rogue-you-" (name lit))))
(fedoras.ui.widget-ui/state!
node (str "on the table " (score state)
" · banked " (:banked state)
" · best " (or (fedoras.reader/stored storage-key) 0)))
(fedoras.ui.widget-ui/html! node ".rogue-status" (status-html state))
(fedoras.ui.widget-ui/html! node ".rogue-stage" (stage-html state))
(fedoras.ui.widget-ui/html! node ".rogue-log"
(str/join "" (map (fn [l] (str "<p>" l "</p>"))
(:log state))))
(fedoras.ui.widget-ui/html! node ".rogue-kit" (kit-html state))
(fedoras.ui.widget-ui/html!
node ".rogue-legend"
(if (= :fight (:scene state))
(let [showing (set (mapcat (fn [e] (map :id (marks-on e))) (:enemies state)))]
(fedoras.ui.legend/html
(filterv (fn [m] (contains? showing (:id m))) marks)))
""))))
(defn- play!
"Play a queue of states back, each held for a beat and then cleared.
Every path out of here lands on an unflashed state. A pending timeout
that finds the reel gone used to simply bail, which left whatever frame
was on screen lit up for good, and pressing a button during playback
did exactly that."
[node states done]
(let [hold (or (get-in @book [:tuning :flash-hold]) 260)
gap (or (get-in @book [:tuning :flash-gap]) 90)]
(reset! reel {:queue (vec states) :pending done})
(letfn [(finish []
(reset! reel nil)
(reset! game done)
(paint! node))
(next-frame []
(if-let [r @reel]
(if-let [frame (first (:queue r))]
(do (reset! game frame)
(swap! reel update :queue (fn [q] (vec (rest q))))
(paint! node)
(js/setTimeout
(fn []
(if @reel
(do (swap! game unflash)
(paint! node)
(js/setTimeout next-frame gap))
(finish)))
hold))
(finish))
(finish)))]
(if (seq states)
(next-frame)
(finish)))))
(defn- skip!
"Jump to the end of an exchange. Lands unflashed, like every other way
out of a playback."
[node]
(when-let [r @reel]
(let [done (:pending r)]
(reset! reel nil)
(reset! game done)
(paint! node))))
;; ---------------------------------------------------------------------
;; Hands
;; ---------------------------------------------------------------------
(defn holding?
[state thing]
(boolean (some (fn [held] (= (:id held) (:id thing)))
(concat (:items state) (:upgrades state)))))
(defn take-thing
"Add one thing to the kit, and honour the ones that do something the
moment you take them.
Most things stack, because two of a thing being twice as good is the
simplest rule and the one a reader will assume. A thing that cannot
stack says `:unique true`, and there are few of those on purpose."
[state where thing]
(if (and (:unique thing) (holding? state thing))
(say state "You already have one of those.")
(-> state
(update where conj thing)
(as-> s (if (:heal-now thing) (assoc s :hp (max-hp s)) s))
(as-> s (assoc s :hp (min (max-hp s) (:hp s)))))))
(defn resolve-turn
"One exchange, a frame per blow.
A frame is one thing landing, not one phase of the round. Three enemies
hitting you is three frames; your two strikes are two. Anything that
changes nothing visible is not a frame at all, so a round with no
poison and no summons has exactly as many beats as it had blows.
The cooldown ticks at the end of the round rather than at the start, so
an ability with a cooldown of one is usable every other turn. An
ability usable every turn is a fourth button, not an ability."
[state what slot]
(let [before (:enemies state)
frames (atom [])
keep! (fn [s]
(when (seq (:flash s)) (swap! frames conj s))
s)
opened (unflash state)
after-you
(case what
:attack (player-attack-framed opened slot false keep!)
:strong (player-attack-framed opened slot true keep!)
:defend (-> opened (assoc :defending true) (say "You brace."))
opened)
poisoned (keep! (tick-poison (unflash after-you)))
tallied (count-kills poisoned before)]
(cond
(<= (:hp tallied) 0)
{:frames @frames :final (-> tallied unflash (assoc :scene :dead :hp 0))}
(empty? (living tallied))
{:frames @frames :final (win-fight tallied)}
:else
(let [struck (reduce (fn [acc enemy]
(keep! (enemy-turn-one (unflash acc) enemy)))
tallied
(living tallied))
summoned (keep! (summon! (unflash struck)))
counted (count-kills summoned (:enemies struck))
ended (cool-down (unflash (end-round counted)))]
(cond
(<= (:hp ended) 0)
{:frames @frames :final (assoc ended :scene :dead :hp 0)}
(empty? (living ended))
{:frames @frames :final (win-fight ended)}
:else {:frames @frames :final ended})))))
(defn handle
"One press.
The prefixed ones are in the `cond`, because they carry a number on the
end and `case` matches whole values; the fixed ones are in the `case`
below it.
Most branches return a state. The three that start a fight exchange
return `{:exchange true :frames [...] :final state}` instead, so `act!`
can show the exchange happening rather than only its result."
[state what]
(cond
(str/starts-with? what "door-")
(let [i (js/parseInt (subs what 5) 10)
taken (nth (:doors state) i)
skipped (nth (:doors state) (- 1 i))]
(-> state
(update-in [:skipped skipped] (fnil inc 0))
(open taken)))
(str/starts-with? what "buy-")
(let [i (js/parseInt (subs what 4) 10)
thing (nth (:stock state) i)
price (js/Math.round (* (get-in @book [:tuning :item-price])
(- 1 (min 0.8 (total state :discount)))))]
(if (< (:gold state) price)
state
(-> state
(update :gold - price)
(take-thing :items thing)
(update :stock (fn [s] (vec (concat (take i s) (drop (inc i) s)))))
(say "Bought " (label-of thing) "."))))
(str/starts-with? what "take-")
(let [i (js/parseInt (subs what 5) 10)
thing (nth (:offers state) i)
owed (max 0 (dec (or (:owed state) 1)))
taken (-> state
(take-thing :upgrades thing)
(say "Learned " (label-of thing) ".")
(assoc :owed owed))]
(if (pos? owed)
(assoc taken :offers (vec (take 3 (shuffled (:upgrades @book)))))
(-> taken
(dissoc :offers)
(assoc :scene (or (:held-scene state) :advance))
(dissoc :held-scene))))
(str/starts-with? what "spoil-")
(let [i (js/parseInt (subs what 6) 10)
thing (nth (:spoils state) i)
where (:from thing)]
(-> state
(take-thing where (dissoc thing :from))
(say "Took " (label-of thing) ".")
(dissoc :spoils)
(assoc :scene :next-floor)))
(str/starts-with? what "attack-")
(let [slot (js/parseInt (subs what 7) 10)
{:keys [frames final]} (resolve-turn state :attack slot)]
{:exchange true
:frames frames
:final (if (= :dead (:scene final))
(assoc final :final-score (keep-score! final))
(check-level final))})
(str/starts-with? what "strong-")
(let [slot (js/parseInt (subs what 7) 10)]
(if (< (:energy state) (strong-at state))
state
(let [{:keys [frames final]} (resolve-turn state :strong slot)]
{:exchange true
:frames frames
:final (if (= :dead (:scene final))
(assoc final :final-score (keep-score! final))
(check-level final))})))
:else
(case what
"boss" (start-fight state (if (superboss-floor? state)
(make-superboss state)
(make-boss state)))
"defend"
(let [{:keys [frames final]} (resolve-turn state :defend nil)]
{:exchange true
:frames frames
:final (if (= :dead (:scene final))
(assoc final :final-score (keep-score! final))
(check-level final))})
"cast"
(if-not (ready? state)
state
(let [frames (atom [])
keep! (fn [s]
(when (seq (:flash s)) (swap! frames conj s))
s)
after (use-ability state keep!)]
{:exchange true :frames @frames :final (unflash after)}))
"leave" (assoc state :scene :advance)
"restock"
(let [n (or (:restocks state) 0)
price (refresh-price n)]
(if (< (:gold state) price)
state
(let [paid (-> state
(update :gold - price)
(update :restocks (fnil inc 0)))]
(if (chance? (refresh-chance n))
(-> paid
(assoc :stock (vec (take 3 (shuffled (:items @book)))))
(say "New stock. " price " gold."))
(-> paid
(assoc :stock [])
(assoc :shop-shut true)
(say "Nothing left to restock with. That was " price " gold."))))))
"rest"
(let [price (or (get-in @book [:tuning :heal-price]) 10)]
(if (< (:gold state) price)
state
(let [amount (between (get-in @book [:tuning :paid-heal]) [14 20])]
(-> state
(update :gold - price)
(update :hp (fn [hp] (min (max-hp state) (+ hp amount))))
(say "You sit down properly. " price " gold, and " amount " back.")
(assoc :scene :advance)))))
"sit"
(let [amount (between (get-in @book [:tuning :free-heal]) [3 7])]
(-> state
(update :hp (fn [hp] (min (max-hp state) (+ hp amount))))
(say "You sit down for a bit. " amount " back.")
(assoc :scene :advance)))
"scavenge"
(let [rolled (between (get-in @book [:tuning :scavenge]) [4 11])
amount (max 1 (js/Math.round (* rolled (+ 1 (total state :gold-mult)))))]
(-> state
(update :gold + amount)
(say "You go through the drawers. " amount " gold.")
(assoc :scene :advance)))
"accept"
(-> state
(update :quests conj (:offer state))
(say "Taken on: " (:desc (:offer state)))
(dissoc :offer)
(assoc :scene :advance))
"decline"
(-> state
(say "You read the board and keep walking.")
(dissoc :offer)
(assoc :scene :advance))
"pay"
(let [offer (:offer state)
[kind amount] (sacrifice-cost state offer)
paid (case kind
:item (update state :items (fn [xs] (vec (drop amount xs))))
:upgrade (update state :upgrades (fn [xs] (vec (drop amount xs))))
:life (-> state
(update :sacrifices conj {:max-hp (- amount)})
(update :hp (fn [hp] (max 1 (- hp amount)))))
:gold (update state :gold - amount)
state)]
(-> paid
(update :sacrifices conj offer)
(say "It takes what it asked for.")
(dissoc :offer)
(assoc :scene :next-floor)))
"refuse"
(-> state
(say "You keep what you have.")
(dissoc :offer)
(assoc :spoils (spoils state))
(assoc :scene :spoils))
"carry-on"
(-> state (dissoc :fortune-held) (assoc :scene :next-floor))
"stop" (bank! state)
"again" (fresh-run (.now js/Date))
"on" (assoc state :scene :advance)
state)))
(defn- act!
"One press, and everything that follows from it without another press.
`handle` returns either a state or an exchange, which is a list of
frames to show in order. A press while one is playing skips to its end,
because a reader who is clicking wants it over with."
[node what]
(if @reel
(skip! node)
(let [outcome (handle @game what)]
(if (:exchange outcome)
(let [final (:final outcome)
moved (if (contains? #{:advance :after-boss :next-floor} (:scene final))
(advance final)
final)
moved (if (= :fortune (:scene moved))
(assoc moved :fortune-held (award-fortune!))
moved)]
(play! node (:frames outcome) moved))
(let [moved (if (contains? #{:advance :after-boss :next-floor} (:scene outcome))
(advance outcome)
outcome)]
(reset! game (if (= :fortune (:scene moved))
(assoc moved :fortune-held (award-fortune!))
moved))
(paint! node))))))
(defn- wire-stage! [node]
(when-let [stage (fedoras.ui.widget-ui/q! node ".rogue-stage")]
(set! (.-onclick stage)
(fn [event]
(when-let [pressed (.closest (.-target event) ".rogue-act")]
(when-not (.-disabled pressed)
(act! node (.getAttribute pressed "data-act"))))))))
(defn- wire-settings! [node id]
(fedoras.ui.widget-ui/settings!
node id
(fn [setting]
(reset! book (merge default-book setting))
(reset! game (fresh-run (.now js/Date)))
(paint! node))))
(defn setup!
"Once per element. The settings callback lays out a fresh run, because
changing the rules mid-run would be the one unfair thing in a game
built entirely out of unfairness."
[node]
(let [id (.-id node)]
(fedoras.ui.widget-ui/wire! node "stage" wire-stage!)
(fedoras.ui.widget-ui/wire! node "settings" (fn [n] (wire-settings! n id)))))
(defn mount!
[{:keys [id]}]
(when (nil? @book) (reset! book default-book))
(when (nil? @game) (reset! game (fresh-run 4471)))
(fedoras.ui.widget-ui/mount!
::rogue (or id "rogue") setup! paint!))</script><div class="widget widget-rogue" id="rogue"><div class="roll-head"><span class="roll-label">ROGUE.CLJS · two doors · one of them</span><span class="widget-state">press Run</span></div><div class="widget-source"><div class="widget-source-head"><span class="widget-source-name">rogue_settings.cljs</span><span class="widget-source-lang">cljs</span></div><textarea class="widget-settings" rows="26" spellcheck="false">;; ---------------------------------------------------------------------
;; ROGUE_SETTINGS.CLJS — everything in the game.
;;
;; Change a number and press Run. Add an item and it is in the shop.
;; Nothing here is checked against anything: an item that gives +900
;; Strength will give you +900 Strength, and the game will let you have
;; it, and you will stop playing about four minutes later.
;;
;; EFFECTS. Stats are added up. Flags are read by the fight.
;;
;; :strength :max-hp :speed :dodge :crit flat, added
;; :energy-gain extra energy per attack (5 is standard)
;; :strong-at energy needed for a strong attack
;; :crit-mult multiplier on a crit (2 is standard)
;; :cleave every attack hits every enemy
;; :damage-mult multiplies all damage you deal
;; :lifesteal heal this fraction of damage dealt
;; :flat-steal heal this many per attack that lands
;; :double-strike attack twice, at :double-power of normal
;; :gold-strength strength per ten gold held
;; :item-strength strength per item held
;; :level-strength strength per level
;; :thorns damage back to whoever hits you
;; :soak damage subtracted from every hit you take
;; :reduce fraction of damage you take, subtracted
;; :regen healed at the end of each round
;; :fight-heal healed at the start of each fight
;; :won-heal healed after each fight won
;; :execute fraction of enemy max hp below which they die outright
;; :low-bonus extra damage against enemies below half
;; :first-bonus extra damage on the first round of a fight
;; :dodge-heal healed whenever you dodge
;; :gold-mult :xp-mult multipliers on what you are given
;; :start-energy energy you begin each fight with
;; :enemy-weaken fraction off every enemy's health
;; :discount fraction off shop prices
;;
;; :name is optional everywhere. Without one, the description is the
;; name, which is how most of these were named anyway.
;; ---------------------------------------------------------------------
{:start {:hp 300 :strength 4 :speed 10 :dodge 0 :crit 5}
:tuning {:energy-gain 5 ; energy per attack landed
:strong-at 30 ; energy for a strong attack
:strong-mult 2 ; and what it multiplies by
:crit-mult 2
:item-price 25
:defend-reduce 0.5 ; damage taken while defending
:enemy-gold 2
:heal-price 10
:heal-amount 12
:paid-heal [14 20] ; what the money buys
:free-heal [3 7] ; what sitting down buys
:scavenge [4 11]
:xp-curve [5 15 30 75 120 200 320]
;; THE SUPERBOSS. Every seventh floor the boss is something
;; else, and beating one at or above `fortune-level` is worth
;; a point of fortune. They keep coming. So does the game.
:superboss-every 7
:fortune-level 5
;; THE SHOP OUT THE BACK. Refreshing costs more every time and
;; works less often every time, and when it fails the shop is
;; shut and you have paid for that. This is a slot machine
;; with shelves drawn on it.
:refresh-price 10
:refresh-price-mult 2
:refresh-chance 85
:refresh-chance-drop 20
;; a fight finished under this fraction of your health is one
;; the log will mention, because nobody remembers those
:near-miss 0.25}
:floor {:steps 6 ; doors before the boss
:encounters 3
:shops 1
:treasures 1
:quests 2
:empties 4}
;; STATUS. Poison ticks at the end of a round and does not
;; care what you are wearing. Shield takes damage before
;; health does and does not come back on its own.
:poison-tick 1 ; damage per stack, each round
:poison-fade 1 ; stacks lost at the end of a round
;; THE QUIET ROOM. Three ways out of it. Paying is the best
;; deal and the free heal is a third of it, which is the ratio
;; that makes the free one feel like a mistake either way.
:heal-price 10
:heal-amount 12
:free-heal 4
:scavenge 6 ; gold, if you take that instead
:sacrifice-chance 50
;; -------------------------------------------------------------------
;; ITEMS. Bought, found, and taken off bosses.
;;
;; Everything stacks unless it says `:unique true`, and the things that
;; say so are the ones that set a threshold rather than add to a total.
;; Two of a thing that lowers your strong attack to 20 energy cannot
;; lower it to 10, so having two would be having one, and the game says
;; so rather than letting you find out.
;; -------------------------------------------------------------------
:items
[{:id :whetstone :desc "+3 Strength" :strength 3}
{:id :ration :desc "+8 Max HP" :max-hp 8}
{:id :boots :desc "+4 Speed" :speed 4}
{:id :cloak :desc "+10% Dodge" :dodge 10}
{:id :lens :desc "+15% Crit" :crit 15}
{:id :cell :desc "+2 Energy per attack" :energy-gain 2}
{:id :leech :desc "Heal 2 per attack that lands" :flat-steal 2}
{:id :ledger :desc "+1 Strength per 10 gold" :gold-strength 1}
{:id :bramble :desc "Reflect 3 damage" :thorns 3}
{:id :anvil :desc "+5 Strength, -2 Speed" :strength 5 :speed -2}
{:id :fork :desc "One more attack at 60% damage" :extra-strikes 1 :double-power 0.6}
{:id :hook :desc "+25% damage below half" :low-bonus 0.25}
{:id :flask :desc "Heal 4 at the start of a fight" :fight-heal 4}
{:id :sigil :desc "+2 Strength per level" :level-strength 2}
{:id :purse :desc "+20% gold" :gold-mult 0.2}
{:id :journal :desc "+1 XP per kill" :xp-flat 1}
{:id :plate :desc "Take 2 less from every hit" :soak 2}
{:id :moss :desc "Heal 3 each round" :regen 3}
{:id :venom :desc "Your attacks poison for 1" :poison 1}
{:id :barrier :desc "Start each fight with 10 shield" :shield 10}
{:id :salve :desc "Poison on you fades 1 faster" :poison-fade 1}
{:id :spur :desc "+15% damage on the first round" :first-bonus 0.15}
{:id :tally :desc "+8 Max HP per sacrifice held" :sacrifice-hp 8}
{:id :capacitor :desc "Strong attack at 20 energy" :strong-at 20 :unique true}
{:id :prism :desc "Crits deal 3x" :crit-mult 3 :unique true}]
;; -------------------------------------------------------------------
;; UPGRADES. Three offered on levelling, one taken.
;; -------------------------------------------------------------------
:upgrades
[{:id :u-frame :desc "+10 Permanent HP" :max-hp 10}
{:id :u-gait :desc "+5 Permanent HP, +5 Speed" :max-hp 5 :speed 5}
{:id :u-arm :desc "+4 Strength" :strength 4}
{:id :u-eye :desc "+20% Crit" :crit 20}
{:id :u-shade :desc "+15% Dodge" :dodge 15}
{:id :u-bulk :desc "+3 Strength, +10 Max HP" :strength 3 :max-hp 10}
{:id :u-charge :desc "Start each fight with 15 energy" :start-energy 15}
{:id :u-sprint :desc "+6 Speed" :speed 6}
{:id :u-mend :desc "Heal 6 after each fight" :won-heal 6}
{:id :u-opener :desc "+30% damage on the first round" :first-bonus 0.3}
{:id :u-dynamo :desc "+2 Strength, +2 Energy per attack" :strength 2 :energy-gain 2}
{:id :u-slip :desc "Dodging heals 3" :dodge-heal 3}
{:id :u-tithe :desc "+50% gold" :gold-mult 0.5}
{:id :u-hoard :desc "+1 Strength per item held" :item-strength 1}
{:id :u-hide :desc "Take 20% less damage" :reduce 0.2}
{:id :u-lesson :desc "+2 XP per kill" :xp-flat 2}
{:id :u-toxin :desc "Your attacks poison for 2" :poison 2}
{:id :u-wall :desc "Start each fight with 20 shield" :shield 20}
{:id :u-second :desc "One more attack at 40% damage" :extra-strikes 1 :double-power 0.4}
{:id :u-grudge :desc "+3 Strength per sacrifice held" :sacrifice-strength 3}
{:id :u-siphon :desc "Heal 15% of damage dealt" :lifesteal 0.15}
{:id :u-coil :desc "Strong attack at 20 energy" :strong-at 20 :unique true}
{:id :u-facet :desc "+10% Crit, crits deal 2.5x" :crit 10 :crit-mult 2.5 :unique true}
{:id :u-vessel :desc "+12 Max HP, and heal to full" :max-hp 12 :heal-now true :unique true}
{:id :u-rot :desc "Enemies have 20% less health" :enemy-weaken 0.2 :unique true}]
;; -------------------------------------------------------------------
;; SACRIFICES. Offered after a boss, and they are not balanced.
;;
;; `:cost` is what it takes in each currency. Which one it asks for
;; depends on what you have: an item or an upgrade first, then life,
;; then gold. So the richer you are the more it can take, and the
;; poorer you are the more it takes from you.
;; -------------------------------------------------------------------
:sacrifices
[{:id :s-wide :desc "Every attack hits every enemy, at half damage"
:cleave true :damage-mult 0.5
:cost {:item 1 :upgrade 1 :life 12 :gold 40}}
{:id :s-drink :desc "Heal for half of all damage you deal"
:lifesteal 0.5
:cost {:item 1 :upgrade 1 :life 15 :gold 45}}
{:id :s-edge :desc "+50% Crit"
:crit 50
:cost {:item 1 :upgrade 1 :life 15 :gold 50}}
{:id :s-again :desc "Attack twice, at full damage"
:double-strike true :double-power 1.0
:cost {:item 2 :upgrade 1 :life 20 :gold 60}}
{:id :s-greed :desc "+10 Strength per 10 gold held"
:gold-strength 10
:cost {:item 1 :upgrade 2 :life 18 :gold 70}}
{:id :s-study :desc "Double all XP"
:xp-mult 2
:cost {:item 1 :upgrade 1 :life 10 :gold 40}}
{:id :s-smoke :desc "+40% Dodge"
:dodge 40
:cost {:item 1 :upgrade 1 :life 16 :gold 50}}
{:id :s-surge :desc "Every attack is a strong attack"
:strong-at 0
:cost {:item 2 :upgrade 2 :life 22 :gold 75}}
{:id :s-swell :desc "Double your maximum health"
:hp-mult 2
:cost {:item 1 :upgrade 2 :life 5 :gold 55}}
{:id :s-finish :desc "Enemies below a quarter health die outright"
:execute 0.25
:cost {:item 2 :upgrade 1 :life 20 :gold 65}}
{:id :s-plague :desc "Poison for 5, and poison spreads to everything"
:poison 5 :poison-cleave true
:cost {:item 1 :upgrade 2 :life 18 :gold 55}}
{:id :s-again :desc "One more attack at full damage"
:extra-strikes 1 :double-power 1.0
:cost {:item 2 :upgrade 1 :life 20 :gold 60}}]
;; -------------------------------------------------------------------
;; MINOR ENEMIES. Twenty. `:kind` is what the quests count.
;; -------------------------------------------------------------------
:minors
[{:id :m-roach :name "ROACH" :kind :vermin :cr 1 :hp 6 :strength 2 :speed 8}
{:id :m-silverfish :name "SILVERFISH" :kind :vermin :cr 1 :hp 8 :strength 3 :speed 5}
{:id :m-rat :name "STAIRWELL RAT" :kind :vermin :cr 1 :hp 10 :strength 3 :speed 6}
{:id :m-fresher :name "FRESHER" :kind :student :cr 1 :hp 12 :strength 4 :speed 5}
{:id :m-email :name "UNREAD EMAIL" :kind :thing :cr 1 :hp 7 :strength 8 :speed 9}
{:id :m-mould :name "SOMETHING IN THE VENT" :kind :vermin :cr 2 :hp 14 :strength 2 :speed 3 :poison 2 :min-level 2}
{:id :m-society :name "SOCIETY RECRUITER" :kind :student :cr 2 :hp 9 :strength 6 :speed 9}
{:id :m-vending :name "VENDING UNIT" :kind :machine :cr 2 :hp 12 :strength 4 :speed 6}
{:id :m-scanner :name "CARD SCANNER" :kind :machine :cr 2 :hp 10 :strength 6 :speed 8}
{:id :m-groupwork :name "GROUP PROJECT" :kind :student :cr 2 :hp 20 :strength 3 :speed 4}
{:id :m-tutor :name "PEER TUTOR" :kind :student :cr 2 :hp 15 :strength 5 :speed 6}
{:id :m-adjunct :name "ADJUNCT" :kind :staff :cr 3 :hp 18 :strength 5 :speed 5}
{:id :m-invigil :name "INVIGILATOR" :kind :staff :cr 3 :hp 14 :strength 7 :speed 5}
{:id :m-turnstile :name "TURNSTILE" :kind :machine :cr 3 :hp 20 :strength 5 :speed 3}
{:id :m-printer :name "THE PRINTER" :kind :machine :cr 3 :hp 25 :strength 3 :speed 2}
{:id :m-queue :name "THE QUEUE" :kind :thing :cr 3 :hp 26 :strength 4 :speed 3}
{:id :m-deadline :name "DEADLINE" :kind :thing :cr 3 :hp 18 :strength 7 :speed 10}
{:id :m-porter :name "NIGHT PORTER" :kind :staff :cr 4 :hp 16 :strength 6 :speed 7
:extra-strikes 1 :double-power 0.5}
{:id :m-registrar :name "REGISTRAR'S CLERK" :kind :staff :cr 4 :hp 22 :strength 4 :speed 4 :shield 10 :min-level 3}
{:id :m-form :name "FORM 17-B" :kind :thing :cr 4 :hp 30 :strength 2 :speed 2 :shield 15 :min-level 4}
{:id :m-office :name "OFFICE HOURS" :kind :staff :cr 4 :hp 24 :strength 3 :speed 4
:summons :m-fresher :summon-every 2 :summon-count 1 :min-level 3}
{:id :m-chain :name "REPLY-ALL CHAIN" :kind :thing :cr 4 :hp 20 :strength 4 :speed 6
:summons :m-email :summon-every 3 :summon-count 2 :min-level 4}
{:id :m-hearing :name "DISCIPLINARY HEARING" :kind :staff :cr 5 :hp 34 :strength 6 :speed 6
:extra-strikes 2 :double-power 0.4 :min-level 4}]
;; -------------------------------------------------------------------
;; BOSSES. Ten. One a floor, and the seventh is the last.
;; -------------------------------------------------------------------
:bosses
[{:id :b-dean :name "SUB-DEAN OF INTAKE" :kind :staff :hp 45 :strength 7 :speed 12}
{:id :b-audit :name "THE AUDIT" :kind :thing :min-level 1 :hp 60 :strength 6 :speed 12 :shield 25}
{:id :b-help :name "A HELP, ESCALATED" :kind :machine :hp 55 :strength 8 :speed 12}
{:id :b-tenure :name "TENURE COMMITTEE" :kind :staff :hp 70 :strength 7 :speed 12}
{:id :b-orient :name "ORIENTATION" :kind :student :hp 65 :strength 9 :speed 12}
{:id :b-loop :name "THE LOOP, DERAILED" :kind :machine :hp 80 :strength 9 :speed 12}
{:id :b-fill :name "WHAT IS UNDER THE FILL" :kind :thing :min-level 4 :hp 95 :strength 12 :speed 12 :poison 3}
{:id :b-alumni :name "ALUMNI RELATIONS" :kind :staff :hp 75 :strength 10 :speed 12}
{:id :b-14 :name "BUILDING 14" :kind :thing :hp 90 :strength 8 :speed 14}
{:id :b-provost :name "THE PROVOST" :kind :staff :hp 110 :strength 13 :speed 12}]
;; WHO YOU MEET. An encounter has a budget in CR, and enemies
;; are drawn until it is spent. So one heavy thing and six
;; light ones are both a fair room, and a summoner has
;; somewhere to put what it produces.
:encounter-cr [4 7] ; at floor 1, level 1
:cr-per-floor 1.6
:cr-per-level 0.9
:max-party 6
:level-reach 2
:min-enemy-growth 2
:summon-cap 6
;; -------------------------------------------------------------------
;; SUPERBOSSES. Every seventh floor, in order, then round again. They
;; scale with the floor like everything else, so the second time you
;; meet one it is not the same meeting.
;; -------------------------------------------------------------------
:superbosses
[{:id :x-review :name "THE REVIEW" :kind :thing :min-level 5 :hp 160 :strength 14 :speed 13}
{:id :x-endow :name "THE ENDOWMENT" :kind :staff :min-level 6 :hp 200 :strength 16 :speed 13}
{:id :x-cohort :name "THE STAKEHOLDERS" :kind :student :min-level 7 :hp 240 :strength 15 :speed 14}
{:id :x-charter :name "THE DIRECTOR" :kind :thing :min-level 8 :hp 300 :strength 18 :speed 15}]
;; -------------------------------------------------------------------
;; QUESTS. Two at a time. Turned in at a quest room once done.
;; -------------------------------------------------------------------
:quests
[{:id :q-vermin :desc "Kill 5 vermin" :goal :kill :kind :vermin :count 5}
{:id :q-staff :desc "Kill 4 staff" :goal :kill :kind :staff :count 4}
{:id :q-machine :desc "Kill 4 machines" :goal :kill :kind :machine :count 4}
{:id :q-student :desc "Kill 4 students" :goal :kill :kind :student :count 4}
{:id :q-thing :desc "Kill 3 things" :goal :kill :kind :thing :count 3}
{:id :q-boss :desc "Beat a boss" :goal :boss :count 1}
{:id :q-bosses :desc "Beat two bosses" :goal :boss :count 2}
{:id :q-items :desc "Hold 3 items at once" :goal :items :count 3}
{:id :q-hoard :desc "Hold 5 items at once" :goal :items :count 5}
{:id :q-upgrade :desc "Hold 4 upgrades" :goal :upgrades :count 4}
{:id :q-rich :desc "Hold 60 gold at once" :goal :gold :count 60}
{:id :q-clean :desc "Win a fight untouched" :goal :clean :count 1}]
:quest-rewards {:gold 30 :xp 4}}</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="rogue-status"></div><div class="rogue-stage"></div><div class="rogue-log"></div><div class="rogue-kit"></div><div class="rogue-legend"></div></div><p class="roll-foot">Your score is kept. Nothing else is.</p></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.cs-class.rogue.rogue-ui/mount!))] (mount {:id "rogue"}) (js/console.error "fedoras: fedoras.cs-class.rogue.rogue-ui/mount! is not on this page. Either the file that declares it was never shipped, or the namespace inside it does not match the path it was shipped from."))</script></div><div class="artifact handout"><p class="handout-title">PLANT SERVICES · DEFECT NOTICE · 14 April · 06:31 · H. HOLLIS</p><p><span>FITTING: collection tray, Meridian basement, under stair pipe. Installed under works order 9-1140, February, described therein as permanent solution.</span></p><p><span>DEFECT: the tray discharges by short pipe into the slab and not to the drain in the corner of the same room, which is nine feet away and is the drain the room was built with.</span></p><p><span>The ground under that slab is fill. It is not soil and it has never been soil and there are men in this department who could have told anybody that.</span></p><p><span>This fitting has discharged into that fill since February. Prior to February the water was removed by hand from this building daily and had been for as long as there has been a department.</span></p><p><span>REQUESTED: removal of the fitting and reinstatement of collection by hand.</span></p><p><span>This is the fourth notice I have raised on this room. The others are in the book above this one.</span></p><p><span>Defect notices are not a component of review. Defect notices are recorded.</span></p></div><div class="screenplay"><p class="action">It goes in the tray by the door and the tray is emptied on Thursdays and nothing happens to it, and Hollis wrote it anyway, in full, at half past six in the morning, and that is the only reason there is a record of any of this anywhere on this campus.</p></div><div class="screenplay"><p class="slug">INT. MERIDIAN HALL — BASEMENT — 6:50 A.M.</p></div><div class="screenplay"><p class="action">The tray. Four bolts. A short pipe out of the corner into the floor.</p><p class="action">The concrete round the pipe is the colour concrete goes and gives under a thumb.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> puts the light on, which is a thing he has never done down here, because there is a light and it works and he has used a torch since October out of a habit nobody gave him.</p><p class="action beat">The room is smaller with the light on.</p><p class="action">He puts the spanner on the first bolt.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. THE BASEMENT — 7:20 A.M.</p></div><div class="screenplay"><p class="action">The first bolt takes eleven minutes and comes out with a noise.</p><p class="action">The second takes four and comes out clean and he is briefly, stupidly pleased about it.</p><p class="action">The third shears.</p><p class="action beat">The head comes off in the spanner and the shank stays in the floor and there is nothing to grip and nothing to do about it.</p><p class="action">He sits on the wet floor with the spanner across his knees for a while.</p><p class="action">Then he takes the fourth one out, which takes twenty minutes and a bar and both hands and one foot on the wall, and then he stands on the corner of the tray by the sheared bolt and rocks it until the slab lets it go.</p><p class="action beat">It comes up with about four inches of the short pipe still in the hole.</p><p class="action">He is wet through to the shoulder and his hands are shaking and there is nothing dramatic about any of it.</p><p class="action">Then he goes and gets a galvanised bucket off the stack by the door in Plant Services, and carries it down, and puts it under the pipe.</p><p class="action beat">It is the least interesting thing that happens in this book.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. THE BASEMENT — 8:05 A.M.</p></div><div class="screenplay"><p class="action">A man comes down the stair in a clean high-vis with a clipboard and a phone in a case on his belt, and stops at the bottom of it, and looks at the tray on the floor and the bucket under the pipe and a first year sitting against the wall.</p><p class="action beat">It is the contractor. He has been coming since February.</p></div><div class="screenplay"><div class="dialogue"><p class="character">THE CONTRACTOR</p><div class="lines"><p class="line">You Plant?</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'm with Plant.</p></div></div><div class="dialogue"><p class="character">THE CONTRACTOR</p><div class="lines"><p class="line">Right. Because I've got that as a completion and</p><p class="line">I've had it as a completion since February and</p><p class="line">it's been sat on my list.</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 out.</p></div></div><div class="dialogue"><p class="character">THE CONTRACTOR</p><div class="lines"><p class="line">I can see it's out. I'm asking whose it is now,</p><p class="line">because if it's out and it's mine I've got to come</p><p class="line">back, and if it's out and it's yours I've got to</p><p class="line">close it.</p></div></div></div><div class="screenplay"><p class="action beat">That is the whole of the conversation and it is not a conversation about a building.</p><p class="action">HOLLIS comes down the stair behind him. He has come down a stair to be here, which he has not done for this room in two years, and he is not quick about it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">It's ours.</p></div></div><div class="dialogue"><p class="character">THE CONTRACTOR</p><div class="lines"><p class="line">You Hollis?</p></div></div><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">I am.</p></div></div><div class="dialogue"><p class="character">THE CONTRACTOR</p><div class="lines"><p class="line">I've been trying to hand this over since the</p><p class="line">middle of February. I've had four dates and</p><p class="line">nobody's ever here.</p></div></div><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">I'm here one day a week and it isn't the day</p><p class="line">you've been coming.</p></div></div></div><div class="screenplay"><p class="action beat">Both of them take that in and neither of them says the obvious thing about it, because both of them have worked for somebody for a long time.</p></div><div class="screenplay"><div class="dialogue"><p class="character">THE CONTRACTOR</p><div class="lines"><p class="line">So do you want it closed or do you want me back?</p></div></div><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">Closed.</p></div></div></div><div class="screenplay"><p class="action">He turns the clipboard round and holds a pen out.</p></div><div class="screenplay"><div class="dialogue"><p class="character">THE CONTRACTOR</p><div class="lines"><p class="line">Condition, and then sign it, and put the date on</p><p class="line">it, and I'm off your list.</p></div></div></div><div class="screenplay"><p class="action beat">Hollis reads the line. There is a box on it that says CONDITION ON HANDOVER and it is about an inch and a half long.</p><p class="action">He writes two words in it.</p><p class="action beat">MADE GOOD.</p><p class="action">He signs it and dates it and gives the pen back, and the contractor tears off a copy and hands it over and goes up the stair, and says cheers on the way, and means it.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. THE BASEMENT — 8:20 A.M.</p></div><div class="screenplay"><p class="action">The two of them and a bucket.</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">It'll be full by tonight.</p></div></div><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">It'll be full by four.</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">And then it's every day.</p></div></div><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">It's every day. It was every day for forty-one</p><p class="line">years and it was every day before that, and the</p><p class="line">only thing that ever changed about it is that</p><p class="line">somebody stopped.</p></div></div></div><div class="screenplay"><p class="action">He looks at the tray on the floor with its four inches of pipe and its one sheared bolt.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">Term's out in five weeks. I'm out at the end of</p><p class="line">it.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">I know.</p></div></div><div class="dialogue"><p class="character">HOLLIS</p><div class="lines"><p class="line">Right.</p></div></div></div><div class="screenplay"><p class="action beat">That is as close as either of them gets to it and neither of them goes any closer.</p></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="artifact handout"><p class="handout-title">MERIDIAN HALL · BASEMENT · WORKS ORDER 9-1140 · CLOSED</p><p><span>Fitting: collection tray, under stair pipe.</span></p><p><span>Installed: February. Described as permanent solution.</span></p><p><span>CONDITION ON HANDOVER: MADE GOOD.</span></p><p><span>Signed, H. HOLLIS, 14 April.</span></p><p><span>Handover accepted. Contractor released from this order.</span></p><p><span>This order is closed and will not be reopened without a further order.</span></p></div><div class="screenplay"><p class="action">Made good. Two words in a box an inch and a half long, in a hand that has been writing in boxes on this campus for forty-one years.</p><p class="action">There is a book in the Collegium that says MADE GOOD, NORTH WALL eleven times in one hand over ninety years, and nobody in this story ever gets to find out whether the two facts are related, and neither does anybody in the next one.</p></div><div class="artifact discord"><div class="discord-head"><span class="discord-server">FEDORA SOCIETY</span><span class="discord-channel">#general</span></div><div class="discord-body"><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">15 Apr, 11:40 PM</span></p><div class="discord-text"><p><span>Attendance three of five.</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:41 PM</span></p><div class="discord-text"><p><span>victor</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:42 PM</span></p><div class="discord-text"><p><span>Three of five. It is in the minutes and it is going in the letter.</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:58 PM</span></p><div class="discord-text"><p><span>is simon alright</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">16 Apr, 12:14 AM</span></p><div class="discord-text"><p><span>hes on the docket thursday. hes on it every week</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">16 Apr, 12:15 AM</span></p><div class="discord-text"><p><span>thats not what i asked</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">16 Apr, 12:31 AM</span></p><div class="discord-text"><p><span>i know</span></p></div></div></div></div></div>
```