plotcoder-board 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ // Type surface for compareStructure.js — a structure beside the wall (R52).
2
+
3
+ import type { BoardState } from "./reducer";
4
+
5
+ export declare const MATCH_PAGES: number;
6
+ export declare const NEAR_PAGES: number;
7
+
8
+ export type ComparedBeat = {
9
+ name: string;
10
+ at: number;
11
+ /** The page the structure's beat falls near on this board's target. */
12
+ page: number;
13
+ /** The wall's beat that answers it, or null. */
14
+ match: { id: string; headline: string; page: number } | null;
15
+ /** Pages the wall's beat is off by: negative is early. Null with no match. */
16
+ drift: number | null;
17
+ /** No match, and the page is past the story so far. */
18
+ beyond: boolean;
19
+ };
20
+
21
+ export type StructureComparison = {
22
+ rows: ComparedBeat[];
23
+ /** The wall's beats no beat of the structure took. */
24
+ unmatched: Array<{ id: string; headline: string; page: number }>;
25
+ /** Pages on the wall so far. */
26
+ soFar: number;
27
+ targetEighths: number;
28
+ };
29
+
30
+ export declare function compareStructure(state: BoardState, beats: ReadonlyArray<{ name: string; at: number }>): StructureComparison;
31
+ export declare function driftWord(drift: number | null | undefined): string | null;
32
+ export declare function describeComparison(comparison: StructureComparison): string[];
@@ -0,0 +1,85 @@
1
+ // A structure beside the wall (R52).
2
+ //
3
+ // A structure's beats each carry the share of the story they tend to fall
4
+ // near. Laying one out makes cards; this is the other thing the sheet's
5
+ // words promise — a comparison. Each of the structure's beats gets the page
6
+ // it falls near on this board's target, and the nearest of the wall's own
7
+ // beats within reach, one to one and in order, with how far off it is. A
8
+ // reading, like read_wall: it moves nothing and makes nothing.
9
+ //
10
+ // Pure and DOM-free, like the kernel: the sheet, the strip and the MCP
11
+ // server all read the same rows.
12
+
13
+ import { readingOrder } from "./readWall.js";
14
+ import { EIGHTHS_PER_PAGE, noteEighths } from "./reducer.js";
15
+ import { beatPage } from "./templates.js";
16
+
17
+ /** Within this many pages a beat of the wall answers a beat of the structure. */
18
+ export const MATCH_PAGES = 6;
19
+ /** Within this many pages the match is "near" rather than early or late. */
20
+ export const NEAR_PAGES = 2;
21
+
22
+ /**
23
+ * Set a structure's beats beside this wall's. `beats` are a template's or a
24
+ * saved structure's: `{ name, at }`. The wall's beats are taken in reading
25
+ * order; each of the structure's takes the nearest wall beat not yet taken
26
+ * and not before the last one taken, within MATCH_PAGES, so the matching
27
+ * never crosses.
28
+ */
29
+ export function compareStructure(state, beats) {
30
+ const order = readingOrder(state.notes);
31
+ const wallBeats = [];
32
+ let at = 0;
33
+ for (const note of order) {
34
+ if (note.rank === "beat") {
35
+ wallBeats.push({ id: note.id, headline: note.headline, page: Math.floor(at / EIGHTHS_PER_PAGE) + 1 });
36
+ }
37
+ at += noteEighths(note);
38
+ }
39
+ const soFar = Math.ceil(at / EIGHTHS_PER_PAGE);
40
+ let from = 0;
41
+ const taken = new Set();
42
+ const rows = beats.map((beat) => {
43
+ const page = beatPage(beat.at, state.targetEighths);
44
+ let best = -1;
45
+ for (let i = from; i < wallBeats.length; i += 1) {
46
+ const gap = Math.abs(wallBeats[i].page - page);
47
+ if (gap > MATCH_PAGES) continue;
48
+ if (best < 0 || gap < Math.abs(wallBeats[best].page - page)) best = i;
49
+ }
50
+ if (best >= 0) {
51
+ taken.add(best);
52
+ from = best + 1;
53
+ }
54
+ const match = best >= 0 ? wallBeats[best] : null;
55
+ return {
56
+ name: beat.name,
57
+ at: beat.at,
58
+ page,
59
+ match,
60
+ drift: match ? match.page - page : null,
61
+ // No match and the page is past the story so far: nothing is there yet.
62
+ beyond: !match && page > soFar,
63
+ };
64
+ });
65
+ const unmatched = wallBeats.filter((_, index) => !taken.has(index));
66
+ return { rows, unmatched, soFar, targetEighths: state.targetEighths };
67
+ }
68
+
69
+ /** The drift as a word or two: here, near, 3 pp early, 12 pp late. */
70
+ export function driftWord(drift) {
71
+ if (drift === null || drift === undefined) return null;
72
+ if (drift === 0) return "here";
73
+ if (Math.abs(drift) <= NEAR_PAGES) return "near";
74
+ return drift < 0 ? `${-drift} pp early` : `${drift} pp late`;
75
+ }
76
+
77
+ /** One line per row, for a reply or a sheet: the structure's beat, its page, and the wall's answer. */
78
+ export function describeComparison(comparison) {
79
+ return comparison.rows.map((row) => {
80
+ const head = `${row.name} (p. ${row.page})`;
81
+ if (row.match) return `${head} — yours: "${row.match.headline}" p. ${row.match.page} · ${driftWord(row.drift)}`;
82
+ if (row.beyond) return `${head} — nothing yet: past p. ${comparison.soFar}, the story so far`;
83
+ return `${head} — none of yours within ${MATCH_PAGES} pages`;
84
+ });
85
+ }
@@ -0,0 +1,15 @@
1
+ import type { Line } from "./paginate";
2
+ import type { BoardState } from "./reducer";
3
+
4
+ /** What a document carries above the script: the board's name, the project's when it has several boards, the premise. */
5
+ export type TakeOptions = { title?: string; project?: string; premise?: string };
6
+
7
+ /** The wall as Markdown (R54): title, premise, logline, beats as headings, a heading per scene, the text or the change line. */
8
+ export declare function toMarkdown(state: BoardState, options?: TakeOptions): string;
9
+
10
+ /** The script as plain text, set as it prints: Courier's columns kept with spaces, scene numbers in the margins. */
11
+ export declare function toPlainText(state: BoardState, options?: TakeOptions): string;
12
+
13
+ export declare const GUTTER: number;
14
+ export declare const COLUMN: Record<"character" | "more" | "parenthetical" | "dialogue", number>;
15
+ export declare function setLine(line: Line): string;
@@ -0,0 +1,177 @@
1
+ // Take the pages with you (R54): the wall as Markdown, and the script as
2
+ // plain text — for a collaborator who lives in Google Docs rather than in a
3
+ // screenwriting app. Both are pure and shared by every door: the Pages
4
+ // panel's sheet, the console, and the MCP server's export_markdown and
5
+ // export_text.
6
+ //
7
+ // Markdown is the wall read out, in wall order: the board as the title, the
8
+ // premise and the logline under it, beats as second-level headings, one
9
+ // third-level heading per scene from its place (with its scene number, locked
10
+ // or by wall order), the headline as a synopsis line, then the scene's text —
11
+ // set by what each element is, from the same rules the paginator reads by —
12
+ // or, unwritten, its change line. Nothing invisible: Markdown has no notes
13
+ // that print as nothing, so the cast and the fold stay on the wall.
14
+ //
15
+ // Plain text is the script as it prints: the paginator's lines, Courier's
16
+ // columns kept with spaces, scene numbers in both margins, no page numbers.
17
+ // It pastes into anything and reads as a script wherever the font is
18
+ // monospaced.
19
+
20
+ import { readingOrder } from "./readWall.js";
21
+ import { sceneHeading } from "./fountain.js";
22
+ import { paginate, parseScene, WIDTH } from "./paginate.js";
23
+ import { sceneNumbers } from "./numbering.js";
24
+
25
+ function upper(text) {
26
+ return text.trim().replace(/\s+/g, " ").toUpperCase();
27
+ }
28
+
29
+ function documentTitle(options) {
30
+ const title = options.title || "Untitled";
31
+ return options.project && options.project !== title ? `${options.project} · ${title}` : title;
32
+ }
33
+
34
+ /** A scene's text as Markdown paragraphs: action as it is, a speech as its cue in bold with the lines hard-broken under it. */
35
+ function sceneMarkdown(text) {
36
+ const out = [];
37
+ for (const element of parseScene(text)) {
38
+ switch (element.kind) {
39
+ case "break":
40
+ out.push("---", "");
41
+ break;
42
+ case "speech": {
43
+ const lines = [`**${element.name}**`];
44
+ for (const part of element.parts) lines.push(part.kind === "parenthetical" ? `*${part.text}*` : part.text);
45
+ // Two trailing spaces: Markdown's line break, so the cue and its lines stay on their own lines.
46
+ out.push(lines.join(" \n"), "");
47
+ break;
48
+ }
49
+ case "centered":
50
+ case "transition":
51
+ case "action":
52
+ default:
53
+ out.push(element.text, "");
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * The whole wall as Markdown.
61
+ *
62
+ * @param state the board
63
+ * @param options.title the board's name; options.project the project's name
64
+ * when the project has several boards; options.premise
65
+ */
66
+ export function toMarkdown(state, options = {}) {
67
+ const order = readingOrder(state.notes);
68
+ const numbers = sceneNumbers(order, state.lock);
69
+ const out = [`# ${documentTitle(options)}`, ""];
70
+ if (options.premise) out.push(`*${options.premise}*`, "");
71
+ if (state.logline) out.push(`**${state.logline}**`, "");
72
+ let beat = 0;
73
+ for (const note of order) {
74
+ if (note.rank === "beat") {
75
+ beat += 1;
76
+ out.push(`## ${beat}. ${note.headline || "Untitled beat"}`, "");
77
+ }
78
+ const heading = sceneHeading(note).slice(1);
79
+ out.push(`### ${numbers.get(note.id) ?? ""} · ${heading}`.replace(/^### · /, "### "), "");
80
+ if (note.headline && upper(note.headline) !== heading) out.push(`*${note.headline.trim()}*`, "");
81
+ if (note.text && note.text.trim()) out.push(...sceneMarkdown(note.text));
82
+ else if (note.change && note.change.trim()) out.push(note.change.trim(), "");
83
+ }
84
+ return `${out.join("\n").trimEnd()}\n`;
85
+ }
86
+
87
+ /** The column the script's body starts at: room for a scene number on the left. */
88
+ export const GUTTER = 5;
89
+
90
+ /** Where each kind of line starts, in characters from the body's left edge — the print stylesheet's columns. */
91
+ export const COLUMN = { character: 22, more: 22, parenthetical: 16, dialogue: 10 };
92
+
93
+ const DUAL_RIGHT = 32;
94
+ const DUAL_CUE = 8;
95
+
96
+ function pad(n) {
97
+ return " ".repeat(Math.max(0, n));
98
+ }
99
+
100
+ function centred(text, width = WIDTH.action) {
101
+ return pad(GUTTER + Math.floor((width - text.length) / 2)) + text;
102
+ }
103
+
104
+ /** One line of a dual-dialogue column, from the column's own left edge. */
105
+ function columnLine(line) {
106
+ if (!line || line.kind === "blank") return "";
107
+ const text = line.text ?? "";
108
+ if (line.kind === "character") return pad(DUAL_CUE) + text;
109
+ if (line.kind === "parenthetical") return pad(4) + text;
110
+ return text;
111
+ }
112
+
113
+ /** A printed line set with spaces. Exported for the test; the file is `toPlainText`. */
114
+ export function setLine(line) {
115
+ const text = line.text ?? "";
116
+ switch (line.kind) {
117
+ case "blank":
118
+ return "";
119
+ case "heading": {
120
+ const number = line.sceneNumber === null || line.sceneNumber === undefined ? "" : String(line.sceneNumber);
121
+ if (!number) return pad(GUTTER) + text;
122
+ return `${number.padEnd(GUTTER)}${text.padEnd(WIDTH.action)} ${number}`.trimEnd();
123
+ }
124
+ case "character":
125
+ case "more":
126
+ case "parenthetical":
127
+ case "dialogue":
128
+ return pad(GUTTER + COLUMN[line.kind]) + text;
129
+ case "transition":
130
+ return pad(GUTTER + WIDTH.action - text.length) + text;
131
+ case "centered":
132
+ return centred(text);
133
+ case "dual": {
134
+ const left = pad(GUTTER) + columnLine(line.left);
135
+ const right = columnLine(line.right);
136
+ return right ? `${left.padEnd(GUTTER + DUAL_RIGHT)}${right}` : left.trimEnd();
137
+ }
138
+ case "action":
139
+ default:
140
+ return pad(GUTTER) + text;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The script as plain text, set as it prints.
146
+ *
147
+ * @param state the board
148
+ * @param options.title the board's name; options.project the project's name
149
+ * when the project has several boards
150
+ */
151
+ export function toPlainText(state, options = {}) {
152
+ const order = readingOrder(state.notes);
153
+ const numbers = sceneNumbers(order, state.lock);
154
+ const result = paginate(
155
+ order.map((note) => ({
156
+ id: note.id,
157
+ heading: sceneHeading(note).slice(1),
158
+ text: note.text,
159
+ change: note.change,
160
+ written: Boolean(note.text && note.text.trim()),
161
+ number: numbers.get(note.id) ?? undefined,
162
+ })),
163
+ );
164
+ const title = options.title || "Untitled";
165
+ const out = [];
166
+ if (options.project && options.project !== title) {
167
+ out.push(centred(upper(options.project)), "", centred(title));
168
+ } else {
169
+ out.push(centred(upper(title)));
170
+ }
171
+ out.push("", "");
172
+ for (const page of result.pages) {
173
+ if (page.number > 1) out.push("");
174
+ for (const line of page.lines) out.push(setLine(line));
175
+ }
176
+ return `${out.join("\n").replace(/\n{4,}/g, "\n\n\n").trimEnd()}\n`;
177
+ }
@@ -1,6 +1,6 @@
1
1
  // Type surface for project.js — the project record (R35).
2
2
 
3
- import type { BoardNote } from "./reducer";
3
+ import type { BoardCharacter, BoardNote, BoardState } from "./reducer";
4
4
 
5
5
  export declare const PROJECT_VERSION: number;
6
6
  export declare const DEFAULT_PROJECT_NAME: string;
@@ -22,6 +22,8 @@ export type ProjectRecord = {
22
22
  activeBoardId: string;
23
23
  /** A writer's own structures, saved from a wall's beats (Roadmap 2, item 7). */
24
24
  structures?: OwnStructure[];
25
+ /** The project's cast (R51): one roster every board draws from. Absent until liftCast has run. */
26
+ characters?: BoardCharacter[];
25
27
  createdAt: string;
26
28
  updatedAt: string;
27
29
  };
@@ -70,3 +72,19 @@ export declare function reidentifyProject(
70
72
  project: ProjectRecord,
71
73
  now?: string,
72
74
  ): ProjectRecord & { renamed: Record<string, string> };
75
+
76
+ /** A board's state composed with the project's cast (R51). */
77
+ export declare function withRoster(state: BoardState, project: ProjectRecord): BoardState;
78
+ export declare function sameRoster(a: BoardCharacter[] | undefined, b: BoardCharacter[] | undefined): boolean;
79
+ export declare function liftCast(
80
+ project: ProjectRecord,
81
+ boards: Record<string, BoardState>,
82
+ now?: string,
83
+ ): { project: ProjectRecord; boards: Record<string, BoardState>; changed: boolean };
84
+ export type CastElsewhere = Record<string, Array<{ board: string; boardId: string; cards: number }>>;
85
+ export declare function castElsewhere(project: ProjectRecord, boards: Record<string, BoardState>, activeBoardId: string): CastElsewhere;
86
+ export declare function mergeRoster(
87
+ project: ProjectRecord,
88
+ state: BoardState,
89
+ now?: string,
90
+ ): { project: ProjectRecord; state: BoardState };
@@ -9,7 +9,7 @@
9
9
  // Plain ESM with a sibling .d.ts, like the kernel, so the browser store and the
10
10
  // MCP server share one idea of what a project is. Keep it free of `window`.
11
11
 
12
- import { newId, noteEighths, nowIso } from "./reducer.js";
12
+ import { CHARACTER_FIELDS, fillCharacter, isCharacter, newId, normalizeState, noteEighths, nowIso, sameName } from "./reducer.js";
13
13
 
14
14
  export const PROJECT_VERSION = 2;
15
15
  export const DEFAULT_PROJECT_NAME = "Untitled project";
@@ -75,7 +75,7 @@ export function normalizeProject(value, now = nowIso()) {
75
75
  : [];
76
76
  // `renamed` is reidentifyProject's map for the store, never part of the record.
77
77
  const { renamed: _renamed, ...rest } = value;
78
- return {
78
+ const record = {
79
79
  ...rest,
80
80
  version: PROJECT_VERSION,
81
81
  name: trimmed(value.name, DEFAULT_PROJECT_NAME),
@@ -86,6 +86,138 @@ export function normalizeProject(value, now = nowIso()) {
86
86
  createdAt: typeof value.createdAt === "string" ? value.createdAt : now,
87
87
  updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : now,
88
88
  };
89
+ // The project's cast (R51). A record with none has not been lifted yet —
90
+ // its boards still carry their own rosters — and liftCast does that at the
91
+ // next load boundary; so absent stays absent, and never becomes [].
92
+ if (Array.isArray(value.characters)) record.characters = value.characters.filter(isCharacter).map(fillCharacter);
93
+ else delete record.characters;
94
+ return record;
95
+ }
96
+
97
+ // --- The project's cast (R51) -----------------------------------------
98
+ //
99
+ // One roster for the project, on the record, that every board draws from.
100
+ // A board's state still carries `characters`, but as a copy of the
101
+ // project's: every load boundary composes it with withRoster, and every
102
+ // store lifts a roster a kernel command changed back onto the record. So
103
+ // the kernel, the readings and the wall go on reading state.characters,
104
+ // and there is one Nessa across the pilot and episode two.
105
+
106
+ /** A board's state with the project's cast in it; unknown cast ids on cards drop. */
107
+ export function withRoster(state, project) {
108
+ if (!Array.isArray(project.characters) || sameRoster(project.characters, state.characters)) return state;
109
+ const next = normalizeState({ ...state, characters: project.characters });
110
+ return next.characters === project.characters ? next : { ...next, characters: project.characters };
111
+ }
112
+
113
+ /** True when two rosters are the same people with the same pages. */
114
+ export function sameRoster(a, b) {
115
+ if (a === b) return true;
116
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
117
+ return a.every((character, index) => {
118
+ const other = b[index];
119
+ return other && character.id === other.id && character.name === other.name && CHARACTER_FIELDS.every((field) => (character[field] ?? "") === (other[field] ?? ""));
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Lift the cast onto the project. A record that already has one composes
125
+ * every board with it. A record with none — written before R51 — takes the
126
+ * boards' rosters in board order and merges them by name: the first record
127
+ * of a name keeps its id, later ones fold into it (a page line fills from the
128
+ * first board that had it), and every card that cast a folded id casts the
129
+ * kept one. Returns the record, every board composed, and whether anything
130
+ * changed.
131
+ */
132
+ export function liftCast(project, boards, now = nowIso()) {
133
+ if (Array.isArray(project.characters)) {
134
+ let changed = false;
135
+ const out = {};
136
+ for (const [id, state] of Object.entries(boards)) {
137
+ const next = withRoster(state, project);
138
+ if (next !== state) changed = true;
139
+ out[id] = next;
140
+ }
141
+ return { project, boards: out, changed };
142
+ }
143
+ const roster = [];
144
+ const folded = {};
145
+ const order = [
146
+ ...project.boards.map((meta) => meta.id).filter((id) => boards[id]),
147
+ ...Object.keys(boards).filter((id) => !project.boards.some((meta) => meta.id === id)),
148
+ ];
149
+ for (const boardId of order) {
150
+ const map = {};
151
+ for (const character of boards[boardId].characters ?? []) {
152
+ if (!isCharacter(character)) continue;
153
+ const kept = roster.find((item) => sameName(item.name, character.name));
154
+ if (!kept) {
155
+ roster.push(fillCharacter({ ...character }));
156
+ continue;
157
+ }
158
+ map[character.id] = kept.id;
159
+ for (const field of CHARACTER_FIELDS) {
160
+ if (!kept[field].trim() && typeof character[field] === "string" && character[field].trim()) kept[field] = character[field];
161
+ }
162
+ }
163
+ folded[boardId] = map;
164
+ }
165
+ const lifted = { ...project, characters: roster, updatedAt: now };
166
+ const out = {};
167
+ for (const [boardId, state] of Object.entries(boards)) {
168
+ const map = folded[boardId] ?? {};
169
+ const notes = state.notes.map((note) => {
170
+ const ids = [...new Set(note.characterIds.map((id) => map[id] ?? id))];
171
+ return ids.length === note.characterIds.length && ids.every((id, index) => id === note.characterIds[index]) ? note : { ...note, characterIds: ids };
172
+ });
173
+ out[boardId] = normalizeState({ ...state, notes, characters: roster });
174
+ }
175
+ return { project: lifted, boards: out, changed: true };
176
+ }
177
+
178
+ /**
179
+ * A board with a roster of its own — an imported file, a board opened from
180
+ * elsewhere — joins the project's cast without shrinking it: a name the
181
+ * project already has keeps the project's record (its cards recast to that
182
+ * id), a new name is appended with the record it came with. Returns the
183
+ * record and the board composed with it.
184
+ */
185
+ export function mergeRoster(project, state, now = nowIso()) {
186
+ if (!Array.isArray(project.characters)) return { project, state };
187
+ const roster = [...project.characters];
188
+ const map = {};
189
+ let grew = false;
190
+ for (const character of state.characters ?? []) {
191
+ if (!isCharacter(character)) continue;
192
+ const kept = roster.find((item) => sameName(item.name, character.name));
193
+ if (kept) {
194
+ if (kept.id !== character.id) map[character.id] = kept.id;
195
+ continue;
196
+ }
197
+ roster.push(fillCharacter({ ...character }));
198
+ grew = true;
199
+ }
200
+ const next = grew ? { ...project, characters: roster, updatedAt: now } : project;
201
+ const notes = state.notes.map((note) => {
202
+ const ids = [...new Set(note.characterIds.map((id) => map[id] ?? id))];
203
+ return ids.length === note.characterIds.length && ids.every((id, index) => id === note.characterIds[index]) ? note : { ...note, characterIds: ids };
204
+ });
205
+ const recast = notes.some((note, index) => note !== state.notes[index]) ? { ...state, notes } : state;
206
+ return { project: next, state: withRoster(recast, next) };
207
+ }
208
+
209
+ /** Which other boards of the project have each person on a card: id -> [{ board, cards }]. */
210
+ export function castElsewhere(project, boards, activeBoardId) {
211
+ const map = {};
212
+ for (const meta of project.boards) {
213
+ if (meta.id === activeBoardId) continue;
214
+ const state = boards[meta.id];
215
+ if (!state) continue;
216
+ const counts = {};
217
+ for (const note of state.notes) for (const id of note.characterIds ?? []) counts[id] = (counts[id] ?? 0) + 1;
218
+ for (const [id, cards] of Object.entries(counts)) (map[id] ??= []).push({ board: meta.name, boardId: meta.id, cards });
219
+ }
220
+ return map;
89
221
  }
90
222
 
91
223
  function touch(project, patch, now) {
@@ -63,11 +63,20 @@ export type WallReading = {
63
63
  payoffs: Record<string, string[]>;
64
64
  /** Folded cards that pay off on another board of the project (R50): the card and the board. */
65
65
  later: { id: string; boardId: string }[];
66
+ /** The questions the wall asks now. A left one (R53) is not here while its words hold. */
66
67
  findings: Finding[];
68
+ /** Questions the writer has left, for now: the same question, with when it was left. */
69
+ left: Array<Finding & { since: string }>;
67
70
  };
68
71
 
69
72
  /** Rows top to bottom, cards left to right within a row. */
70
73
  export declare function readingOrder(notes: BoardNote[]): BoardNote[];
71
- export declare function readWall(state: BoardState): WallReading;
74
+ export declare function readWall(
75
+ state: BoardState,
76
+ options?: {
77
+ /** Cast ids on a card of another board of the project (R51): not asked about as uncast here. */
78
+ elsewhere?: string[];
79
+ },
80
+ ): WallReading;
72
81
  export declare function describeRuns(reading: WallReading, state: BoardState): string[];
73
82
  export declare function describeSetups(reading: WallReading, state: BoardState): string[];
@@ -110,7 +110,10 @@ function list(notes) {
110
110
  * Read the board. Returns the reading and the findings; see readWall.d.ts for
111
111
  * the shape. Never mutates the state.
112
112
  */
113
- export function readWall(state) {
113
+ export function readWall(state, options = {}) {
114
+ // People on a card of another board of the project (R51) are cast, and
115
+ // are not asked about here.
116
+ const elsewhere = new Set(Array.isArray(options.elsewhere) ? options.elsewhere : []);
114
117
  const order = readingOrder(state.notes);
115
118
  const beats = order.filter((note) => note.rank === "beat");
116
119
 
@@ -334,6 +337,7 @@ export function readWall(state) {
334
337
  for (const character of state.characters ?? []) {
335
338
  const scenes = order.filter((note) => note.characterIds?.includes(character.id));
336
339
  if (scenes.length === 0) {
340
+ if (elsewhere.has(character.id)) continue;
337
341
  findings.push({
338
342
  kind: "uncast",
339
343
  ids: [character.id],
@@ -372,6 +376,21 @@ export function readWall(state) {
372
376
  }
373
377
  }
374
378
 
379
+ // A question the writer has left (R53) is held back while it is still the
380
+ // same question — same kind, same cards, same words. The moment it would
381
+ // read differently (a page moved, a headline changed, the median shifted)
382
+ // it is a new question and is asked. The kernel never decides this; the
383
+ // reading does, on every read.
384
+ const left = [];
385
+ const asked = findings.filter((finding) => {
386
+ const entry = (state.left ?? []).find(
387
+ (item) => item.kind === finding.kind && sameList(item.ids, finding.ids) && item.text === finding.text,
388
+ );
389
+ if (!entry) return true;
390
+ left.push({ ...finding, since: entry.since });
391
+ return false;
392
+ });
393
+
375
394
  return {
376
395
  order: order.map((note) => note.id),
377
396
  beats: beats.map((note) => ({ id: note.id, headline: note.headline })),
@@ -379,10 +398,15 @@ export function readWall(state) {
379
398
  setups,
380
399
  payoffs,
381
400
  later,
382
- findings,
401
+ findings: asked,
402
+ left,
383
403
  };
384
404
  }
385
405
 
406
+ function sameList(a, b) {
407
+ return a.length === b.length && a.every((id, index) => id === b[index]);
408
+ }
409
+
386
410
  /** The setups as prose lines: what plants what, and how far apart. */
387
411
  export function describeSetups(reading, state) {
388
412
  const byId = new Map(state.notes.map((note) => [note.id, note]));
@@ -117,10 +117,25 @@ export type BoardState = {
117
117
  lock: import("./numbering").Lock | null;
118
118
  /** The revision in progress — a name, a colour, a snapshot — or null. */
119
119
  revision: import("./numbering").Revision | null;
120
+ /** Questions the writer has left, for now (R53): kept until the question would read differently. */
121
+ left: LeftQuestion[];
122
+ };
123
+
124
+ /** A question the wall asked and the writer left (R53). */
125
+ export type LeftQuestion = {
126
+ kind: string;
127
+ ids: string[];
128
+ /** The question's words when it was left; it comes back when they would differ. */
129
+ text: string;
130
+ since: string;
120
131
  };
121
132
 
122
133
  export type Pose = { id: string; x: number; y: number; rotate: number };
123
134
 
135
+ export declare function isCharacter(value: unknown): value is { id: string; name: string };
136
+ export declare function fillCharacter(character: { id: string; name: string } & Partial<BoardCharacter>): BoardCharacter;
137
+ export declare function sameName(a: string, b: string): boolean;
138
+
124
139
  export type Command =
125
140
  | { type: "set_logline"; logline: string }
126
141
  | { type: "set_rank"; ids: string[]; rank: NoteRank }
@@ -170,7 +185,9 @@ export type Command =
170
185
  | { type: "lock_numbers"; order?: string[] }
171
186
  | { type: "unlock_numbers" }
172
187
  | { type: "start_revision"; name: string; color?: string }
173
- | { type: "end_revision" };
188
+ | { type: "end_revision" }
189
+ | { type: "leave_question"; kind: string; ids: string[]; text: string }
190
+ | { type: "ask_again"; kind: string; ids?: string[] };
174
191
 
175
192
  export type CommandResult = {
176
193
  state: BoardState;