plotcoder-board 0.1.0
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 +99 -0
- package/package.json +57 -0
- package/scripts/plotcoder-call.mjs +141 -0
- package/scripts/plotcoder-http.mjs +114 -0
- package/scripts/plotcoder-mcp-server.mjs +2663 -0
- package/scripts/plotcoder-mcp.mjs +93 -0
- package/src/board/agents.d.ts +14 -0
- package/src/board/agents.js +78 -0
- package/src/board/fdx.d.ts +26 -0
- package/src/board/fdx.js +206 -0
- package/src/board/fountain.d.ts +48 -0
- package/src/board/fountain.js +246 -0
- package/src/board/numbering.d.ts +13 -0
- package/src/board/numbering.js +89 -0
- package/src/board/organize.d.ts +24 -0
- package/src/board/organize.js +151 -0
- package/src/board/paginate.d.ts +51 -0
- package/src/board/paginate.js +375 -0
- package/src/board/project.d.ts +72 -0
- package/src/board/project.js +236 -0
- package/src/board/projectFile.d.ts +33 -0
- package/src/board/projectFile.js +97 -0
- package/src/board/readWall.d.ts +70 -0
- package/src/board/readWall.js +406 -0
- package/src/board/reducer.d.ts +191 -0
- package/src/board/reducer.js +921 -0
- package/src/board/reminders.d.ts +6 -0
- package/src/board/reminders.js +53 -0
- package/src/board/sync.d.ts +65 -0
- package/src/board/sync.js +198 -0
- package/src/board/templates.d.ts +21 -0
- package/src/board/templates.js +110 -0
- package/src/board/words.d.ts +10 -0
- package/src/board/words.js +201 -0
- package/src/board/workflows.d.ts +24 -0
- package/src/board/workflows.js +116 -0
- package/src/board/zip.d.ts +5 -0
- package/src/board/zip.js +134 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Type surface for projectFile.js — the project as a file (R12, R46).
|
|
2
|
+
|
|
3
|
+
import type { BoardState } from "./reducer";
|
|
4
|
+
import type { ProjectRecord } from "./project";
|
|
5
|
+
|
|
6
|
+
export declare const PROJECT_APP: "plotcoder";
|
|
7
|
+
export declare const PROJECT_VERSION: number;
|
|
8
|
+
export declare const PROJECT_PREFIX: string;
|
|
9
|
+
export declare const PROJECT_KEY: string;
|
|
10
|
+
export declare const REMINDERS_KEY: string;
|
|
11
|
+
export declare const boardKey: (id: string) => string;
|
|
12
|
+
|
|
13
|
+
export type ProjectFile = {
|
|
14
|
+
app: "plotcoder";
|
|
15
|
+
version: number;
|
|
16
|
+
exportedAt: string;
|
|
17
|
+
storage: Record<string, string>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type OpenedProject = {
|
|
21
|
+
project: ProjectRecord;
|
|
22
|
+
boards: Record<string, BoardState>;
|
|
23
|
+
reminders: unknown[] | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export declare function isProjectFile(value: unknown): value is ProjectFile;
|
|
27
|
+
export declare function toProjectFile(input: {
|
|
28
|
+
project: ProjectRecord;
|
|
29
|
+
boards: Record<string, BoardState>;
|
|
30
|
+
reminders?: unknown[] | null;
|
|
31
|
+
exportedAt?: string;
|
|
32
|
+
}): ProjectFile;
|
|
33
|
+
export declare function fromProjectFile(value: unknown): OpenedProject | null;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The project as a file (R12, R46): what Save project writes and Open project
|
|
2
|
+
// takes, as a pure module so the browser store and the MCP server read and
|
|
3
|
+
// write the same file. DOM-free.
|
|
4
|
+
//
|
|
5
|
+
// The file is `{ app, version, exportedAt, storage }`, where `storage` is the
|
|
6
|
+
// project's localStorage keys as they were: `plotcoder.project` for the record,
|
|
7
|
+
// `plotcoder.board.<id>` for each board's state, `plotcoder.reminders` for the
|
|
8
|
+
// reminders. Version 1 files (one board under the pre-R35 keys) still open.
|
|
9
|
+
|
|
10
|
+
import { emptyState, isBoardState, normalizeState } from "./reducer.js";
|
|
11
|
+
import { emptyProject, isProjectRecord, normalizeProject } from "./project.js";
|
|
12
|
+
|
|
13
|
+
export const PROJECT_APP = "plotcoder";
|
|
14
|
+
export const PROJECT_VERSION = 2;
|
|
15
|
+
export const PROJECT_PREFIX = "plotcoder.";
|
|
16
|
+
export const PROJECT_KEY = "plotcoder.project";
|
|
17
|
+
export const REMINDERS_KEY = "plotcoder.reminders";
|
|
18
|
+
export const boardKey = (id) => `plotcoder.board.${id}`;
|
|
19
|
+
|
|
20
|
+
const LEGACY = {
|
|
21
|
+
logline: "plotcoder.logline",
|
|
22
|
+
target: "plotcoder.target",
|
|
23
|
+
characters: "plotcoder.characters",
|
|
24
|
+
notes: "plotcoder.notes",
|
|
25
|
+
groups: "plotcoder.groups",
|
|
26
|
+
arrows: "plotcoder.arrows",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Is this the file Save project writes? Shape only; what the keys hold is read by fromProjectFile. */
|
|
30
|
+
export function isProjectFile(value) {
|
|
31
|
+
if (!value || typeof value !== "object") return false;
|
|
32
|
+
const file = value;
|
|
33
|
+
return (
|
|
34
|
+
file.app === PROJECT_APP &&
|
|
35
|
+
typeof file.version === "number" &&
|
|
36
|
+
typeof file.exportedAt === "string" &&
|
|
37
|
+
!!file.storage &&
|
|
38
|
+
typeof file.storage === "object" &&
|
|
39
|
+
!Array.isArray(file.storage) &&
|
|
40
|
+
Object.values(file.storage).every((item) => typeof item === "string")
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The file for a project: its record, each board it names, and the reminders when there are any. */
|
|
45
|
+
export function toProjectFile({ project, boards, reminders = null, exportedAt = new Date().toISOString() }) {
|
|
46
|
+
const record = normalizeProject(project);
|
|
47
|
+
const storage = { [PROJECT_KEY]: JSON.stringify(record) };
|
|
48
|
+
for (const meta of record.boards) {
|
|
49
|
+
const state = boards[meta.id];
|
|
50
|
+
storage[boardKey(meta.id)] = JSON.stringify(isBoardState(state) ? normalizeState(state) : emptyState());
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(reminders) && reminders.length) storage[REMINDERS_KEY] = JSON.stringify(reminders);
|
|
53
|
+
return { app: PROJECT_APP, version: PROJECT_VERSION, exportedAt, storage };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What a file holds: the record and its boards, both through the normalizers,
|
|
58
|
+
* and the reminders. A version 1 file becomes a one-board project. Null when
|
|
59
|
+
* the file is not a project file, or holds neither a record nor a board.
|
|
60
|
+
*/
|
|
61
|
+
export function fromProjectFile(value) {
|
|
62
|
+
if (!isProjectFile(value)) return null;
|
|
63
|
+
const parse = (key) => {
|
|
64
|
+
if (typeof value.storage[key] !== "string") return undefined;
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(value.storage[key]);
|
|
67
|
+
} catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const remindersRaw = parse(REMINDERS_KEY);
|
|
72
|
+
const reminders = Array.isArray(remindersRaw) ? remindersRaw : null;
|
|
73
|
+
const record = parse(PROJECT_KEY);
|
|
74
|
+
if (isProjectRecord(record)) {
|
|
75
|
+
const project = normalizeProject(record);
|
|
76
|
+
const boards = {};
|
|
77
|
+
for (const meta of project.boards) {
|
|
78
|
+
const state = parse(boardKey(meta.id));
|
|
79
|
+
boards[meta.id] = isBoardState(state) ? normalizeState(state) : emptyState();
|
|
80
|
+
}
|
|
81
|
+
return { project, boards, reminders };
|
|
82
|
+
}
|
|
83
|
+
// Version 1: one board under its own keys, with no record around it.
|
|
84
|
+
const notes = parse(LEGACY.notes);
|
|
85
|
+
if (!Array.isArray(notes)) return null;
|
|
86
|
+
const legacy = {
|
|
87
|
+
notes,
|
|
88
|
+
groups: parse(LEGACY.groups) ?? [],
|
|
89
|
+
arrows: parse(LEGACY.arrows) ?? [],
|
|
90
|
+
characters: parse(LEGACY.characters) ?? [],
|
|
91
|
+
logline: typeof value.storage[LEGACY.logline] === "string" ? value.storage[LEGACY.logline] : "",
|
|
92
|
+
targetEighths: parse(LEGACY.target),
|
|
93
|
+
};
|
|
94
|
+
if (!isBoardState(legacy)) return null;
|
|
95
|
+
const project = emptyProject();
|
|
96
|
+
return { project, boards: { [project.activeBoardId]: normalizeState(legacy) }, reminders };
|
|
97
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Type surface for readWall.js — read the wall (R22, first slice).
|
|
2
|
+
|
|
3
|
+
import type { BoardNote, BoardState } from "./reducer";
|
|
4
|
+
|
|
5
|
+
export declare const PLACEHOLDER_HEADLINE: string;
|
|
6
|
+
export declare const PLACEHOLDER_CHANGE: string;
|
|
7
|
+
|
|
8
|
+
export type FindingKind =
|
|
9
|
+
/** No beats are marked, so runs cannot be read. */
|
|
10
|
+
| "unmarked"
|
|
11
|
+
/** One run between beats is out of proportion with the others. */
|
|
12
|
+
| "sag"
|
|
13
|
+
/** Beats back to back, with no scene between them; consecutive pairs are one finding naming the chain. */
|
|
14
|
+
| "empty"
|
|
15
|
+
/** Cards that say no place, once any card has one. */
|
|
16
|
+
| "unplaced"
|
|
17
|
+
/** A card still carries a placeholder headline or has no change line. */
|
|
18
|
+
| "unwritten"
|
|
19
|
+
/** The wall uses arrows, and these cards have none. */
|
|
20
|
+
| "unlinked"
|
|
21
|
+
/** Two headlines read like the same scene. */
|
|
22
|
+
| "duplicate"
|
|
23
|
+
/** A group runs too long to be one sequence. */
|
|
24
|
+
| "sequence"
|
|
25
|
+
/** A character in the roster who is on no card. */
|
|
26
|
+
| "uncast"
|
|
27
|
+
/** A character gone for more than a third of the story between two appearances. */
|
|
28
|
+
| "absent"
|
|
29
|
+
/** A setup arrow whose payoff comes before its setup on the wall. */
|
|
30
|
+
| "backwards"
|
|
31
|
+
/** A card with a folded corner and no setup arrow leaving it. */
|
|
32
|
+
| "unpaid";
|
|
33
|
+
|
|
34
|
+
export type Finding = {
|
|
35
|
+
kind: FindingKind;
|
|
36
|
+
/** Card ids; a group id for "sequence"; a character id (then card ids) for "uncast" and "absent"; an arrow id (then card ids) for "backwards". Empty for "unmarked". */
|
|
37
|
+
ids: string[];
|
|
38
|
+
/** The question, written for a writer. */
|
|
39
|
+
text: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Scene pages strictly between two beats. `from` null = opening, `to` null = closing. */
|
|
43
|
+
export type Run = {
|
|
44
|
+
from: string | null;
|
|
45
|
+
to: string | null;
|
|
46
|
+
eighths: number;
|
|
47
|
+
cards: number;
|
|
48
|
+
/** The scene cards in the run, in reading order. */
|
|
49
|
+
ids: string[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** A setup arrow, with the distance from where it is planted to where it pays off. Negative means backwards. */
|
|
53
|
+
export type Setup = { id: string; from: string; to: string; eighths: number };
|
|
54
|
+
|
|
55
|
+
export type WallReading = {
|
|
56
|
+
/** Every card id in reading order. */
|
|
57
|
+
order: string[];
|
|
58
|
+
beats: Array<{ id: string; headline: string }>;
|
|
59
|
+
runs: Run[];
|
|
60
|
+
setups: Setup[];
|
|
61
|
+
/** Every planted card: the scene that pays it off (first setup arrow, by wall order), or null while unpaid. */
|
|
62
|
+
payoffs: Record<string, string | null>;
|
|
63
|
+
findings: Finding[];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Rows top to bottom, cards left to right within a row. */
|
|
67
|
+
export declare function readingOrder(notes: BoardNote[]): BoardNote[];
|
|
68
|
+
export declare function readWall(state: BoardState): WallReading;
|
|
69
|
+
export declare function describeRuns(reading: WallReading, state: BoardState): string[];
|
|
70
|
+
export declare function describeSetups(reading: WallReading, state: BoardState): string[];
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
// Read the wall (R22, first slice).
|
|
2
|
+
//
|
|
3
|
+
// Step 4 of the method: look at the board and find what is wrong with it. This
|
|
4
|
+
// module reads a BoardState and returns two things — a *reading* (the cards in
|
|
5
|
+
// wall order, the beats, and the runs of pages between them) and a list of
|
|
6
|
+
// *findings*, each written as a question. It never fixes anything and it never
|
|
7
|
+
// says how many beats there should be (D21).
|
|
8
|
+
//
|
|
9
|
+
// Pure and DOM-free like the reducer, so the Reminders modal, the MCP server and
|
|
10
|
+
// the tests all read the same wall the same way.
|
|
11
|
+
//
|
|
12
|
+
// Order: the wall gives the order, and the wall is free (D20), so order here is
|
|
13
|
+
// reading order — rows top to bottom, cards left to right within a row. Arrows
|
|
14
|
+
// do not yet change the order; that is the first refinement to make once this
|
|
15
|
+
// slice has been used.
|
|
16
|
+
|
|
17
|
+
import { boardEighths, EIGHTHS_PER_PAGE, formatPages, NOTE_HEIGHT, noteEighths } from "./reducer.js";
|
|
18
|
+
|
|
19
|
+
/** What create_note writes before a person has. */
|
|
20
|
+
export const PLACEHOLDER_HEADLINE = "New beat";
|
|
21
|
+
export const PLACEHOLDER_CHANGE = "What changes?";
|
|
22
|
+
|
|
23
|
+
// A run more than twice the median run is worth a question. "Sagging" is a
|
|
24
|
+
// claim about proportion (R25), so the threshold is relative, not a page count.
|
|
25
|
+
const SAG_RATIO = 2;
|
|
26
|
+
// Longer than this and a group is probably two sequences wearing one frame.
|
|
27
|
+
const SEQUENCE_MAX_EIGHTHS = 20 * EIGHTHS_PER_PAGE;
|
|
28
|
+
// Two cards whose tops are within half a card of each other share a row.
|
|
29
|
+
const ROW_TOLERANCE = NOTE_HEIGHT / 2;
|
|
30
|
+
// A character gone for more than this share of the story is worth asking about.
|
|
31
|
+
const ABSENCE_FRACTION = 1 / 3;
|
|
32
|
+
// ...and at least this long, so a short wall of one-page estimates does not
|
|
33
|
+
// call every gap a disappearance (round seven, finding 20): ten pages, a reel.
|
|
34
|
+
const ABSENCE_FLOOR_EIGHTHS = 10 * EIGHTHS_PER_PAGE;
|
|
35
|
+
// Headlines this alike are probably the same scene twice. Measured on the
|
|
36
|
+
// content words only — "Tom lies about the job" and "Tom lies about his job"
|
|
37
|
+
// are the same scene — and as overlap with the shorter headline, so a headline
|
|
38
|
+
// that contains another counts.
|
|
39
|
+
const DUPLICATE_OVERLAP = 0.75;
|
|
40
|
+
const FILLER = new Set([
|
|
41
|
+
"a", "an", "the", "and", "or", "of", "to", "in", "on", "at", "for", "with",
|
|
42
|
+
"about", "into", "from", "by", "his", "her", "hers", "its", "their", "is", "it",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Cards in reading order: banded into rows by y, then left to right. A free
|
|
47
|
+
* wall has no rows, so this is the order a person's eye takes across it.
|
|
48
|
+
*/
|
|
49
|
+
export function readingOrder(notes) {
|
|
50
|
+
const byTop = [...notes].sort((a, b) => a.y - b.y || a.x - b.x);
|
|
51
|
+
const rows = [];
|
|
52
|
+
let row = null;
|
|
53
|
+
for (const note of byTop) {
|
|
54
|
+
if (row && note.y - row.top <= ROW_TOLERANCE) {
|
|
55
|
+
row.notes.push(note);
|
|
56
|
+
} else {
|
|
57
|
+
row = { top: note.y, notes: [note] };
|
|
58
|
+
rows.push(row);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return rows.flatMap((band) => band.notes.sort((a, b) => a.x - b.x || a.y - b.y));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function words(text) {
|
|
65
|
+
return (text ?? "")
|
|
66
|
+
.toLowerCase()
|
|
67
|
+
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
|
68
|
+
.split(/\s+/)
|
|
69
|
+
.filter((word) => word && !FILLER.has(word));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sameScene(a, b) {
|
|
73
|
+
const wa = words(a);
|
|
74
|
+
const wb = words(b);
|
|
75
|
+
if (wa.length === 0 || wb.length === 0) return false;
|
|
76
|
+
if (wa.join(" ") === wb.join(" ")) return true;
|
|
77
|
+
if (wa.length < 2 || wb.length < 2) return false;
|
|
78
|
+
const setA = new Set(wa);
|
|
79
|
+
const setB = new Set(wb);
|
|
80
|
+
let shared = 0;
|
|
81
|
+
for (const word of setA) if (setB.has(word)) shared += 1;
|
|
82
|
+
return shared / Math.min(setA.size, setB.size) >= DUPLICATE_OVERLAP;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function median(values) {
|
|
86
|
+
if (values.length === 0) return 0;
|
|
87
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
88
|
+
const mid = Math.floor(sorted.length / 2);
|
|
89
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Small counts as words, the way the sheet reads them. */
|
|
93
|
+
function countWord(n) {
|
|
94
|
+
return ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"][n] ?? String(n);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function pages(eighths) {
|
|
98
|
+
return formatPages(Math.round(eighths));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function quote(note) {
|
|
102
|
+
return `"${note.headline}"`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function list(notes) {
|
|
106
|
+
return notes.map(quote).join(", ");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read the board. Returns the reading and the findings; see readWall.d.ts for
|
|
111
|
+
* the shape. Never mutates the state.
|
|
112
|
+
*/
|
|
113
|
+
export function readWall(state) {
|
|
114
|
+
const order = readingOrder(state.notes);
|
|
115
|
+
const beats = order.filter((note) => note.rank === "beat");
|
|
116
|
+
|
|
117
|
+
// Runs: the scene pages strictly between consecutive beats, plus the opening
|
|
118
|
+
// run before the first beat and the closing run after the last. A beat's own
|
|
119
|
+
// pages belong to no run — "between" means between.
|
|
120
|
+
const runs = [];
|
|
121
|
+
let from = null;
|
|
122
|
+
let eighths = 0;
|
|
123
|
+
let cards = 0;
|
|
124
|
+
let ids = [];
|
|
125
|
+
for (const note of order) {
|
|
126
|
+
if (note.rank === "beat") {
|
|
127
|
+
if (from !== null || cards > 0) runs.push({ from, to: note.id, eighths, cards, ids });
|
|
128
|
+
from = note.id;
|
|
129
|
+
eighths = 0;
|
|
130
|
+
cards = 0;
|
|
131
|
+
ids = [];
|
|
132
|
+
} else {
|
|
133
|
+
eighths += noteEighths(note);
|
|
134
|
+
cards += 1;
|
|
135
|
+
ids.push(note.id);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (from !== null && cards > 0) runs.push({ from, to: null, eighths, cards, ids });
|
|
139
|
+
|
|
140
|
+
const findings = [];
|
|
141
|
+
const byId = new Map(state.notes.map((note) => [note.id, note]));
|
|
142
|
+
const headline = (id) => (id ? byId.get(id)?.headline ?? id : null);
|
|
143
|
+
const position = new Map(order.map((note, index) => [note.id, index]));
|
|
144
|
+
|
|
145
|
+
// Setups (R30): where each one is planted and paid off, in pages.
|
|
146
|
+
const startAt = new Map();
|
|
147
|
+
let offset = 0;
|
|
148
|
+
for (const note of order) {
|
|
149
|
+
startAt.set(note.id, offset);
|
|
150
|
+
offset += noteEighths(note);
|
|
151
|
+
}
|
|
152
|
+
const setups = state.arrows
|
|
153
|
+
.filter((arrow) => arrow.kind === "setup" && byId.has(arrow.from) && byId.has(arrow.to))
|
|
154
|
+
.map((arrow) => ({
|
|
155
|
+
id: arrow.id,
|
|
156
|
+
from: arrow.from,
|
|
157
|
+
to: arrow.to,
|
|
158
|
+
eighths: startAt.get(arrow.to) - startAt.get(arrow.from),
|
|
159
|
+
}));
|
|
160
|
+
|
|
161
|
+
// Step 2 has not been done, so step 4 cannot read runs. A fact, not a nudge.
|
|
162
|
+
if (state.notes.length > 0 && beats.length === 0) {
|
|
163
|
+
findings.push({
|
|
164
|
+
kind: "unmarked",
|
|
165
|
+
ids: [],
|
|
166
|
+
text: "No card is marked as a beat, so the runs between turns cannot be read yet.",
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The sag detector (R25): one run out of proportion with the others.
|
|
171
|
+
const between = runs.filter((run) => run.from !== null && run.to !== null);
|
|
172
|
+
if (between.length >= 2) {
|
|
173
|
+
const typical = median(between.map((run) => run.eighths));
|
|
174
|
+
const longest = between.reduce((top, run) => (run.eighths > top.eighths ? run : top));
|
|
175
|
+
if (typical > 0 && longest.eighths > SAG_RATIO * typical) {
|
|
176
|
+
findings.push({
|
|
177
|
+
kind: "sag",
|
|
178
|
+
ids: [longest.from, longest.to],
|
|
179
|
+
text: `About ${pages(longest.eighths)} pages run between "${headline(longest.from)}" and "${headline(longest.to)}"; the middle run here is about ${pages(typical)} (a beat's own pages are in no run). Is something sagging there, or is it one long set piece?`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Beats back to back: no scene between two turns. A question, not a
|
|
185
|
+
// verdict — they may be one beat, or a scene may be missing. Consecutive
|
|
186
|
+
// empty runs are one question naming the chain, not one per pair: a pilot
|
|
187
|
+
// whose turns come thick at the end would otherwise ask the same sentence
|
|
188
|
+
// six times (round four, finding 19).
|
|
189
|
+
let chain = [];
|
|
190
|
+
const askChain = () => {
|
|
191
|
+
if (chain.length === 0) return;
|
|
192
|
+
const ids = [chain[0].from, ...chain.map((run) => run.to)];
|
|
193
|
+
const names = ids.map((id) => `"${headline(id)}"`);
|
|
194
|
+
findings.push({
|
|
195
|
+
kind: "empty",
|
|
196
|
+
ids,
|
|
197
|
+
text:
|
|
198
|
+
ids.length === 2
|
|
199
|
+
? `Nothing runs between ${names[0]} and ${names[1]}: two turns back to back. Are they one beat, or is a scene missing?`
|
|
200
|
+
: `Nothing runs between ${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}: ${countWord(ids.length)} turns back to back. Are some of them one beat, or are scenes missing between them?`,
|
|
201
|
+
});
|
|
202
|
+
chain = [];
|
|
203
|
+
};
|
|
204
|
+
for (const run of between) {
|
|
205
|
+
const continues = chain.length > 0 && chain[chain.length - 1].to === run.from;
|
|
206
|
+
if (run.cards !== 0 || !continues) askChain();
|
|
207
|
+
if (run.cards === 0) chain.push(run);
|
|
208
|
+
}
|
|
209
|
+
askChain();
|
|
210
|
+
|
|
211
|
+
// A card that says no place, once the writer has started placing cards.
|
|
212
|
+
// One question however many there are; a wall with no places at all is a
|
|
213
|
+
// wall the writer has not placed yet, and is not asked.
|
|
214
|
+
const unplaced = order.filter((note) => !(note.location ?? "").trim());
|
|
215
|
+
if (unplaced.length > 0 && unplaced.length < order.length) {
|
|
216
|
+
findings.push({
|
|
217
|
+
kind: "unplaced",
|
|
218
|
+
ids: unplaced.map((note) => note.id),
|
|
219
|
+
text:
|
|
220
|
+
unplaced.length === 1
|
|
221
|
+
? `${quote(unplaced[0])} says no place. Where does it happen?`
|
|
222
|
+
: `${unplaced.length} cards say no place: ${unplaced.map(quote).join(", ")}. Where do they happen?`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// A card that has not earned its place yet.
|
|
227
|
+
for (const note of order) {
|
|
228
|
+
const change = (note.change ?? "").trim();
|
|
229
|
+
const title = (note.headline ?? "").trim();
|
|
230
|
+
if (title === "" || title === PLACEHOLDER_HEADLINE) {
|
|
231
|
+
findings.push({
|
|
232
|
+
kind: "unwritten",
|
|
233
|
+
ids: [note.id],
|
|
234
|
+
text: `A card still reads ${quote({ headline: title || PLACEHOLDER_HEADLINE })}. What scene is it?`,
|
|
235
|
+
});
|
|
236
|
+
} else if (change === "" || change === PLACEHOLDER_CHANGE) {
|
|
237
|
+
findings.push({
|
|
238
|
+
kind: "unwritten",
|
|
239
|
+
ids: [note.id],
|
|
240
|
+
text: `${quote(note)} has no change line. What is different when it ends?`,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Only once the wall uses arrows does a card without one mean anything.
|
|
246
|
+
const linked = new Set();
|
|
247
|
+
for (const arrow of state.arrows) {
|
|
248
|
+
linked.add(arrow.from);
|
|
249
|
+
linked.add(arrow.to);
|
|
250
|
+
}
|
|
251
|
+
if (state.notes.length > 0 && linked.size * 2 >= state.notes.length) {
|
|
252
|
+
const loose = order.filter((note) => !linked.has(note.id));
|
|
253
|
+
if (loose.length > 0) {
|
|
254
|
+
findings.push({
|
|
255
|
+
kind: "unlinked",
|
|
256
|
+
ids: loose.map((note) => note.id),
|
|
257
|
+
text: `${loose.length === 1 ? "One card has" : `${loose.length} cards have`} no arrow in or out: ${list(loose)}. What sets ${loose.length === 1 ? "it" : "them"} up, and what ${loose.length === 1 ? "does it" : "do they"} pay off?`,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Two cards doing the same job.
|
|
263
|
+
for (let i = 0; i < order.length; i += 1) {
|
|
264
|
+
for (let j = i + 1; j < order.length; j += 1) {
|
|
265
|
+
const a = order[i];
|
|
266
|
+
const b = order[j];
|
|
267
|
+
if (sameScene(a.headline, b.headline)) {
|
|
268
|
+
findings.push({
|
|
269
|
+
kind: "duplicate",
|
|
270
|
+
ids: [a.id, b.id],
|
|
271
|
+
text: `${quote(a)} and ${quote(b)} read like the same scene. Are they doing the same job?`,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// A payoff that lands before its setup. The wall gives the order (D20), so
|
|
278
|
+
// if the arrow and the wall disagree, one of them is wrong — ask which.
|
|
279
|
+
for (const setup of setups) {
|
|
280
|
+
if (position.get(setup.to) < position.get(setup.from)) {
|
|
281
|
+
findings.push({
|
|
282
|
+
kind: "backwards",
|
|
283
|
+
ids: [setup.id, setup.from, setup.to],
|
|
284
|
+
text: `${quote(byId.get(setup.from))} sets up ${quote(byId.get(setup.to))}, but on the wall the payoff comes first. Which order do you mean?`,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// A folded corner nothing has paid off (R31). The fold says "this plants
|
|
290
|
+
// something"; a setup arrow leaving the card is the payoff. Until one does,
|
|
291
|
+
// the debt is open.
|
|
292
|
+
// Which scene pays each plant off: the first setup arrow leaving the card,
|
|
293
|
+
// by wall order of its head. Every planted card is here, paid or not
|
|
294
|
+
// (null), so a card can say its state.
|
|
295
|
+
const paysOff = new Set(
|
|
296
|
+
state.arrows.filter((arrow) => arrow.kind === "setup").map((arrow) => arrow.from),
|
|
297
|
+
);
|
|
298
|
+
const wallIndex = new Map(order.map((note, index) => [note.id, index]));
|
|
299
|
+
const payoffs = {};
|
|
300
|
+
for (const note of order) {
|
|
301
|
+
if (!note.plants) continue;
|
|
302
|
+
const heads = state.arrows
|
|
303
|
+
.filter((arrow) => arrow.kind === "setup" && arrow.from === note.id)
|
|
304
|
+
.map((arrow) => arrow.to)
|
|
305
|
+
.sort((a, b) => (wallIndex.get(a) ?? Infinity) - (wallIndex.get(b) ?? Infinity));
|
|
306
|
+
payoffs[note.id] = heads[0] ?? null;
|
|
307
|
+
}
|
|
308
|
+
for (const note of order) {
|
|
309
|
+
if (note.plants && !paysOff.has(note.id)) {
|
|
310
|
+
findings.push({
|
|
311
|
+
kind: "unpaid",
|
|
312
|
+
ids: [note.id],
|
|
313
|
+
text: `${quote(note)} plants something, and no arrow pays it off. Where does it come back?`,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// The cast (R29): someone who vanishes for a stretch, or never appears.
|
|
319
|
+
const total = boardEighths(state);
|
|
320
|
+
const at = new Map();
|
|
321
|
+
let cursor = 0;
|
|
322
|
+
for (const note of order) {
|
|
323
|
+
at.set(note.id, cursor);
|
|
324
|
+
cursor += noteEighths(note);
|
|
325
|
+
}
|
|
326
|
+
for (const character of state.characters ?? []) {
|
|
327
|
+
const scenes = order.filter((note) => note.characterIds?.includes(character.id));
|
|
328
|
+
if (scenes.length === 0) {
|
|
329
|
+
findings.push({
|
|
330
|
+
kind: "uncast",
|
|
331
|
+
ids: [character.id],
|
|
332
|
+
text: `${character.name} is in the cast but on no card. Where do they come in?`,
|
|
333
|
+
});
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
let longest = null;
|
|
337
|
+
for (let i = 1; i < scenes.length; i += 1) {
|
|
338
|
+
const prev = scenes[i - 1];
|
|
339
|
+
const next = scenes[i];
|
|
340
|
+
const gap = at.get(next.id) - (at.get(prev.id) + noteEighths(prev));
|
|
341
|
+
if (!longest || gap > longest.gap) longest = { gap, from: prev, to: next };
|
|
342
|
+
}
|
|
343
|
+
if (longest && total > 0 && longest.gap > total * ABSENCE_FRACTION && longest.gap >= ABSENCE_FLOOR_EIGHTHS) {
|
|
344
|
+
findings.push({
|
|
345
|
+
kind: "absent",
|
|
346
|
+
ids: [character.id, longest.from.id, longest.to.id],
|
|
347
|
+
text: `${character.name} is in ${quote(longest.from)} and then not again until ${quote(longest.to)}, about ${pages(longest.gap)} pages later. Where are they in between?`,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// A frame too big to be one sequence. A group titled as an act ("Act two")
|
|
353
|
+
// is an act, not a sequence, and is not asked about.
|
|
354
|
+
for (const group of state.groups) {
|
|
355
|
+
if (/^act\b/i.test((group.title ?? "").trim())) continue;
|
|
356
|
+
const members = group.noteIds.map((id) => byId.get(id)).filter(Boolean);
|
|
357
|
+
const total = members.reduce((sum, note) => sum + noteEighths(note), 0);
|
|
358
|
+
if (total > SEQUENCE_MAX_EIGHTHS) {
|
|
359
|
+
findings.push({
|
|
360
|
+
kind: "sequence",
|
|
361
|
+
ids: [group.id],
|
|
362
|
+
text: `"${group.title}" runs about ${pages(total)} pages across ${members.length} cards. Is it one sequence or two?`,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
order: order.map((note) => note.id),
|
|
369
|
+
beats: beats.map((note) => ({ id: note.id, headline: note.headline })),
|
|
370
|
+
runs,
|
|
371
|
+
setups,
|
|
372
|
+
payoffs,
|
|
373
|
+
findings,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** The setups as prose lines: what plants what, and how far apart. */
|
|
378
|
+
export function describeSetups(reading, state) {
|
|
379
|
+
const byId = new Map(state.notes.map((note) => [note.id, note]));
|
|
380
|
+
const name = (id) => byId.get(id)?.headline ?? id;
|
|
381
|
+
return reading.setups.map((setup) => {
|
|
382
|
+
const distance =
|
|
383
|
+
setup.eighths > 0
|
|
384
|
+
? `about ${pages(setup.eighths)} pages later`
|
|
385
|
+
: setup.eighths === 0
|
|
386
|
+
? "in the same place on the wall"
|
|
387
|
+
: `about ${pages(-setup.eighths)} pages earlier`;
|
|
388
|
+
return `"${name(setup.from)}" sets up "${name(setup.to)}", ${distance}`;
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** The reading as prose lines, shared by the modal and the MCP tool. */
|
|
393
|
+
export function describeRuns(reading, state) {
|
|
394
|
+
const byId = new Map(state.notes.map((note) => [note.id, note]));
|
|
395
|
+
const name = (id) => byId.get(id)?.headline ?? id;
|
|
396
|
+
return reading.runs.map((run) => {
|
|
397
|
+
const span =
|
|
398
|
+
run.from === null
|
|
399
|
+
? `Before "${name(run.to)}"`
|
|
400
|
+
: run.to === null
|
|
401
|
+
? `After "${name(run.from)}"`
|
|
402
|
+
: `"${name(run.from)}" → "${name(run.to)}"`;
|
|
403
|
+
const count = run.cards === 1 ? "1 card" : `${run.cards} cards`;
|
|
404
|
+
return `${span}: about ${pages(run.eighths)} pages, ${count}`;
|
|
405
|
+
});
|
|
406
|
+
}
|