plotcoder-board 0.1.33 → 0.1.35
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 +80 -35
- package/src/board/agents.js +1 -1
- package/src/board/fountain.js +1 -0
- package/src/board/project.d.ts +4 -0
- package/src/board/project.js +13 -2
- package/src/board/readWall.d.ts +2 -2
- package/src/board/readWall.js +33 -73
- package/src/board/reducer.d.ts +4 -1
- package/src/board/reducer.js +102 -9
- package/src/board/words.js +2 -2
- 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.35. 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
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
setPremise,
|
|
72
72
|
setPremiseOpen,
|
|
73
73
|
setBoardNameOpen,
|
|
74
|
+
setProjectNameOpen,
|
|
74
75
|
structureBeats,
|
|
75
76
|
reidentifyProject,
|
|
76
77
|
renameProject,
|
|
@@ -995,16 +996,16 @@ const CHECK_WORDS = {
|
|
|
995
996
|
};
|
|
996
997
|
/** What an open card would be asked once closed, as the question and not the check's clean form (round nineteen, entry 31). */
|
|
997
998
|
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",
|
|
999
|
+
unwritten: "its change line (update_note)",
|
|
1000
|
+
unlinked: "what comes before and after it (move_scene, or create_arrow)",
|
|
1001
|
+
duplicate: "which of two alike headlines it is (update_note)",
|
|
1001
1002
|
sequence: "whether its group is one sequence",
|
|
1002
|
-
nobody: "who is in it",
|
|
1003
|
+
nobody: "who is in it (cast)",
|
|
1003
1004
|
absent: "who has been gone too long",
|
|
1004
1005
|
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",
|
|
1006
|
+
unpaid: "where its fold pays off (create_arrow, kind setup)",
|
|
1007
|
+
unplanted: "what plants its payoff (set_plant on the card that does)",
|
|
1008
|
+
unplaced: "where it happens (set_location)",
|
|
1008
1009
|
};
|
|
1009
1010
|
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
1011
|
|
|
@@ -1065,7 +1066,7 @@ function summarize(state) {
|
|
|
1065
1066
|
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("");
|
|
1066
1067
|
const snap = state.revision?.snapshot?.[note.id];
|
|
1067
1068
|
const revised = snap && (snap.headline !== note.headline || snap.change !== note.change || (snap.text ?? "") !== (note.text ?? "") || (snap.location ?? "") !== (note.location ?? "")) ? `, changed in ${state.revision.color}` : "";
|
|
1068
|
-
const place = note.location ? `, at: ${note.location}` : "";
|
|
1069
|
+
const place = note.location ? `, at: ${note.location}` : note.locationOpen ? `, at: open, by the writer's word — "${note.locationOpen}"` : "";
|
|
1069
1070
|
const when = note.when ? `, when: ${note.when}` : note.whenOpen ? `, when: open, by the writer's word — "${note.whenOpen}"` : "";
|
|
1070
1071
|
const openWord = note.open ? `, open (the writer's words): "${note.open}"` : "";
|
|
1071
1072
|
const count = formatPages(noteEighths(note));
|
|
@@ -1143,7 +1144,7 @@ function summarize(state) {
|
|
|
1143
1144
|
`beats: ${beats}, scenes: ${scenes}`,
|
|
1144
1145
|
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
1145
1146
|
? `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)}`,
|
|
1147
|
+
: `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
1148
|
`notes: ${state.notes.length}, groups: ${state.groups.length}, arrows: ${state.arrows.length}, cast: ${state.characters.length}`,
|
|
1148
1149
|
"cards (in story order — the follows arrows over the rows; each with its id):",
|
|
1149
1150
|
notes || " (no cards)",
|
|
@@ -1344,7 +1345,8 @@ server.registerTool(
|
|
|
1344
1345
|
rank: rankSchema.optional(),
|
|
1345
1346
|
pages: pagesSchema.optional(),
|
|
1346
1347
|
plants: z.boolean().optional(),
|
|
1347
|
-
|
|
1348
|
+
plantsWhat: z.string().optional(),
|
|
1349
|
+
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
1350
|
before: z.string().optional().describe("Or before this card (id or headline)."),
|
|
1349
1351
|
location: z.string().optional(),
|
|
1350
1352
|
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.'),
|
|
@@ -1575,21 +1577,38 @@ server.registerTool(
|
|
|
1575
1577
|
const beatEighths = reading.beats.reduce((sum, beat) => sum + noteEighths(state.notes.find((note) => note.id === beat.id) ?? {}), 0);
|
|
1576
1578
|
// 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
1579
|
const openFieldLines = [
|
|
1580
|
+
...(projectForRead.nameOpen ? [` - the project's name — ${projectForRead.nameOpen}`] : []),
|
|
1578
1581
|
...reading.openFields.filter((field) => field.field === "logline").map((field) => ` - the logline — ${field.words}`),
|
|
1579
1582
|
...(projectForRead.premiseOpen ? [` - the premise — ${projectForRead.premiseOpen}`] : []),
|
|
1580
1583
|
...(readBoardMeta?.nameOpen ? [` - this board's name — ${readBoardMeta.nameOpen}`] : []),
|
|
1581
|
-
...reading.openFields.filter((field) => field.field === "
|
|
1584
|
+
...reading.openFields.filter((field) => field.field === "location").map((field) => ` - "${state.notes.find((note) => note.id === field.id)?.headline ?? field.id}" — where: ${field.words}`),
|
|
1585
|
+
// Whens left open in the same words are one line, not one per card (round twenty, entry 22).
|
|
1586
|
+
...(() => {
|
|
1587
|
+
const byWords = new Map();
|
|
1588
|
+
for (const field of reading.openFields.filter((item) => item.field === "when")) {
|
|
1589
|
+
const key = field.words.toLowerCase();
|
|
1590
|
+
if (!byWords.has(key)) byWords.set(key, { words: field.words, ids: [] });
|
|
1591
|
+
byWords.get(key).ids.push(field.id);
|
|
1592
|
+
}
|
|
1593
|
+
const name = (id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`;
|
|
1594
|
+
return [...byWords.values()].map((group) =>
|
|
1595
|
+
group.ids.length === 1
|
|
1596
|
+
? ` - ${name(group.ids[0])} — when: ${group.words}`
|
|
1597
|
+
: ` - 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(", ")}`}`,
|
|
1598
|
+
);
|
|
1599
|
+
})(),
|
|
1582
1600
|
];
|
|
1583
1601
|
const lines = [
|
|
1584
1602
|
`PlotCoder wall (${door(live, base)})`,
|
|
1585
1603
|
...(state.lock ? [`numbers: locked since ${String(state.lock.at).slice(0, 10)}; read_pages shows each scene's number`] : []),
|
|
1586
1604
|
`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` : ""}`,
|
|
1605
|
+
...(projectForRead.nameOpen ? [`project: "${projectForRead.name}" — its name is open, by the writer's word: "${projectForRead.nameOpen}"`] : []),
|
|
1587
1606
|
...(projectForRead.premiseOpen ? [`premise: open, by the writer's word — "${projectForRead.premiseOpen}"`] : []),
|
|
1588
1607
|
`logline: ${state.loglineOpen ? `open, by the writer's word — "${state.loglineOpen}"` : state.logline ? `"${state.logline}"` : "(none yet)"}`,
|
|
1589
1608
|
"the cast and the places are list_board's, not the reading's",
|
|
1590
1609
|
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
1591
1610
|
? `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"}
|
|
1611
|
+
: `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
1612
|
`groups: ${
|
|
1594
1613
|
state.groups.length
|
|
1595
1614
|
? state.groups
|
|
@@ -1621,7 +1640,7 @@ server.registerTool(
|
|
|
1621
1640
|
? ["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
1641
|
: []),
|
|
1623
1642
|
...(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" : ""}`)]
|
|
1643
|
+
? ["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
1644
|
: []),
|
|
1626
1645
|
"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
1646
|
...(reading.findings.length
|
|
@@ -2024,11 +2043,15 @@ server.registerTool(
|
|
|
2024
2043
|
async () => {
|
|
2025
2044
|
const { project } = await readProject();
|
|
2026
2045
|
const own = project.structures ?? [];
|
|
2046
|
+
// Pages beside the percentages, against the open board's target, so the two readings agree without arithmetic (round twenty, entry 57).
|
|
2047
|
+
const { state } = await readBoard();
|
|
2048
|
+
const target = state?.targetEighths ?? DEFAULT_TARGET_EIGHTHS;
|
|
2049
|
+
const at = (beat) => `${beat.name} at ${Math.round(beat.at * 100)}% (p. ${Math.floor((beat.at * target) / EIGHTHS_PER_PAGE) + 1})`;
|
|
2027
2050
|
const lines = [
|
|
2028
2051
|
`the writer's own: ${own.length}`,
|
|
2029
|
-
...own.map((structure) => ` - ${structure.id} — "${structure.name}" (${structure.beats.length} beats: ${structure.beats.map(
|
|
2052
|
+
...own.map((structure) => ` - ${structure.id} — "${structure.name}" (${structure.beats.length} beats: ${structure.beats.map(at).join(", ")})`),
|
|
2030
2053
|
`built in: ${TEMPLATES.length}`,
|
|
2031
|
-
...TEMPLATES.map((template) => ` - ${template.id} — "${template.name}" (${template.beats.length} beats: ${template.beats.map(
|
|
2054
|
+
...TEMPLATES.map((template) => ` - ${template.id} — "${template.name}" (${template.beats.length} beats: ${template.beats.map(at).join(", ")})`),
|
|
2032
2055
|
"compare_structure sets one of these beside this wall's beats, page by page, and lays nothing",
|
|
2033
2056
|
];
|
|
2034
2057
|
return ok(lines.join("\n"), { builtIn: TEMPLATES.map((template) => ({ id: template.id, name: template.name, beats: template.beats })), own });
|
|
@@ -2057,10 +2080,11 @@ server.registerTool(
|
|
|
2057
2080
|
const allMeasured = state.notes.length > 0 && state.notes.every((note) => isMeasured(note));
|
|
2058
2081
|
const short = state.targetEighths > 0 && boardEighths(state) * 2 < state.targetEighths;
|
|
2059
2082
|
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:`,
|
|
2083
|
+
`"${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
2084
|
// On a wall under half its target the pairing is arithmetic; say so before the rows, not after them (round eighteen, entry 52).
|
|
2062
2085
|
...(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
|
-
|
|
2086
|
+
// On a thin wall a match is arithmetic, and the row says so where it says "here" (round twenty, entry 54).
|
|
2087
|
+
...describeComparison(comparison).map((line) => ` - ${short ? line.replace(/\bhere\b/, "here, by arithmetic") : line}`),
|
|
2064
2088
|
comparison.unmatched.length
|
|
2065
2089
|
? `beats of the wall no beat of the structure answers: ${comparison.unmatched.map((beat) => `"${beat.headline}" (p. ${beat.page})`).join(", ")}`
|
|
2066
2090
|
: "every beat of the wall answers one of the structure's",
|
|
@@ -2726,7 +2750,7 @@ server.registerTool(
|
|
|
2726
2750
|
{
|
|
2727
2751
|
title: "Set when scenes happen",
|
|
2728
2752
|
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
|
|
2753
|
+
"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
2754
|
inputSchema: { ids: z.array(z.string()).min(1), when: z.string().optional(), open: z.string().optional() },
|
|
2731
2755
|
},
|
|
2732
2756
|
async (args) => {
|
|
@@ -2990,12 +3014,15 @@ server.registerTool(
|
|
|
2990
3014
|
});
|
|
2991
3015
|
const total = parts.reduce((sum, part) => sum + part.on.length, 0);
|
|
2992
3016
|
// An open card reads as open on a person's page too (round eighteen, entry 53).
|
|
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(" · ");
|
|
3017
|
+
const where_ = (note) => [note.location ? `at ${note.location}` : note.locationOpen ? `where open: "${note.locationOpen}"` : "", note.when ? note.when : note.whenOpen ? `when open: "${note.whenOpen}"` : "", note.rank === "beat" ? "beat" : "", note.open ? `open: "${note.open}"` : ""].filter(Boolean).join(" · ");
|
|
2994
3018
|
// A read opens with the door it came through, like every reading (round sixteen, entry 28); one scene a line (29).
|
|
2995
3019
|
const lines = [
|
|
2996
3020
|
`PlotCoder cast (${door(live, base)})`,
|
|
2997
3021
|
`${person.name} (${person.id}) — on ${total} card${total === 1 ? "" : "s"} across ${project.boards.length} board${project.boards.length === 1 ? "" : "s"} of the project`,
|
|
2998
|
-
|
|
3022
|
+
// Four empty lines say the same thing four times (round twenty, entry 56): once is enough, and it says what empty means.
|
|
3023
|
+
...(CHARACTER_FIELDS.every((field) => !(person[field] ?? "").trim())
|
|
3024
|
+
? [` ${CHARACTER_FIELDS.join(", ")}: (empty — nothing given yet, nothing invented; update_character fills a line)`]
|
|
3025
|
+
: CHARACTER_FIELDS.map((field) => ` ${field}: ${(person[field] ?? "").trim() || "(empty)"}`)),
|
|
2999
3026
|
...parts.flatMap((part) =>
|
|
3000
3027
|
part.on.length
|
|
3001
3028
|
? [` "${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)})` : ""}`)]
|
|
@@ -3064,14 +3091,16 @@ server.registerTool(
|
|
|
3064
3091
|
{
|
|
3065
3092
|
title: "Set where scenes happen",
|
|
3066
3093
|
description:
|
|
3067
|
-
"Set the place of one or more cards: where the scene happens, as the writer would say it ('the piano shop', 'the flat, kitchen') — a phrase, not a slugline. The same phrase on several cards is one place in the Cast panel; an empty string clears it. list_board shows each card's place as 'at: …'.",
|
|
3068
|
-
inputSchema: { ids: z.array(z.string()).min(1), location: z.string() },
|
|
3094
|
+
"Set the place of one or more cards: where the scene happens, as the writer would say it ('the piano shop', 'the flat, kitchen') — a phrase, not a slugline. The same phrase on several cards is one place in the Cast panel; an empty string clears it. Or leave the place open: pass open with the writer's words for why it is not decided — \"where it happens\" — and the reading lists it under open, by the writer's word, and stops asking where, while the card's other questions stand; a place decides it, open \"\" leaves it blank. list_board shows each card's place as 'at: …'.",
|
|
3095
|
+
inputSchema: { ids: z.array(z.string()).min(1), location: z.string().optional(), open: z.string().optional() },
|
|
3069
3096
|
},
|
|
3070
3097
|
async (args) => {
|
|
3098
|
+
if (args.location === undefined && args.open === undefined) return ok("Say which: location (the place, or \"\" to clear it), or open (the writer's words for why it is not decided).");
|
|
3071
3099
|
const { state, changed, result, live } = await commit({
|
|
3072
3100
|
type: "set_location",
|
|
3073
3101
|
ids: args.ids,
|
|
3074
|
-
location: args.location,
|
|
3102
|
+
...(args.location !== undefined ? { location: args.location } : {}),
|
|
3103
|
+
...(args.open !== undefined ? { open: args.open } : {}),
|
|
3075
3104
|
});
|
|
3076
3105
|
if (!changed) {
|
|
3077
3106
|
const known = args.ids.filter((id) => state.notes.some((note) => note.id === id));
|
|
@@ -3079,6 +3108,10 @@ server.registerTool(
|
|
|
3079
3108
|
return ok("No place changed: those cards already read that way.");
|
|
3080
3109
|
}
|
|
3081
3110
|
const place = result[0].location;
|
|
3111
|
+
const placeOpen = result[0].locationOpen ?? "";
|
|
3112
|
+
if (placeOpen) {
|
|
3113
|
+
return ok(`${result.length} card(s) have their place left open, by the writer's word: "${placeOpen}"${where(live)}. The reading lists it and stops asking where; the heading prints the headline in its stead; set_location with a place decides it, open "" leaves it blank. The card is still asked about everything else.${stillOpen(result)}`, result);
|
|
3114
|
+
}
|
|
3082
3115
|
return ok(
|
|
3083
3116
|
`${result.length} card(s) now ${place ? `at ${place}` : "nowhere"}${where(live)}.${nearPlaces(state, place)}${stillOpen(result)}`,
|
|
3084
3117
|
result,
|
|
@@ -3191,7 +3224,8 @@ function foldLine(state, fold, thread) {
|
|
|
3191
3224
|
const head = (id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`;
|
|
3192
3225
|
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
3226
|
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
|
-
|
|
3227
|
+
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.` : "";
|
|
3228
|
+
return did.length ? ` Tied at both ends, so it is the fold's now: ${did.join(", ")}.${adjacent}` : adjacent;
|
|
3195
3229
|
}
|
|
3196
3230
|
|
|
3197
3231
|
function threadLine(state, thread) {
|
|
@@ -3483,7 +3517,7 @@ server.registerTool(
|
|
|
3483
3517
|
const { project, boards, live, base, changedAt } = await readProject();
|
|
3484
3518
|
return ok(
|
|
3485
3519
|
[
|
|
3486
|
-
`Project "${project.name}" (${door(live, base)})`,
|
|
3520
|
+
`Project "${project.name}"${project.nameOpen ? ` — its name is open, by the writer's word: "${project.nameOpen}"` : ""} (${door(live, base)})`,
|
|
3487
3521
|
`premise: ${project.premiseOpen ? `open, by the writer's word — "${project.premiseOpen}"` : project.premise ? `"${project.premise}"` : "(not set)"}`,
|
|
3488
3522
|
`boards: ${project.boards.length}`,
|
|
3489
3523
|
describeBoards(project, boards, changedAt),
|
|
@@ -3508,7 +3542,7 @@ server.registerTool(
|
|
|
3508
3542
|
{
|
|
3509
3543
|
title: "Set the project's premise",
|
|
3510
3544
|
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:
|
|
3545
|
+
"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
3546
|
inputSchema: { premise: z.string().optional(), open: z.string().optional() },
|
|
3513
3547
|
},
|
|
3514
3548
|
async (args) => {
|
|
@@ -3526,11 +3560,18 @@ server.registerTool(
|
|
|
3526
3560
|
"rename_project",
|
|
3527
3561
|
{
|
|
3528
3562
|
title: "Rename the project",
|
|
3529
|
-
description: "Rename the project — the name at the top of the wall, over every board.",
|
|
3530
|
-
inputSchema: { name: z.string().min(1) },
|
|
3563
|
+
description: "Rename the project — the name at the top of the wall, over every board. Or leave its name open: pass open with the writer's words for why the title is not decided — \"The Allotments, or Plot 14\" — and the name stands as it is while the reading lists the words; a name decides it, open \"\" takes the words back.",
|
|
3564
|
+
inputSchema: { name: z.string().min(1).optional(), open: z.string().optional() },
|
|
3531
3565
|
},
|
|
3532
3566
|
async (args) => {
|
|
3567
|
+
if (args.name === undefined && args.open === undefined) return ok("Say which: name, or open (the writer's words for why the title is not decided).");
|
|
3533
3568
|
const { project, boards, rev, base, live } = await readProject();
|
|
3569
|
+
if (args.name === undefined) {
|
|
3570
|
+
const opened = setProjectNameOpen(project, args.open);
|
|
3571
|
+
if (opened === project) return ok("Project name unchanged: it already reads that way.");
|
|
3572
|
+
await writeProject(opened, boards, rev, base);
|
|
3573
|
+
return ok(opened.nameOpen ? `The project keeps its name, "${opened.name}", and its name is left open, by the writer's word: "${opened.nameOpen}"${where(live)}. The reading lists it; rename_project with a name decides it.` : `The project's name, "${opened.name}", is no longer open${where(live)}.`, { name: opened.name, nameOpen: opened.nameOpen });
|
|
3574
|
+
}
|
|
3534
3575
|
const next = renameProject(project, args.name);
|
|
3535
3576
|
if (next === project) return ok("Project name unchanged.");
|
|
3536
3577
|
await writeProject(next, boards, rev, base);
|
|
@@ -3538,7 +3579,7 @@ server.registerTool(
|
|
|
3538
3579
|
// follow the rename (round fifteen, entry 40).
|
|
3539
3580
|
if (accountDoor) workingProject(next.id, next.name);
|
|
3540
3581
|
const several = (next.boards ?? []).length > 1;
|
|
3541
|
-
return ok(`Project renamed to "${next.name}"${where(live)}. It shows at the head of every reply, in list_boards and read_wall, and as the title of every script out${several ? `, where each board follows it as an episode line (Episode 1 of ${next.boards.length} · ${next.boards[0].name})` : ""}. The boards keep their names.`, next);
|
|
3582
|
+
return ok(`Project renamed to "${next.name}"${where(live)}.${project.nameOpen ? " Its name is decided; the open words are gone." : ""} It shows at the head of every reply, in list_boards and read_wall, and as the title of every script out${several ? `, where each board follows it as an episode line (Episode 1 of ${next.boards.length} · ${next.boards[0].name})` : ""}. The boards keep their names.`, next);
|
|
3542
3583
|
},
|
|
3543
3584
|
);
|
|
3544
3585
|
|
|
@@ -3716,13 +3757,17 @@ server.registerTool(
|
|
|
3716
3757
|
{
|
|
3717
3758
|
title: "Start a project",
|
|
3718
3759
|
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
|
|
3720
|
-
inputSchema: { name: z.string().min(1), board: z.string().optional(), boardOpen: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
|
|
3760
|
+
"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. A title not decided: open with the writer's words (\"The Allotments, or Plot 14\") instead of a name, and the project starts as Untitled project with those words beside it. 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\".",
|
|
3761
|
+
inputSchema: { name: z.string().min(1).optional(), open: z.string().optional(), board: z.string().optional(), boardOpen: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
|
|
3721
3762
|
},
|
|
3722
3763
|
async (args) => {
|
|
3723
3764
|
const account = await findAccount();
|
|
3724
3765
|
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.");
|
|
3725
|
-
|
|
3766
|
+
if (!args.name?.trim() && !args.open?.trim()) return ok("Say which: name (the title), or open (the writer's words for why the title is not decided — the project starts as \"Untitled project\" with those words beside it).");
|
|
3767
|
+
let record = emptyProject();
|
|
3768
|
+
if (args.name?.trim()) record = renameProject(record, args.name.trim());
|
|
3769
|
+
// A project born from a maybe is born open on its name (R61's edge): open holds the writer's words.
|
|
3770
|
+
if (args.open?.trim()) record = setProjectNameOpen(record, args.open);
|
|
3726
3771
|
if (args.board?.trim()) record = renameBoard(record, record.activeBoardId, args.board.trim());
|
|
3727
3772
|
// A board born from a maybe is born open on its name (R61): boardOpen holds the writer's words.
|
|
3728
3773
|
if (args.boardOpen?.trim()) record = setBoardNameOpen(record, record.activeBoardId, args.boardOpen);
|
|
@@ -3736,7 +3781,7 @@ server.registerTool(
|
|
|
3736
3781
|
joinPresence(record.id);
|
|
3737
3782
|
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.`;
|
|
3738
3783
|
const first = record.boards[0];
|
|
3739
|
-
const nameOpenLine = first.nameOpen ? ` The board's name is left open, by the writer's word: "${first.nameOpen}"; rename_board decides it.` : ""
|
|
3784
|
+
const nameOpenLine = `${record.nameOpen ? ` The project's name is left open, by the writer's word: "${record.nameOpen}"; rename_project decides it.` : ""}${first.nameOpen ? ` The board's name is left open, by the writer's word: "${first.nameOpen}"; rename_board decides it.` : ""}`;
|
|
3740
3785
|
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 });
|
|
3741
3786
|
},
|
|
3742
3787
|
);
|
|
@@ -3837,7 +3882,7 @@ server.registerTool(
|
|
|
3837
3882
|
{
|
|
3838
3883
|
title: "Save the project as a file",
|
|
3839
3884
|
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
|
|
3885
|
+
`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
3886
|
inputSchema: { path: z.string().optional() },
|
|
3842
3887
|
},
|
|
3843
3888
|
async (args) => {
|
|
@@ -3957,7 +4002,7 @@ server.registerTool(
|
|
|
3957
4002
|
"rename_board",
|
|
3958
4003
|
{
|
|
3959
4004
|
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
|
|
4005
|
+
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
4006
|
inputSchema: { board: z.union([z.string().min(1), z.number()]), name: z.string().min(1).optional(), open: z.string().optional() },
|
|
3962
4007
|
},
|
|
3963
4008
|
async (args) => {
|
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), 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.",
|
|
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 card's place or when, a board's or the project'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
|
@@ -124,6 +124,7 @@ export function toFountain(state, options = {}) {
|
|
|
124
124
|
if (cast.length) marks.push(`with ${cast.join(", ")}`);
|
|
125
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.locationOpen) marks.push(`place open: ${note.locationOpen}`);
|
|
127
128
|
if (note.whenOpen) marks.push(`when open: ${note.whenOpen}`);
|
|
128
129
|
const onThreads = (state.threads ?? []).filter((thread) => thread.noteIds.includes(note.id)).map((thread) => thread.name);
|
|
129
130
|
if (onThreads.length) marks.push(`thread: ${onThreads.join(", ")}`);
|
package/src/board/project.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type ProjectRecord = {
|
|
|
19
19
|
version: number;
|
|
20
20
|
id: string;
|
|
21
21
|
name: string;
|
|
22
|
+
/** The writer's words for why the project's name is not decided (R61's edge), or empty; the name stands meanwhile. */
|
|
23
|
+
nameOpen: string;
|
|
22
24
|
premise: string;
|
|
23
25
|
/** The writer's words for why there is no premise yet (R61), or empty. */
|
|
24
26
|
premiseOpen: string;
|
|
@@ -75,6 +77,8 @@ export declare function setPremise(project: ProjectRecord, premise: string, now?
|
|
|
75
77
|
export declare function setPremiseOpen(project: ProjectRecord, words: string, now?: string): ProjectRecord;
|
|
76
78
|
/** The writer's words for why a board's name is not decided (R61); the name stands meanwhile. */
|
|
77
79
|
export declare function setBoardNameOpen(project: ProjectRecord, id: string, words: string, now?: string): ProjectRecord;
|
|
80
|
+
/** The writer's words for why the project's name is not decided (R61's edge); the name stands meanwhile. */
|
|
81
|
+
export declare function setProjectNameOpen(project: ProjectRecord, words: string, now?: string): ProjectRecord;
|
|
78
82
|
export declare function boardById(project: ProjectRecord, id: string): BoardMeta | null;
|
|
79
83
|
/** What a script going out is called: a named project is the title, its board beside it only when the project has several. */
|
|
80
84
|
export declare function scriptTitles(project: ProjectRecord, board: BoardMeta | null | undefined): { title: string; episode?: string };
|
package/src/board/project.js
CHANGED
|
@@ -33,6 +33,7 @@ export function emptyProject(now = nowIso()) {
|
|
|
33
33
|
version: PROJECT_VERSION,
|
|
34
34
|
id: newId(),
|
|
35
35
|
name: DEFAULT_PROJECT_NAME,
|
|
36
|
+
nameOpen: "",
|
|
36
37
|
premise: "",
|
|
37
38
|
premiseOpen: "",
|
|
38
39
|
boards: [board],
|
|
@@ -87,6 +88,8 @@ export function normalizeProject(value, now = nowIso()) {
|
|
|
87
88
|
...rest,
|
|
88
89
|
version: PROJECT_VERSION,
|
|
89
90
|
name: trimmed(value.name, DEFAULT_PROJECT_NAME),
|
|
91
|
+
// A project named before R61's edge has no open name; the name stands until the writer says it is not decided.
|
|
92
|
+
nameOpen: openWords(value.nameOpen),
|
|
90
93
|
premise: typeof value.premise === "string" ? value.premise.trim() : "",
|
|
91
94
|
// A project written before R61 has no open premise (R61).
|
|
92
95
|
premiseOpen: openWords(value.premiseOpen),
|
|
@@ -342,8 +345,16 @@ export function setActiveBoard(project, id, now = nowIso()) {
|
|
|
342
345
|
|
|
343
346
|
export function renameProject(project, name, now = nowIso()) {
|
|
344
347
|
const next = trimmed(name, "");
|
|
345
|
-
if (!next || next === project.name) return project;
|
|
346
|
-
|
|
348
|
+
if (!next || (next === project.name && !(project.nameOpen ?? ""))) return project;
|
|
349
|
+
// A name decides the field: the open words go (R61's edge).
|
|
350
|
+
return touch(project, { name: next, nameOpen: "" }, now);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The writer's words for why the project's name is not decided (R61's edge), or "" to take them back; the name stands meanwhile. */
|
|
354
|
+
export function setProjectNameOpen(project, words, now = nowIso()) {
|
|
355
|
+
const next = openWords(words);
|
|
356
|
+
if (next === (project.nameOpen ?? "")) return project;
|
|
357
|
+
return touch(project, { nameOpen: next }, now);
|
|
347
358
|
}
|
|
348
359
|
|
|
349
360
|
export function setPremise(project, premise, now = nowIso()) {
|
package/src/board/readWall.d.ts
CHANGED
|
@@ -72,9 +72,9 @@ export type WallReading = {
|
|
|
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
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 }>;
|
|
75
|
+
openFields: Array<{ field: "logline"; words: string } | { field: "location"; id: string; 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
|
};
|
|
@@ -290,7 +241,8 @@ export function readWall(state, options = {}) {
|
|
|
290
241
|
// A card that says no place, once the writer has started placing cards.
|
|
291
242
|
// One question however many there are; a wall with no places at all is a
|
|
292
243
|
// wall the writer has not placed yet, and is not asked.
|
|
293
|
-
|
|
244
|
+
// A place left open in the writer's words (R61's edge) is listed, not asked.
|
|
245
|
+
const unplaced = order.filter((note) => askable(note) && !(note.location ?? "").trim() && !(note.locationOpen ?? "").trim());
|
|
294
246
|
if (unplaced.length > 0 && unplaced.length < order.length) {
|
|
295
247
|
findings.push({
|
|
296
248
|
kind: "unplaced",
|
|
@@ -323,8 +275,12 @@ export function readWall(state, options = {}) {
|
|
|
323
275
|
}
|
|
324
276
|
|
|
325
277
|
// Only once the wall uses arrows does a card without one mean anything.
|
|
278
|
+
// A setup arrow is a claim, not a place in the story (round twenty, entry
|
|
279
|
+
// 25): a card touched only by one still has no order, so only follows
|
|
280
|
+
// arrows link a card.
|
|
326
281
|
const linked = new Set();
|
|
327
282
|
for (const arrow of state.arrows) {
|
|
283
|
+
if (arrow.kind === "setup") continue;
|
|
328
284
|
linked.add(arrow.from);
|
|
329
285
|
linked.add(arrow.to);
|
|
330
286
|
}
|
|
@@ -339,14 +295,17 @@ export function readWall(state, options = {}) {
|
|
|
339
295
|
}
|
|
340
296
|
}
|
|
341
297
|
|
|
342
|
-
// Two cards doing the same job.
|
|
298
|
+
// Two cards doing the same job. The cast's names are not the scene's words
|
|
299
|
+
// (round twenty, entry 15): "Con gives Ruth the key" and "Con gives Ruth
|
|
300
|
+
// his tools" share their people, not their job.
|
|
301
|
+
const nameWords = new Set((state.characters ?? []).flatMap((person) => words(person.name ?? "")));
|
|
343
302
|
for (let i = 0; i < order.length; i += 1) {
|
|
344
303
|
for (let j = i + 1; j < order.length; j += 1) {
|
|
345
304
|
const a = order[i];
|
|
346
305
|
const b = order[j];
|
|
347
306
|
// A leading "Day three." is the guide's convention for when a scene
|
|
348
307
|
// happens, not the scene's words (round fourteen, entry 13).
|
|
349
|
-
if (sameScene(a.headline.replace(DAY_PREFIX, ""), b.headline.replace(DAY_PREFIX, ""))) {
|
|
308
|
+
if (sameScene(a.headline.replace(DAY_PREFIX, ""), b.headline.replace(DAY_PREFIX, ""), nameWords)) {
|
|
350
309
|
findings.push({
|
|
351
310
|
kind: "duplicate",
|
|
352
311
|
ids: [a.id, b.id],
|
|
@@ -505,13 +464,12 @@ export function readWall(state, options = {}) {
|
|
|
505
464
|
// Threads (R60): a named string through cards, in story order, either end
|
|
506
465
|
// open until the writer ties it. The reading asks about each loose end from
|
|
507
466
|
// 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
|
-
}));
|
|
467
|
+
const threads = (state.threads ?? []).map((thread) => {
|
|
468
|
+
const ids = order.filter((note) => thread.noteIds.includes(note.id)).map((note) => note.id);
|
|
469
|
+
// How far the string runs, in the same estimated pages a setup's distance uses (round twenty, entry 44).
|
|
470
|
+
const apart = ids.length >= 2 ? (startAt.get(ids[ids.length - 1]) ?? 0) - (startAt.get(ids[0]) ?? 0) : 0;
|
|
471
|
+
return { id: thread.id, name: thread.name, ids, startOpen: thread.startOpen === true, endOpen: thread.endOpen === true, apart };
|
|
472
|
+
});
|
|
515
473
|
for (const thread of threads) {
|
|
516
474
|
const first = thread.ids.length ? byId.get(thread.ids[0]) : null;
|
|
517
475
|
const last = thread.ids.length ? byId.get(thread.ids[thread.ids.length - 1]) : null;
|
|
@@ -578,6 +536,7 @@ export function readWall(state, options = {}) {
|
|
|
578
536
|
function describeOpenFields(state, order) {
|
|
579
537
|
const fields = [];
|
|
580
538
|
if ((state.loglineOpen ?? "").trim()) fields.push({ field: "logline", words: state.loglineOpen.trim() });
|
|
539
|
+
for (const note of order) if ((note.locationOpen ?? "").trim()) fields.push({ field: "location", id: note.id, words: note.locationOpen.trim() });
|
|
581
540
|
for (const note of order) if ((note.whenOpen ?? "").trim()) fields.push({ field: "when", id: note.id, words: note.whenOpen.trim() });
|
|
582
541
|
return fields;
|
|
583
542
|
}
|
|
@@ -635,6 +594,7 @@ export function describeRuns(reading, state) {
|
|
|
635
594
|
// from written text, or the cards' guess, or some of each.
|
|
636
595
|
const written = (run.ids ?? []).filter((id) => isMeasured(byId.get(id) ?? {})).length;
|
|
637
596
|
const whose = !run.cards ? "" : written === run.cards ? ", measured" : written ? `, ${written} of ${run.cards} measured` : ", estimated";
|
|
638
|
-
|
|
597
|
+
const amount = pages(run.eighths);
|
|
598
|
+
return `${span}: about ${amount} ${amount === "1" ? "page" : "pages"}, ${count}${whose}`;
|
|
639
599
|
});
|
|
640
600
|
}
|
package/src/board/reducer.d.ts
CHANGED
|
@@ -93,6 +93,8 @@ export type BoardNote = {
|
|
|
93
93
|
when: string;
|
|
94
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
95
|
whenOpen: string;
|
|
96
|
+
/** The writer's words for why the place is not decided (R61's edge), or empty; while they stand the place is blank, listed, and not asked. */
|
|
97
|
+
locationOpen: string;
|
|
96
98
|
/** The scene's text in Fountain (R23 b): action, cues, dialogue; empty until written. */
|
|
97
99
|
text: string;
|
|
98
100
|
createdAt: string;
|
|
@@ -186,6 +188,7 @@ export type Command =
|
|
|
186
188
|
location?: string;
|
|
187
189
|
when?: string;
|
|
188
190
|
whenOpen?: string;
|
|
191
|
+
locationOpen?: string;
|
|
189
192
|
open?: string;
|
|
190
193
|
text?: string;
|
|
191
194
|
}
|
|
@@ -216,7 +219,7 @@ export type Command =
|
|
|
216
219
|
| { type: "set_plant"; ids: string[]; plants?: boolean; what?: string }
|
|
217
220
|
| { type: "set_open"; ids: string[]; open: string }
|
|
218
221
|
| { type: "set_payoff_board"; ids: string[]; boardId: string | null; noteId?: string | null }
|
|
219
|
-
| { type: "set_location"; ids: string[]; location
|
|
222
|
+
| { type: "set_location"; ids: string[]; location?: string; open?: string }
|
|
220
223
|
| { type: "set_when"; ids: string[]; when?: string; open?: string }
|
|
221
224
|
| { type: "apply_template"; template: string; beats?: Array<{ name: string; prompt: string; at: number }> }
|
|
222
225
|
| { type: "set_text"; id: string; text: string }
|
package/src/board/reducer.js
CHANGED
|
@@ -247,6 +247,8 @@ export function seedState(now = nowIso()) {
|
|
|
247
247
|
lengthEighths: null,
|
|
248
248
|
characterIds,
|
|
249
249
|
location: "",
|
|
250
|
+
// The writer's words for why the place is not decided (R61), or nothing.
|
|
251
|
+
locationOpen: "",
|
|
250
252
|
when: "",
|
|
251
253
|
// The writer's words for why the when is not decided (R61), or nothing.
|
|
252
254
|
whenOpen: "",
|
|
@@ -368,6 +370,8 @@ export function normalizeState(value) {
|
|
|
368
370
|
const open = typeof note?.open === "string" ? note.open : "";
|
|
369
371
|
// Cards written before R37 have no place; a scene is nowhere until it is.
|
|
370
372
|
const location = typeof note?.location === "string" ? note.location : "";
|
|
373
|
+
// Cards written before R61's edge have no open place; a place is decided or blank until the writer says otherwise.
|
|
374
|
+
const locationOpen = typeof note?.locationOpen === "string" ? note.locationOpen : "";
|
|
371
375
|
// Cards written before R55 have no when; a scene is at no time until it is.
|
|
372
376
|
const when = typeof note?.when === "string" ? note.when : "";
|
|
373
377
|
// Cards written before R61 have no open when; a when is decided or blank until the writer says otherwise.
|
|
@@ -386,6 +390,7 @@ export function normalizeState(value) {
|
|
|
386
390
|
note.payoffNoteId === payoffNoteId &&
|
|
387
391
|
note.open === open &&
|
|
388
392
|
note.location === location &&
|
|
393
|
+
note.locationOpen === locationOpen &&
|
|
389
394
|
note.when === when &&
|
|
390
395
|
note.whenOpen === whenOpen &&
|
|
391
396
|
note.text === text
|
|
@@ -393,7 +398,7 @@ export function normalizeState(value) {
|
|
|
393
398
|
return note;
|
|
394
399
|
}
|
|
395
400
|
patched = true;
|
|
396
|
-
return { ...note, rank, lengthEighths, characterIds, plants, plantsWhat, payoffBoardId, payoffNoteId, open, location, when, whenOpen, text };
|
|
401
|
+
return { ...note, rank, lengthEighths, characterIds, plants, plantsWhat, payoffBoardId, payoffNoteId, open, location, locationOpen, when, whenOpen, text };
|
|
397
402
|
});
|
|
398
403
|
|
|
399
404
|
// Boards written before the production half (Roadmap 2, item 8) have no
|
|
@@ -474,6 +479,73 @@ function cleanOpen(value) {
|
|
|
474
479
|
return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
|
|
475
480
|
}
|
|
476
481
|
|
|
482
|
+
|
|
483
|
+
// --- Order (R56, moved here for R62) -------------------------------------
|
|
484
|
+
//
|
|
485
|
+
// Story order is the follows arrows where they exist and the wall's reading
|
|
486
|
+
// order — rows top to bottom, cards left to right — where they do not. The
|
|
487
|
+
// reading composes everything from it; the kernel needs it to hold a
|
|
488
|
+
// thread's cards in story order, so the tie rule folds the first card and
|
|
489
|
+
// not the last one the writer happened to add.
|
|
490
|
+
|
|
491
|
+
/** Cards within half a card's height of each other sit on one row. */
|
|
492
|
+
const ROW_TOLERANCE = NOTE_HEIGHT / 2;
|
|
493
|
+
|
|
494
|
+
export function readingOrder(notes) {
|
|
495
|
+
const byTop = [...notes].sort((a, b) => a.y - b.y || a.x - b.x);
|
|
496
|
+
const rows = [];
|
|
497
|
+
let row = null;
|
|
498
|
+
for (const note of byTop) {
|
|
499
|
+
if (row && note.y - row.top <= ROW_TOLERANCE) {
|
|
500
|
+
row.notes.push(note);
|
|
501
|
+
} else {
|
|
502
|
+
row = { top: note.y, notes: [note] };
|
|
503
|
+
rows.push(row);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return rows.flatMap((band) => band.notes.sort((a, b) => a.x - b.x || a.y - b.y));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Cards in reading order: banded into rows by y, then left to right. A free
|
|
511
|
+
* wall has no rows, so this is the order a person's eye takes across it.
|
|
512
|
+
*/
|
|
513
|
+
/**
|
|
514
|
+
* Story order: reading order, with each follows arrow pulling its source in
|
|
515
|
+
* front of its target; a pair pointing both ways is a tie and reading order
|
|
516
|
+
* keeps it. The order organize lays the wall out in, and the order every
|
|
517
|
+
* reading, numbering and page uses (R56). Returns the notes.
|
|
518
|
+
*/
|
|
519
|
+
export function storyOrder(state, ids) {
|
|
520
|
+
const scope = ids ? new Set(ids) : null;
|
|
521
|
+
const notes = state.notes.filter((note) => !scope || scope.has(note.id));
|
|
522
|
+
const reading = readingOrder(notes);
|
|
523
|
+
const byId = new Map(reading.map((note) => [note.id, note]));
|
|
524
|
+
const rank = new Map(reading.map((note, index) => [note.id, index]));
|
|
525
|
+
const preds = new Map(reading.map((note) => [note.id, []]));
|
|
526
|
+
for (const arrow of state.arrows ?? []) {
|
|
527
|
+
if (arrow.kind === "setup") continue;
|
|
528
|
+
if (!rank.has(arrow.from) || !rank.has(arrow.to)) continue;
|
|
529
|
+
preds.get(arrow.to).push(arrow.from);
|
|
530
|
+
}
|
|
531
|
+
for (const list of preds.values()) list.sort((a, b) => rank.get(a) - rank.get(b));
|
|
532
|
+
const placed = new Set();
|
|
533
|
+
const visiting = new Set();
|
|
534
|
+
const order = [];
|
|
535
|
+
function visit(id) {
|
|
536
|
+
if (placed.has(id) || visiting.has(id)) return;
|
|
537
|
+
visiting.add(id);
|
|
538
|
+
for (const from of preds.get(id)) {
|
|
539
|
+
if (preds.get(from).includes(id)) continue;
|
|
540
|
+
visit(from);
|
|
541
|
+
}
|
|
542
|
+
visiting.delete(id);
|
|
543
|
+
placed.add(id);
|
|
544
|
+
order.push(byId.get(id));
|
|
545
|
+
}
|
|
546
|
+
for (const note of reading) visit(note.id);
|
|
547
|
+
return order;
|
|
548
|
+
}
|
|
477
549
|
/**
|
|
478
550
|
* The combine log's rule, both halves (R60, R62). A thread tied at both ends
|
|
479
551
|
* through two or more cards is a plant between two scenes that exist, and
|
|
@@ -504,16 +576,20 @@ function tieIntoFold(state, thread, now) {
|
|
|
504
576
|
notes: next.notes.map((note) => (note.id === firstId ? bump(note, { plants: true, plantsWhat: thread.name }, now) : note)),
|
|
505
577
|
};
|
|
506
578
|
}
|
|
507
|
-
|
|
579
|
+
// One arrow per direction between two cards (R15): a follows arrow already
|
|
580
|
+
// running from the first card to the last means the payoff is the very next
|
|
581
|
+
// scene, and the setup cannot be drawn over it.
|
|
582
|
+
const existing = next.arrows.find((arrow) => arrow.from === firstId && arrow.to === lastId);
|
|
508
583
|
let arrow = null;
|
|
509
|
-
|
|
584
|
+
const adjacent = Boolean(existing && existing.kind !== "setup");
|
|
585
|
+
if (!existing) {
|
|
510
586
|
const drawn = applyCommand(next, { type: "create_arrow", from: firstId, to: lastId, kind: "setup" }, now);
|
|
511
587
|
if (drawn.changed) {
|
|
512
588
|
next = drawn.state;
|
|
513
589
|
arrow = { from: firstId, to: lastId };
|
|
514
590
|
}
|
|
515
591
|
}
|
|
516
|
-
return { state: next, fold: { kept: false, firstId, lastId, folded, named, arrow } };
|
|
592
|
+
return { state: next, fold: { kept: false, firstId, lastId, folded, named, arrow, adjacent } };
|
|
517
593
|
}
|
|
518
594
|
|
|
519
595
|
/** A thread's name as the writer typed it, one line, spaces collapsed (R60). */
|
|
@@ -565,7 +641,8 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
565
641
|
payoffBoardId: null,
|
|
566
642
|
payoffNoteId: null,
|
|
567
643
|
open: cleanOpen(command.open),
|
|
568
|
-
location: cleanPlace(command.location),
|
|
644
|
+
location: cleanOpen(command.locationOpen) ? "" : cleanPlace(command.location),
|
|
645
|
+
locationOpen: cleanOpen(command.locationOpen),
|
|
569
646
|
when: cleanOpen(command.whenOpen) ? "" : cleanWhen(command.when),
|
|
570
647
|
whenOpen: cleanOpen(command.whenOpen),
|
|
571
648
|
text: typeof command.text === "string" ? command.text : "",
|
|
@@ -809,7 +886,9 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
809
886
|
case "create_thread": {
|
|
810
887
|
const name = cleanThreadName(command.name);
|
|
811
888
|
if (!name) return { state, changed: false };
|
|
812
|
-
const
|
|
889
|
+
const wanted = (command.noteIds ?? []).filter((id, index, all) => all.indexOf(id) === index && state.notes.some((note) => note.id === id));
|
|
890
|
+
// Held in story order, whatever order the writer named them (R62).
|
|
891
|
+
const noteIds = storyOrder(state, wanted).map((note) => note.id);
|
|
813
892
|
const thread = {
|
|
814
893
|
id: typeof command.id === "string" && command.id && !(state.threads ?? []).some((item) => item.id === command.id) ? command.id : newId(),
|
|
815
894
|
name,
|
|
@@ -828,6 +907,9 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
828
907
|
let noteIds = Array.isArray(command.noteIds) ? command.noteIds.filter((id, index, all) => all.indexOf(id) === index && exists(id)) : [...current.noteIds];
|
|
829
908
|
if (Array.isArray(command.add)) for (const id of command.add) if (exists(id) && !noteIds.includes(id)) noteIds.push(id);
|
|
830
909
|
if (Array.isArray(command.remove)) noteIds = noteIds.filter((id) => !command.remove.includes(id));
|
|
910
|
+
// Held in story order, whatever order the cards were added (R62): the
|
|
911
|
+
// rule below folds the first card in the story, not the last one named.
|
|
912
|
+
noteIds = storyOrder(state, noteIds).map((note) => note.id);
|
|
831
913
|
const name = command.name === undefined ? current.name : cleanThreadName(command.name) || current.name;
|
|
832
914
|
const startOpen = typeof command.startOpen === "boolean" ? command.startOpen : current.startOpen;
|
|
833
915
|
const endOpen = typeof command.endOpen === "boolean" ? command.endOpen : current.endOpen;
|
|
@@ -1089,6 +1171,7 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
1089
1171
|
payoffNoteId: null,
|
|
1090
1172
|
open: "",
|
|
1091
1173
|
location: "",
|
|
1174
|
+
locationOpen: "",
|
|
1092
1175
|
when: "",
|
|
1093
1176
|
whenOpen: "",
|
|
1094
1177
|
text: "",
|
|
@@ -1180,11 +1263,21 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
1180
1263
|
case "set_location": {
|
|
1181
1264
|
const ids = new Set(command.ids);
|
|
1182
1265
|
if (ids.size === 0) return { state, changed: false };
|
|
1183
|
-
|
|
1266
|
+
// The place, or the writer's words for why there is none yet (R61's
|
|
1267
|
+
// edge, round twenty entry 16): words clear the place, a place clears
|
|
1268
|
+
// the words, open "" with a place does both in one command.
|
|
1269
|
+
const hasOpen = typeof command.open === "string";
|
|
1270
|
+
const locationOpen = hasOpen ? cleanOpen(command.open) : null;
|
|
1271
|
+
const location = hasOpen ? (locationOpen ? "" : typeof command.location === "string" ? cleanPlace(command.location) : null) : cleanPlace(command.location);
|
|
1184
1272
|
const touched = [];
|
|
1185
1273
|
const notes = state.notes.map((note) => {
|
|
1186
|
-
if (!ids.has(note.id)
|
|
1187
|
-
const
|
|
1274
|
+
if (!ids.has(note.id)) return note;
|
|
1275
|
+
const patch = {
|
|
1276
|
+
location: location === null ? (note.location ?? "") : location,
|
|
1277
|
+
locationOpen: locationOpen === null ? (location ? "" : (note.locationOpen ?? "")) : locationOpen,
|
|
1278
|
+
};
|
|
1279
|
+
if (patch.location === (note.location ?? "") && patch.locationOpen === (note.locationOpen ?? "")) return note;
|
|
1280
|
+
const next = bump(note, patch, now);
|
|
1188
1281
|
touched.push(next);
|
|
1189
1282
|
return next;
|
|
1190
1283
|
});
|
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. 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.",
|
|
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 place or when, a board's or the project'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, 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" },
|