plotcoder-board 0.1.32 → 0.1.34
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 +4 -3
- package/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +47 -23
- package/src/board/readWall.d.ts +1 -1
- package/src/board/readWall.js +30 -72
- package/src/board/reducer.js +80 -4
- package/src/board/words.js +1 -1
- package/src/board/workflows.js +1 -0
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ An agent should call the tools, never fake mouse drags. The skill in `.cursor/sk
|
|
|
51
51
|
A **blind run** is a fresh agent given the on-ramp and a treatment and nothing
|
|
52
52
|
else, asked to build a wall and to keep a log of everything that made the job
|
|
53
53
|
harder than it should have been. The friction log is the product; the wall is
|
|
54
|
-
just what produces it.
|
|
54
|
+
just what produces it. Twenty rounds have been run, the first three through a
|
|
55
55
|
repo checkout and the rest through the account door; every finding from the
|
|
56
56
|
first seventeen is fixed or decided. Round seventeen, the first to start from
|
|
57
57
|
a page of notes instead of a treatment, found the wall has no way to hold a
|
|
@@ -59,7 +59,8 @@ maybe — the open card, R59, built from it; round eighteen confirmed that a
|
|
|
59
59
|
thread is not a fold — the thread, R60, built from it, a named string through
|
|
60
60
|
cards with either end open until the writer ties it; round nineteen measured
|
|
61
61
|
the thread and found the fold beside it cannot say which of two things a
|
|
62
|
-
scene plants
|
|
62
|
+
scene plants — the open field, R61, and the fold's words, R62, built from it,
|
|
63
|
+
and round twenty measured both. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
|
|
63
64
|
the table of rounds, and the next round's prompt with the test account filled in.
|
|
64
65
|
|
|
65
66
|
A round works a **test account** — a throwaway marked on its writer row, and the
|
|
@@ -104,4 +105,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
|
|
|
104
105
|
|
|
105
106
|
## Status
|
|
106
107
|
|
|
107
|
-
Version 0.1.
|
|
108
|
+
Version 0.1.34. A project of boards; sign in with your email and a password from the PlotCoder mark and your projects follow you to every device, share one with another writer by email and write it together live, or stay signed out and work on this device as before. Pages sit beside the wall: a scene's text lives on its card, measures it, paginates to the industry's rules, prints, goes out and comes in as Fountain or Final Draft, and goes out as Markdown or plain text for a collaborator in Google Docs. It installs as a progressive web app and opens offline; plotcoder.com serves over HTTPS. The wall, beats, card length, groups, arrows, pan and zoom, save and open, and the agent surface are in use.
|
package/package.json
CHANGED
|
@@ -995,16 +995,16 @@ const CHECK_WORDS = {
|
|
|
995
995
|
};
|
|
996
996
|
/** What an open card would be asked once closed, as the question and not the check's clean form (round nineteen, entry 31). */
|
|
997
997
|
const ASK_WORDS = {
|
|
998
|
-
unwritten: "its change line",
|
|
999
|
-
unlinked: "what comes before and after it",
|
|
1000
|
-
duplicate: "which of two alike headlines it is",
|
|
998
|
+
unwritten: "its change line (update_note)",
|
|
999
|
+
unlinked: "what comes before and after it (move_scene, or create_arrow)",
|
|
1000
|
+
duplicate: "which of two alike headlines it is (update_note)",
|
|
1001
1001
|
sequence: "whether its group is one sequence",
|
|
1002
|
-
nobody: "who is in it",
|
|
1002
|
+
nobody: "who is in it (cast)",
|
|
1003
1003
|
absent: "who has been gone too long",
|
|
1004
1004
|
backwards: "why its payoff comes before its setup",
|
|
1005
|
-
unpaid: "where its fold pays off",
|
|
1006
|
-
unplanted: "what plants its payoff",
|
|
1007
|
-
unplaced: "where it happens",
|
|
1005
|
+
unpaid: "where its fold pays off (create_arrow, kind setup)",
|
|
1006
|
+
unplanted: "what plants its payoff (set_plant on the card that does)",
|
|
1007
|
+
unplaced: "where it happens (set_location)",
|
|
1008
1008
|
};
|
|
1009
1009
|
const SAMPLE_NOTE = "sample: this is the wall PlotCoder starts with (Maya, Tom, the letter); nothing here is the writer's. Replace it, or new_board.";
|
|
1010
1010
|
|
|
@@ -1143,7 +1143,7 @@ function summarize(state) {
|
|
|
1143
1143
|
`beats: ${beats}, scenes: ${scenes}`,
|
|
1144
1144
|
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
1145
1145
|
? `runtime: about ${formatPages(runtime)} pages (an estimate from the cards; a page runs about a minute); no target set — set_target for a pilot (60) or a half-hour (30); against the feature default of 120 it would be ${formatPages(-over)} under${runtimeKinds(state)}`
|
|
1146
|
-
: `runtime: about ${formatPages(runtime)} pages of the ${formatPages(state.targetEighths)}-page target the writer set (set_target changes it) — ${over > 0 ? `${formatPages(over)} over` : over < 0 ? `${formatPages(-over)} under` : "on it"} (an estimate from the cards; a page runs about a minute)${runtimeKinds(state)}`,
|
|
1146
|
+
: `runtime: about ${formatPages(runtime)} pages of the ${formatPages(state.targetEighths)}-page target the writer set (set_target changes it) — ${over > 0 ? `${formatPages(over)} over` : over < 0 ? `${formatPages(-over)} under` : "on it"} (an estimate from the cards; a page runs about a minute; page_count is the script so far)${runtimeKinds(state)}`,
|
|
1147
1147
|
`notes: ${state.notes.length}, groups: ${state.groups.length}, arrows: ${state.arrows.length}, cast: ${state.characters.length}`,
|
|
1148
1148
|
"cards (in story order — the follows arrows over the rows; each with its id):",
|
|
1149
1149
|
notes || " (no cards)",
|
|
@@ -1344,7 +1344,8 @@ server.registerTool(
|
|
|
1344
1344
|
rank: rankSchema.optional(),
|
|
1345
1345
|
pages: pagesSchema.optional(),
|
|
1346
1346
|
plants: z.boolean().optional(),
|
|
1347
|
-
|
|
1347
|
+
plantsWhat: z.string().optional(),
|
|
1348
|
+
after: z.string().optional().describe("Wire the new scene into the story after this card (id or headline): one call, one number under a lock. On a wall with no follows arrows yet this draws the first, so a wall can be built in order from its second card."),
|
|
1348
1349
|
before: z.string().optional().describe("Or before this card (id or headline)."),
|
|
1349
1350
|
location: z.string().optional(),
|
|
1350
1351
|
when: z.string().optional().describe('When the scene happens, as the writer says it — "night", "day four, dawn" — printed after the place on the scene heading.'),
|
|
@@ -1578,7 +1579,21 @@ server.registerTool(
|
|
|
1578
1579
|
...reading.openFields.filter((field) => field.field === "logline").map((field) => ` - the logline — ${field.words}`),
|
|
1579
1580
|
...(projectForRead.premiseOpen ? [` - the premise — ${projectForRead.premiseOpen}`] : []),
|
|
1580
1581
|
...(readBoardMeta?.nameOpen ? [` - this board's name — ${readBoardMeta.nameOpen}`] : []),
|
|
1581
|
-
|
|
1582
|
+
// Whens left open in the same words are one line, not one per card (round twenty, entry 22).
|
|
1583
|
+
...(() => {
|
|
1584
|
+
const byWords = new Map();
|
|
1585
|
+
for (const field of reading.openFields.filter((item) => item.field === "when")) {
|
|
1586
|
+
const key = field.words.toLowerCase();
|
|
1587
|
+
if (!byWords.has(key)) byWords.set(key, { words: field.words, ids: [] });
|
|
1588
|
+
byWords.get(key).ids.push(field.id);
|
|
1589
|
+
}
|
|
1590
|
+
const name = (id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`;
|
|
1591
|
+
return [...byWords.values()].map((group) =>
|
|
1592
|
+
group.ids.length === 1
|
|
1593
|
+
? ` - ${name(group.ids[0])} — when: ${group.words}`
|
|
1594
|
+
: ` - when, on ${group.ids.length} cards${group.ids.length === state.notes.length ? " (every card)" : ""} — ${group.words}${group.ids.length === state.notes.length ? "" : `: ${group.ids.map(name).join(", ")}`}`,
|
|
1595
|
+
);
|
|
1596
|
+
})(),
|
|
1582
1597
|
];
|
|
1583
1598
|
const lines = [
|
|
1584
1599
|
`PlotCoder wall (${door(live, base)})`,
|
|
@@ -1589,7 +1604,7 @@ server.registerTool(
|
|
|
1589
1604
|
"the cast and the places are list_board's, not the reading's",
|
|
1590
1605
|
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
1591
1606
|
? `runtime: about ${formatPages(boardEighths(state))} pages (${whose || "no cards"}); no target set (set_target)`
|
|
1592
|
-
: `runtime: about ${formatPages(boardEighths(state))} pages of the ${formatPages(state.targetEighths)}-page target the writer set (set_target changes it) — ${boardEighths(state) > state.targetEighths ? `${formatPages(boardEighths(state) - state.targetEighths)} over` : boardEighths(state) < state.targetEighths ? `${formatPages(state.targetEighths - boardEighths(state))} under` : "on it"}
|
|
1607
|
+
: `runtime: about ${formatPages(boardEighths(state))} pages of the ${formatPages(state.targetEighths)}-page target the writer set (set_target changes it) — ${boardEighths(state) > state.targetEighths ? `${formatPages(boardEighths(state) - state.targetEighths)} over` : boardEighths(state) < state.targetEighths ? `${formatPages(state.targetEighths - boardEighths(state))} under` : "on it"} (${whose || "no cards"}; page_count is the script so far)`,
|
|
1593
1608
|
`groups: ${
|
|
1594
1609
|
state.groups.length
|
|
1595
1610
|
? state.groups
|
|
@@ -1621,7 +1636,7 @@ server.registerTool(
|
|
|
1621
1636
|
? ["open, by the writer's word (listed, not asked about while the words stand; set_open with \"\" closes a card, the field's own tool with open \"\" a field):", ...openFieldLines, ...reading.open.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" — ${item.words}${item.hides.length ? ` (closed, it would be asked ${item.hides.map((kind) => ASK_WORDS[kind] ?? CHECK_WORDS[kind] ?? kind).join("; ")})` : ""}`)]
|
|
1622
1637
|
: []),
|
|
1623
1638
|
...(reading.threads.length
|
|
1624
|
-
? ["threads (the writer's strings through the story; a loose end is asked about below):", ...reading.threads.map((thread) => ` - "${thread.name}": ${thread.ids.length ? thread.ids.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(" → ") : "no card yet"}${thread.startOpen ? " — starts nowhere yet" : ""}${thread.endOpen ? " — ends nowhere yet" : ""}`)]
|
|
1639
|
+
? ["threads (the writer's strings through the story; a loose end is asked about below):", ...reading.threads.map((thread) => ` - "${thread.name}": ${thread.ids.length ? thread.ids.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(" → ") : "no card yet"}${thread.startOpen ? " — starts nowhere yet" : ""}${thread.endOpen ? " — ends nowhere yet" : ""}${!thread.startOpen && !thread.endOpen && thread.ids.length >= 2 ? ` — both ends tied, about ${formatPages(thread.apart)} pages apart` : ""}`)]
|
|
1625
1640
|
: []),
|
|
1626
1641
|
"questions the wall raises (each stands on every reading until the wall changes to answer it, or the writer leaves it — leave_question, with their reason):",
|
|
1627
1642
|
...(reading.findings.length
|
|
@@ -2024,11 +2039,15 @@ server.registerTool(
|
|
|
2024
2039
|
async () => {
|
|
2025
2040
|
const { project } = await readProject();
|
|
2026
2041
|
const own = project.structures ?? [];
|
|
2042
|
+
// Pages beside the percentages, against the open board's target, so the two readings agree without arithmetic (round twenty, entry 57).
|
|
2043
|
+
const { state } = await readBoard();
|
|
2044
|
+
const target = state?.targetEighths ?? DEFAULT_TARGET_EIGHTHS;
|
|
2045
|
+
const at = (beat) => `${beat.name} at ${Math.round(beat.at * 100)}% (p. ${Math.floor((beat.at * target) / EIGHTHS_PER_PAGE) + 1})`;
|
|
2027
2046
|
const lines = [
|
|
2028
2047
|
`the writer's own: ${own.length}`,
|
|
2029
|
-
...own.map((structure) => ` - ${structure.id} — "${structure.name}" (${structure.beats.length} beats: ${structure.beats.map(
|
|
2048
|
+
...own.map((structure) => ` - ${structure.id} — "${structure.name}" (${structure.beats.length} beats: ${structure.beats.map(at).join(", ")})`),
|
|
2030
2049
|
`built in: ${TEMPLATES.length}`,
|
|
2031
|
-
...TEMPLATES.map((template) => ` - ${template.id} — "${template.name}" (${template.beats.length} beats: ${template.beats.map(
|
|
2050
|
+
...TEMPLATES.map((template) => ` - ${template.id} — "${template.name}" (${template.beats.length} beats: ${template.beats.map(at).join(", ")})`),
|
|
2032
2051
|
"compare_structure sets one of these beside this wall's beats, page by page, and lays nothing",
|
|
2033
2052
|
];
|
|
2034
2053
|
return ok(lines.join("\n"), { builtIn: TEMPLATES.map((template) => ({ id: template.id, name: template.name, beats: template.beats })), own });
|
|
@@ -2057,10 +2076,11 @@ server.registerTool(
|
|
|
2057
2076
|
const allMeasured = state.notes.length > 0 && state.notes.every((note) => isMeasured(note));
|
|
2058
2077
|
const short = state.targetEighths > 0 && boardEighths(state) * 2 < state.targetEighths;
|
|
2059
2078
|
const lines = [
|
|
2060
|
-
`"${chosen.name}" beside this wall's ${beats} beat${beats === 1 ? "" : "s"}, of ${formatPages(state.targetEighths)} pages (the story so far runs to p. ${comparison.soFar}, the page its last card ends on — read_wall counts the same cards as about ${formatPages(boardEighths(state))} pages${allMeasured ? ", measured" : ", an estimate: unsized cards read as a page each"}); a match is the nearest of the wall's beats within ${MATCH_PAGES} pages, one to one and in order:`,
|
|
2079
|
+
`"${chosen.name}" beside this wall's ${beats} beat${beats === 1 ? "" : "s"}, of ${formatPages(state.targetEighths)} pages (the story so far runs to p. ${comparison.soFar}, the page its last card ends on — read_wall counts the same cards as about ${formatPages(boardEighths(state))} pages, and page_count the script so far${allMeasured ? ", measured" : ", an estimate: unsized cards read as a page each"}); a match is the nearest of the wall's beats within ${MATCH_PAGES} pages, one to one and in order:`,
|
|
2061
2080
|
// On a wall under half its target the pairing is arithmetic; say so before the rows, not after them (round eighteen, entry 52).
|
|
2062
2081
|
...(short ? [`the wall runs to less than half its target, so its beats sit early and the ${MATCH_PAGES}-page window pairs them with the structure's first beats by arithmetic; the pairing says more once the cards are sized or written, and whether a turn is missing is the writer's call, not this reading's`] : []),
|
|
2063
|
-
|
|
2082
|
+
// On a thin wall a match is arithmetic, and the row says so where it says "here" (round twenty, entry 54).
|
|
2083
|
+
...describeComparison(comparison).map((line) => ` - ${short ? line.replace(/\bhere\b/, "here, by arithmetic") : line}`),
|
|
2064
2084
|
comparison.unmatched.length
|
|
2065
2085
|
? `beats of the wall no beat of the structure answers: ${comparison.unmatched.map((beat) => `"${beat.headline}" (p. ${beat.page})`).join(", ")}`
|
|
2066
2086
|
: "every beat of the wall answers one of the structure's",
|
|
@@ -2726,7 +2746,7 @@ server.registerTool(
|
|
|
2726
2746
|
{
|
|
2727
2747
|
title: "Set when scenes happen",
|
|
2728
2748
|
description:
|
|
2729
|
-
"When one or more scenes happen, as the writer would say it — \"night\", \"day four, dawn\", \"the next morning\" — on the card beside its place, and printed after the place on every scene heading: THE PIER AT FENIT - NIGHT. Free text, the writer's phrase; an empty string clears it. Or leave the when open: pass open with the writer's words for why it is not decided — \"after the
|
|
2749
|
+
"When one or more scenes happen, as the writer would say it — \"night\", \"day four, dawn\", \"the next morning\" — on the card beside its place, and printed after the place on every scene heading: THE PIER AT FENIT - NIGHT. Free text, the writer's phrase; an empty string clears it. Or leave the when open: pass open with the writer's words for why it is not decided — \"after the party; which night\" — and the reading lists it under open, by the writer's word, while the card's other questions still stand; a when decides it, open \"\" leaves it blank. This is where a scene's day and time live, not the headline, so the duplicate check never reads a day as a scene's words. create_note and update_note take when too; list_board shows it as when: …",
|
|
2730
2750
|
inputSchema: { ids: z.array(z.string()).min(1), when: z.string().optional(), open: z.string().optional() },
|
|
2731
2751
|
},
|
|
2732
2752
|
async (args) => {
|
|
@@ -2995,7 +3015,10 @@ server.registerTool(
|
|
|
2995
3015
|
const lines = [
|
|
2996
3016
|
`PlotCoder cast (${door(live, base)})`,
|
|
2997
3017
|
`${person.name} (${person.id}) — on ${total} card${total === 1 ? "" : "s"} across ${project.boards.length} board${project.boards.length === 1 ? "" : "s"} of the project`,
|
|
2998
|
-
|
|
3018
|
+
// Four empty lines say the same thing four times (round twenty, entry 56): once is enough, and it says what empty means.
|
|
3019
|
+
...(CHARACTER_FIELDS.every((field) => !(person[field] ?? "").trim())
|
|
3020
|
+
? [` ${CHARACTER_FIELDS.join(", ")}: (empty — nothing given yet, nothing invented; update_character fills a line)`]
|
|
3021
|
+
: CHARACTER_FIELDS.map((field) => ` ${field}: ${(person[field] ?? "").trim() || "(empty)"}`)),
|
|
2999
3022
|
...parts.flatMap((part) =>
|
|
3000
3023
|
part.on.length
|
|
3001
3024
|
? [` "${part.meta.name}", ${part.on.length} card${part.on.length === 1 ? "" : "s"} in story order:`, ...part.on.map((note, index) => ` ${index + 1}. "${note.headline}"${where_(note) ? ` (${where_(note)})` : ""}`)]
|
|
@@ -3191,7 +3214,8 @@ function foldLine(state, fold, thread) {
|
|
|
3191
3214
|
const head = (id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`;
|
|
3192
3215
|
if (fold.kept) return ` ${head(fold.firstId)} is folded for ${fold.what}, so "${thread.name}" stays a thread and no arrow is drawn: a card has one fold.`;
|
|
3193
3216
|
const did = [fold.folded ? `folded ${head(fold.firstId)}` : null, fold.named ? `named its fold "${thread.name}"` : null, fold.arrow ? `drew the setup arrow to ${head(fold.lastId)}` : null].filter(Boolean);
|
|
3194
|
-
|
|
3217
|
+
const adjacent = fold.adjacent ? ` No setup arrow: a follows arrow already runs from ${head(fold.firstId)} to ${head(fold.lastId)}, so the payoff is the very next scene and the fold says so on its own.` : "";
|
|
3218
|
+
return did.length ? ` Tied at both ends, so it is the fold's now: ${did.join(", ")}.${adjacent}` : adjacent;
|
|
3195
3219
|
}
|
|
3196
3220
|
|
|
3197
3221
|
function threadLine(state, thread) {
|
|
@@ -3508,7 +3532,7 @@ server.registerTool(
|
|
|
3508
3532
|
{
|
|
3509
3533
|
title: "Set the project's premise",
|
|
3510
3534
|
description:
|
|
3511
|
-
"Set the project's premise: the line above every board's logline, held by the project whatever its board count — what a series is about, or what is true before a film starts ('the winter the shop closes'). An empty string clears it. Or leave it open: pass open with the writer's words for why there is no premise yet — \"the buyer:
|
|
3535
|
+
"Set the project's premise: the line above every board's logline, held by the project whatever its board count — what a series is about, or what is true before a film starts ('the winter the shop closes'). An empty string clears it. Or leave it open: pass open with the writer's words for why there is no premise yet — \"the buyer: a sale, or a lease\" — and the reading lists it under open, by the writer's word; a premise decides it, open \"\" leaves it blank. Boards keep their own loglines.",
|
|
3512
3536
|
inputSchema: { premise: z.string().optional(), open: z.string().optional() },
|
|
3513
3537
|
},
|
|
3514
3538
|
async (args) => {
|
|
@@ -3716,7 +3740,7 @@ server.registerTool(
|
|
|
3716
3740
|
{
|
|
3717
3741
|
title: "Start a project",
|
|
3718
3742
|
description:
|
|
3719
|
-
"Through the account door: start a new project of the writer's with this name — one empty board, nothing on it — and work it from now on. The writer sees it under Projects on every device. board names the first board; boardOpen leaves its name open in the writer's words instead (\"the
|
|
3743
|
+
"Through the account door: start a new project of the writer's with this name — one empty board, nothing on it — and work it from now on. The writer sees it under Projects on every device. board names the first board; boardOpen leaves its name open in the writer's words instead (\"the pilot, or the film\"), so a board born from a maybe is not silently \"Board 1\".",
|
|
3720
3744
|
inputSchema: { name: z.string().min(1), board: z.string().optional(), boardOpen: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
|
|
3721
3745
|
},
|
|
3722
3746
|
async (args) => {
|
|
@@ -3837,7 +3861,7 @@ server.registerTool(
|
|
|
3837
3861
|
{
|
|
3838
3862
|
title: "Save the project as a file",
|
|
3839
3863
|
description:
|
|
3840
|
-
`The project the server is working, as the file Save project writes and Open project takes: the record, every board with its cards, the reminders and the writer's structures. Pass path to write it (a .json) — an absolute path, since a relative one resolves from the folder the server was started in, which is ${process.cwd()}, not
|
|
3864
|
+
`The project the server is working, as the file Save project writes and Open project takes: the record, every board with its cards, the reminders and the writer's structures. Pass path to write it (a .json) — an absolute path, since a relative one resolves from the folder the server was started in, which is ${process.cwd()} — this session's own folder when the server was started from it, and somewhere else when it was not; without a path, the reply's JSON is the file. Pictures and takes on the account are not in the file. Works through every door.`,
|
|
3841
3865
|
inputSchema: { path: z.string().optional() },
|
|
3842
3866
|
},
|
|
3843
3867
|
async (args) => {
|
|
@@ -3957,7 +3981,7 @@ server.registerTool(
|
|
|
3957
3981
|
"rename_board",
|
|
3958
3982
|
{
|
|
3959
3983
|
title: "Rename board",
|
|
3960
|
-
description: "Rename a board of the project by id, name, or number. Or leave its name open: pass open with the writer's words for why it is not decided — \"the
|
|
3984
|
+
description: "Rename a board of the project by id, name, or number. Or leave its name open: pass open with the writer's words for why it is not decided — \"the pilot, or the film\" — and the name stands as it is (every reply still calls it that) while the reading lists the words; a name decides it, open \"\" takes the words back.",
|
|
3961
3985
|
inputSchema: { board: z.union([z.string().min(1), z.number()]), name: z.string().min(1).optional(), open: z.string().optional() },
|
|
3962
3986
|
},
|
|
3963
3987
|
async (args) => {
|
package/src/board/readWall.d.ts
CHANGED
|
@@ -74,7 +74,7 @@ export type WallReading = {
|
|
|
74
74
|
/** Fields left open by the writer's word (R61): the board's logline, and each card's when, in story order. Listed, not asked. */
|
|
75
75
|
openFields: Array<{ field: "logline"; words: string } | { field: "when"; id: string; words: string }>;
|
|
76
76
|
/** Threads (R60): each named string with its cards in story order and which ends are open. */
|
|
77
|
-
threads: Array<{ id: string; name: string; ids: string[]; startOpen: boolean; endOpen: boolean }>;
|
|
77
|
+
threads: Array<{ id: string; name: string; ids: string[]; startOpen: boolean; endOpen: boolean; /** Eighths from the first card's start to the last card's start; 0 with fewer than two cards. */ apart: number }>;
|
|
78
78
|
/** Cards here that pay off a fold of another board (R58), composed by the door from the project. */
|
|
79
79
|
paidBy: Array<{ id: string; fromBoardId: string; fromBoardName: string; fromNoteId: string; fromHeadline: string; fromColor: string }>;
|
|
80
80
|
/** The questions the wall asks now. A left one (R53) is not here while its words hold. */
|
package/src/board/readWall.js
CHANGED
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
// before any tidy — the arrows are the writer's claim about the order, and
|
|
18
18
|
// where they say nothing the positions decide.
|
|
19
19
|
|
|
20
|
-
import { isMeasured, boardEighths, EIGHTHS_PER_PAGE, formatPages,
|
|
20
|
+
import { isMeasured, boardEighths, EIGHTHS_PER_PAGE, formatPages, noteEighths, readingOrder, storyOrder } from "./reducer.js";
|
|
21
|
+
|
|
22
|
+
// The two orders live in the kernel (R62: a thread's cards are held in story
|
|
23
|
+
// order), and every reader still imports them from here.
|
|
24
|
+
export { readingOrder, storyOrder };
|
|
21
25
|
|
|
22
26
|
/** What create_note writes before a person has. */
|
|
23
27
|
export const PLACEHOLDER_HEADLINE = "New beat";
|
|
@@ -29,7 +33,6 @@ const SAG_RATIO = 2;
|
|
|
29
33
|
// Longer than this and a group is probably two sequences wearing one frame.
|
|
30
34
|
const SEQUENCE_MAX_EIGHTHS = 20 * EIGHTHS_PER_PAGE;
|
|
31
35
|
// Two cards whose tops are within half a card of each other share a row.
|
|
32
|
-
const ROW_TOLERANCE = NOTE_HEIGHT / 2;
|
|
33
36
|
// A character gone for more than this share of the story is worth asking about.
|
|
34
37
|
const ABSENCE_FRACTION = 1 / 3;
|
|
35
38
|
// ...and at least this long, so a short wall of one-page estimates does not
|
|
@@ -45,64 +48,12 @@ const FILLER = new Set([
|
|
|
45
48
|
"about", "into", "from", "by", "his", "her", "hers", "its", "their", "is", "it",
|
|
46
49
|
]);
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
* Cards in reading order: banded into rows by y, then left to right. A free
|
|
50
|
-
* wall has no rows, so this is the order a person's eye takes across it.
|
|
51
|
-
*/
|
|
52
|
-
/**
|
|
53
|
-
* Story order: reading order, with each follows arrow pulling its source in
|
|
54
|
-
* front of its target; a pair pointing both ways is a tie and reading order
|
|
55
|
-
* keeps it. The order organize lays the wall out in, and the order every
|
|
56
|
-
* reading, numbering and page uses (R56). Returns the notes.
|
|
57
|
-
*/
|
|
58
|
-
export function storyOrder(state, ids) {
|
|
59
|
-
const scope = ids ? new Set(ids) : null;
|
|
60
|
-
const notes = state.notes.filter((note) => !scope || scope.has(note.id));
|
|
61
|
-
const reading = readingOrder(notes);
|
|
62
|
-
const byId = new Map(reading.map((note) => [note.id, note]));
|
|
63
|
-
const rank = new Map(reading.map((note, index) => [note.id, index]));
|
|
64
|
-
const preds = new Map(reading.map((note) => [note.id, []]));
|
|
65
|
-
for (const arrow of state.arrows ?? []) {
|
|
66
|
-
if (arrow.kind === "setup") continue;
|
|
67
|
-
if (!rank.has(arrow.from) || !rank.has(arrow.to)) continue;
|
|
68
|
-
preds.get(arrow.to).push(arrow.from);
|
|
69
|
-
}
|
|
70
|
-
for (const list of preds.values()) list.sort((a, b) => rank.get(a) - rank.get(b));
|
|
71
|
-
const placed = new Set();
|
|
72
|
-
const visiting = new Set();
|
|
73
|
-
const order = [];
|
|
74
|
-
function visit(id) {
|
|
75
|
-
if (placed.has(id) || visiting.has(id)) return;
|
|
76
|
-
visiting.add(id);
|
|
77
|
-
for (const from of preds.get(id)) {
|
|
78
|
-
if (preds.get(from).includes(id)) continue;
|
|
79
|
-
visit(from);
|
|
80
|
-
}
|
|
81
|
-
visiting.delete(id);
|
|
82
|
-
placed.add(id);
|
|
83
|
-
order.push(byId.get(id));
|
|
84
|
-
}
|
|
85
|
-
for (const note of reading) visit(note.id);
|
|
86
|
-
return order;
|
|
87
|
-
}
|
|
51
|
+
|
|
88
52
|
|
|
89
53
|
/** The leading "Day three, night." of a headline, the convention the guide asks for until a card has a when: not a scene's words. */
|
|
90
54
|
const DAY_PREFIX = /^\s*day\s+[\w-]+(?:\s*,\s*[\w\s-]+?)?\s*[.:]\s*/i;
|
|
91
55
|
|
|
92
|
-
|
|
93
|
-
const byTop = [...notes].sort((a, b) => a.y - b.y || a.x - b.x);
|
|
94
|
-
const rows = [];
|
|
95
|
-
let row = null;
|
|
96
|
-
for (const note of byTop) {
|
|
97
|
-
if (row && note.y - row.top <= ROW_TOLERANCE) {
|
|
98
|
-
row.notes.push(note);
|
|
99
|
-
} else {
|
|
100
|
-
row = { top: note.y, notes: [note] };
|
|
101
|
-
rows.push(row);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
return rows.flatMap((band) => band.notes.sort((a, b) => a.x - b.x || a.y - b.y));
|
|
105
|
-
}
|
|
56
|
+
|
|
106
57
|
|
|
107
58
|
function words(text) {
|
|
108
59
|
return (text ?? "")
|
|
@@ -112,9 +63,9 @@ function words(text) {
|
|
|
112
63
|
.filter((word) => word && !FILLER.has(word));
|
|
113
64
|
}
|
|
114
65
|
|
|
115
|
-
function sameScene(a, b) {
|
|
116
|
-
const wa = words(a);
|
|
117
|
-
const wb = words(b);
|
|
66
|
+
function sameScene(a, b, ignore = new Set()) {
|
|
67
|
+
const wa = words(a).filter((word) => !ignore.has(word));
|
|
68
|
+
const wb = words(b).filter((word) => !ignore.has(word));
|
|
118
69
|
if (wa.length === 0 || wb.length === 0) return false;
|
|
119
70
|
if (wa.join(" ") === wb.join(" ")) return true;
|
|
120
71
|
if (wa.length < 2 || wb.length < 2) return false;
|
|
@@ -265,7 +216,7 @@ export function readWall(state, options = {}) {
|
|
|
265
216
|
// On a wall where most runs hold nothing, the turns are back to back by
|
|
266
217
|
// construction, and the question stands until scenes go in; say so rather
|
|
267
218
|
// than ask it as if it were a choice (round eighteen, entry 29).
|
|
268
|
-
const thin = between.length >= 2 && between.filter((run) => run.cards === 0).length * 2 > between.length ? " Most runs hold nothing yet, so this stands until scenes go in." : "";
|
|
219
|
+
const thin = between.length >= 2 && between.filter((run) => run.cards === 0).length * 2 > between.length ? " Most runs hold nothing yet, so this stands until scenes go in, or until the writer leaves it as the pace." : "";
|
|
269
220
|
const askChain = () => {
|
|
270
221
|
if (chain.length === 0) return;
|
|
271
222
|
const ids = [chain[0].from, ...chain.map((run) => run.to)];
|
|
@@ -275,8 +226,8 @@ export function readWall(state, options = {}) {
|
|
|
275
226
|
ids,
|
|
276
227
|
text:
|
|
277
228
|
ids.length === 2
|
|
278
|
-
? `Nothing runs between ${names[0]} and ${names[1]}: two turns back to back. Are they one beat,
|
|
279
|
-
: `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,
|
|
229
|
+
? `Nothing runs between ${names[0]} and ${names[1]}: two turns back to back. Are they one beat, is a scene missing, or is that the pace?${thin}`
|
|
230
|
+
: `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, are scenes missing between them, or is that the pace?${thin}`,
|
|
280
231
|
});
|
|
281
232
|
chain = [];
|
|
282
233
|
};
|
|
@@ -323,8 +274,12 @@ export function readWall(state, options = {}) {
|
|
|
323
274
|
}
|
|
324
275
|
|
|
325
276
|
// Only once the wall uses arrows does a card without one mean anything.
|
|
277
|
+
// A setup arrow is a claim, not a place in the story (round twenty, entry
|
|
278
|
+
// 25): a card touched only by one still has no order, so only follows
|
|
279
|
+
// arrows link a card.
|
|
326
280
|
const linked = new Set();
|
|
327
281
|
for (const arrow of state.arrows) {
|
|
282
|
+
if (arrow.kind === "setup") continue;
|
|
328
283
|
linked.add(arrow.from);
|
|
329
284
|
linked.add(arrow.to);
|
|
330
285
|
}
|
|
@@ -339,14 +294,17 @@ export function readWall(state, options = {}) {
|
|
|
339
294
|
}
|
|
340
295
|
}
|
|
341
296
|
|
|
342
|
-
// Two cards doing the same job.
|
|
297
|
+
// Two cards doing the same job. The cast's names are not the scene's words
|
|
298
|
+
// (round twenty, entry 15): "Con gives Ruth the key" and "Con gives Ruth
|
|
299
|
+
// his tools" share their people, not their job.
|
|
300
|
+
const nameWords = new Set((state.characters ?? []).flatMap((person) => words(person.name ?? "")));
|
|
343
301
|
for (let i = 0; i < order.length; i += 1) {
|
|
344
302
|
for (let j = i + 1; j < order.length; j += 1) {
|
|
345
303
|
const a = order[i];
|
|
346
304
|
const b = order[j];
|
|
347
305
|
// A leading "Day three." is the guide's convention for when a scene
|
|
348
306
|
// happens, not the scene's words (round fourteen, entry 13).
|
|
349
|
-
if (sameScene(a.headline.replace(DAY_PREFIX, ""), b.headline.replace(DAY_PREFIX, ""))) {
|
|
307
|
+
if (sameScene(a.headline.replace(DAY_PREFIX, ""), b.headline.replace(DAY_PREFIX, ""), nameWords)) {
|
|
350
308
|
findings.push({
|
|
351
309
|
kind: "duplicate",
|
|
352
310
|
ids: [a.id, b.id],
|
|
@@ -505,13 +463,12 @@ export function readWall(state, options = {}) {
|
|
|
505
463
|
// Threads (R60): a named string through cards, in story order, either end
|
|
506
464
|
// open until the writer ties it. The reading asks about each loose end from
|
|
507
465
|
// that end — the question a fold cannot ask, where a thing is first seen.
|
|
508
|
-
const threads = (state.threads ?? []).map((thread) =>
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
ids
|
|
512
|
-
startOpen: thread.startOpen === true,
|
|
513
|
-
|
|
514
|
-
}));
|
|
466
|
+
const threads = (state.threads ?? []).map((thread) => {
|
|
467
|
+
const ids = order.filter((note) => thread.noteIds.includes(note.id)).map((note) => note.id);
|
|
468
|
+
// How far the string runs, in the same estimated pages a setup's distance uses (round twenty, entry 44).
|
|
469
|
+
const apart = ids.length >= 2 ? (startAt.get(ids[ids.length - 1]) ?? 0) - (startAt.get(ids[0]) ?? 0) : 0;
|
|
470
|
+
return { id: thread.id, name: thread.name, ids, startOpen: thread.startOpen === true, endOpen: thread.endOpen === true, apart };
|
|
471
|
+
});
|
|
515
472
|
for (const thread of threads) {
|
|
516
473
|
const first = thread.ids.length ? byId.get(thread.ids[0]) : null;
|
|
517
474
|
const last = thread.ids.length ? byId.get(thread.ids[thread.ids.length - 1]) : null;
|
|
@@ -635,6 +592,7 @@ export function describeRuns(reading, state) {
|
|
|
635
592
|
// from written text, or the cards' guess, or some of each.
|
|
636
593
|
const written = (run.ids ?? []).filter((id) => isMeasured(byId.get(id) ?? {})).length;
|
|
637
594
|
const whose = !run.cards ? "" : written === run.cards ? ", measured" : written ? `, ${written} of ${run.cards} measured` : ", estimated";
|
|
638
|
-
|
|
595
|
+
const amount = pages(run.eighths);
|
|
596
|
+
return `${span}: about ${amount} ${amount === "1" ? "page" : "pages"}, ${count}${whose}`;
|
|
639
597
|
});
|
|
640
598
|
}
|
package/src/board/reducer.js
CHANGED
|
@@ -474,6 +474,73 @@ function cleanOpen(value) {
|
|
|
474
474
|
return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
|
|
475
475
|
}
|
|
476
476
|
|
|
477
|
+
|
|
478
|
+
// --- Order (R56, moved here for R62) -------------------------------------
|
|
479
|
+
//
|
|
480
|
+
// Story order is the follows arrows where they exist and the wall's reading
|
|
481
|
+
// order — rows top to bottom, cards left to right — where they do not. The
|
|
482
|
+
// reading composes everything from it; the kernel needs it to hold a
|
|
483
|
+
// thread's cards in story order, so the tie rule folds the first card and
|
|
484
|
+
// not the last one the writer happened to add.
|
|
485
|
+
|
|
486
|
+
/** Cards within half a card's height of each other sit on one row. */
|
|
487
|
+
const ROW_TOLERANCE = NOTE_HEIGHT / 2;
|
|
488
|
+
|
|
489
|
+
export function readingOrder(notes) {
|
|
490
|
+
const byTop = [...notes].sort((a, b) => a.y - b.y || a.x - b.x);
|
|
491
|
+
const rows = [];
|
|
492
|
+
let row = null;
|
|
493
|
+
for (const note of byTop) {
|
|
494
|
+
if (row && note.y - row.top <= ROW_TOLERANCE) {
|
|
495
|
+
row.notes.push(note);
|
|
496
|
+
} else {
|
|
497
|
+
row = { top: note.y, notes: [note] };
|
|
498
|
+
rows.push(row);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return rows.flatMap((band) => band.notes.sort((a, b) => a.x - b.x || a.y - b.y));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Cards in reading order: banded into rows by y, then left to right. A free
|
|
506
|
+
* wall has no rows, so this is the order a person's eye takes across it.
|
|
507
|
+
*/
|
|
508
|
+
/**
|
|
509
|
+
* Story order: reading order, with each follows arrow pulling its source in
|
|
510
|
+
* front of its target; a pair pointing both ways is a tie and reading order
|
|
511
|
+
* keeps it. The order organize lays the wall out in, and the order every
|
|
512
|
+
* reading, numbering and page uses (R56). Returns the notes.
|
|
513
|
+
*/
|
|
514
|
+
export function storyOrder(state, ids) {
|
|
515
|
+
const scope = ids ? new Set(ids) : null;
|
|
516
|
+
const notes = state.notes.filter((note) => !scope || scope.has(note.id));
|
|
517
|
+
const reading = readingOrder(notes);
|
|
518
|
+
const byId = new Map(reading.map((note) => [note.id, note]));
|
|
519
|
+
const rank = new Map(reading.map((note, index) => [note.id, index]));
|
|
520
|
+
const preds = new Map(reading.map((note) => [note.id, []]));
|
|
521
|
+
for (const arrow of state.arrows ?? []) {
|
|
522
|
+
if (arrow.kind === "setup") continue;
|
|
523
|
+
if (!rank.has(arrow.from) || !rank.has(arrow.to)) continue;
|
|
524
|
+
preds.get(arrow.to).push(arrow.from);
|
|
525
|
+
}
|
|
526
|
+
for (const list of preds.values()) list.sort((a, b) => rank.get(a) - rank.get(b));
|
|
527
|
+
const placed = new Set();
|
|
528
|
+
const visiting = new Set();
|
|
529
|
+
const order = [];
|
|
530
|
+
function visit(id) {
|
|
531
|
+
if (placed.has(id) || visiting.has(id)) return;
|
|
532
|
+
visiting.add(id);
|
|
533
|
+
for (const from of preds.get(id)) {
|
|
534
|
+
if (preds.get(from).includes(id)) continue;
|
|
535
|
+
visit(from);
|
|
536
|
+
}
|
|
537
|
+
visiting.delete(id);
|
|
538
|
+
placed.add(id);
|
|
539
|
+
order.push(byId.get(id));
|
|
540
|
+
}
|
|
541
|
+
for (const note of reading) visit(note.id);
|
|
542
|
+
return order;
|
|
543
|
+
}
|
|
477
544
|
/**
|
|
478
545
|
* The combine log's rule, both halves (R60, R62). A thread tied at both ends
|
|
479
546
|
* through two or more cards is a plant between two scenes that exist, and
|
|
@@ -504,16 +571,20 @@ function tieIntoFold(state, thread, now) {
|
|
|
504
571
|
notes: next.notes.map((note) => (note.id === firstId ? bump(note, { plants: true, plantsWhat: thread.name }, now) : note)),
|
|
505
572
|
};
|
|
506
573
|
}
|
|
507
|
-
|
|
574
|
+
// One arrow per direction between two cards (R15): a follows arrow already
|
|
575
|
+
// running from the first card to the last means the payoff is the very next
|
|
576
|
+
// scene, and the setup cannot be drawn over it.
|
|
577
|
+
const existing = next.arrows.find((arrow) => arrow.from === firstId && arrow.to === lastId);
|
|
508
578
|
let arrow = null;
|
|
509
|
-
|
|
579
|
+
const adjacent = Boolean(existing && existing.kind !== "setup");
|
|
580
|
+
if (!existing) {
|
|
510
581
|
const drawn = applyCommand(next, { type: "create_arrow", from: firstId, to: lastId, kind: "setup" }, now);
|
|
511
582
|
if (drawn.changed) {
|
|
512
583
|
next = drawn.state;
|
|
513
584
|
arrow = { from: firstId, to: lastId };
|
|
514
585
|
}
|
|
515
586
|
}
|
|
516
|
-
return { state: next, fold: { kept: false, firstId, lastId, folded, named, arrow } };
|
|
587
|
+
return { state: next, fold: { kept: false, firstId, lastId, folded, named, arrow, adjacent } };
|
|
517
588
|
}
|
|
518
589
|
|
|
519
590
|
/** A thread's name as the writer typed it, one line, spaces collapsed (R60). */
|
|
@@ -809,7 +880,9 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
809
880
|
case "create_thread": {
|
|
810
881
|
const name = cleanThreadName(command.name);
|
|
811
882
|
if (!name) return { state, changed: false };
|
|
812
|
-
const
|
|
883
|
+
const wanted = (command.noteIds ?? []).filter((id, index, all) => all.indexOf(id) === index && state.notes.some((note) => note.id === id));
|
|
884
|
+
// Held in story order, whatever order the writer named them (R62).
|
|
885
|
+
const noteIds = storyOrder(state, wanted).map((note) => note.id);
|
|
813
886
|
const thread = {
|
|
814
887
|
id: typeof command.id === "string" && command.id && !(state.threads ?? []).some((item) => item.id === command.id) ? command.id : newId(),
|
|
815
888
|
name,
|
|
@@ -828,6 +901,9 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
828
901
|
let noteIds = Array.isArray(command.noteIds) ? command.noteIds.filter((id, index, all) => all.indexOf(id) === index && exists(id)) : [...current.noteIds];
|
|
829
902
|
if (Array.isArray(command.add)) for (const id of command.add) if (exists(id) && !noteIds.includes(id)) noteIds.push(id);
|
|
830
903
|
if (Array.isArray(command.remove)) noteIds = noteIds.filter((id) => !command.remove.includes(id));
|
|
904
|
+
// Held in story order, whatever order the cards were added (R62): the
|
|
905
|
+
// rule below folds the first card in the story, not the last one named.
|
|
906
|
+
noteIds = storyOrder(state, noteIds).map((note) => note.id);
|
|
831
907
|
const name = command.name === undefined ? current.name : cleanThreadName(command.name) || current.name;
|
|
832
908
|
const startOpen = typeof command.startOpen === "boolean" ? command.startOpen : current.startOpen;
|
|
833
909
|
const endOpen = typeof command.endOpen === "boolean" ? command.endOpen : current.endOpen;
|
package/src/board/words.js
CHANGED
|
@@ -77,7 +77,7 @@ export const WORD_GROUPS = [
|
|
|
77
77
|
id: "corner",
|
|
78
78
|
name: "The folded corner",
|
|
79
79
|
sentence:
|
|
80
|
-
"This card plants something — a gun on the wall — that must pay off later, and can say what, in the writer’s words: Plants · the
|
|
80
|
+
"This card plants something — a gun on the wall — that must pay off later, and can say what, in the writer’s words: Plants · the letter. The card says unpaid, and the bar’s Asks line keeps asking where it comes back, until a setup arrow leaves it. One thing, three words: the corner is folded, the tool’s flag is plants, the wall’s question is unpaid.",
|
|
81
81
|
target: "corner",
|
|
82
82
|
},
|
|
83
83
|
{
|
package/src/board/workflows.js
CHANGED
|
@@ -25,6 +25,7 @@ export const WORKFLOWS = [
|
|
|
25
25
|
needs: [
|
|
26
26
|
{ question: "How long is it?", hint: "An hour, a half-hour, a feature — or a page count, if you have one.", tool: "set_target" },
|
|
27
27
|
{ question: "What is the central question, in one sentence?", hint: "And if this is one episode of something, what is the series about? Not decided: either takes open with the writer's words, and the reading lists it.", tool: "set_logline, set_premise" },
|
|
28
|
+
{ question: "In what order do the scenes come?", hint: "The follows arrows are the order, and the wall reads, numbers and prints by them: create_note with after wires each card as it lands, create_arrow draws one, move_scene moves a scene. Not decided for a card: leave it unwired and say so; the reading asks what comes before and after it.", tool: "create_note, create_arrow, move_scene" },
|
|
28
29
|
{ question: "Which scenes are the turns?", hint: "Name them, say \"propose them and I will strike\", or say \"mark none yet\" — every card stays a scene and the reading asks once for a beat until you do.", tool: "set_rank" },
|
|
29
30
|
{ question: "Does it have acts?", hint: "If so, where does each break fall?", tool: "create_group" },
|
|
30
31
|
{ question: "Where does each scene happen?", hint: "In your own words. A scene that moves through one location is still one place.", tool: "set_location" },
|