Files
Jesse.MarkowitzandClaude Fable 5.1 e47cd3d400 v0.8.4 — the multiplayer transport: server and browser
The second release from the audit. Every fault here was invisible in solitaire, and four of
the five server faults were in the one file no test had ever stood up; `http.ts` now has an
end-to-end suite on a real port. CHANGELOG has the reasoning.

SERVER. Leaving a lobby freed the chair and kept the token, so a leaver could stream and
move for whoever took the seat next — revoked now, in memory and on disk. The browser
numbered intents from 1 per page load while the server remembered the seat's last number,
so the first move after a reload was swallowed as a resend — the connect push carries the
count and the client continues from it. Nothing serialised moves within a game and every
write shared one `.tmp` name, so two moves at once tore `game.json` (measured: 6 of 200),
and the boot's bare `JSON.parse` then took every game down — per-path write queues, a
per-game move queue, and a boot that skips one bad file. An error after the SSE head was
sent crashed the process. Bodies were unbounded before any secret check.

BROWSER. A double-click did the thing twice: one submit in flight at a time. A failed
submit is `false`, not an unhandled rejection. The documentation renderer flattened nested
bullets into a literal "- " mid-sentence on the published home-deck page. The make-up panel
promised cars the engine refuses; it asks `acceptsCar` now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
2026-09-29 17:02:32 -04:00

287 lines
11 KiB
TypeScript

/**
* A small Markdown renderer, for the player-facing documentation only.
*
* WHY NOT A LIBRARY. This project has no runtime dependencies at all, and the guide uses a small,
* known subset of Markdown — headings, paragraphs, lists, tables, links, code spans, block quotes
* and rules. A Markdown library would be the first dependency in the tree, pulled in to render five
* files whose whole vocabulary fits below. `docs/` is written by hand, not by users, so this does
* not have to survive hostile input; it has to render what those five documents actually contain
* and fail loudly on anything else.
*
* WHY NOT HAND-WRITTEN HTML. The Markdown is the one copy (TODO #15a). An HTML twin drifts from it
* on the first edit, which is the failure the whole documentation pass was about.
*
* ESCAPING IS UNCONDITIONAL. Every scrap of text goes through `esc` before any markup is added, and
* the inline pass only ever inserts tags around already-escaped content. A `<` in the prose is a
* less-than sign, not the start of an element — there is no raw-HTML passthrough, deliberately.
*/
/** HTML-escape. Ampersand first, or it double-escapes the entities added after it. */
export function esc(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** A heading found while rendering, for the contents list the page builds from it. */
export type Heading = { level: number; text: string; id: string };
/**
* `## 3. The shape of a Stage` → `the-shape-of-a-stage`.
*
* The leading number is dropped: it is a position in the document, and a link that carries it
* breaks when a section is inserted above. Duplicate slugs get a numeric suffix rather than
* silently pointing at the first one.
*/
function slug(text: string, taken: Set<string>): string {
const base =
text
.toLowerCase()
// A whole section number, dotted or not: "4.2 Local Operations" and "7. FAQ" both lose it.
.replace(/^\d+(?:\.\d+)*[.)]?\s+/, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'section';
let id = base;
for (let n = 2; taken.has(id); n++) id = `${base}-${n}`;
taken.add(id);
return id;
}
/**
* Inline markup, applied to text that is ALREADY escaped.
*
* Order matters: code spans are taken out first and put back last, so `**` inside backticks stays
* literal. That is not a corner case here — the rules reference quotes field names like
* `**Version**` when describing the page header.
*/
function inline(escaped: string, linkHref: (href: string) => string): string {
const code: string[] = [];
let s = escaped.replace(/`([^`]+)`/g, (_m, body: string) => {
code.push(`<code>${body}</code>`);
return `\u0000${code.length - 1}\u0000`;
});
// Links: [text](target). The target is rewritten so a link between documents lands on the
// rendered page rather than the Markdown source.
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text: string, href: string) => {
const target = linkHref(href);
const external = /^https?:/.test(target);
return `<a href="${target}"${external ? ' target="_blank" rel="noopener"' : ''}>${text}</a>`;
});
// Bold before italic, or `**x**` is read as an empty italic wrapping a bold.
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
s = s.replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1<em>$2</em>');
return s.replace(/\u0000(\d+)\u0000/g, (_m, i: string) => code[Number(i)]!);
}
/** One table row's cells, from `| a | b |`. */
function cells(line: string): string[] {
return line
.replace(/^\s*\|/, '')
.replace(/\|\s*$/, '')
.split('|')
.map((c) => c.trim());
}
/** `---`, `:--`, `--:` and `:-:` → the CSS alignment a column wants. */
function alignments(sep: string): (string | null)[] {
return cells(sep).map((c) => {
const left = c.startsWith(':');
const right = c.endsWith(':');
if (left && right) return 'center';
if (right) return 'right';
if (left) return 'left';
return null;
});
}
const isTableSep = (line: string): boolean => /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes('-');
export type Rendered = { html: string; headings: Heading[] };
/**
* Render a Markdown document to HTML.
*
* `linkHref` rewrites link targets — the build uses it to send `rules.md` to `rules.html` — and
* defaults to leaving them alone so the function is testable on its own.
*/
/**
* One list at one indent level. An item is its first line plus any lines indented under it; the
* more-indented BULLETS among those are the item's own nested list and render recursively, while
* plain indented lines are wrapped continuations of its text.
*/
function listHtml(block: string[], text: (s: string) => string): string {
const first = /^(\s*)([-*+]|\d+[.)])\s+/.exec(block.find((l) => l.trim() !== '') ?? '');
if (!first) return '';
const ordered = /\d/.test(first[2]!);
const base = first[1]!.length;
const items: { text: string[]; sub: string[] }[] = [];
for (const l of block) {
const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(l);
const current = items[items.length - 1];
if (m && m[1]!.length <= base) {
items.push({ text: [m[3]!], sub: [] });
} else if (!current) {
continue;
} else if (current.sub.length > 0 || (m && m[1]!.length > base)) {
// Once a nested list has begun, everything further belongs to it, wrapped lines included.
current.sub.push(l);
} else if (l.trim() !== '') {
current.text.push(l.trim());
}
}
const tag = ordered ? 'ol' : 'ul';
return (
`<${tag}>` +
items.map((it) => `<li>${text(it.text.join(' '))}${it.sub.length > 0 ? listHtml(it.sub, text) : ''}</li>`).join('') +
`</${tag}>`
);
}
export function renderMarkdown(src: string, linkHref: (href: string) => string = (h) => h): Rendered {
const lines = src.replace(/\r\n/g, '\n').split('\n');
const out: string[] = [];
const headings: Heading[] = [];
const taken = new Set<string>();
const ln = (s: string): void => void out.push(s);
const text = (s: string): string => inline(esc(s), linkHref);
let i = 0;
while (i < lines.length) {
const line = lines[i]!;
// The generated-card markers, and any other HTML comment: structural, never shown.
if (/^\s*<!--/.test(line)) {
while (i < lines.length && !lines[i]!.includes('-->')) i++;
i++;
continue;
}
if (line.trim() === '') {
i++;
continue;
}
if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) {
ln('<hr>');
i++;
continue;
}
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
if (heading) {
const level = heading[1]!.length;
const raw = heading[2]!.trim();
const id = slug(raw, taken);
headings.push({ level, text: raw.replace(/[*`]/g, ''), id });
// The anchor is a link to itself, so a section can be pointed at without a separate widget.
ln(
`<h${level} id="${id}">${text(raw)}` +
`<a class="anchor" href="#${id}" aria-label="Link to this section">#</a></h${level}>`,
);
i++;
continue;
}
// Fenced code.
if (/^\s*```/.test(line)) {
i++;
const body: string[] = [];
while (i < lines.length && !/^\s*```/.test(lines[i]!)) body.push(lines[i++]!);
i++;
ln(`<pre><code>${esc(body.join('\n'))}</code></pre>`);
continue;
}
// Tables: a header row, an alignment row, then body rows.
if (line.includes('|') && i + 1 < lines.length && isTableSep(lines[i + 1]!)) {
const head = cells(line);
const align = alignments(lines[i + 1]!);
i += 2;
const body: string[][] = [];
while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim() !== '') body.push(cells(lines[i++]!));
const th = head
.map((c, n) => `<th${align[n] ? ` class="ta-${align[n]}"` : ''}>${text(c)}</th>`)
.join('');
const rows = body
.map(
(r) =>
'<tr>' +
r.map((c, n) => `<td${align[n] ? ` class="ta-${align[n]}"` : ''}>${text(c)}</td>`).join('') +
'</tr>',
)
.join('');
// Wrapped so a wide table scrolls inside the page rather than widening it on a phone.
ln(`<div class="tablewrap"><table><thead><tr>${th}</tr></thead><tbody>${rows}</tbody></table></div>`);
continue;
}
// Block quote: consecutive `>` lines, rendered through this same function so a quote may hold
// a list or a table — the rules reference puts both inside one.
if (/^\s*>/.test(line)) {
const body: string[] = [];
while (i < lines.length && /^\s*>/.test(lines[i]!)) body.push(lines[i++]!.replace(/^\s*>\s?/, ''));
ln(`<blockquote>${renderMarkdown(body.join('\n'), linkHref).html}</blockquote>`);
continue;
}
// Lists. A bullet or a number opens one; continuation lines are indented under their item, and
// a more-indented bullet under an item is a nested list, rendered by recursion (v0.8.4 — the
// comment said so before and the code appended the nested bullet to its parent as text, so the
// published home-deck page carried a literal "- " mid-sentence).
const bullet = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
if (bullet) {
const baseIndent = bullet[1]!.length;
const block: string[] = [];
while (i < lines.length) {
const l = lines[i]!;
if (l.trim() === '') {
// A blank line ends the list unless the next line is still inside it.
const next = lines[i + 1] ?? '';
const continues = /^(\s*)([-*+]|\d+[.)])\s+/.test(next) || /^\s{2,}\S/.test(next);
if (!continues) break;
block.push('');
i++;
continue;
}
const m = /^(\s*)([-*+]|\d+[.)])\s+/.exec(l);
if ((m && m[1]!.length <= baseIndent) || (m && m[1]!.length > baseIndent) || /^\s{2,}\S/.test(l)) {
block.push(l);
i++;
continue;
}
break;
}
ln(listHtml(block, text));
continue;
}
// Anything else is a paragraph, running to the next blank line or block opener.
const para: string[] = [];
while (i < lines.length) {
const l = lines[i]!;
if (
l.trim() === '' ||
/^(#{1,6})\s/.test(l) ||
/^\s*>/.test(l) ||
/^\s*```/.test(l) ||
/^\s*<!--/.test(l) ||
/^\s*(---|\*\*\*|___)\s*$/.test(l) ||
/^(\s*)([-*+]|\d+[.)])\s+/.test(l) ||
(l.includes('|') && isTableSep(lines[i + 1] ?? ''))
) {
break;
}
para.push(l.trim());
i++;
}
if (para.length) ln(`<p>${text(para.join(' '))}</p>`);
}
return { html: out.join('\n'), headings };
}