plotcoder-board 0.1.24 → 0.1.26
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 +6 -5
- package/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +70 -30
- package/src/board/agents.js +2 -2
- package/src/board/readWall.d.ts +6 -0
- package/src/board/readWall.js +44 -4
- package/src/board/reducer.d.ts +4 -0
- package/src/board/reducer.js +32 -1
- package/src/board/words.js +6 -1
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Every board verb goes through one command kernel, `src/board/reducer.js`, and th
|
|
|
41
41
|
|
|
42
42
|
- **The wall.** Tap the words to type, drag the paper to move. Lasso to select, then Group. Drag a card's handle onto another card for an arrow. ⌘Z takes back any change, whichever door made it.
|
|
43
43
|
- **`window.plotcoder`** on the page, for a console or a CDP session.
|
|
44
|
-
- **The MCP server**, published to npm as `plotcoder-board`: `npx -y plotcoder-board@latest` is the server, `npx -y plotcoder-board@latest call <tool> '{json}'` one call from a shell, `npx -y plotcoder-board@latest serve` the hosted door on a port (a `Dockerfile` is here too). Inside the repo it is `scripts/plotcoder-mcp.mjs`, wired for Cursor in `.cursor/mcp.json` and for Claude Code in `.mcp.json` (run `npm ci` once first). A version tag (`v0.2.0`) publishes it, with `NPM_TOKEN` in the repo's secrets. Eighty-
|
|
44
|
+
- **The MCP server**, published to npm as `plotcoder-board`: `npx -y plotcoder-board@latest` is the server, `npx -y plotcoder-board@latest call <tool> '{json}'` one call from a shell, `npx -y plotcoder-board@latest serve` the hosted door on a port (a `Dockerfile` is here too). Inside the repo it is `scripts/plotcoder-mcp.mjs`, wired for Cursor in `.cursor/mcp.json` and for Claude Code in `.mcp.json` (run `npm ci` once first). A version tag (`v0.2.0`) publishes it, with `NPM_TOKEN` in the repo's secrets. Eighty-two tools: reading (`list_board`, `read_wall`, `read_pages`, `read_character`, `page_count`, `list_words`, `list_workflows`, `segment_brief`, `compare_structure`); the card, cast, place and when (`set_when`), group (with `add_to_group`) and arrow verbs, `set_logline`, `set_target`, `set_rank`, `set_open` (a card the writer has not decided), `set_plant` (with `later` and `at`) and `set_payoff`, `write_scene`, `edit_scene`, `move_scene`, `organize`, `apply_template` with `list_structures`, `save_structure`, `remove_structure`; `leave_question` and `ask_again`; `undo` and `redo`; the project's `list_boards`, `open_board`, `new_board`, `rename_board`, `delete_board`, `set_premise`, `rename_project` and the reminders; Fountain and Final Draft both ways, Markdown and plain text out (`export_markdown`, `export_text`); the production half (`lock_numbers`, `unlock_numbers`, `start_revision`, `end_revision`); the project as a file both ways (`export_project`, `import_project`); and, through the account door, `list_projects`, `open_project`, `new_project`, `delete_project`, `empty_account`, `delete_account`, `add_picture`, `add_take`, `list_takes`, `list_files`, `remove_file`, `build_segment`. If the dev app is open, a tool call lands on the wall within a second; if not, it edits the board file and the wall catches up on the next load.
|
|
45
45
|
- **The account door.** With `PLOTCODER_EMAIL` and `PLOTCODER_PASSWORD` in the agent's environment — the writer's own — and no dev app answering, the same server works the writer's project on the account directly, and every change lands live on every open wall. `PLOTCODER_PROJECT` picks a project by name or id. No account yet? `claim_account` makes one with the writer's email and a password they chose. The on-ramp — the doors, what to call first, the rules — is in the app behind *Are you an agent? Start here* and served at [plotcoder.com/llms.txt](https://plotcoder.com/llms.txt), both from `src/board/agents.js`.
|
|
46
46
|
|
|
47
47
|
An agent should call the tools, never fake mouse drags. The skill in `.cursor/skills/plotcoder-board/SKILL.md` says how; `.claude/skills/plotcoder-board` is a symlink to the same file.
|
|
@@ -51,10 +51,11 @@ 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. Seventeen 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
|
-
first
|
|
57
|
-
|
|
56
|
+
first sixteen is fixed or decided, and round seventeen, the first to start from
|
|
57
|
+
a page of notes instead of a treatment, found the wall has no way to hold a
|
|
58
|
+
maybe — the open card, R59, proposed and mocked from it. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
|
|
58
59
|
the table of rounds, and the next round's prompt with the test account filled in.
|
|
59
60
|
|
|
60
61
|
A round works a **test account** — a throwaway marked on its writer row, and the
|
|
@@ -99,4 +100,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
|
|
|
99
100
|
|
|
100
101
|
## Status
|
|
101
102
|
|
|
102
|
-
Version 0.1.
|
|
103
|
+
Version 0.1.26. 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
|
@@ -805,7 +805,8 @@ let lastReading = null;
|
|
|
805
805
|
const sinceRead = [];
|
|
806
806
|
/** What the last write did to the wall's questions and runtime, said once on that write's tail (round fourteen, entries 18, 19, 42). */
|
|
807
807
|
let lastChange = null;
|
|
808
|
-
|
|
808
|
+
// The same question in the same words is the same question, whatever order its names come in (round seventeen, entry 21).
|
|
809
|
+
const findingKey = (finding) => `${finding.kind}|${[...finding.ids].sort().join(",")}`;
|
|
809
810
|
function noteChange(before, after, boardId = null) {
|
|
810
811
|
// The same reading read_wall gives: a person cast on another board is not
|
|
811
812
|
// asked about, so a write's tail never names a question the reading does not.
|
|
@@ -967,9 +968,10 @@ function isSampleWall(state) {
|
|
|
967
968
|
return state.notes.map((note) => note.headline).sort().join("\n") === sample;
|
|
968
969
|
}
|
|
969
970
|
/** Every check read_wall runs, so silence can be named. */
|
|
970
|
-
const CHECKS = ["sag", "empty", "unwritten", "unlinked", "duplicate", "sequence", "uncast", "absent", "backwards", "unpaid", "unplaced"];
|
|
971
|
+
const CHECKS = ["unmarked", "sag", "empty", "unwritten", "unlinked", "duplicate", "sequence", "uncast", "nobody", "absent", "backwards", "unpaid", "unplanted", "unplaced"];
|
|
971
972
|
/** What each check looks for, in words, so "clean" says what was checked rather than a kind's name. */
|
|
972
973
|
const CHECK_WORDS = {
|
|
974
|
+
unmarked: "a beat is marked",
|
|
973
975
|
sag: "no run out of proportion",
|
|
974
976
|
empty: "no beats back to back",
|
|
975
977
|
unwritten: "no card without a headline or change line",
|
|
@@ -977,9 +979,11 @@ const CHECK_WORDS = {
|
|
|
977
979
|
duplicate: "no two headlines alike",
|
|
978
980
|
sequence: "no group too long for one sequence (act groups are not asked)",
|
|
979
981
|
uncast: "nobody in the cast on no card of the project",
|
|
982
|
+
nobody: "no card with nobody in it",
|
|
980
983
|
absent: "nobody gone for a third of the story",
|
|
981
984
|
backwards: "no payoff before its setup",
|
|
982
985
|
unpaid: "no fold without a payoff",
|
|
986
|
+
unplanted: "no payoff without its fold",
|
|
983
987
|
unplaced: "no card without a place",
|
|
984
988
|
};
|
|
985
989
|
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.";
|
|
@@ -1035,11 +1039,12 @@ function summarize(state) {
|
|
|
1035
1039
|
const revised = snap && (snap.headline !== note.headline || snap.change !== note.change || (snap.text ?? "") !== (note.text ?? "") || (snap.location ?? "") !== (note.location ?? "")) ? `, changed in ${state.revision.color}` : "";
|
|
1036
1040
|
const place = note.location ? `, at: ${note.location}` : "";
|
|
1037
1041
|
const when = note.when ? `, when: ${note.when}` : "";
|
|
1042
|
+
const openWord = note.open ? `, open: ${note.open}` : "";
|
|
1038
1043
|
const count = formatPages(noteEighths(note));
|
|
1039
1044
|
// A written card's estimate is kept underneath for when the text goes; say it, or it is invisible (round sixteen, entry 44).
|
|
1040
1045
|
const underneath = isMeasured(note) && note.lengthEighths !== null ? `; the writer's estimate underneath: ${formatPages(note.lengthEighths)}` : "";
|
|
1041
1046
|
const pages = isMeasured(note) ? `${count} ${count === "1" ? "page" : "pages"}, written${underneath}` : note.lengthEighths === null ? "about a page, unsized" : `${count} ${count === "1" ? "page" : "pages"}`;
|
|
1042
|
-
return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${when}${plant}${pays}${revised}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
|
|
1047
|
+
return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${when}${openWord}${plant}${pays}${revised}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
|
|
1043
1048
|
})
|
|
1044
1049
|
.join("\n");
|
|
1045
1050
|
const cast = state.characters
|
|
@@ -1307,6 +1312,7 @@ server.registerTool(
|
|
|
1307
1312
|
before: z.string().optional().describe("Or before this card (id or headline)."),
|
|
1308
1313
|
location: z.string().optional(),
|
|
1309
1314
|
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.'),
|
|
1315
|
+
open: z.string().optional().describe("The writer's words for what is not decided about this card (R59) — \"where, and whether Ruth is there\" — so the card is born open: the reading lists it and asks nothing else of it until the words are cleared."),
|
|
1310
1316
|
characters: z.array(z.string().min(1)).optional(),
|
|
1311
1317
|
x: z.number().optional(),
|
|
1312
1318
|
y: z.number().optional(),
|
|
@@ -1341,6 +1347,7 @@ server.registerTool(
|
|
|
1341
1347
|
plants: args.plants,
|
|
1342
1348
|
location: args.location,
|
|
1343
1349
|
when: args.when,
|
|
1350
|
+
open: args.open,
|
|
1344
1351
|
x: landing.x,
|
|
1345
1352
|
y: landing.y,
|
|
1346
1353
|
}).result;
|
|
@@ -1377,6 +1384,7 @@ server.registerTool(
|
|
|
1377
1384
|
result?.plants ? "corner folded" : null,
|
|
1378
1385
|
result?.location ? `at ${result.location}` : `no place yet${once("place", " (location here, or set_location)")}`,
|
|
1379
1386
|
result?.when ? `when: ${result.when}` : null,
|
|
1387
|
+
result?.open ? `open: "${result.open}" (listed, not asked about)` : null,
|
|
1380
1388
|
].filter(Boolean).join(", ");
|
|
1381
1389
|
// Where it landed matters only until the tidy, so the reply says the rule once and never the coordinates (round fourteen, entry 11).
|
|
1382
1390
|
const placed = beside
|
|
@@ -1491,7 +1499,7 @@ server.registerTool(
|
|
|
1491
1499
|
{
|
|
1492
1500
|
title: "Read the wall",
|
|
1493
1501
|
description:
|
|
1494
|
-
"Read the board back: the beats in wall order (rows top to bottom, cards left to right), the pages of scenes between consecutive beats with the cards in each, every setup with the distance to its payoff, and the questions the wall raises — no beat marked yet; a run out of proportion with the others; beats back to back with nothing between them (a chain of them is one question); a card with a placeholder headline or no change line; a card no arrow touches; two headlines that read like the same scene; a group too long to be one sequence; a person in the cast on no card; a person gone for more than a third of the story and ten pages; a payoff before its setup on the wall; a folded card no setup arrow pays off; cards that say no place once any card has one. These are questions, not fixes: put them to the writer and do not act on them unasked. A question the writer answers with \"leave it\" is left with leave_question and listed under \"left, for now\" instead, until it would read differently. It says nothing about how many beats there should be, and neither should you. The prose carries every id; PLOTCODER_JSON=1 in the server's environment adds the same reading as JSON after it, for a program.",
|
|
1502
|
+
"Read the board back: the beats in wall order (rows top to bottom, cards left to right), the pages of scenes between consecutive beats with the cards in each, every setup with the distance to its payoff, and the questions the wall raises — no beat marked yet; a run out of proportion with the others; beats back to back with nothing between them (a chain of them is one question); a card with a placeholder headline or no change line; a card no arrow touches; two headlines that read like the same scene; a group too long to be one sequence; a person in the cast on no card; a person gone for more than a third of the story and ten pages; a payoff before its setup on the wall; a folded card no setup arrow pays off; a setup arrow leaving a card that is not folded; a card with nobody in it once the wall has a cast; cards that say no place once any card has one. These are questions, not fixes: put them to the writer and do not act on them unasked. A question the writer answers with \"leave it\" is left with leave_question and listed under \"left, for now\" instead, until it would read differently. It says nothing about how many beats there should be, and neither should you. The prose carries every id; PLOTCODER_JSON=1 in the server's environment adds the same reading as JSON after it, for a program.",
|
|
1495
1503
|
inputSchema: {},
|
|
1496
1504
|
},
|
|
1497
1505
|
async () => {
|
|
@@ -1519,7 +1527,7 @@ server.registerTool(
|
|
|
1519
1527
|
"the cast and the places are list_board's, not the reading's",
|
|
1520
1528
|
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
1521
1529
|
? `runtime: about ${formatPages(boardEighths(state))} pages; no target set (set_target)`
|
|
1522
|
-
: `runtime: about ${formatPages(boardEighths(state))} pages of a ${formatPages(state.targetEighths)}-page target — ${boardEighths(state) > state.targetEighths ? `${formatPages(boardEighths(state) - state.targetEighths)} over` : boardEighths(state) < state.targetEighths ? `${formatPages(state.targetEighths - boardEighths(state))} under` : "on it"}`,
|
|
1530
|
+
: `runtime: about ${formatPages(boardEighths(state))} pages of a ${formatPages(state.targetEighths)}-page target — ${boardEighths(state) > state.targetEighths ? `${formatPages(boardEighths(state) - state.targetEighths)} over` : boardEighths(state) < state.targetEighths ? `${formatPages(state.targetEighths - boardEighths(state))} under` : "on it"} (the number to use until the scenes are written; page_count is the script so far)`,
|
|
1523
1531
|
`groups: ${
|
|
1524
1532
|
state.groups.length
|
|
1525
1533
|
? state.groups
|
|
@@ -1532,6 +1540,8 @@ server.registerTool(
|
|
|
1532
1540
|
: "(none)"
|
|
1533
1541
|
}`,
|
|
1534
1542
|
`pages: ${written === 0 ? "all estimates — no scene is written yet, so every card is the writer's guess" : written === state.notes.length ? "measured — every scene is written" : `estimates — ${written} of ${state.notes.length} cards are written${written <= 5 ? ` (${state.notes.filter((note) => isMeasured(note)).map((note) => `"${note.headline}"`).join(", ")})` : ""}, the rest are guesses`}`,
|
|
1543
|
+
// A wall with cards and no follows arrows has no story order yet; say so rather than read the rows as one (round seventeen, entries 10, 11).
|
|
1544
|
+
`story order: ${state.notes.length > 1 && !state.arrows.some((arrow) => arrow.kind !== "setup") ? "unset — no follows arrows, so the rows stand in for it; create_arrow the sequence and the reading, the numbers and every export follow the arrows" : "the follows arrows, and the rows where they say nothing"}`,
|
|
1535
1545
|
`beats in wall order: ${
|
|
1536
1546
|
reading.beats.length
|
|
1537
1547
|
? reading.beats.map((beat) => `"${beat.headline}"`).join(", ")
|
|
@@ -1545,6 +1555,9 @@ server.registerTool(
|
|
|
1545
1555
|
: [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)"]),
|
|
1546
1556
|
...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"}`),
|
|
1547
1557
|
...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`),
|
|
1558
|
+
...(reading.open.length
|
|
1559
|
+
? ["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}`)]
|
|
1560
|
+
: []),
|
|
1548
1561
|
"questions the wall raises:",
|
|
1549
1562
|
...(reading.findings.length
|
|
1550
1563
|
? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}${finding.ids.length ? ` (ids: ${finding.ids.join(", ")})` : ""}`)
|
|
@@ -1562,8 +1575,12 @@ server.registerTool(
|
|
|
1562
1575
|
const counts = new Map();
|
|
1563
1576
|
for (const finding of asked) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
|
|
1564
1577
|
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}`;
|
|
1565
|
-
})()}${reading.left.length ? `; left by the writer, so not clean: ${[...new Set(reading.left.map((finding) => finding.kind))].map((kind) => `[${kind}]`).join(" ")}` : ""}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind) && !reading.left.some((finding) => finding.kind === kind)).map((kind) => {
|
|
1566
|
-
if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked: no arrows yet)";
|
|
1578
|
+
})()}${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) => {
|
|
1579
|
+
if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked until half the cards are wired: no arrows yet)";
|
|
1580
|
+
if (kind === "unlinked") {
|
|
1581
|
+
const linked = new Set(state.arrows.flatMap((arrow) => [arrow.from, arrow.to]));
|
|
1582
|
+
if (linked.size * 2 < state.notes.length) return `no card without an arrow (not asked until half the cards are wired: ${linked.size} of ${state.notes.length} are)`;
|
|
1583
|
+
}
|
|
1567
1584
|
if (kind === "unplaced" && !state.notes.some((note) => (note.location ?? "").trim())) return "no card without a place (not asked: no card placed yet)";
|
|
1568
1585
|
if (kind === "sequence" && state.groups.length === 0) return "no group too long for one sequence (not asked: no groups)";
|
|
1569
1586
|
return CHECK_WORDS[kind];
|
|
@@ -2653,6 +2670,33 @@ server.registerTool(
|
|
|
2653
2670
|
},
|
|
2654
2671
|
);
|
|
2655
2672
|
|
|
2673
|
+
// The open card (R59): the writer's word that a card is not decided. The
|
|
2674
|
+
// per-card twin of leave_question — that one leaves a question, this one
|
|
2675
|
+
// leaves a card.
|
|
2676
|
+
server.registerTool(
|
|
2677
|
+
"set_open",
|
|
2678
|
+
{
|
|
2679
|
+
title: "Leave a card open",
|
|
2680
|
+
description:
|
|
2681
|
+
"Mark one or more cards open, with the writer's words for what is not decided — \"where, and whether Ruth is there\", \"the buyer\". An open card is listed by read_wall under its own head and asked nothing else of while the words stand: not its place, not its change line, not its arrows. It is still counted, in the order, and a page. open \"\" closes the card and its questions come back on their own. Only on the writer's word — a card is open because they said so, never because you could not decide; where the notes have two versions, ask, and if they say leave it, this is how.",
|
|
2682
|
+
inputSchema: { ids: z.array(z.string()).min(1), open: z.string() },
|
|
2683
|
+
},
|
|
2684
|
+
async (args) => {
|
|
2685
|
+
const { state, changed, result, live } = await commit({ type: "set_open", ids: args.ids, open: args.open });
|
|
2686
|
+
if (!changed) {
|
|
2687
|
+
const missing = args.ids.filter((id) => !state.notes.some((note) => note.id === id));
|
|
2688
|
+
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 that" : "those cards already say that"}.`);
|
|
2689
|
+
}
|
|
2690
|
+
const words = result[0]?.open ?? "";
|
|
2691
|
+
return ok(
|
|
2692
|
+
words
|
|
2693
|
+
? `${result.length} card(s) open: "${words}"${where(live)}. The reading lists ${result.length === 1 ? "it" : "them"} under "open, by the writer's word" and asks nothing else of ${result.length === 1 ? "it" : "them"} while the words stand; the card wears the words on its edge. set_open with "" closes it.`
|
|
2694
|
+
: `${result.length} card(s) closed${where(live)}: decided, so the wall's questions about ${result.length === 1 ? "it" : "them"} come back on their own.`,
|
|
2695
|
+
result,
|
|
2696
|
+
);
|
|
2697
|
+
},
|
|
2698
|
+
);
|
|
2699
|
+
|
|
2656
2700
|
server.registerTool(
|
|
2657
2701
|
"set_plant",
|
|
2658
2702
|
{
|
|
@@ -2985,34 +3029,30 @@ server.registerTool(
|
|
|
2985
3029
|
{
|
|
2986
3030
|
title: "Cast a scene",
|
|
2987
3031
|
description:
|
|
2988
|
-
"Set who is in one or more cards of the open board (open_board first for another board's cards). Takes card ids and character names or ids; the list replaces the card's cast, so pass everyone who is in the scene. An empty list clears it.
|
|
3032
|
+
"Set who is in one or more cards of the open board (open_board first for another board's cards). Takes card ids and character names or ids; the list replaces the card's cast, so pass everyone who is in the scene. An empty list clears it. A name not yet in the cast is added to it, as create_note does, and the reply says so — the writer named them, so it is not inventing; a role is a name.",
|
|
2989
3033
|
inputSchema: {
|
|
2990
3034
|
noteIds: z.array(z.string()).min(1),
|
|
2991
3035
|
characters: z.array(z.string()),
|
|
2992
3036
|
},
|
|
2993
3037
|
},
|
|
2994
3038
|
async (args) => {
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
const
|
|
2998
|
-
|
|
2999
|
-
const
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
);
|
|
3010
|
-
}
|
|
3011
|
-
const { state, changed, result, live } = await commit({
|
|
3012
|
-
type: "set_cast",
|
|
3013
|
-
ids: args.noteIds,
|
|
3014
|
-
characterIds,
|
|
3039
|
+
// A name the cast does not have is added to it in the same frame, as
|
|
3040
|
+
// create_note does — two tools, one rule (round seventeen, entry 14).
|
|
3041
|
+
const added = [];
|
|
3042
|
+
const { state, changed, value: result, live } = await commitAll(`cast ${args.noteIds.length} card(s)`, (step, current) => {
|
|
3043
|
+
const characterIds = [];
|
|
3044
|
+
for (const who of args.characters) {
|
|
3045
|
+
const wanted = who.trim().toLowerCase();
|
|
3046
|
+
let person = current().characters.find((character) => character.id === who || character.name.trim().toLowerCase() === wanted);
|
|
3047
|
+
if (!person && wanted) {
|
|
3048
|
+
person = step({ type: "add_character", name: who.trim() }).result;
|
|
3049
|
+
if (person) added.push(`${person.name} (${person.id})`);
|
|
3050
|
+
}
|
|
3051
|
+
if (person && !characterIds.includes(person.id)) characterIds.push(person.id);
|
|
3052
|
+
}
|
|
3053
|
+
return step({ type: "set_cast", ids: args.noteIds, characterIds }).result;
|
|
3015
3054
|
});
|
|
3055
|
+
const characterIds = (result ?? []).length ? result[0].characterIds : [];
|
|
3016
3056
|
if (!changed) {
|
|
3017
3057
|
const missing = args.noteIds.filter((id) => !state.notes.some((note) => note.id === id));
|
|
3018
3058
|
return ok(
|
|
@@ -3025,7 +3065,7 @@ server.registerTool(
|
|
|
3025
3065
|
(id) => state.characters.find((character) => character.id === id)?.name ?? id,
|
|
3026
3066
|
);
|
|
3027
3067
|
return ok(
|
|
3028
|
-
`${result.length} card(s) now cast ${names.length ? names.join(", ") : "nobody"}: ${result.map((note) => `"${note.headline}"`).join(", ")}${where(live)}.`,
|
|
3068
|
+
`${result.length} card(s) now cast ${names.length ? names.join(", ") : "nobody"}: ${result.map((note) => `"${note.headline}"`).join(", ")}${added.length ? ` (added to the cast: ${added.join(", ")})` : ""}${where(live)}.`,
|
|
3029
3069
|
result,
|
|
3030
3070
|
);
|
|
3031
3071
|
},
|
|
@@ -3251,7 +3291,7 @@ server.registerTool(
|
|
|
3251
3291
|
{
|
|
3252
3292
|
title: "Set the project's premise",
|
|
3253
3293
|
description:
|
|
3254
|
-
"Set the project's premise: the
|
|
3294
|
+
"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 third year; the crowns can be cut for the first time'). An empty string clears it. Boards keep their own loglines.",
|
|
3255
3295
|
inputSchema: { premise: z.string() },
|
|
3256
3296
|
},
|
|
3257
3297
|
async (args) => {
|
package/src/board/agents.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
export const AGENTS = {
|
|
9
9
|
lead:
|
|
10
|
-
"A storyline wall. Cards are scenes, beats are the big turns, arrows say what follows or pays off what. An agent driven by a person has every tool a person here has; the person directs, the agent operates. Call the tools; never fake a mouse. Already have the plotcoder-board tools in front of you? Then your way in is: read the guide, make the
|
|
10
|
+
"A storyline wall. Cards are scenes, beats are the big turns, arrows say what follows or pays off what. An agent driven by a person has every tool a person here has; the person directs, the agent operates. Call the tools; never fake a mouse. Already have the plotcoder-board tools in front of you? Then your way in is: read the guide, make the five calls under Call these first, and skip the doors at the end — they are for wiring a server in.",
|
|
11
11
|
doors: [
|
|
12
12
|
{
|
|
13
13
|
id: "mcp",
|
|
@@ -42,7 +42,7 @@ export const AGENTS = {
|
|
|
42
42
|
text: "Skip this when the account is the wall. Without an account, a wall is a folder: any folder, empty is fine — choose one that will outlive your session, never a scratch one. The app run from that folder shows the wall, and the server writes it there (PLOTCODER_ROOT, or the folder it is run from). A fresh folder holds the sample; new_board for the writer's wall, then rename_project. No app running? export_fountain is the wall in order, as text.",
|
|
43
43
|
},
|
|
44
44
|
],
|
|
45
|
-
firstNote: "Make these five before anything else; none depends on another, so any order is fine. list_words and list_workflows are the app's and read no project, and their first line says so; every other reading opens with the door it came through — the project it read and how many the account holds — and a write's tail says where it landed; list_projects lists the projects. read_wall, list_reminders and list_board are about the wall you will work, so after open_project or open_board make those three again, and after new_project read the wall once it holds cards. An emptied account has nothing to read: go straight to new_project
|
|
45
|
+
firstNote: "Make these five before anything else; none depends on another, so any order is fine. list_words and list_workflows are the app's and read no project, and their first line says so; every other reading opens with the door it came through — the project it read and how many the account holds — and a write's tail says where it landed; list_projects lists the projects. read_wall, list_reminders and list_board are about the wall you will work, so after open_project or open_board make those three again, and after new_project read the wall once it holds cards. An emptied account has nothing to read: go straight to new_project: a name is enough; pages sets the target and board names the first board when the writer has them, and set_target and rename_board set them later. On an account with no project yet, read_wall has nothing to read and says so, and list_reminders gives the house principles every project starts with. No server in front of you, and no shell to take the shell door? Nothing gets you in from inside the session: say so, and ask the person to wire the server and start a new session.",
|
|
46
46
|
first: [
|
|
47
47
|
{ tool: "list_words", why: "the room's words, the app's meaning." },
|
|
48
48
|
{ tool: "read_wall", why: "the reading: the beats, the runs, the setups, and what the wall asks. The records — every card, the cast, the places, the rows — are list_board's. A fresh folder holds a sample wall (Maya, Tom, the letter) and the reading says so only when it is the sample; it is not the writer's." },
|
package/src/board/readWall.d.ts
CHANGED
|
@@ -20,6 +20,10 @@ export type FindingKind =
|
|
|
20
20
|
| "unlinked"
|
|
21
21
|
/** Two headlines read like the same scene. */
|
|
22
22
|
| "duplicate"
|
|
23
|
+
/** A setup arrow leaves a card whose corner is not folded: a payoff with no fold. */
|
|
24
|
+
| "unplanted"
|
|
25
|
+
/** A card with nobody in it, on a wall that has a cast. */
|
|
26
|
+
| "nobody"
|
|
23
27
|
/** A group runs too long to be one sequence. */
|
|
24
28
|
| "sequence"
|
|
25
29
|
/** A character in the roster who is on no card. */
|
|
@@ -63,6 +67,8 @@ export type WallReading = {
|
|
|
63
67
|
payoffs: Record<string, string[]>;
|
|
64
68
|
/** Folded cards that pay off on another board of the project (R50): the card, the board, and the scene there that claims it (R58) or null while the board is a promise. */
|
|
65
69
|
later: { id: string; boardId: string; noteId: string | null }[];
|
|
70
|
+
/** Open cards (R59): the writer's words for what is not decided, in story order; not asked about while they stand. */
|
|
71
|
+
open: Array<{ id: string; words: string }>;
|
|
66
72
|
/** Cards here that pay off a fold of another board (R58), composed by the door from the project. */
|
|
67
73
|
paidBy: Array<{ id: string; fromBoardId: string; fromBoardName: string; fromNoteId: string; fromHeadline: string; fromColor: string }>;
|
|
68
74
|
/** 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,7 @@
|
|
|
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 { boardEighths, EIGHTHS_PER_PAGE, formatPages, NOTE_HEIGHT, noteEighths } from "./reducer.js";
|
|
20
|
+
import { isMeasured, boardEighths, EIGHTHS_PER_PAGE, formatPages, NOTE_HEIGHT, noteEighths } from "./reducer.js";
|
|
21
21
|
|
|
22
22
|
/** What create_note writes before a person has. */
|
|
23
23
|
export const PLACEHOLDER_HEADLINE = "New beat";
|
|
@@ -197,6 +197,10 @@ export function readWall(state, options = {}) {
|
|
|
197
197
|
if (from !== null && cards > 0) runs.push({ from, to: null, eighths, cards, ids });
|
|
198
198
|
|
|
199
199
|
const findings = [];
|
|
200
|
+
// Open cards (R59): the writer's word that a card is not decided. Listed,
|
|
201
|
+
// and asked nothing else while the words stand — the card's own "leave it".
|
|
202
|
+
const openIds = new Set(order.filter((note) => (note.open ?? "").trim()).map((note) => note.id));
|
|
203
|
+
const askable = (note) => !openIds.has(note.id);
|
|
200
204
|
const byId = new Map(state.notes.map((note) => [note.id, note]));
|
|
201
205
|
const headline = (id) => (id ? byId.get(id)?.headline ?? id : null);
|
|
202
206
|
const position = new Map(order.map((note, index) => [note.id, index]));
|
|
@@ -282,7 +286,7 @@ export function readWall(state, options = {}) {
|
|
|
282
286
|
// A card that says no place, once the writer has started placing cards.
|
|
283
287
|
// One question however many there are; a wall with no places at all is a
|
|
284
288
|
// wall the writer has not placed yet, and is not asked.
|
|
285
|
-
const unplaced = order.filter((note) => !(note.location ?? "").trim());
|
|
289
|
+
const unplaced = order.filter((note) => askable(note) && !(note.location ?? "").trim());
|
|
286
290
|
if (unplaced.length > 0 && unplaced.length < order.length) {
|
|
287
291
|
findings.push({
|
|
288
292
|
kind: "unplaced",
|
|
@@ -296,6 +300,7 @@ export function readWall(state, options = {}) {
|
|
|
296
300
|
|
|
297
301
|
// A card that has not earned its place yet.
|
|
298
302
|
for (const note of order) {
|
|
303
|
+
if (!askable(note)) continue;
|
|
299
304
|
const change = (note.change ?? "").trim();
|
|
300
305
|
const title = (note.headline ?? "").trim();
|
|
301
306
|
if (title === "" || title === PLACEHOLDER_HEADLINE) {
|
|
@@ -320,7 +325,7 @@ export function readWall(state, options = {}) {
|
|
|
320
325
|
linked.add(arrow.to);
|
|
321
326
|
}
|
|
322
327
|
if (state.notes.length > 0 && linked.size * 2 >= state.notes.length) {
|
|
323
|
-
const loose = order.filter((note) => !linked.has(note.id));
|
|
328
|
+
const loose = order.filter((note) => askable(note) && !linked.has(note.id));
|
|
324
329
|
if (loose.length > 0) {
|
|
325
330
|
findings.push({
|
|
326
331
|
kind: "unlinked",
|
|
@@ -359,6 +364,20 @@ export function readWall(state, options = {}) {
|
|
|
359
364
|
}
|
|
360
365
|
}
|
|
361
366
|
|
|
367
|
+
// A payoff with no fold (round seventeen, entry 12): a setup arrow leaves a
|
|
368
|
+
// card whose corner is not folded. The arrow says "this pays off", the
|
|
369
|
+
// card says nothing was planted — ask which.
|
|
370
|
+
for (const setup of setups) {
|
|
371
|
+
const tail = byId.get(setup.from);
|
|
372
|
+
if (tail && !tail.plants) {
|
|
373
|
+
findings.push({
|
|
374
|
+
kind: "unplanted",
|
|
375
|
+
ids: [setup.id, setup.from],
|
|
376
|
+
text: `${quote(tail)} pays off at ${quote(byId.get(setup.to))} by a setup arrow, but its corner is not folded. Fold it, or is the arrow wrong?`,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
362
381
|
// A folded corner nothing has paid off (R31). The fold says "this plants
|
|
363
382
|
// something"; a setup arrow leaving the card is the payoff. Until one does,
|
|
364
383
|
// the debt is open.
|
|
@@ -416,6 +435,19 @@ export function readWall(state, options = {}) {
|
|
|
416
435
|
const hereIds = new Set(order.map((note) => note.id));
|
|
417
436
|
const paidHere = paidBy.filter((item) => hereIds.has(item.id));
|
|
418
437
|
|
|
438
|
+
// A card with nobody in it, once the wall has a cast (round seventeen,
|
|
439
|
+
// entry 28): a scene nobody is in passed every check.
|
|
440
|
+
if ((state.characters ?? []).length > 0) {
|
|
441
|
+
const empty = order.filter((note) => askable(note) && !(note.characterIds ?? []).length);
|
|
442
|
+
if (empty.length) {
|
|
443
|
+
findings.push({
|
|
444
|
+
kind: "nobody",
|
|
445
|
+
ids: empty.map((note) => note.id),
|
|
446
|
+
text: `${empty.length === 1 ? `${quote(empty[0])} has nobody in it` : `${empty.length} cards have nobody in them: ${empty.map((note) => quote(note)).join(", ")}`}. Who is in the scene${empty.length === 1 ? "" : "s"}?`,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
419
451
|
// The cast (R29): someone who vanishes for a stretch, or never appears.
|
|
420
452
|
const total = boardEighths(state);
|
|
421
453
|
const at = new Map();
|
|
@@ -472,7 +504,10 @@ export function readWall(state, options = {}) {
|
|
|
472
504
|
// it is a new question and is asked. The kernel never decides this; the
|
|
473
505
|
// reading does, on every read.
|
|
474
506
|
const left = [];
|
|
507
|
+
// A question whose every card is open is not asked (R59): the writer's word covers it.
|
|
508
|
+
const openCards = openIds.size ? findings.filter((finding) => finding.ids.some((id) => byId.has(id)) && finding.ids.filter((id) => byId.has(id)).every((id) => openIds.has(id))) : [];
|
|
475
509
|
const asked = findings.filter((finding) => {
|
|
510
|
+
if (openCards.includes(finding)) return false;
|
|
476
511
|
const entry = (state.left ?? []).find(
|
|
477
512
|
(item) => item.kind === finding.kind && sameList(item.ids, finding.ids) && item.text === finding.text,
|
|
478
513
|
);
|
|
@@ -489,6 +524,7 @@ export function readWall(state, options = {}) {
|
|
|
489
524
|
payoffs,
|
|
490
525
|
later,
|
|
491
526
|
paidBy: paidHere,
|
|
527
|
+
open: order.filter((note) => openIds.has(note.id)).map((note) => ({ id: note.id, words: note.open.trim() })),
|
|
492
528
|
findings: asked,
|
|
493
529
|
left,
|
|
494
530
|
};
|
|
@@ -525,6 +561,10 @@ export function describeRuns(reading, state) {
|
|
|
525
561
|
? `After "${name(run.from)}"`
|
|
526
562
|
: `"${name(run.from)}" → "${name(run.to)}"`;
|
|
527
563
|
const count = run.cards === 1 ? "1 card" : `${run.cards} cards`;
|
|
528
|
-
|
|
564
|
+
// Whose number a run's pages are (round seventeen, entry 39): measured
|
|
565
|
+
// from written text, or the cards' guess, or some of each.
|
|
566
|
+
const written = (run.ids ?? []).filter((id) => isMeasured(byId.get(id) ?? {})).length;
|
|
567
|
+
const whose = !run.cards ? "" : written === run.cards ? ", measured" : written ? `, ${written} of ${run.cards} measured` : ", estimated";
|
|
568
|
+
return `${span}: about ${pages(run.eighths)} pages, ${count}${whose}`;
|
|
529
569
|
});
|
|
530
570
|
}
|
package/src/board/reducer.d.ts
CHANGED
|
@@ -83,6 +83,8 @@ export type BoardNote = {
|
|
|
83
83
|
payoffBoardId: string | null;
|
|
84
84
|
/** The scene on that board that pays it off (R58), or null while the board is only a promise. */
|
|
85
85
|
payoffNoteId: string | null;
|
|
86
|
+
/** The writer's words for what is not decided about this card (R59), or empty: the reading lists an open card and asks nothing else of it. */
|
|
87
|
+
open: string;
|
|
86
88
|
/** Where the scene happens (R37): a phrase in the writer's words; empty until set. */
|
|
87
89
|
location: string;
|
|
88
90
|
/** When the scene happens, as the writer says it — "night", "day four, dawn" — printed after the place on the heading (R55). Empty when unsaid. */
|
|
@@ -162,6 +164,7 @@ export type Command =
|
|
|
162
164
|
plants?: boolean;
|
|
163
165
|
location?: string;
|
|
164
166
|
when?: string;
|
|
167
|
+
open?: string;
|
|
165
168
|
text?: string;
|
|
166
169
|
}
|
|
167
170
|
| { type: "update_note"; id: string; headline?: string; change?: string; location?: string; when?: string }
|
|
@@ -186,6 +189,7 @@ export type Command =
|
|
|
186
189
|
| ({ type: "update_character"; id: string } & Partial<Record<CharacterField, string>>)
|
|
187
190
|
| { type: "set_cast"; ids: string[]; characterIds: string[] }
|
|
188
191
|
| { type: "set_plant"; ids: string[]; plants: boolean }
|
|
192
|
+
| { type: "set_open"; ids: string[]; open: string }
|
|
189
193
|
| { type: "set_payoff_board"; ids: string[]; boardId: string | null; noteId?: string | null }
|
|
190
194
|
| { type: "set_location"; ids: string[]; location: string }
|
|
191
195
|
| { type: "set_when"; ids: string[]; when: string }
|
package/src/board/reducer.js
CHANGED
|
@@ -253,6 +253,10 @@ export function seedState(now = nowIso()) {
|
|
|
253
253
|
// does (R58); null while the board is a promise.
|
|
254
254
|
payoffBoardId: null,
|
|
255
255
|
payoffNoteId: null,
|
|
256
|
+
// Open: the writer's words for what is not decided about this card, or
|
|
257
|
+
// nothing (R59). While they stand the reading lists the card and asks
|
|
258
|
+
// nothing else of it.
|
|
259
|
+
open: "",
|
|
256
260
|
createdAt: now,
|
|
257
261
|
updatedAt: now,
|
|
258
262
|
});
|
|
@@ -350,6 +354,8 @@ export function normalizeState(value) {
|
|
|
350
354
|
const payoffBoardId = plants && typeof note?.payoffBoardId === "string" && note.payoffBoardId ? note.payoffBoardId : null;
|
|
351
355
|
// Cards written before R58 name a board and no scene on it.
|
|
352
356
|
const payoffNoteId = payoffBoardId && typeof note?.payoffNoteId === "string" && note.payoffNoteId ? note.payoffNoteId : null;
|
|
357
|
+
// Cards written before R59 are not open; a card claims to be decided until the writer says otherwise.
|
|
358
|
+
const open = typeof note?.open === "string" ? note.open : "";
|
|
353
359
|
// Cards written before R37 have no place; a scene is nowhere until it is.
|
|
354
360
|
const location = typeof note?.location === "string" ? note.location : "";
|
|
355
361
|
// Cards written before R55 have no when; a scene is at no time until it is.
|
|
@@ -365,6 +371,7 @@ export function normalizeState(value) {
|
|
|
365
371
|
note.plants === plants &&
|
|
366
372
|
note.payoffBoardId === payoffBoardId &&
|
|
367
373
|
note.payoffNoteId === payoffNoteId &&
|
|
374
|
+
note.open === open &&
|
|
368
375
|
note.location === location &&
|
|
369
376
|
note.when === when &&
|
|
370
377
|
note.text === text
|
|
@@ -372,7 +379,7 @@ export function normalizeState(value) {
|
|
|
372
379
|
return note;
|
|
373
380
|
}
|
|
374
381
|
patched = true;
|
|
375
|
-
return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, payoffNoteId, location, when, text };
|
|
382
|
+
return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, payoffNoteId, open, location, when, text };
|
|
376
383
|
});
|
|
377
384
|
|
|
378
385
|
// Boards written before the production half (Roadmap 2, item 8) have no
|
|
@@ -423,6 +430,11 @@ function bump(note, patch, now) {
|
|
|
423
430
|
return { ...note, ...patch, updatedAt: now };
|
|
424
431
|
}
|
|
425
432
|
|
|
433
|
+
/** The writer's words for what is open about a card, one line, spaces collapsed; empty closes it (R59). */
|
|
434
|
+
function cleanOpen(value) {
|
|
435
|
+
return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
|
|
436
|
+
}
|
|
437
|
+
|
|
426
438
|
/** A when as the writer typed it, one line, spaces collapsed. */
|
|
427
439
|
function cleanWhen(value) {
|
|
428
440
|
return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
|
|
@@ -459,6 +471,7 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
459
471
|
plants: command.plants === true,
|
|
460
472
|
payoffBoardId: null,
|
|
461
473
|
payoffNoteId: null,
|
|
474
|
+
open: cleanOpen(command.open),
|
|
462
475
|
location: cleanPlace(command.location),
|
|
463
476
|
when: cleanWhen(command.when),
|
|
464
477
|
text: typeof command.text === "string" ? command.text : "",
|
|
@@ -917,6 +930,7 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
917
930
|
plants: false,
|
|
918
931
|
payoffBoardId: null,
|
|
919
932
|
payoffNoteId: null,
|
|
933
|
+
open: "",
|
|
920
934
|
location: "",
|
|
921
935
|
when: "",
|
|
922
936
|
text: "",
|
|
@@ -1037,6 +1051,23 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
1037
1051
|
return { state: { ...state, notes }, changed: true, result: touched };
|
|
1038
1052
|
}
|
|
1039
1053
|
|
|
1054
|
+
// Leave a card open (R59): the writer's words for what is not decided,
|
|
1055
|
+
// or nothing. A claim about the card, like rank: it never moves it.
|
|
1056
|
+
case "set_open": {
|
|
1057
|
+
const ids = new Set(command.ids);
|
|
1058
|
+
if (ids.size === 0) return { state, changed: false };
|
|
1059
|
+
const open = cleanOpen(command.open);
|
|
1060
|
+
const touched = [];
|
|
1061
|
+
const notes = state.notes.map((note) => {
|
|
1062
|
+
if (!ids.has(note.id) || (note.open ?? "") === open) return note;
|
|
1063
|
+
const next = bump(note, { open }, now);
|
|
1064
|
+
touched.push(next);
|
|
1065
|
+
return next;
|
|
1066
|
+
});
|
|
1067
|
+
if (touched.length === 0) return { state, changed: false };
|
|
1068
|
+
return { state: { ...state, notes }, changed: true, result: touched };
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1040
1071
|
// Fold the corner (R31): this card plants something. A claim about the
|
|
1041
1072
|
// card, like rank, so it never moves it and never touches its arrows.
|
|
1042
1073
|
case "set_plant": {
|
package/src/board/words.js
CHANGED
|
@@ -36,7 +36,7 @@ export const WORD_GROUPS = [
|
|
|
36
36
|
{
|
|
37
37
|
id: "premise",
|
|
38
38
|
name: "The premise",
|
|
39
|
-
sentence: "
|
|
39
|
+
sentence: "The line above every board's logline, the project's whatever its board count: what a series is about, or what is true before a film starts.",
|
|
40
40
|
},
|
|
41
41
|
{
|
|
42
42
|
id: "beat",
|
|
@@ -63,6 +63,11 @@ export const WORD_GROUPS = [
|
|
|
63
63
|
sentence: "The second line. If nothing is different after the scene, the scene is usually decoration.",
|
|
64
64
|
target: "change",
|
|
65
65
|
},
|
|
66
|
+
{
|
|
67
|
+
id: "open",
|
|
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.",
|
|
70
|
+
},
|
|
66
71
|
{
|
|
67
72
|
id: "corner",
|
|
68
73
|
name: "The folded corner",
|