plotcoder-board 0.1.30 → 0.1.32
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 +1 -1
- package/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +90 -41
- package/src/board/agents.js +1 -1
- package/src/board/fountain.js +2 -1
- package/src/board/project.d.ts +10 -1
- package/src/board/project.js +45 -8
- package/src/board/readWall.d.ts +2 -0
- package/src/board/readWall.js +17 -2
- package/src/board/reducer.d.ts +12 -3
- package/src/board/reducer.js +106 -15
- package/src/board/words.js +2 -2
- package/src/board/workflows.js +3 -3
package/README.md
CHANGED
|
@@ -104,4 +104,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
|
|
|
104
104
|
|
|
105
105
|
## Status
|
|
106
106
|
|
|
107
|
-
Version 0.1.
|
|
107
|
+
Version 0.1.32. 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
|
@@ -69,6 +69,8 @@ import {
|
|
|
69
69
|
removeStructure,
|
|
70
70
|
setActiveBoard,
|
|
71
71
|
setPremise,
|
|
72
|
+
setPremiseOpen,
|
|
73
|
+
setBoardNameOpen,
|
|
72
74
|
structureBeats,
|
|
73
75
|
reidentifyProject,
|
|
74
76
|
renameProject,
|
|
@@ -1057,13 +1059,14 @@ function summarize(state) {
|
|
|
1057
1059
|
// The board it pays off on, named here as read_wall names it (round fifteen, entry 44).
|
|
1058
1060
|
const laterName = note.payoffBoardId ? (lastHeld?.project?.boards?.find((meta) => meta.id === note.payoffBoardId)?.name ?? note.payoffBoardId) : null;
|
|
1059
1061
|
const laterAt = note.payoffBoardId && note.payoffNoteId && lastHeld?.project ? ` at ${episodeLabel(lastHeld.project, lastHeld.boards, note.payoffBoardId, note.payoffNoteId)} "${lastHeld.boards[note.payoffBoardId]?.notes?.find((item) => item.id === note.payoffNoteId)?.headline ?? note.payoffNoteId}"` : "";
|
|
1060
|
-
const
|
|
1062
|
+
const what = note.plantsWhat ? `: ${note.plantsWhat}` : "";
|
|
1063
|
+
const plant = note.plants ? (note.payoffBoardId ? `, plants${what} → pays off later on "${laterName}"${laterAt || ", no scene there claimed yet"}` : `, plants${what}`) : "";
|
|
1061
1064
|
// The receiving end (R58): what this card pays off from another board, composed from the project.
|
|
1062
1065
|
const pays = paidByHere.filter((item) => item.id === note.id).map((item) => `, pays off "${item.fromHeadline}" from "${item.fromBoardName}" (${episodeLabel(lastHeld.project, lastHeld.boards, item.fromBoardId, item.fromNoteId)})`).join("");
|
|
1063
1066
|
const snap = state.revision?.snapshot?.[note.id];
|
|
1064
1067
|
const revised = snap && (snap.headline !== note.headline || snap.change !== note.change || (snap.text ?? "") !== (note.text ?? "") || (snap.location ?? "") !== (note.location ?? "")) ? `, changed in ${state.revision.color}` : "";
|
|
1065
1068
|
const place = note.location ? `, at: ${note.location}` : "";
|
|
1066
|
-
const when = note.when ? `, when: ${note.when}` : "";
|
|
1069
|
+
const when = note.when ? `, when: ${note.when}` : note.whenOpen ? `, when: open, by the writer's word — "${note.whenOpen}"` : "";
|
|
1067
1070
|
const openWord = note.open ? `, open (the writer's words): "${note.open}"` : "";
|
|
1068
1071
|
const count = formatPages(noteEighths(note));
|
|
1069
1072
|
// A written card's estimate is kept underneath for when the text goes; say it, or it is invisible (round sixteen, entry 44).
|
|
@@ -1134,7 +1137,7 @@ function summarize(state) {
|
|
|
1134
1137
|
const leftCount = (state.left ?? []).length;
|
|
1135
1138
|
return [
|
|
1136
1139
|
...(isSampleWall(state) ? [SAMPLE_NOTE] : []),
|
|
1137
|
-
`logline: ${state.logline ? `"${state.logline}"` : "(not set)"}`,
|
|
1140
|
+
`logline: ${state.loglineOpen ? `open, by the writer's word — "${state.loglineOpen}"` : state.logline ? `"${state.logline}"` : "(not set)"}`,
|
|
1138
1141
|
...production,
|
|
1139
1142
|
`left, for now: ${leftCount ? `${leftCount} question(s) the writer left; read_wall lists them` : "none"}`,
|
|
1140
1143
|
`beats: ${beats}, scenes: ${scenes}`,
|
|
@@ -1221,18 +1224,24 @@ server.registerTool(
|
|
|
1221
1224
|
{
|
|
1222
1225
|
title: "Set logline",
|
|
1223
1226
|
description:
|
|
1224
|
-
"Set the board's logline — the central question, what this story is arguing. One sentence. Every card on the wall should be checkable against it. Pass an empty string to clear it.",
|
|
1227
|
+
"Set the board's logline — the central question, what this story is arguing. One sentence. Every card on the wall should be checkable against it. Pass an empty string to clear it. Or leave it open: pass open with the writer's words for why there is no logline yet — \"two candidates, not chosen\" — and the reading lists it under open, by the writer's word, and asks nothing; text decides it and clears the words; open \"\" takes the words back and leaves the field blank. Only on the writer's word: an open field is theirs, never a guess of yours.",
|
|
1225
1228
|
inputSchema: {
|
|
1226
|
-
logline: z.string(),
|
|
1229
|
+
logline: z.string().optional(),
|
|
1230
|
+
open: z.string().optional(),
|
|
1227
1231
|
},
|
|
1228
1232
|
},
|
|
1229
1233
|
async (args) => {
|
|
1230
|
-
|
|
1234
|
+
if (args.logline === undefined && args.open === undefined) return ok("Say which: logline (the sentence, or \"\" to clear it), or open (the writer's words for why there is none yet).");
|
|
1235
|
+
const { state, changed, live } = await commit({ type: "set_logline", ...(args.logline !== undefined ? { logline: args.logline } : {}), ...(args.open !== undefined ? { open: args.open } : {}) });
|
|
1236
|
+
if (!changed) return ok(args.logline === "" && !state.loglineOpen ? "Logline cleared." : "Logline unchanged: it already read that way.");
|
|
1237
|
+
if (state.loglineOpen) {
|
|
1238
|
+
return ok(`Logline left open, by the writer's word: "${state.loglineOpen}"${where(live)}. The reading lists it and asks nothing; set_logline with text decides it, open "" leaves it blank.`, { logline: state.logline, loglineOpen: state.loglineOpen });
|
|
1239
|
+
}
|
|
1231
1240
|
return ok(
|
|
1232
1241
|
state.logline
|
|
1233
1242
|
? `Logline set: "${state.logline}"${where(live)}.`
|
|
1234
|
-
:
|
|
1235
|
-
{ logline: state.logline },
|
|
1243
|
+
: `Logline cleared${where(live)}.`,
|
|
1244
|
+
{ logline: state.logline, loglineOpen: state.loglineOpen },
|
|
1236
1245
|
);
|
|
1237
1246
|
},
|
|
1238
1247
|
);
|
|
@@ -1376,6 +1385,7 @@ server.registerTool(
|
|
|
1376
1385
|
rank: args.rank,
|
|
1377
1386
|
lengthEighths: args.pages === undefined ? undefined : toEighths(args.pages),
|
|
1378
1387
|
plants: args.plants,
|
|
1388
|
+
plantsWhat: args.plantsWhat,
|
|
1379
1389
|
location: args.location,
|
|
1380
1390
|
when: args.when,
|
|
1381
1391
|
open: args.open,
|
|
@@ -1420,7 +1430,7 @@ server.registerTool(
|
|
|
1420
1430
|
result?.rank === "beat" ? "a beat" : "a scene",
|
|
1421
1431
|
result?.lengthEighths === null ? "about a page (unsized: the writer's guess until set_length)" : `${formatPages(noteEighths(result))} ${formatPages(noteEighths(result)) === "1" ? "page" : "pages"}`,
|
|
1422
1432
|
result?.color ? `${result.color} paper${args.color ? "" : once("paper", " (pass color to choose)")}` : null,
|
|
1423
|
-
result?.plants ?
|
|
1433
|
+
result?.plants ? `corner folded${result.plantsWhat ? ` — plants ${result.plantsWhat}` : ""}` : null,
|
|
1424
1434
|
result?.location ? `at ${result.location}` : `no place yet${once("place", " (location here, or set_location)")}`,
|
|
1425
1435
|
result?.when ? `when: ${result.when}` : null,
|
|
1426
1436
|
result?.open ? `open: "${result.open}" (listed, not asked about)` : null,
|
|
@@ -1563,11 +1573,19 @@ server.registerTool(
|
|
|
1563
1573
|
const whose = runtimeKinds(state).replace(/^; /, "");
|
|
1564
1574
|
// A beat's own pages are in no run (entry 48); say how many pages that is.
|
|
1565
1575
|
const beatEighths = reading.beats.reduce((sum, beat) => sum + noteEighths(state.notes.find((note) => note.id === beat.id) ?? {}), 0);
|
|
1576
|
+
// Fields left open by the writer's word (R61): the logline and the whens are the reading's; the premise and the board's name are the project's.
|
|
1577
|
+
const openFieldLines = [
|
|
1578
|
+
...reading.openFields.filter((field) => field.field === "logline").map((field) => ` - the logline — ${field.words}`),
|
|
1579
|
+
...(projectForRead.premiseOpen ? [` - the premise — ${projectForRead.premiseOpen}`] : []),
|
|
1580
|
+
...(readBoardMeta?.nameOpen ? [` - this board's name — ${readBoardMeta.nameOpen}`] : []),
|
|
1581
|
+
...reading.openFields.filter((field) => field.field === "when").map((field) => ` - "${state.notes.find((note) => note.id === field.id)?.headline ?? field.id}" — when: ${field.words}`),
|
|
1582
|
+
];
|
|
1566
1583
|
const lines = [
|
|
1567
1584
|
`PlotCoder wall (${door(live, base)})`,
|
|
1568
1585
|
...(state.lock ? [`numbers: locked since ${String(state.lock.at).slice(0, 10)}; read_pages shows each scene's number`] : []),
|
|
1569
|
-
`board: "${readBoardMeta?.name ?? "?"}"${projectForRead.boards.length > 1 ? ` — board ${projectForRead.boards.findIndex((meta) => meta.id === readBoardMeta?.id) + 1} of ${projectForRead.boards.length} in the project "${projectForRead.name}"; open_board reads another` : ""}`,
|
|
1570
|
-
`
|
|
1586
|
+
`board: "${readBoardMeta?.name ?? "?"}"${readBoardMeta?.nameOpen ? ` — its name is open, by the writer's word: "${readBoardMeta.nameOpen}"` : ""}${projectForRead.boards.length > 1 ? ` — board ${projectForRead.boards.findIndex((meta) => meta.id === readBoardMeta?.id) + 1} of ${projectForRead.boards.length} in the project "${projectForRead.name}"; open_board reads another` : ""}`,
|
|
1587
|
+
...(projectForRead.premiseOpen ? [`premise: open, by the writer's word — "${projectForRead.premiseOpen}"`] : []),
|
|
1588
|
+
`logline: ${state.loglineOpen ? `open, by the writer's word — "${state.loglineOpen}"` : state.logline ? `"${state.logline}"` : "(none yet)"}`,
|
|
1571
1589
|
"the cast and the places are list_board's, not the reading's",
|
|
1572
1590
|
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
1573
1591
|
? `runtime: about ${formatPages(boardEighths(state))} pages (${whose || "no cards"}); no target set (set_target)`
|
|
@@ -1599,8 +1617,8 @@ server.registerTool(
|
|
|
1599
1617
|
: [reading.paidBy.length ? " (no setup arrow on this board; what pays off a fold of another board is listed below)" : " (no arrow is marked as a setup)"]),
|
|
1600
1618
|
...reading.later.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" is folded and pays off later, on "${boardById(projectForRead, item.boardId)?.name ?? item.boardId}"${item.noteId ? `, at ${episodeLabel(projectForRead, boardsNow, item.boardId, item.noteId)} "${boardsNow[item.boardId]?.notes?.find((note) => note.id === item.noteId)?.headline ?? item.noteId}"` : " — no scene there claims it yet"}`),
|
|
1601
1619
|
...reading.paidBy.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" pays off "${item.fromHeadline}" from "${item.fromBoardName}" (${episodeLabel(projectForRead, boardsNow, item.fromBoardId, item.fromNoteId)}), one board earlier`),
|
|
1602
|
-
...(reading.open.length
|
|
1603
|
-
? ["open, by the writer's word (listed, not asked about while the words stand; set_open with \"\" closes):", ...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("; ")})` : ""}`)]
|
|
1620
|
+
...(reading.open.length || openFieldLines.length
|
|
1621
|
+
? ["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("; ")})` : ""}`)]
|
|
1604
1622
|
: []),
|
|
1605
1623
|
...(reading.threads.length
|
|
1606
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" : ""}`)]
|
|
@@ -1622,7 +1640,7 @@ server.registerTool(
|
|
|
1622
1640
|
const counts = new Map();
|
|
1623
1641
|
for (const finding of asked) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
|
|
1624
1642
|
return `asking ${asked.length} question${asked.length === 1 ? "" : "s"} of ${counts.size} kind${counts.size === 1 ? "" : "s"}: ${[...counts.entries()].map(([kind, n]) => (n > 1 ? `${kind} ×${n}` : kind)).join(", ")}${held}`;
|
|
1625
|
-
})()}${reading.left.length ? `; left by the writer, so not clean: ${[...new Set(reading.left.map((finding) => finding.kind))].map((kind) => `[${kind}]`).join(" ")}` : ""}${reading.open.length ? `; ${reading.open.length} card${reading.open.length === 1 ? "" : "s"} open by the writer's word, not asked` : ""}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind) && !reading.left.some((finding) => finding.kind === kind)).map((kind) => {
|
|
1643
|
+
})()}${reading.left.length ? `; left by the writer, so not clean: ${[...new Set(reading.left.map((finding) => finding.kind))].map((kind) => `[${kind}]`).join(" ")}` : ""}${reading.open.length || openFieldLines.length ? `; ${[reading.open.length ? `${reading.open.length} card${reading.open.length === 1 ? "" : "s"}` : "", openFieldLines.length ? `${openFieldLines.length} field${openFieldLines.length === 1 ? "" : "s"}` : ""].filter(Boolean).join(" and ")} open by the writer's word, not asked` : ""}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind) && !reading.left.some((finding) => finding.kind === kind)).map((kind) => {
|
|
1626
1644
|
if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked until half the cards are wired: no arrows yet)";
|
|
1627
1645
|
if (kind === "unlinked") {
|
|
1628
1646
|
const linked = new Set(state.arrows.flatMap((arrow) => [arrow.from, arrow.to]));
|
|
@@ -2708,16 +2726,21 @@ server.registerTool(
|
|
|
2708
2726
|
{
|
|
2709
2727
|
title: "Set when scenes happen",
|
|
2710
2728
|
description:
|
|
2711
|
-
"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. 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: …",
|
|
2712
|
-
inputSchema: { ids: z.array(z.string()).min(1), when: z.string() },
|
|
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 break-in; which day\" — 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
|
+
inputSchema: { ids: z.array(z.string()).min(1), when: z.string().optional(), open: z.string().optional() },
|
|
2713
2731
|
},
|
|
2714
2732
|
async (args) => {
|
|
2715
|
-
|
|
2733
|
+
if (args.when === undefined && args.open === undefined) return ok("Say which: when (the writer's phrase, or \"\" to clear it), or open (their words for why the when is not decided).");
|
|
2734
|
+
const { state, changed, result, live } = await commit({ type: "set_when", ids: args.ids, ...(args.when !== undefined ? { when: args.when } : {}), ...(args.open !== undefined ? { open: args.open } : {}) });
|
|
2716
2735
|
if (!changed) {
|
|
2717
2736
|
const missing = args.ids.filter((id) => !state.notes.some((note) => note.id === id));
|
|
2718
|
-
return ok(missing.length ? `No card with id ${missing.join(", ")}. Call list_board for the real ids.` : `Nothing changed: ${args.ids.length === 1 ? "the card already says" : "those cards already say"} "${args.when.trim()}".`);
|
|
2737
|
+
return ok(missing.length ? `No card with id ${missing.join(", ")}. Call list_board for the real ids.` : `Nothing changed: ${args.ids.length === 1 ? "the card already says" : "those cards already say"} "${(args.when ?? args.open ?? "").trim()}".`);
|
|
2719
2738
|
}
|
|
2720
2739
|
const when = result[0]?.when ?? "";
|
|
2740
|
+
const whenOpen = result[0]?.whenOpen ?? "";
|
|
2741
|
+
if (whenOpen) {
|
|
2742
|
+
return ok(`${result.length} card(s) have their when left open, by the writer's word: "${whenOpen}"${where(live)}. The reading lists it and asks nothing; the heading prints no time; set_when with a when decides it, open "" leaves it blank. The card is still asked about everything else.${stillOpen(result)}`, result);
|
|
2743
|
+
}
|
|
2721
2744
|
return ok(
|
|
2722
2745
|
when
|
|
2723
2746
|
? `${result.length} card(s) now happen ${/^(at|on|in|by|the)\b/i.test(when) ? "" : "at "}"${when}"${where(live)}. The heading prints as ${sceneHeading(result[0]).slice(1)}.${stillOpen(result)}`
|
|
@@ -2762,12 +2785,14 @@ server.registerTool(
|
|
|
2762
2785
|
`Fold the corner of cards — mark them as planting something — or unfold them. ${wordSentence("corner")} The setup arrow is create_arrow with kind 'setup'. A fold that pays off in a later episode: pass later, another board of the project by name, id or number — a board that exists; new_board makes one — and, once you know it, at: the scene on that board that pays it off, by id or headline. A board alone is a promise: the reading lists the card under 'later' and, once that board holds cards, asks which scene until one claims it; with at, both boards' readings name the payoff and the paying-off card says so. later '' forgets the board; at '' keeps the board and forgets the scene. set_payoff makes the same claim from the other board. Folding never moves a card.`,
|
|
2763
2786
|
inputSchema: {
|
|
2764
2787
|
ids: z.array(z.string()).min(1),
|
|
2765
|
-
plants: z.boolean(),
|
|
2788
|
+
plants: z.boolean().optional(),
|
|
2789
|
+
what: z.string().optional(),
|
|
2766
2790
|
later: z.string().optional(),
|
|
2767
2791
|
at: z.string().optional(),
|
|
2768
2792
|
},
|
|
2769
2793
|
},
|
|
2770
2794
|
async (args) => {
|
|
2795
|
+
if (args.plants === undefined && args.what === undefined) return ok("Say which: plants (true folds, false unfolds), or what (the writer's words for what it plants, which folds the card; \"\" keeps the fold and drops the words).");
|
|
2771
2796
|
// A series plant (R50): the fold pays off on another board of the project.
|
|
2772
2797
|
// The kernel cannot check the board exists; this door can, before anything lands.
|
|
2773
2798
|
let target = null;
|
|
@@ -2802,7 +2827,7 @@ server.registerTool(
|
|
|
2802
2827
|
// What the folds claimed before, so the reply can say what a new claim replaced (round sixteen, entry 26).
|
|
2803
2828
|
const before = new Map(current().notes.filter((note) => args.ids.includes(note.id)).map((note) => [note.id, { plants: note.plants, boardId: note.payoffBoardId, noteId: note.payoffNoteId }]));
|
|
2804
2829
|
const alreadyFolded = args.plants && args.ids.every((id) => before.get(id)?.plants);
|
|
2805
|
-
let { result } = step({ type: "set_plant", ids: args.ids, plants: args.plants });
|
|
2830
|
+
let { result } = step({ type: "set_plant", ids: args.ids, ...(args.plants !== undefined ? { plants: args.plants } : {}), ...(args.what !== undefined ? { what: args.what } : {}) });
|
|
2806
2831
|
let laterLine = alreadyFolded ? " (already folded)" : "";
|
|
2807
2832
|
if (forgetting) {
|
|
2808
2833
|
const cleared = step({ type: "set_payoff_board", ids: args.ids, boardId: null });
|
|
@@ -2830,10 +2855,11 @@ server.registerTool(
|
|
|
2830
2855
|
const { result, laterLine } = value;
|
|
2831
2856
|
const count = result?.length ?? 0;
|
|
2832
2857
|
if (!changed || count === 0) return ok("No change: those cards were already that way, or the ids are not on the board.");
|
|
2858
|
+
const what = result?.[0]?.plantsWhat ?? "";
|
|
2833
2859
|
return ok(
|
|
2834
|
-
args.plants
|
|
2835
|
-
? `${count} card(s) now plant something${where(live)}.${
|
|
2836
|
-
: `${count} card(s) no longer marked as planting${where(live)}
|
|
2860
|
+
args.plants !== false
|
|
2861
|
+
? `${count} card(s) now plant ${what ? `"${what}"` : "something"}${where(live)}.${what ? ` The card says "Plants · ${what}", and read_wall asks where ${what} come${/s$/i.test(what) ? "" : "s"} back` : " read_wall will ask about each"}${laterLine || " until a setup arrow pays it off, or later names the board it pays off on."}${args.what === "" ? " The fold keeps no words now." : ""}`
|
|
2862
|
+
: `${count} card(s) no longer marked as planting${where(live)}.${result?.some((note) => !note.plantsWhat) && args.ids.length ? "" : ""}`,
|
|
2837
2863
|
result,
|
|
2838
2864
|
);
|
|
2839
2865
|
},
|
|
@@ -2964,7 +2990,7 @@ server.registerTool(
|
|
|
2964
2990
|
});
|
|
2965
2991
|
const total = parts.reduce((sum, part) => sum + part.on.length, 0);
|
|
2966
2992
|
// An open card reads as open on a person's page too (round eighteen, entry 53).
|
|
2967
|
-
const where_ = (note) => [note.location ? `at ${note.location}` : "", note.when ? note.when : "", note.rank === "beat" ? "beat" : "", note.open ? `open: "${note.open}"` : ""].filter(Boolean).join(" · ");
|
|
2993
|
+
const where_ = (note) => [note.location ? `at ${note.location}` : "", note.when ? note.when : note.whenOpen ? `when open: "${note.whenOpen}"` : "", note.rank === "beat" ? "beat" : "", note.open ? `open: "${note.open}"` : ""].filter(Boolean).join(" · ");
|
|
2968
2994
|
// A read opens with the door it came through, like every reading (round sixteen, entry 28); one scene a line (29).
|
|
2969
2995
|
const lines = [
|
|
2970
2996
|
`PlotCoder cast (${door(live, base)})`,
|
|
@@ -3159,6 +3185,15 @@ function cardsByRef(state, refs) {
|
|
|
3159
3185
|
return { found, missing };
|
|
3160
3186
|
}
|
|
3161
3187
|
|
|
3188
|
+
/** What tying a thread at both ends did to the fold (R62), in words for the reply. */
|
|
3189
|
+
function foldLine(state, fold, thread) {
|
|
3190
|
+
if (!fold) return "";
|
|
3191
|
+
const head = (id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`;
|
|
3192
|
+
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
|
+
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
|
+
return did.length ? ` Tied at both ends, so it is the fold's now: ${did.join(", ")}.` : "";
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3162
3197
|
function threadLine(state, thread) {
|
|
3163
3198
|
const order = storyOrder(state).filter((note) => thread.noteIds.includes(note.id));
|
|
3164
3199
|
const cards = order.length ? order.map((note) => `"${note.headline}"`).join(" → ") : "no card yet";
|
|
@@ -3187,7 +3222,7 @@ server.registerTool(
|
|
|
3187
3222
|
if (!changed) return ok("No thread made: a thread needs a name.");
|
|
3188
3223
|
const asks = [result.startOpen ? "where it is first seen" : null, result.endOpen ? "where it comes out" : null].filter(Boolean);
|
|
3189
3224
|
return ok(
|
|
3190
|
-
`Named the thread ${threadLine(state, result)}${where(live)}.${asks.length ? ` The reading asks ${asks.join(" and ")} until update_thread ties ${asks.length === 1 ? "that end" : "them"}.` : result.noteIds.length ? " Both ends are tied; the reading lists it and asks nothing." : " No card yet: the reading asks where it is first seen and where it comes out."} The wall draws it as a string through its cards, a loose end where one is open.`,
|
|
3225
|
+
`Named the thread ${threadLine(state, result)}${where(live)}.${asks.length ? ` The reading asks ${asks.join(" and ")} until update_thread ties ${asks.length === 1 ? "that end" : "them"}.` : result.noteIds.length ? " Both ends are tied; the reading lists it and asks nothing." : " No card yet: the reading asks where it is first seen and where it comes out."}${foldLine(state, result.fold, result)} The wall draws it as a string through its cards, a loose end where one is open.`,
|
|
3191
3226
|
result,
|
|
3192
3227
|
);
|
|
3193
3228
|
},
|
|
@@ -3229,7 +3264,7 @@ server.registerTool(
|
|
|
3229
3264
|
const tied = [result.before.startOpen && !result.thread.startOpen ? "its start" : null, result.before.endOpen && !result.thread.endOpen ? "its end" : null].filter(Boolean);
|
|
3230
3265
|
const reopened = [!result.before.startOpen && result.thread.startOpen ? "its start" : null, !result.before.endOpen && result.thread.endOpen ? "its end" : null].filter(Boolean);
|
|
3231
3266
|
return ok(
|
|
3232
|
-
`Now ${threadLine(state, result.thread)}${where(live)}.${tied.length ? ` Tied ${tied.join(" and ")}; the reading stops asking about ${tied.length === 1 ? "it" : "them"}.` : ""}${reopened.length ? ` Opened ${reopened.join(" and ")}; the reading asks about ${reopened.length === 1 ? "it" : "them"} again.` : ""}`,
|
|
3267
|
+
`Now ${threadLine(state, result.thread)}${where(live)}.${tied.length ? ` Tied ${tied.join(" and ")}; the reading stops asking about ${tied.length === 1 ? "it" : "them"}.` : ""}${reopened.length ? ` Opened ${reopened.join(" and ")}; the reading asks about ${reopened.length === 1 ? "it" : "them"} again.` : ""}${foldLine(state, result.fold, result.thread)}`,
|
|
3233
3268
|
result.thread,
|
|
3234
3269
|
);
|
|
3235
3270
|
},
|
|
@@ -3430,7 +3465,8 @@ function describeBoards(project, boards, changedAt = null) {
|
|
|
3430
3465
|
? `${state.notes.length} cards, about ${formatPages(boardEighths(normalizeState(state)))} of ${formatPages(normalizeState(state).targetEighths)} pages`
|
|
3431
3466
|
: "no cards";
|
|
3432
3467
|
const changed = changedAt?.[board.id] ? `, last changed ${changedAt[board.id]}` : "";
|
|
3433
|
-
|
|
3468
|
+
const nameOpen = board.nameOpen ? ` (name open, by the writer's word: "${board.nameOpen}")` : "";
|
|
3469
|
+
return ` ${index + 1}. ${board.id} — "${board.name}"${nameOpen}${open}: ${shape}${changed}`;
|
|
3434
3470
|
})
|
|
3435
3471
|
.join("\n");
|
|
3436
3472
|
}
|
|
@@ -3448,7 +3484,7 @@ server.registerTool(
|
|
|
3448
3484
|
return ok(
|
|
3449
3485
|
[
|
|
3450
3486
|
`Project "${project.name}" (${door(live, base)})`,
|
|
3451
|
-
`premise: ${project.premise ? `"${project.premise}"` : "(not set)"}`,
|
|
3487
|
+
`premise: ${project.premiseOpen ? `open, by the writer's word — "${project.premiseOpen}"` : project.premise ? `"${project.premise}"` : "(not set)"}`,
|
|
3452
3488
|
`boards: ${project.boards.length}`,
|
|
3453
3489
|
describeBoards(project, boards, changedAt),
|
|
3454
3490
|
// The project's length as one line, so a series is not arithmetic by hand (round fifteen, entry 38).
|
|
@@ -3472,14 +3508,16 @@ server.registerTool(
|
|
|
3472
3508
|
{
|
|
3473
3509
|
title: "Set the project's premise",
|
|
3474
3510
|
description:
|
|
3475
|
-
"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. Boards keep their own loglines.",
|
|
3476
|
-
inputSchema: { premise: z.string() },
|
|
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: housing, or a supermarket\" — 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
|
+
inputSchema: { premise: z.string().optional(), open: z.string().optional() },
|
|
3477
3513
|
},
|
|
3478
3514
|
async (args) => {
|
|
3515
|
+
if (args.premise === undefined && args.open === undefined) return ok("Say which: premise (the line, or \"\" to clear it), or open (the writer's words for why there is none yet).");
|
|
3479
3516
|
const { project, boards, rev, base, live } = await readProject();
|
|
3480
|
-
const next = setPremise(project, args.premise);
|
|
3517
|
+
const next = args.open !== undefined ? setPremiseOpen(project, args.open) : setPremise(project, args.premise);
|
|
3481
3518
|
if (next === project) return ok("Premise unchanged.");
|
|
3482
3519
|
await writeProject(next, boards, rev, base);
|
|
3520
|
+
if (next.premiseOpen) return ok(`Premise left open, by the writer's word: "${next.premiseOpen}"${where(live)}. The reading lists it and asks nothing; set_premise with a line decides it, open "" leaves it blank.`, next);
|
|
3483
3521
|
return ok(`Premise ${next.premise ? `set to "${next.premise}"` : "cleared"}${where(live)}.`, next);
|
|
3484
3522
|
},
|
|
3485
3523
|
);
|
|
@@ -3678,14 +3716,16 @@ server.registerTool(
|
|
|
3678
3716
|
{
|
|
3679
3717
|
title: "Start a project",
|
|
3680
3718
|
description:
|
|
3681
|
-
"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.",
|
|
3682
|
-
inputSchema: { name: z.string().min(1), board: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
|
|
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 title, or Feature\"), so a board born from a maybe is not silently \"Board 1\".",
|
|
3720
|
+
inputSchema: { name: z.string().min(1), board: z.string().optional(), boardOpen: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
|
|
3683
3721
|
},
|
|
3684
3722
|
async (args) => {
|
|
3685
3723
|
const account = await findAccount();
|
|
3686
3724
|
if (!account) return shut("No account door: there is one project here, the open one. Set PLOTCODER_EMAIL and PLOTCODER_PASSWORD to start another on the writer's account.");
|
|
3687
3725
|
let record = renameProject(emptyProject(), args.name.trim());
|
|
3688
3726
|
if (args.board?.trim()) record = renameBoard(record, record.activeBoardId, args.board.trim());
|
|
3727
|
+
// A board born from a maybe is born open on its name (R61): boardOpen holds the writer's words.
|
|
3728
|
+
if (args.boardOpen?.trim()) record = setBoardNameOpen(record, record.activeBoardId, args.boardOpen);
|
|
3689
3729
|
const inserted = await account.client.from("projects").insert({ id: record.id, record, reminders: null, rev: 1 });
|
|
3690
3730
|
if (inserted.error) return ok(`Could not start the project: ${inserted.error.message}`);
|
|
3691
3731
|
const target = args.pages ?? args.minutes;
|
|
@@ -3696,7 +3736,8 @@ server.registerTool(
|
|
|
3696
3736
|
joinPresence(record.id);
|
|
3697
3737
|
const targetLine = target === undefined ? ` Its target is ${formatPages(state.targetEighths)} pages, the default for a feature; set_target for a pilot or a half-hour, or pass pages or minutes here.` : ` Its target is ${formatPages(state.targetEighths)} pages.`;
|
|
3698
3738
|
const first = record.boards[0];
|
|
3699
|
-
|
|
3739
|
+
const nameOpenLine = first.nameOpen ? ` The board's name is left open, by the writer's word: "${first.nameOpen}"; rename_board decides it.` : "";
|
|
3740
|
+
return ok(`Started "${record.name}" (${record.id}) with its first board "${first.name}" (${first.id}), and working it now, as ${account.email}.${targetLine}${nameOpenLine}${oneCallHint(record)}`, { id: record.id, name: record.name, boardId: first.id, boardName: first.name, boardNameOpen: first.nameOpen ?? "", targetEighths: state.targetEighths });
|
|
3700
3741
|
},
|
|
3701
3742
|
);
|
|
3702
3743
|
|
|
@@ -3894,19 +3935,19 @@ server.registerTool(
|
|
|
3894
3935
|
{
|
|
3895
3936
|
title: "New board",
|
|
3896
3937
|
description:
|
|
3897
|
-
"Add a board to the project and open it: an empty wall with the logline placeholder, under the same premise, with the same target length as the board that was open. Nothing else is touched — the other boards stay as they are. Name it for what it is: an episode, a draft, a story.",
|
|
3898
|
-
inputSchema: { name: z.string().optional() },
|
|
3938
|
+
"Add a board to the project and open it: an empty wall with the logline placeholder, under the same premise, with the same target length as the board that was open. Nothing else is touched — the other boards stay as they are. Name it for what it is: an episode, a draft, a story; or pass open with the writer's words for why the name is not decided, and it is born open on its name.",
|
|
3939
|
+
inputSchema: { name: z.string().optional(), open: z.string().optional() },
|
|
3899
3940
|
},
|
|
3900
3941
|
async (args) => {
|
|
3901
3942
|
const { project, boards, rev, base } = await readProject();
|
|
3902
3943
|
const previous = boards[project.activeBoardId];
|
|
3903
3944
|
const target =
|
|
3904
3945
|
previous && isBoardState(previous) ? normalizeState(previous).targetEighths : undefined;
|
|
3905
|
-
const { project: next, board } = addBoard(project, args.name ?? "");
|
|
3946
|
+
const { project: next, board } = addBoard(project, args.name ?? "", undefined, args.open ?? "");
|
|
3906
3947
|
const fresh = { ...emptyState(), ...(target ? { targetEighths: target } : {}) };
|
|
3907
3948
|
const { live } = await openBoardEverywhere(next, { ...boards, [board.id]: fresh }, rev, base, board.id);
|
|
3908
3949
|
return ok(
|
|
3909
|
-
`Added "${board.name}" (${board.id}) and opened it${where(live)}: every card call lands there now, and the writer's open wall switched with it; open_board ${project.boards.findIndex((item) => item.id === project.activeBoardId) + 1} returns to "${project.boards.find((item) => item.id === project.activeBoardId)?.name ?? "the one before"}". It is empty, and the project's cast is already there to cast from. The logline is the story's question when the writer has one — leave it empty rather than invent it — and the cards come next.${next.name === "Untitled project" ? " The project is still \"Untitled project\": rename_project names it." : ""}${next.boards.length === 2 && isSampleWall(isBoardState(boards[next.boards[0].id]) ? normalizeState(boards[next.boards[0].id]) : emptyState()) ? " The sample stays as Board 1; delete_board drops it." : ""}`,
|
|
3950
|
+
`Added "${board.name}" (${board.id})${board.nameOpen ? ` — its name left open, by the writer's word: "${board.nameOpen}"` : ""} and opened it${where(live)}: every card call lands there now, and the writer's open wall switched with it; open_board ${project.boards.findIndex((item) => item.id === project.activeBoardId) + 1} returns to "${project.boards.find((item) => item.id === project.activeBoardId)?.name ?? "the one before"}". It is empty, and the project's cast is already there to cast from. The logline is the story's question when the writer has one — leave it empty rather than invent it — and the cards come next.${next.name === "Untitled project" ? " The project is still \"Untitled project\": rename_project names it." : ""}${next.boards.length === 2 && isSampleWall(isBoardState(boards[next.boards[0].id]) ? normalizeState(boards[next.boards[0].id]) : emptyState()) ? " The sample stays as Board 1; delete_board drops it." : ""}`,
|
|
3910
3951
|
board,
|
|
3911
3952
|
);
|
|
3912
3953
|
},
|
|
@@ -3916,17 +3957,25 @@ server.registerTool(
|
|
|
3916
3957
|
"rename_board",
|
|
3917
3958
|
{
|
|
3918
3959
|
title: "Rename board",
|
|
3919
|
-
description: "Rename a board of the project by id, name, or number.",
|
|
3920
|
-
inputSchema: { board: z.union([z.string().min(1), z.number()]), name: z.string().min(1) },
|
|
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 title, or Feature\" — 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
|
+
inputSchema: { board: z.union([z.string().min(1), z.number()]), name: z.string().min(1).optional(), open: z.string().optional() },
|
|
3921
3962
|
},
|
|
3922
3963
|
async (args) => {
|
|
3964
|
+
if (args.name === undefined && args.open === undefined) return ok("Say which: name, or open (the writer's words for why the name is not decided).");
|
|
3923
3965
|
const { project, boards, rev, base } = await readProject();
|
|
3924
3966
|
const target = findBoard(project, String(args.board));
|
|
3925
3967
|
if (!target) return ok(`No board matches "${args.board}". Call list_boards for the real ones.`);
|
|
3968
|
+
if (args.name === undefined) {
|
|
3969
|
+
const next = setBoardNameOpen(project, target.id, args.open);
|
|
3970
|
+
if (next === project) return ok(`"${target.name}" already reads that way.`);
|
|
3971
|
+
const live = await writeProject(next, boards, rev, base);
|
|
3972
|
+
const words = boardById(next, target.id)?.nameOpen ?? "";
|
|
3973
|
+
return ok(words ? `Board "${target.name}" (${target.id}) keeps its name and its name is left open, by the writer's word: "${words}"${where(live)}. The reading lists it; rename_board with a name decides it.` : `Board "${target.name}" (${target.id}) is no longer open on its name${where(live)}.`, { id: target.id, name: target.name, nameOpen: words });
|
|
3974
|
+
}
|
|
3926
3975
|
const next = renameBoard(project, target.id, args.name);
|
|
3927
3976
|
if (next === project) return ok(`"${target.name}" already has that name.`);
|
|
3928
3977
|
const live = await writeProject(next, boards, rev, base);
|
|
3929
|
-
return ok(`Renamed board "${target.name}" (${target.id}) to "${args.name.trim()}"${where(live)}
|
|
3978
|
+
return ok(`Renamed board "${target.name}" (${target.id}) to "${args.name.trim()}"${where(live)}.${target.nameOpen ? " Its name is decided; the open words are gone." : ""}`, { id: target.id, name: args.name.trim() });
|
|
3930
3979
|
},
|
|
3931
3980
|
);
|
|
3932
3981
|
|
package/src/board/agents.js
CHANGED
|
@@ -55,7 +55,7 @@ export const AGENTS = {
|
|
|
55
55
|
"No opinions about how many beats there should be. Marking the turns a treatment plainly makes is reading it, not an opinion: mark them, say which, and let the writer strike or add.",
|
|
56
56
|
"Page counts are estimates.",
|
|
57
57
|
"Ask before delete_board, delete_project, empty_account, delete_account, unlock_numbers, remove_file, an import_project that replaces, or claim_account — the writer gives the email and the password; never invent one. export_project first, when something might be wanted back.",
|
|
58
|
-
"Do not invent people or a logline. What the treatment states — an age, a job, a bad knee — is not invented: it goes in the person's notes. An unnamed person is named by their role — Dana's mother, the dispatcher — which is a name until the writer gives one. A scene is one place and one stretch of time; a new place or time is a new card. A beat is a whole card; a setup arrow lands on the scene's card, so a payoff never needs a card of its own. Acts are groups titled Act one, Act two, when the treatment has them; the wall never asks whether an act is a sequence. Paper colour means nothing to the app. Under target is a fact to report plainly, like over; neither is a verdict. A thing the writer has not decided is an open card in their words (set_open), never a guess to fill the field; a thing whose far end the writer knows and not where it is first seen — the letter, the ring — is a thread (create_thread) with an open start, and the wall asks from that end.",
|
|
58
|
+
"Do not invent people or a logline. What the treatment states — an age, a job, a bad knee — is not invented: it goes in the person's notes. An unnamed person is named by their role — Dana's mother, the dispatcher — which is a name until the writer gives one. A scene is one place and one stretch of time; a new place or time is a new card. A beat is a whole card; a setup arrow lands on the scene's card, so a payoff never needs a card of its own. Acts are groups titled Act one, Act two, when the treatment has them; the wall never asks whether an act is a sequence. Paper colour means nothing to the app. Under target is a fact to report plainly, like over; neither is a verdict. A thing the writer has not decided is an open card in their words (set_open), or an open field — the logline, the premise, a when, a board's name take open beside the value — never a guess to fill the field; a thing whose far end the writer knows and not where it is first seen — the letter, the ring — is a thread (create_thread) with an open start, and the wall asks from that end.",
|
|
59
59
|
],
|
|
60
60
|
person:
|
|
61
61
|
"Give your agent the account door only on a machine you trust; it signs in as you and shows under People as “an agent, as you” while it runs. Wire the server before you start the agent's session, with the two sign-in lines beside it, and the agent has every tool from its first message; wired from inside a session, the server connects only on the next one. Your agent can also make your account: give it your email and a password of your choosing.",
|
package/src/board/fountain.js
CHANGED
|
@@ -122,8 +122,9 @@ export function toFountain(state, options = {}) {
|
|
|
122
122
|
const revisionMarksFor = (_list, card) => Boolean(revisionOf.get(card.id)?.revised);
|
|
123
123
|
const cast = (note.characterIds ?? []).map((id) => nameOf.get(id)).filter(Boolean);
|
|
124
124
|
if (cast.length) marks.push(`with ${cast.join(", ")}`);
|
|
125
|
-
if (note.plants) marks.push("plants something to pay off later");
|
|
125
|
+
if (note.plants) marks.push(note.plantsWhat ? `plants ${note.plantsWhat}` : "plants something to pay off later");
|
|
126
126
|
if (note.open) marks.push(`open: ${note.open}`);
|
|
127
|
+
if (note.whenOpen) marks.push(`when open: ${note.whenOpen}`);
|
|
127
128
|
const onThreads = (state.threads ?? []).filter((thread) => thread.noteIds.includes(note.id)).map((thread) => thread.name);
|
|
128
129
|
if (onThreads.length) marks.push(`thread: ${onThreads.join(", ")}`);
|
|
129
130
|
if (revisionMarksFor(marks, note)) marks.push(`changed in the ${state.revision.color} revision`);
|
package/src/board/project.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export declare const DEFAULT_PROJECT_NAME: string;
|
|
|
8
8
|
export type BoardMeta = {
|
|
9
9
|
id: string;
|
|
10
10
|
name: string;
|
|
11
|
+
/** The writer's words for why the name is not decided (R61), or empty; the name stands meanwhile. */
|
|
12
|
+
nameOpen: string;
|
|
11
13
|
createdAt: string;
|
|
12
14
|
updatedAt: string;
|
|
13
15
|
};
|
|
@@ -18,6 +20,8 @@ export type ProjectRecord = {
|
|
|
18
20
|
id: string;
|
|
19
21
|
name: string;
|
|
20
22
|
premise: string;
|
|
23
|
+
/** The writer's words for why there is no premise yet (R61), or empty. */
|
|
24
|
+
premiseOpen: string;
|
|
21
25
|
boards: BoardMeta[];
|
|
22
26
|
activeBoardId: string;
|
|
23
27
|
/** A writer's own structures, saved from a wall's beats (Roadmap 2, item 7). */
|
|
@@ -41,7 +45,7 @@ export declare function addStructure(
|
|
|
41
45
|
): { project: ProjectRecord; structure: OwnStructure };
|
|
42
46
|
export declare function removeStructure(project: ProjectRecord, id: string, now?: string): ProjectRecord;
|
|
43
47
|
|
|
44
|
-
export declare function newBoardMeta(name: string, now?: string): BoardMeta;
|
|
48
|
+
export declare function newBoardMeta(name: string, now?: string, nameOpen?: string): BoardMeta;
|
|
45
49
|
export declare function emptyProject(now?: string): ProjectRecord;
|
|
46
50
|
export declare function isProjectRecord(value: unknown): value is ProjectRecord;
|
|
47
51
|
export declare function normalizeProject(value: unknown, now?: string): ProjectRecord;
|
|
@@ -49,6 +53,7 @@ export declare function addBoard(
|
|
|
49
53
|
project: ProjectRecord,
|
|
50
54
|
name: string,
|
|
51
55
|
now?: string,
|
|
56
|
+
nameOpen?: string,
|
|
52
57
|
): { project: ProjectRecord; board: BoardMeta };
|
|
53
58
|
export declare function renameBoard(
|
|
54
59
|
project: ProjectRecord,
|
|
@@ -66,6 +71,10 @@ export declare function moveBoard(
|
|
|
66
71
|
export declare function setActiveBoard(project: ProjectRecord, id: string, now?: string): ProjectRecord;
|
|
67
72
|
export declare function renameProject(project: ProjectRecord, name: string, now?: string): ProjectRecord;
|
|
68
73
|
export declare function setPremise(project: ProjectRecord, premise: string, now?: string): ProjectRecord;
|
|
74
|
+
/** The writer's words for why there is no premise yet (R61); words clear the premise, "" takes them back. */
|
|
75
|
+
export declare function setPremiseOpen(project: ProjectRecord, words: string, now?: string): ProjectRecord;
|
|
76
|
+
/** The writer's words for why a board's name is not decided (R61); the name stands meanwhile. */
|
|
77
|
+
export declare function setBoardNameOpen(project: ProjectRecord, id: string, words: string, now?: string): ProjectRecord;
|
|
69
78
|
export declare function boardById(project: ProjectRecord, id: string): BoardMeta | null;
|
|
70
79
|
/** What a script going out is called: a named project is the title, its board beside it only when the project has several. */
|
|
71
80
|
export declare function scriptTitles(project: ProjectRecord, board: BoardMeta | null | undefined): { title: string; episode?: string };
|
package/src/board/project.js
CHANGED
|
@@ -18,8 +18,13 @@ function trimmed(value, fallback) {
|
|
|
18
18
|
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export function newBoardMeta(name, now = nowIso()) {
|
|
22
|
-
return { id: newId(), name: trimmed(name, "Board"), createdAt: now, updatedAt: now };
|
|
21
|
+
export function newBoardMeta(name, now = nowIso(), nameOpen = "") {
|
|
22
|
+
return { id: newId(), name: trimmed(name, "Board"), nameOpen: openWords(nameOpen), createdAt: now, updatedAt: now };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The writer's words for why a field is not decided (R61), one line, spaces collapsed; empty is decided or blank. */
|
|
26
|
+
function openWords(value) {
|
|
27
|
+
return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
export function emptyProject(now = nowIso()) {
|
|
@@ -29,6 +34,7 @@ export function emptyProject(now = nowIso()) {
|
|
|
29
34
|
id: newId(),
|
|
30
35
|
name: DEFAULT_PROJECT_NAME,
|
|
31
36
|
premise: "",
|
|
37
|
+
premiseOpen: "",
|
|
32
38
|
boards: [board],
|
|
33
39
|
activeBoardId: board.id,
|
|
34
40
|
createdAt: now,
|
|
@@ -60,6 +66,8 @@ export function normalizeProject(value, now = nowIso()) {
|
|
|
60
66
|
const boards = value.boards.filter(isBoardMeta).map((board) => ({
|
|
61
67
|
...board,
|
|
62
68
|
name: trimmed(board.name, "Board"),
|
|
69
|
+
// A board named before R61 has no open name; the name stands until the writer says it is not decided.
|
|
70
|
+
nameOpen: openWords(board.nameOpen),
|
|
63
71
|
createdAt: typeof board.createdAt === "string" ? board.createdAt : now,
|
|
64
72
|
updatedAt: typeof board.updatedAt === "string" ? board.updatedAt : now,
|
|
65
73
|
}));
|
|
@@ -80,6 +88,8 @@ export function normalizeProject(value, now = nowIso()) {
|
|
|
80
88
|
version: PROJECT_VERSION,
|
|
81
89
|
name: trimmed(value.name, DEFAULT_PROJECT_NAME),
|
|
82
90
|
premise: typeof value.premise === "string" ? value.premise.trim() : "",
|
|
91
|
+
// A project written before R61 has no open premise (R61).
|
|
92
|
+
premiseOpen: openWords(value.premiseOpen),
|
|
83
93
|
boards,
|
|
84
94
|
activeBoardId,
|
|
85
95
|
structures,
|
|
@@ -261,9 +271,9 @@ function touch(project, patch, now) {
|
|
|
261
271
|
}
|
|
262
272
|
|
|
263
273
|
/** Add a board after the others and open it. Returns the project and the new board. */
|
|
264
|
-
export function addBoard(project, name, now = nowIso()) {
|
|
274
|
+
export function addBoard(project, name, now = nowIso(), nameOpen = "") {
|
|
265
275
|
const fallback = `Board ${project.boards.length + 1}`;
|
|
266
|
-
const board = newBoardMeta(trimmed(name, fallback), now);
|
|
276
|
+
const board = newBoardMeta(trimmed(name, fallback), now, nameOpen);
|
|
267
277
|
return {
|
|
268
278
|
project: touch(project, { boards: [...project.boards, board], activeBoardId: board.id }, now),
|
|
269
279
|
board,
|
|
@@ -275,9 +285,26 @@ export function renameBoard(project, id, name, now = nowIso()) {
|
|
|
275
285
|
if (!next) return project;
|
|
276
286
|
let changed = false;
|
|
277
287
|
const boards = project.boards.map((board) => {
|
|
278
|
-
if (board.id !== id || board.name === next) return board;
|
|
288
|
+
if (board.id !== id || (board.name === next && !(board.nameOpen ?? ""))) return board;
|
|
279
289
|
changed = true;
|
|
280
|
-
|
|
290
|
+
// A name decides the field: the open words go (R61).
|
|
291
|
+
return { ...board, name: next, nameOpen: "", updatedAt: now };
|
|
292
|
+
});
|
|
293
|
+
return changed ? touch(project, { boards }, now) : project;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The writer's words for why a board's name is not decided (R61), or "" to
|
|
298
|
+
* take them back. The name stands as it is — "Board 1" is still what every
|
|
299
|
+
* reply calls it — but the reading lists the words and the crumb draws them.
|
|
300
|
+
*/
|
|
301
|
+
export function setBoardNameOpen(project, id, words, now = nowIso()) {
|
|
302
|
+
const next = openWords(words);
|
|
303
|
+
let changed = false;
|
|
304
|
+
const boards = project.boards.map((board) => {
|
|
305
|
+
if (board.id !== id || (board.nameOpen ?? "") === next) return board;
|
|
306
|
+
changed = true;
|
|
307
|
+
return { ...board, nameOpen: next, updatedAt: now };
|
|
281
308
|
});
|
|
282
309
|
return changed ? touch(project, { boards }, now) : project;
|
|
283
310
|
}
|
|
@@ -321,8 +348,18 @@ export function renameProject(project, name, now = nowIso()) {
|
|
|
321
348
|
|
|
322
349
|
export function setPremise(project, premise, now = nowIso()) {
|
|
323
350
|
const next = typeof premise === "string" ? premise.trim() : "";
|
|
324
|
-
|
|
325
|
-
|
|
351
|
+
// A premise decides the field: the open words go (R61); clearing it leaves them.
|
|
352
|
+
const premiseOpen = next ? "" : (project.premiseOpen ?? "");
|
|
353
|
+
if (next === project.premise && premiseOpen === (project.premiseOpen ?? "")) return project;
|
|
354
|
+
return touch(project, { premise: next, premiseOpen }, now);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The writer's words for why there is no premise yet (R61), or "" to take them back; words clear the premise. */
|
|
358
|
+
export function setPremiseOpen(project, words, now = nowIso()) {
|
|
359
|
+
const next = openWords(words);
|
|
360
|
+
const premise = next ? "" : project.premise;
|
|
361
|
+
if (next === (project.premiseOpen ?? "") && premise === project.premise) return project;
|
|
362
|
+
return touch(project, { premise, premiseOpen: next }, now);
|
|
326
363
|
}
|
|
327
364
|
|
|
328
365
|
/**
|
package/src/board/readWall.d.ts
CHANGED
|
@@ -71,6 +71,8 @@ export type WallReading = {
|
|
|
71
71
|
later: { id: string; boardId: string; noteId: string | null }[];
|
|
72
72
|
/** Open cards (R59): the writer's words for what is not decided, in story order; not asked about while they stand. */
|
|
73
73
|
open: Array<{ id: string; words: string; hides: FindingKind[] }>;
|
|
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
|
+
openFields: Array<{ field: "logline"; words: string } | { field: "when"; id: string; words: string }>;
|
|
74
76
|
/** Threads (R60): each named string with its cards in story order and which ends are open. */
|
|
75
77
|
threads: Array<{ id: string; name: string; ids: string[]; startOpen: boolean; endOpen: boolean }>;
|
|
76
78
|
/** Cards here that pay off a fold of another board (R58), composed by the door from the project. */
|
package/src/board/readWall.js
CHANGED
|
@@ -420,7 +420,7 @@ export function readWall(state, options = {}) {
|
|
|
420
420
|
findings.push({
|
|
421
421
|
kind: "unpaid",
|
|
422
422
|
ids: [note.id],
|
|
423
|
-
text: `${quote(note)} plants something, and no arrow pays it off. Where does it come back
|
|
423
|
+
text: `${quote(note)} plants ${note.plantsWhat || "something"}, and no arrow pays it off. Where does ${note.plantsWhat ? "it" : "it"} come back?`.replace("Where does it come back?", note.plantsWhat ? `Where ${/s$/i.test(note.plantsWhat) ? "do" : "does"} ${note.plantsWhat} come back?` : "Where does it come back?"),
|
|
424
424
|
});
|
|
425
425
|
}
|
|
426
426
|
}
|
|
@@ -557,6 +557,7 @@ export function readWall(state, options = {}) {
|
|
|
557
557
|
later,
|
|
558
558
|
paidBy: paidHere,
|
|
559
559
|
open: describeOpen(state, options, order, openIds),
|
|
560
|
+
openFields: describeOpenFields(state, order),
|
|
560
561
|
threads,
|
|
561
562
|
findings: asked,
|
|
562
563
|
left,
|
|
@@ -568,6 +569,19 @@ export function readWall(state, options = {}) {
|
|
|
568
569
|
* closed (round eighteen, entry 27): the reading of the same wall with the
|
|
569
570
|
* words cleared, read once more, so the writer can see what the words hide.
|
|
570
571
|
*/
|
|
572
|
+
/**
|
|
573
|
+
* The fields the writer has left open (R61), in the writer's words: the
|
|
574
|
+
* board's logline, and a card's when, in story order. The premise and the
|
|
575
|
+
* board's name live on the project, and the door adds them. Listed, never
|
|
576
|
+
* asked about: no check asks about a missing logline or when.
|
|
577
|
+
*/
|
|
578
|
+
function describeOpenFields(state, order) {
|
|
579
|
+
const fields = [];
|
|
580
|
+
if ((state.loglineOpen ?? "").trim()) fields.push({ field: "logline", words: state.loglineOpen.trim() });
|
|
581
|
+
for (const note of order) if ((note.whenOpen ?? "").trim()) fields.push({ field: "when", id: note.id, words: note.whenOpen.trim() });
|
|
582
|
+
return fields;
|
|
583
|
+
}
|
|
584
|
+
|
|
571
585
|
/** The question kinds an open card does not silence: about the story around it, not the card (R59, R60). */
|
|
572
586
|
const ASKED_OF_OPEN_CARDS = new Set(["loose", "empty", "sag"]);
|
|
573
587
|
|
|
@@ -600,7 +614,8 @@ export function describeSetups(reading, state) {
|
|
|
600
614
|
: setup.eighths === 0
|
|
601
615
|
? "in the same place on the wall"
|
|
602
616
|
: `about ${pages(-setup.eighths)} pages earlier`;
|
|
603
|
-
|
|
617
|
+
const what = byId.get(setup.from)?.plantsWhat ?? "";
|
|
618
|
+
return `"${name(setup.from)}" sets up "${name(setup.to)}"${what ? ` — ${what}` : ""}, ${distance}`;
|
|
604
619
|
});
|
|
605
620
|
}
|
|
606
621
|
|
package/src/board/reducer.d.ts
CHANGED
|
@@ -79,6 +79,8 @@ export type BoardNote = {
|
|
|
79
79
|
characterIds: string[];
|
|
80
80
|
/** The corner is folded: this card plants something that must pay off (R31). */
|
|
81
81
|
plants: boolean;
|
|
82
|
+
/** What the fold plants, in the writer's words (R62), or empty; never set on an unfolded card. */
|
|
83
|
+
plantsWhat: string;
|
|
82
84
|
/** When folded: the id of another board of the project where it pays off (R50), or null. */
|
|
83
85
|
payoffBoardId: string | null;
|
|
84
86
|
/** The scene on that board that pays it off (R58), or null while the board is only a promise. */
|
|
@@ -89,6 +91,8 @@ export type BoardNote = {
|
|
|
89
91
|
location: string;
|
|
90
92
|
/** When the scene happens, as the writer says it — "night", "day four, dawn" — printed after the place on the heading (R55). Empty when unsaid. */
|
|
91
93
|
when: string;
|
|
94
|
+
/** The writer's words for why the when is not decided (R61), or empty; while they stand the when is blank and the reading lists them. */
|
|
95
|
+
whenOpen: string;
|
|
92
96
|
/** The scene's text in Fountain (R23 b): action, cues, dialogue; empty until written. */
|
|
93
97
|
text: string;
|
|
94
98
|
createdAt: string;
|
|
@@ -112,6 +116,8 @@ export type BoardArrow = {
|
|
|
112
116
|
export type BoardState = {
|
|
113
117
|
/** The board's central question — what this story is arguing (R19). */
|
|
114
118
|
logline: string;
|
|
119
|
+
/** The writer's words for why there is no logline yet (R61), or empty. */
|
|
120
|
+
loglineOpen: string;
|
|
115
121
|
/** Target script length in eighths of a page; 120 pages for a feature (R25). */
|
|
116
122
|
targetEighths: number;
|
|
117
123
|
/** The roster: every person in the story, whether or not they are on a card yet (R29). */
|
|
@@ -158,7 +164,7 @@ export declare function fillCharacter(character: { id: string; name: string } &
|
|
|
158
164
|
export declare function sameName(a: string, b: string): boolean;
|
|
159
165
|
|
|
160
166
|
export type Command =
|
|
161
|
-
| { type: "set_logline"; logline
|
|
167
|
+
| { type: "set_logline"; logline?: string; open?: string }
|
|
162
168
|
| { type: "set_rank"; ids: string[]; rank: NoteRank }
|
|
163
169
|
| { type: "set_length"; ids: string[]; lengthEighths: number | null }
|
|
164
170
|
| { type: "set_target"; targetEighths: number }
|
|
@@ -175,8 +181,11 @@ export type Command =
|
|
|
175
181
|
lengthEighths?: number;
|
|
176
182
|
characterIds?: string[];
|
|
177
183
|
plants?: boolean;
|
|
184
|
+
/** What it plants, in the writer's words (R62); naming a plant folds the card. */
|
|
185
|
+
plantsWhat?: string;
|
|
178
186
|
location?: string;
|
|
179
187
|
when?: string;
|
|
188
|
+
whenOpen?: string;
|
|
180
189
|
open?: string;
|
|
181
190
|
text?: string;
|
|
182
191
|
}
|
|
@@ -204,11 +213,11 @@ export type Command =
|
|
|
204
213
|
| { type: "remove_character"; id: string }
|
|
205
214
|
| ({ type: "update_character"; id: string } & Partial<Record<CharacterField, string>>)
|
|
206
215
|
| { type: "set_cast"; ids: string[]; characterIds: string[] }
|
|
207
|
-
| { type: "set_plant"; ids: string[]; plants
|
|
216
|
+
| { type: "set_plant"; ids: string[]; plants?: boolean; what?: string }
|
|
208
217
|
| { type: "set_open"; ids: string[]; open: string }
|
|
209
218
|
| { type: "set_payoff_board"; ids: string[]; boardId: string | null; noteId?: string | null }
|
|
210
219
|
| { type: "set_location"; ids: string[]; location: string }
|
|
211
|
-
| { type: "set_when"; ids: string[]; when
|
|
220
|
+
| { type: "set_when"; ids: string[]; when?: string; open?: string }
|
|
212
221
|
| { type: "apply_template"; template: string; beats?: Array<{ name: string; prompt: string; at: number }> }
|
|
213
222
|
| { type: "set_text"; id: string; text: string }
|
|
214
223
|
| { type: "lock_numbers"; order?: string[] }
|
package/src/board/reducer.js
CHANGED
|
@@ -220,6 +220,7 @@ export function emptyState() {
|
|
|
220
220
|
return {
|
|
221
221
|
logline: "",
|
|
222
222
|
targetEighths: DEFAULT_TARGET_EIGHTHS,
|
|
223
|
+
loglineOpen: "",
|
|
223
224
|
characters: [],
|
|
224
225
|
notes: [],
|
|
225
226
|
groups: [],
|
|
@@ -247,8 +248,12 @@ export function seedState(now = nowIso()) {
|
|
|
247
248
|
characterIds,
|
|
248
249
|
location: "",
|
|
249
250
|
when: "",
|
|
251
|
+
// The writer's words for why the when is not decided (R61), or nothing.
|
|
252
|
+
whenOpen: "",
|
|
250
253
|
text: "",
|
|
251
254
|
plants: false,
|
|
255
|
+
// What the fold plants, in the writer's words (R62), or nothing.
|
|
256
|
+
plantsWhat: "",
|
|
252
257
|
// A fold that pays off on another board — a later episode — names it here;
|
|
253
258
|
// null claims nothing (R50). The scene there that pays it off, once one
|
|
254
259
|
// does (R58); null while the board is a promise.
|
|
@@ -267,6 +272,7 @@ export function seedState(now = nowIso()) {
|
|
|
267
272
|
// new writer finds out the logline is there at all.
|
|
268
273
|
logline: "",
|
|
269
274
|
targetEighths: DEFAULT_TARGET_EIGHTHS,
|
|
275
|
+
loglineOpen: "",
|
|
270
276
|
// Two people, cast on the cards, so a new writer sees what the roster is for.
|
|
271
277
|
characters: [
|
|
272
278
|
fillCharacter({ id: "maya", name: "Maya", createdAt: now, updatedAt: now }),
|
|
@@ -352,6 +358,8 @@ export function normalizeState(value) {
|
|
|
352
358
|
const characterIds = knownCast(note?.characterIds, characters);
|
|
353
359
|
// Cards written before R31 have no fold; a plant is a claim you make.
|
|
354
360
|
const plants = note?.plants === true;
|
|
361
|
+
// Cards folded before R62 say "something": the fold's words are the writer's, or nothing.
|
|
362
|
+
const plantsWhat = plants && typeof note?.plantsWhat === "string" ? note.plantsWhat : "";
|
|
355
363
|
// Cards written before R50 pay off on their own board or not at all.
|
|
356
364
|
const payoffBoardId = plants && typeof note?.payoffBoardId === "string" && note.payoffBoardId ? note.payoffBoardId : null;
|
|
357
365
|
// Cards written before R58 name a board and no scene on it.
|
|
@@ -362,6 +370,8 @@ export function normalizeState(value) {
|
|
|
362
370
|
const location = typeof note?.location === "string" ? note.location : "";
|
|
363
371
|
// Cards written before R55 have no when; a scene is at no time until it is.
|
|
364
372
|
const when = typeof note?.when === "string" ? note.when : "";
|
|
373
|
+
// Cards written before R61 have no open when; a when is decided or blank until the writer says otherwise.
|
|
374
|
+
const whenOpen = typeof note?.whenOpen === "string" ? note.whenOpen : "";
|
|
365
375
|
// Cards written before pages (R23 b) have no text; a scene is unwritten until it is.
|
|
366
376
|
const text = typeof note?.text === "string" ? note.text : "";
|
|
367
377
|
if (
|
|
@@ -371,17 +381,19 @@ export function normalizeState(value) {
|
|
|
371
381
|
Array.isArray(note.characterIds) &&
|
|
372
382
|
sameIds(note.characterIds, characterIds) &&
|
|
373
383
|
note.plants === plants &&
|
|
384
|
+
note.plantsWhat === plantsWhat &&
|
|
374
385
|
note.payoffBoardId === payoffBoardId &&
|
|
375
386
|
note.payoffNoteId === payoffNoteId &&
|
|
376
387
|
note.open === open &&
|
|
377
388
|
note.location === location &&
|
|
378
389
|
note.when === when &&
|
|
390
|
+
note.whenOpen === whenOpen &&
|
|
379
391
|
note.text === text
|
|
380
392
|
) {
|
|
381
393
|
return note;
|
|
382
394
|
}
|
|
383
395
|
patched = true;
|
|
384
|
-
return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, payoffNoteId, open, location, when, text };
|
|
396
|
+
return { ...note, rank, lengthEighths, characterIds, plants, plantsWhat, payoffBoardId, payoffNoteId, open, location, when, whenOpen, text };
|
|
385
397
|
});
|
|
386
398
|
|
|
387
399
|
// Boards written before the production half (Roadmap 2, item 8) have no
|
|
@@ -411,8 +423,11 @@ export function normalizeState(value) {
|
|
|
411
423
|
return { ...thread, noteIds, startOpen, endOpen };
|
|
412
424
|
})
|
|
413
425
|
.filter(Boolean);
|
|
426
|
+
// Boards written before R61 have no open logline; a logline is decided or blank until the writer says otherwise.
|
|
427
|
+
const loglineOpen = typeof value.loglineOpen === "string" ? value.loglineOpen : "";
|
|
414
428
|
if (
|
|
415
429
|
value.logline === logline &&
|
|
430
|
+
value.loglineOpen === loglineOpen &&
|
|
416
431
|
value.targetEighths === targetEighths &&
|
|
417
432
|
!rosterPatched &&
|
|
418
433
|
!arrowsPatched &&
|
|
@@ -427,6 +442,7 @@ export function normalizeState(value) {
|
|
|
427
442
|
return {
|
|
428
443
|
...value,
|
|
429
444
|
logline,
|
|
445
|
+
loglineOpen,
|
|
430
446
|
targetEighths,
|
|
431
447
|
characters,
|
|
432
448
|
notes: patched ? notes : value.notes,
|
|
@@ -458,6 +474,48 @@ function cleanOpen(value) {
|
|
|
458
474
|
return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
|
|
459
475
|
}
|
|
460
476
|
|
|
477
|
+
/**
|
|
478
|
+
* The combine log's rule, both halves (R60, R62). A thread tied at both ends
|
|
479
|
+
* through two or more cards is a plant between two scenes that exist, and
|
|
480
|
+
* that is the fold's and the setup arrow's to draw: when the first card's
|
|
481
|
+
* fold is free — unfolded, or folded for the same thing, or folded with no
|
|
482
|
+
* words — the card is folded, the fold takes the thread's name, and a setup
|
|
483
|
+
* arrow runs from the first card to the last. When the first card's fold is
|
|
484
|
+
* another thing's, the thread stays a thread and nothing is drawn. Returns
|
|
485
|
+
* the state and what it did, so the door can say it.
|
|
486
|
+
*/
|
|
487
|
+
function tieIntoFold(state, thread, now) {
|
|
488
|
+
const none = { state, fold: null };
|
|
489
|
+
if (thread.startOpen || thread.endOpen || thread.noteIds.length < 2) return none;
|
|
490
|
+
const firstId = thread.noteIds[0];
|
|
491
|
+
const lastId = thread.noteIds[thread.noteIds.length - 1];
|
|
492
|
+
const first = state.notes.find((note) => note.id === firstId);
|
|
493
|
+
if (!first) return none;
|
|
494
|
+
const what = first.plantsWhat ?? "";
|
|
495
|
+
if (first.plants && what && what.toLowerCase() !== thread.name.toLowerCase()) {
|
|
496
|
+
return { state, fold: { kept: true, firstId, what } };
|
|
497
|
+
}
|
|
498
|
+
let next = state;
|
|
499
|
+
const folded = !first.plants;
|
|
500
|
+
const named = !what;
|
|
501
|
+
if (folded || named) {
|
|
502
|
+
next = {
|
|
503
|
+
...next,
|
|
504
|
+
notes: next.notes.map((note) => (note.id === firstId ? bump(note, { plants: true, plantsWhat: thread.name }, now) : note)),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
const hasArrow = next.arrows.some((arrow) => arrow.kind === "setup" && arrow.from === firstId && arrow.to === lastId);
|
|
508
|
+
let arrow = null;
|
|
509
|
+
if (!hasArrow) {
|
|
510
|
+
const drawn = applyCommand(next, { type: "create_arrow", from: firstId, to: lastId, kind: "setup" }, now);
|
|
511
|
+
if (drawn.changed) {
|
|
512
|
+
next = drawn.state;
|
|
513
|
+
arrow = { from: firstId, to: lastId };
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return { state: next, fold: { kept: false, firstId, lastId, folded, named, arrow } };
|
|
517
|
+
}
|
|
518
|
+
|
|
461
519
|
/** A thread's name as the writer typed it, one line, spaces collapsed (R60). */
|
|
462
520
|
function cleanThreadName(value) {
|
|
463
521
|
return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
|
|
@@ -474,10 +532,15 @@ function pruneGroups(groups) {
|
|
|
474
532
|
|
|
475
533
|
export function applyCommand(state, command, now = nowIso()) {
|
|
476
534
|
switch (command.type) {
|
|
535
|
+
// The logline, or the writer's words for why there is none yet (R61): a
|
|
536
|
+
// value clears the open words, open words clear the value, and open ""
|
|
537
|
+
// leaves the field blank.
|
|
477
538
|
case "set_logline": {
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
539
|
+
const hasOpen = typeof command.open === "string";
|
|
540
|
+
const logline = hasOpen && command.open.trim() ? "" : typeof command.logline === "string" ? command.logline.trim() : (state.logline ?? "");
|
|
541
|
+
const loglineOpen = hasOpen ? cleanOpen(command.open) : logline ? "" : (state.loglineOpen ?? "");
|
|
542
|
+
if (logline === (state.logline ?? "") && loglineOpen === (state.loglineOpen ?? "")) return { state, changed: false };
|
|
543
|
+
return { state: { ...state, logline, loglineOpen }, changed: true, result: { logline, loglineOpen } };
|
|
481
544
|
}
|
|
482
545
|
|
|
483
546
|
case "create_note": {
|
|
@@ -496,12 +559,15 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
496
559
|
? null
|
|
497
560
|
: clampEighths(command.lengthEighths, DEFAULT_NOTE_EIGHTHS, MAX_NOTE_EIGHTHS),
|
|
498
561
|
characterIds: knownCast(command.characterIds, state.characters ?? []),
|
|
499
|
-
plants: command.plants === true,
|
|
562
|
+
plants: command.plants === true || Boolean(cleanOpen(command.plantsWhat)),
|
|
563
|
+
// What it plants, in the writer's words (R62): naming a plant folds the card.
|
|
564
|
+
plantsWhat: command.plants === false ? "" : cleanOpen(command.plantsWhat),
|
|
500
565
|
payoffBoardId: null,
|
|
501
566
|
payoffNoteId: null,
|
|
502
567
|
open: cleanOpen(command.open),
|
|
503
568
|
location: cleanPlace(command.location),
|
|
504
|
-
when: cleanWhen(command.when),
|
|
569
|
+
when: cleanOpen(command.whenOpen) ? "" : cleanWhen(command.when),
|
|
570
|
+
whenOpen: cleanOpen(command.whenOpen),
|
|
505
571
|
text: typeof command.text === "string" ? command.text : "",
|
|
506
572
|
z: maxZ(state.notes) + 1,
|
|
507
573
|
createdAt: now,
|
|
@@ -751,7 +817,8 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
751
817
|
startOpen: command.startOpen === true,
|
|
752
818
|
endOpen: command.endOpen === true,
|
|
753
819
|
};
|
|
754
|
-
|
|
820
|
+
const tied = tieIntoFold({ ...state, threads: [...(state.threads ?? []), thread] }, thread, now);
|
|
821
|
+
return { state: tied.state, changed: true, result: { ...thread, fold: tied.fold } };
|
|
755
822
|
}
|
|
756
823
|
|
|
757
824
|
case "update_thread": {
|
|
@@ -768,10 +835,17 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
768
835
|
return { state, changed: false };
|
|
769
836
|
}
|
|
770
837
|
const next = { ...current, name, noteIds, startOpen, endOpen };
|
|
838
|
+
// A fold this thread named follows a rename (R62), so the two never drift apart on the wall's word alone.
|
|
839
|
+
let notes = state.notes;
|
|
840
|
+
if (name !== current.name && noteIds.length) {
|
|
841
|
+
const firstId = noteIds[0];
|
|
842
|
+
notes = notes.map((note) => (note.id === firstId && note.plants && (note.plantsWhat ?? "").toLowerCase() === current.name.toLowerCase() ? bump(note, { plantsWhat: name }, now) : note));
|
|
843
|
+
}
|
|
844
|
+
const tied = tieIntoFold({ ...state, notes, threads: state.threads.map((thread) => (thread.id === command.id ? next : thread)) }, next, now);
|
|
771
845
|
return {
|
|
772
|
-
state:
|
|
846
|
+
state: tied.state,
|
|
773
847
|
changed: true,
|
|
774
|
-
result: { thread: next, before: current },
|
|
848
|
+
result: { thread: next, before: current, fold: tied.fold },
|
|
775
849
|
};
|
|
776
850
|
}
|
|
777
851
|
|
|
@@ -1010,11 +1084,13 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
1010
1084
|
lengthEighths: null,
|
|
1011
1085
|
characterIds: [],
|
|
1012
1086
|
plants: false,
|
|
1087
|
+
plantsWhat: "",
|
|
1013
1088
|
payoffBoardId: null,
|
|
1014
1089
|
payoffNoteId: null,
|
|
1015
1090
|
open: "",
|
|
1016
1091
|
location: "",
|
|
1017
1092
|
when: "",
|
|
1093
|
+
whenOpen: "",
|
|
1018
1094
|
text: "",
|
|
1019
1095
|
createdAt: now,
|
|
1020
1096
|
updatedAt: now,
|
|
@@ -1121,11 +1197,21 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
1121
1197
|
case "set_when": {
|
|
1122
1198
|
const ids = new Set(command.ids);
|
|
1123
1199
|
if (ids.size === 0) return { state, changed: false };
|
|
1124
|
-
|
|
1200
|
+
// The when, or the writer's words for why there is none yet (R61): a
|
|
1201
|
+
// value clears the open words, open words clear the value, open "" leaves
|
|
1202
|
+
// the when blank.
|
|
1203
|
+
const hasOpen = typeof command.open === "string";
|
|
1204
|
+
const whenOpen = hasOpen ? cleanOpen(command.open) : null;
|
|
1205
|
+
const when = hasOpen ? (whenOpen ? "" : typeof command.when === "string" ? cleanWhen(command.when) : null) : cleanWhen(command.when);
|
|
1125
1206
|
const touched = [];
|
|
1126
1207
|
const notes = state.notes.map((note) => {
|
|
1127
|
-
if (!ids.has(note.id)
|
|
1128
|
-
const
|
|
1208
|
+
if (!ids.has(note.id)) return note;
|
|
1209
|
+
const patch = {
|
|
1210
|
+
when: when === null ? (note.when ?? "") : when,
|
|
1211
|
+
whenOpen: whenOpen === null ? (when ? "" : (note.whenOpen ?? "")) : whenOpen,
|
|
1212
|
+
};
|
|
1213
|
+
if (patch.when === (note.when ?? "") && patch.whenOpen === (note.whenOpen ?? "")) return note;
|
|
1214
|
+
const next = bump(note, patch, now);
|
|
1129
1215
|
touched.push(next);
|
|
1130
1216
|
return next;
|
|
1131
1217
|
});
|
|
@@ -1155,15 +1241,20 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
1155
1241
|
case "set_plant": {
|
|
1156
1242
|
const ids = new Set(command.ids);
|
|
1157
1243
|
if (ids.size === 0) return { state, changed: false };
|
|
1158
|
-
|
|
1244
|
+
// What the fold plants, in the writer's words (R62): `what` names it and
|
|
1245
|
+
// folds an unfolded card; `plants` alone keeps the words; unfolding
|
|
1246
|
+
// forgets them, as it forgets where it paid off.
|
|
1247
|
+
const what = typeof command.what === "string" ? cleanOpen(command.what) : null;
|
|
1159
1248
|
const touched = [];
|
|
1160
1249
|
const notes = state.notes.map((note) => {
|
|
1161
1250
|
if (!ids.has(note.id)) return note;
|
|
1251
|
+
const plants = typeof command.plants === "boolean" ? command.plants : note.plants || Boolean(what);
|
|
1252
|
+
const plantsWhat = !plants ? "" : what === null ? (note.plantsWhat ?? "") : what;
|
|
1162
1253
|
// Unfolding forgets where it paid off; a claim that no longer stands.
|
|
1163
1254
|
const payoffBoardId = plants ? note.payoffBoardId : null;
|
|
1164
1255
|
const payoffNoteId = plants ? (note.payoffNoteId ?? null) : null;
|
|
1165
|
-
if (note.plants === plants && note.payoffBoardId === payoffBoardId && note.payoffNoteId === payoffNoteId) return note;
|
|
1166
|
-
const next = bump(note, { plants, payoffBoardId, payoffNoteId }, now);
|
|
1256
|
+
if (note.plants === plants && (note.plantsWhat ?? "") === plantsWhat && note.payoffBoardId === payoffBoardId && note.payoffNoteId === payoffNoteId) return note;
|
|
1257
|
+
const next = bump(note, { plants, plantsWhat, payoffBoardId, payoffNoteId }, now);
|
|
1167
1258
|
touched.push(next);
|
|
1168
1259
|
return next;
|
|
1169
1260
|
});
|
package/src/board/words.js
CHANGED
|
@@ -66,7 +66,7 @@ export const WORD_GROUPS = [
|
|
|
66
66
|
{
|
|
67
67
|
id: "open",
|
|
68
68
|
name: "Open",
|
|
69
|
-
sentence: "A card the writer has not decided, in their words on its edge: the reading lists it and asks nothing else of it until the words are cleared.",
|
|
69
|
+
sentence: "A card the writer has not decided, in their words on its edge: the reading lists it and asks nothing else of it until the words are cleared. A field can be open the same way — the logline, the premise, a card's when, a board's name — the words where the value would be, listed and not asked.",
|
|
70
70
|
},
|
|
71
71
|
{
|
|
72
72
|
id: "thread",
|
|
@@ -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. The card says unpaid, and the bar’s Asks line keeps asking, 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.",
|
|
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 wrong tools. 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
|
@@ -24,15 +24,15 @@ export const WORKFLOWS = [
|
|
|
24
24
|
// an agent asks the ones the treatment leaves open, and invents none.
|
|
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
|
-
{ question: "What is the central question, in one sentence?", hint: "And if this is one episode of something, what is the series about?", tool: "set_logline, set_premise" },
|
|
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
28
|
{ 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
29
|
{ question: "Does it have acts?", hint: "If so, where does each break fall?", tool: "create_group" },
|
|
30
30
|
{ 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" },
|
|
31
|
-
{ question: "When does a scene happen, where that matters?", hint: "That night; the fourth of October. It goes beside the place, never in the headline.", tool: "set_when" },
|
|
31
|
+
{ question: "When does a scene happen, where that matters?", hint: "That night; the fourth of October. It goes beside the place, never in the headline. Not decided: set_when with open and the writer's words, and the card is still asked about the rest.", tool: "set_when" },
|
|
32
32
|
{ question: "Who is in each scene, and what do we call them?", hint: "A full name, or a role for someone unnamed — the man in 42. And who is only spoken of, never in a scene? They go in someone's notes, not the cast.", tool: "add_character, cast, update_character" },
|
|
33
33
|
{ question: "What is planted, and where does it pay off?", hint: "Name the episode when it pays off outside this one, so the fold is deliberate and the wall knows where to look. A thing whose far end you know and not its first sighting — the key, the bucket — is a thread with an open start.", tool: "set_plant with later, create_arrow; create_thread" },
|
|
34
34
|
{ question: "Which scenes do you already know run long or short?", hint: "A day in the story is not a page count; leave the rest unsized.", tool: "set_length" },
|
|
35
|
-
{ question: "What are the project and the board called?", hint: "The series, and this episode.", tool: "rename_project, rename_board" },
|
|
35
|
+
{ question: "What are the project and the board called?", hint: "The series, and this episode. A board's name not decided: rename_board, new_project and new_board take open with the writer's words.", tool: "rename_project, rename_board" },
|
|
36
36
|
{ question: "What must not be invented?", hint: "Looks and voices are yours until you say; so is anything the treatment does not state.", tool: "update_character, later" },
|
|
37
37
|
],
|
|
38
38
|
},
|