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.
- package/README.md +5 -5
- package/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +368 -118
- package/src/board/compareStructure.d.ts +32 -0
- package/src/board/compareStructure.js +85 -0
- package/src/board/markdown.d.ts +15 -0
- package/src/board/markdown.js +177 -0
- package/src/board/project.d.ts +19 -1
- package/src/board/project.js +134 -2
- package/src/board/readWall.d.ts +10 -1
- package/src/board/readWall.js +26 -2
- package/src/board/reducer.d.ts +18 -1
- package/src/board/reducer.js +50 -4
|
@@ -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
|
+
}
|
package/src/board/project.d.ts
CHANGED
|
@@ -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 };
|
package/src/board/project.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/board/readWall.d.ts
CHANGED
|
@@ -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(
|
|
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[];
|
package/src/board/readWall.js
CHANGED
|
@@ -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]));
|
package/src/board/reducer.d.ts
CHANGED
|
@@ -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;
|