plotcoder-board 0.1.20 → 0.1.22
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 +2 -2
- package/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +116 -20
- package/src/board/project.d.ts +5 -0
- package/src/board/project.js +36 -0
- package/src/board/readWall.d.ts +8 -2
- package/src/board/readWall.js +27 -1
- package/src/board/reducer.d.ts +3 -1
- package/src/board/reducer.js +19 -6
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 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_plant`, `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.
|
|
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-one 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_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.
|
|
@@ -99,4 +99,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
|
|
|
99
99
|
|
|
100
100
|
## Status
|
|
101
101
|
|
|
102
|
-
Version 0.1.
|
|
102
|
+
Version 0.1.22. 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
|
@@ -73,6 +73,8 @@ import {
|
|
|
73
73
|
reidentifyProject,
|
|
74
74
|
renameProject,
|
|
75
75
|
castElsewhere,
|
|
76
|
+
landingsOn,
|
|
77
|
+
laterBoards,
|
|
76
78
|
liftCast,
|
|
77
79
|
mergeRoster,
|
|
78
80
|
sameRoster,
|
|
@@ -577,6 +579,27 @@ function elsewhereIds(boardId) {
|
|
|
577
579
|
if (!lastHeld?.project) return [];
|
|
578
580
|
return Object.keys(castElsewhere(lastHeld.project, lastHeld.boards, boardId ?? lastHeld.project.activeBoardId));
|
|
579
581
|
}
|
|
582
|
+
/** Everything a reading of one board needs to know about the rest of the project (R51, R58), from the last project read. */
|
|
583
|
+
function readOptions(boardId, state = null) {
|
|
584
|
+
if (!lastHeld?.project) return { elsewhere: [] };
|
|
585
|
+
const id = boardId ?? lastHeld.project.activeBoardId;
|
|
586
|
+
const boards = state ? { ...lastHeld.boards, [id]: state } : lastHeld.boards;
|
|
587
|
+
return { elsewhere: elsewhereIds(id), laterBoards: laterBoards(lastHeld.project, boards), paidBy: landingsOn(lastHeld.project, boards, id).paid };
|
|
588
|
+
}
|
|
589
|
+
/** A card's number as the reading prints it: the lock's when locked, else its place in story order. */
|
|
590
|
+
function sceneLabel(state, noteId) {
|
|
591
|
+
const numbers = state.lock ? sceneNumbers(storyOrder(state), state.lock) : null;
|
|
592
|
+
const numbered = numbers?.get?.(noteId);
|
|
593
|
+
if (numbered) return numbered;
|
|
594
|
+
const index = storyOrder(state).findIndex((note) => note.id === noteId);
|
|
595
|
+
return index >= 0 ? String(index + 1) : "?";
|
|
596
|
+
}
|
|
597
|
+
/** "Ep 1, sc 4": a scene on another board, named by the board's place in the project and the scene's in its story (R58). */
|
|
598
|
+
function episodeLabel(project, boards, boardId, noteId) {
|
|
599
|
+
const index = project.boards.findIndex((meta) => meta.id === boardId);
|
|
600
|
+
const state = isBoardState(boards[boardId]) ? normalizeState(boards[boardId]) : null;
|
|
601
|
+
return `Ep ${index + 1}${state && noteId ? `, sc ${sceneLabel(state, noteId)}` : ""}`;
|
|
602
|
+
}
|
|
580
603
|
async function readProjectUncached() {
|
|
581
604
|
const viaAccount = await throughAccount(accountReadProject);
|
|
582
605
|
if (viaAccount) return viaAccount;
|
|
@@ -786,9 +809,8 @@ const findingKey = (finding) => `${finding.kind}|${finding.ids.join(",")}|${find
|
|
|
786
809
|
function noteChange(before, after, boardId = null) {
|
|
787
810
|
// The same reading read_wall gives: a person cast on another board is not
|
|
788
811
|
// asked about, so a write's tail never names a question the reading does not.
|
|
789
|
-
const
|
|
790
|
-
const
|
|
791
|
-
const now = readWall(after, options);
|
|
812
|
+
const was = readWall(before, readOptions(boardId, before));
|
|
813
|
+
const now = readWall(after, readOptions(boardId, after));
|
|
792
814
|
const wasKeys = new Set(was.findings.map(findingKey));
|
|
793
815
|
const nowKeys = new Set(now.findings.map(findingKey));
|
|
794
816
|
lastChange = {
|
|
@@ -974,19 +996,25 @@ function runtimeKinds(state) {
|
|
|
974
996
|
|
|
975
997
|
function summarize(state) {
|
|
976
998
|
const nameOf = new Map(state.characters.map((character) => [character.id, character.name]));
|
|
999
|
+
// What lands here from the other boards' folds (R58), from the last project read.
|
|
1000
|
+
const paidByHere = lastHeld?.project ? landingsOn(lastHeld.project, { ...lastHeld.boards, [lastHeld.project.activeBoardId]: state }, lastHeld.project.activeBoardId).paid : [];
|
|
977
1001
|
const notes = storyOrder(state)
|
|
978
1002
|
.map((note) => {
|
|
979
1003
|
const cast = note.characterIds.map((id) => nameOf.get(id) ?? id);
|
|
980
1004
|
const who = cast.length ? `, cast: ${cast.join(", ")}` : "";
|
|
981
1005
|
// The board it pays off on, named here as read_wall names it (round fifteen, entry 44).
|
|
982
|
-
const
|
|
1006
|
+
const laterName = note.payoffBoardId ? (lastHeld?.project?.boards?.find((meta) => meta.id === note.payoffBoardId)?.name ?? note.payoffBoardId) : null;
|
|
1007
|
+
const laterAt = note.payoffBoardId && note.payoffNoteId && lastHeld?.project ? ` at ${episodeLabel(lastHeld.project, lastHeld.boards, note.payoffBoardId, note.payoffNoteId)} "${lastHeld.boards[note.payoffBoardId]?.notes?.find((item) => item.id === note.payoffNoteId)?.headline ?? note.payoffNoteId}"` : "";
|
|
1008
|
+
const plant = note.plants ? (note.payoffBoardId ? `, plants → pays off later on "${laterName}"${laterAt || ", no scene there claimed yet"}` : ", plants") : "";
|
|
1009
|
+
// The receiving end (R58): what this card pays off from another board, composed from the project.
|
|
1010
|
+
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("");
|
|
983
1011
|
const snap = state.revision?.snapshot?.[note.id];
|
|
984
1012
|
const revised = snap && (snap.headline !== note.headline || snap.change !== note.change || (snap.text ?? "") !== (note.text ?? "") || (snap.location ?? "") !== (note.location ?? "")) ? `, changed in ${state.revision.color}` : "";
|
|
985
1013
|
const place = note.location ? `, at: ${note.location}` : "";
|
|
986
1014
|
const when = note.when ? `, when: ${note.when}` : "";
|
|
987
1015
|
const count = formatPages(noteEighths(note));
|
|
988
1016
|
const pages = isMeasured(note) ? `${count} ${count === "1" ? "page" : "pages"}, written` : note.lengthEighths === null ? "about a page, unsized" : `${count} ${count === "1" ? "page" : "pages"}`;
|
|
989
|
-
return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${when}${plant}${revised}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
|
|
1017
|
+
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)}`;
|
|
990
1018
|
})
|
|
991
1019
|
.join("\n");
|
|
992
1020
|
const cast = state.characters
|
|
@@ -1424,8 +1452,10 @@ server.registerTool(
|
|
|
1424
1452
|
const { project: projectForRead } = await readProject();
|
|
1425
1453
|
const readBoardMeta = boardById(projectForRead, readBoardId ?? projectForRead.activeBoardId);
|
|
1426
1454
|
const { boards: boardsForRead } = await readProject();
|
|
1427
|
-
const
|
|
1428
|
-
const
|
|
1455
|
+
const readId = readBoardId ?? projectForRead.activeBoardId;
|
|
1456
|
+
const boardsNow = { ...boardsForRead, [readId]: state };
|
|
1457
|
+
const elsewhereForRead = castElsewhere(projectForRead, boardsForRead, readId);
|
|
1458
|
+
const reading = readWall(state, { elsewhere: Object.keys(elsewhereForRead), laterBoards: laterBoards(projectForRead, boardsNow), paidBy: landingsOn(projectForRead, boardsNow, readId).paid });
|
|
1429
1459
|
lastReading = { findings: reading.findings };
|
|
1430
1460
|
sinceRead.length = 0;
|
|
1431
1461
|
const runs = describeRuns(reading, state).map((line, index) => {
|
|
@@ -1466,7 +1496,8 @@ server.registerTool(
|
|
|
1466
1496
|
...(reading.setups.length
|
|
1467
1497
|
? describeSetups(reading, state).map((line) => ` - ${line}`)
|
|
1468
1498
|
: [" (no arrow is marked as a setup)"]),
|
|
1469
|
-
...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}"`),
|
|
1499
|
+
...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"}`),
|
|
1500
|
+
...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`),
|
|
1470
1501
|
"questions the wall raises:",
|
|
1471
1502
|
...(reading.findings.length
|
|
1472
1503
|
? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}${finding.ids.length ? ` (ids: ${finding.ids.join(", ")})` : ""}`)
|
|
@@ -1517,8 +1548,8 @@ server.registerTool(
|
|
|
1517
1548
|
const wanted = args.questions?.length ? args.questions : args.kind ? [{ kind: args.kind, ids: args.ids, why: args.why }] : [];
|
|
1518
1549
|
if (!wanted.length) return ok("Say which question: its kind as read_wall names it (and ids when that kind is asked more than once), or a list under questions.");
|
|
1519
1550
|
const { state, boardId: leaveBoardId } = await readBoard();
|
|
1520
|
-
const
|
|
1521
|
-
const reading = readWall(state,
|
|
1551
|
+
const readOptions_ = readOptions(leaveBoardId, state);
|
|
1552
|
+
const reading = readWall(state, readOptions_);
|
|
1522
1553
|
const replies = [];
|
|
1523
1554
|
const toLeave = [];
|
|
1524
1555
|
for (const want of wanted) {
|
|
@@ -1558,7 +1589,7 @@ server.registerTool(
|
|
|
1558
1589
|
after = out.state;
|
|
1559
1590
|
for (const item of toLeave) replies.push(`Left, for now: [${item.finding.kind}] ${item.finding.text}${item.why ? ` — "${item.why}"` : ""}`);
|
|
1560
1591
|
}
|
|
1561
|
-
const still = readWall(after, readOptions).findings;
|
|
1592
|
+
const still = readWall(after, readOptions(leaveBoardId, after)).findings;
|
|
1562
1593
|
const tail = toLeave.length
|
|
1563
1594
|
? `${where(live)} ${once("leave-rule", "The wall keeps the writer's word and asks a left question again on its own when it would read differently; ask_again brings one back now. ")}The wall still asks ${still.length === 0 ? "nothing" : `${still.length}: ${still.map((finding) => `[${finding.kind}] ${finding.text}`).join(" ")}`}.`
|
|
1564
1595
|
: "";
|
|
@@ -1579,7 +1610,7 @@ server.registerTool(
|
|
|
1579
1610
|
if (held.length === 0) return ok(`Nothing of kind "${args.kind}"${args.ids ? ` about ids ${args.ids.join(", ")}` : ""} is left. read_wall lists what is, under "left, for now".`);
|
|
1580
1611
|
const { result, live } = await commit({ type: "ask_again", kind: args.kind, ids: args.ids });
|
|
1581
1612
|
const again = await readBoard();
|
|
1582
|
-
const reading = readWall(again.state,
|
|
1613
|
+
const reading = readWall(again.state, readOptions(again.boardId, again.state));
|
|
1583
1614
|
const back = reading.findings.filter((finding) => held.some((item) => item.kind === finding.kind && sameList(item.ids, finding.ids)));
|
|
1584
1615
|
return ok(
|
|
1585
1616
|
`Asked again${where(live)}: ${held.length} question${held.length === 1 ? "" : "s"} of kind "${args.kind}" ${held.length === 1 ? "is" : "are"} no longer left${back.length ? ` — the wall asks ${back.length === 1 ? "it" : `${back.length} of them`} now: ${back.map((finding) => finding.text).join(" ")}` : " — and the wall no longer asks it; the question had already changed"}.`,
|
|
@@ -2542,11 +2573,12 @@ server.registerTool(
|
|
|
2542
2573
|
{
|
|
2543
2574
|
title: "Fold the corner",
|
|
2544
2575
|
description:
|
|
2545
|
-
`Fold the corner of cards — mark them as planting something — or unfold them. ${wordSentence("corner")} The setup arrow is create_arrow with kind 'setup'. A fold that pays off in a later episode: pass later, another board of the project by name, id or number — a board that exists; new_board makes one — and the
|
|
2576
|
+
`Fold the corner of cards — mark them as planting something — or unfold them. ${wordSentence("corner")} The setup arrow is create_arrow with kind 'setup'. A fold that pays off in a later episode: pass later, another board of the project by name, id or number — a board that exists; new_board makes one — and, once you know it, at: the scene on that board that pays it off, by id or headline. A board alone is a promise: the reading lists the card under 'later' and, once that board holds cards, asks which scene until one claims it; with at, both boards' readings name the payoff and the paying-off card says so. later '' forgets the board; at '' keeps the board and forgets the scene. set_payoff makes the same claim from the other board. Folding never moves a card.`,
|
|
2546
2577
|
inputSchema: {
|
|
2547
2578
|
ids: z.array(z.string()).min(1),
|
|
2548
2579
|
plants: z.boolean(),
|
|
2549
2580
|
later: z.string().optional(),
|
|
2581
|
+
at: z.string().optional(),
|
|
2550
2582
|
},
|
|
2551
2583
|
},
|
|
2552
2584
|
async (args) => {
|
|
@@ -2554,15 +2586,29 @@ server.registerTool(
|
|
|
2554
2586
|
// The kernel cannot check the board exists; this door can, before anything lands.
|
|
2555
2587
|
let target = null;
|
|
2556
2588
|
let forgetting = false;
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2589
|
+
let atNote = null;
|
|
2590
|
+
let atClearing = false;
|
|
2591
|
+
if (args.plants && (args.later !== undefined || args.at !== undefined)) {
|
|
2592
|
+
const { project, boards } = await readProject();
|
|
2593
|
+
const { state: here, boardId: current } = await readBoard();
|
|
2594
|
+
if (args.later !== undefined && args.later.trim() === "") {
|
|
2561
2595
|
forgetting = true;
|
|
2562
2596
|
} else {
|
|
2563
|
-
|
|
2597
|
+
// `at` alone means the board the fold already names.
|
|
2598
|
+
const key = args.later ?? here.notes.find((note) => args.ids.includes(note.id) && note.payoffBoardId)?.payoffBoardId;
|
|
2599
|
+
if (!key) return ok("Say which board with later before at: a scene belongs to a board.");
|
|
2600
|
+
target = findBoard(project, String(key));
|
|
2564
2601
|
if (!target) return ok(`No board called "${args.later}" yet. A fold pays off later on a board of the project: new_board "${args.later}" makes it (empty), open_board back to this one, then set_plant again with later.`);
|
|
2565
2602
|
if (target.id === (current ?? project.activeBoardId)) return ok(`"${target.name}" is this board. A payoff on the same board is a setup arrow: create_arrow from the fold to the scene, kind 'setup'.`);
|
|
2603
|
+
if (args.at !== undefined) {
|
|
2604
|
+
if (args.at.trim() === "") atClearing = true;
|
|
2605
|
+
else {
|
|
2606
|
+
const there = isBoardState(boards[target.id]) ? normalizeState(boards[target.id]) : emptyState();
|
|
2607
|
+
const wanted = args.at.trim().toLowerCase();
|
|
2608
|
+
atNote = there.notes.find((note) => note.id === args.at.trim()) ?? there.notes.find((note) => note.headline.trim().toLowerCase() === wanted) ?? null;
|
|
2609
|
+
if (!atNote) return ok(`No card on "${target.name}" with id or headline "${args.at}". open_board there and list_board for its cards; or leave at out and the fold stays a promise on that board.`);
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2566
2612
|
}
|
|
2567
2613
|
}
|
|
2568
2614
|
// The fold and the board it pays off on land as one change: one ⌘Z on the wall.
|
|
@@ -2576,10 +2622,14 @@ server.registerTool(
|
|
|
2576
2622
|
laterLine = " The board it paid off on is forgotten; read_wall asks again until a setup arrow or a board pays it off.";
|
|
2577
2623
|
}
|
|
2578
2624
|
} else if (target) {
|
|
2579
|
-
const named = step({ type: "set_payoff_board", ids: args.ids, boardId: target.id });
|
|
2625
|
+
const named = step({ type: "set_payoff_board", ids: args.ids, boardId: target.id, noteId: atNote?.id ?? null });
|
|
2580
2626
|
if (named.changed) result = named.result;
|
|
2581
2627
|
const here = current().notes.filter((note) => args.ids.includes(note.id));
|
|
2582
|
-
laterLine =
|
|
2628
|
+
laterLine = atNote
|
|
2629
|
+
? ` ${here.length} card(s) pay off at "${atNote.headline}" on "${target.name}": both boards' readings name it, and that card says what it pays off.`
|
|
2630
|
+
: atClearing
|
|
2631
|
+
? ` ${here.length} card(s) pay off later, on "${target.name}", and no scene there is claimed: read_wall asks which once that board holds cards.`
|
|
2632
|
+
: ` ${here.length} card(s) pay off later, on "${target.name}": the card says so, and read_wall asks which scene once that board holds cards — set_plant with at, or set_payoff from that board, names it.`;
|
|
2583
2633
|
}
|
|
2584
2634
|
return { result, laterLine };
|
|
2585
2635
|
});
|
|
@@ -2597,6 +2647,52 @@ server.registerTool(
|
|
|
2597
2647
|
|
|
2598
2648
|
// --- Characters -------------------------------------------------------
|
|
2599
2649
|
|
|
2650
|
+
// The receiving end of a series plant (R58), claimed from the board it lands
|
|
2651
|
+
// on: the same record set_plant's `at` writes, on the fold's own card, so one
|
|
2652
|
+
// claim has one owner. Two board switches around one frame on the fold's
|
|
2653
|
+
// board, which is where undo takes it back.
|
|
2654
|
+
server.registerTool(
|
|
2655
|
+
"set_payoff",
|
|
2656
|
+
{
|
|
2657
|
+
title: "Pay off a fold from another board",
|
|
2658
|
+
description:
|
|
2659
|
+
"Say that a card on this board pays off a fold of another board of the project: id (the card here), from (that board, by name, id or number), and fold (the folded card there, by id or headline). The claim is written on the fold's card — the twin of set_plant with later and at — so both boards' readings name it and the card here says what it pays off. fold '' takes back every claim of that board on this card. The wall's other board is opened for the write and this one reopened after; undo on that board takes the claim back.",
|
|
2660
|
+
inputSchema: { id: z.string(), from: z.union([z.string().min(1), z.number()]), fold: z.string() },
|
|
2661
|
+
},
|
|
2662
|
+
async (args) => {
|
|
2663
|
+
const { project, boards } = await readProject();
|
|
2664
|
+
const { state: here, boardId } = await readBoard();
|
|
2665
|
+
const hereId = boardId ?? project.activeBoardId;
|
|
2666
|
+
const card = here.notes.find((note) => note.id === args.id);
|
|
2667
|
+
if (!card) return ok(`No card with id ${args.id} on this board. Call list_board.`);
|
|
2668
|
+
const source = findBoard(project, String(args.from));
|
|
2669
|
+
if (!source) return ok(`No board matches "${args.from}". Call list_boards for the real ones.`);
|
|
2670
|
+
if (source.id === hereId) return ok(`"${source.name}" is this board. A payoff on the same board is a setup arrow: create_arrow from the fold to this card, kind 'setup'.`);
|
|
2671
|
+
const there = isBoardState(boards[source.id]) ? normalizeState(boards[source.id]) : emptyState();
|
|
2672
|
+
const clearing = args.fold.trim() === "";
|
|
2673
|
+
const wanted = args.fold.trim().toLowerCase();
|
|
2674
|
+
const folds = clearing
|
|
2675
|
+
? there.notes.filter((note) => note.plants && note.payoffBoardId === hereId && note.payoffNoteId === card.id)
|
|
2676
|
+
: [there.notes.find((note) => note.id === args.fold.trim()) ?? there.notes.find((note) => note.headline.trim().toLowerCase() === wanted)].filter(Boolean);
|
|
2677
|
+
if (!folds.length) return ok(clearing ? `No fold of "${source.name}" claims "${card.headline}".` : `No card on "${source.name}" with id or headline "${args.fold}". open_board there and list_board for its cards.`);
|
|
2678
|
+
if (!clearing && !folds[0].plants) return ok(`"${folds[0].headline}" on "${source.name}" is not folded: nothing to pay off. set_plant it there first, with later "${boardById(project, hereId)?.name ?? hereId}".`);
|
|
2679
|
+
const held = await readProject();
|
|
2680
|
+
await openBoardEverywhere(held.project, held.boards, held.rev, held.base, source.id);
|
|
2681
|
+
const { changed } = await commitAll(`set_payoff "${card.headline}"`, (step) => {
|
|
2682
|
+
for (const fold of folds) step({ type: "set_payoff_board", ids: [fold.id], boardId: clearing ? hereId : hereId, noteId: clearing ? null : card.id });
|
|
2683
|
+
});
|
|
2684
|
+
const back = await readProject();
|
|
2685
|
+
const { live } = await openBoardEverywhere(back.project, back.boards, back.rev, back.base, hereId);
|
|
2686
|
+
if (!changed) return ok(`No change: ${clearing ? "nothing was claimed" : `"${folds[0].headline}" already pays off at "${card.headline}"`}.`);
|
|
2687
|
+
return ok(
|
|
2688
|
+
clearing
|
|
2689
|
+
? `"${card.headline}" no longer pays off ${folds.map((fold) => `"${fold.headline}"`).join(", ")} from "${source.name}"; ${folds.length === 1 ? "that fold is" : "those folds are"} a promise on this board again${where(live)}. This board is open again; undo on "${source.name}" takes it back.`
|
|
2690
|
+
: `"${card.headline}" pays off "${folds[0].headline}" from "${source.name}" (${episodeLabel(project, { ...boards, [hereId]: here }, source.id, folds[0].id)})${where(live)}. The fold's card there says "paid off in ${episodeLabel(project, { ...boards, [hereId]: here }, hereId, card.id)}", this card says what it pays off, and both readings list it. This board is open again; undo on "${source.name}" takes the claim back.`,
|
|
2691
|
+
{ fold: folds.map((fold) => fold.id), board: source.id, card: card.id },
|
|
2692
|
+
);
|
|
2693
|
+
},
|
|
2694
|
+
);
|
|
2695
|
+
|
|
2600
2696
|
server.registerTool(
|
|
2601
2697
|
"add_character",
|
|
2602
2698
|
{
|
package/src/board/project.d.ts
CHANGED
|
@@ -85,6 +85,11 @@ export declare function liftCast(
|
|
|
85
85
|
): { project: ProjectRecord; boards: Record<string, BoardState>; changed: boolean };
|
|
86
86
|
export type CastElsewhere = Record<string, Array<{ board: string; boardId: string; cards: number }>>;
|
|
87
87
|
export declare function castElsewhere(project: ProjectRecord, boards: Record<string, BoardState>, activeBoardId: string): CastElsewhere;
|
|
88
|
+
|
|
89
|
+
/** A fold on another board that lands here (R58): the fold's board, card and colour, and the paying-off card here (null while waiting). */
|
|
90
|
+
export type Landing = { id: string | null; fromBoardId: string; fromBoardName: string; fromNoteId: string; fromHeadline: string; fromColor: string };
|
|
91
|
+
export declare function landingsOn(project: ProjectRecord, boards: Record<string, BoardState>, boardId: string): { paid: Array<Landing & { id: string }>; waiting: Landing[] };
|
|
92
|
+
export declare function laterBoards(project: ProjectRecord, boards: Record<string, BoardState>): Record<string, { name: string; cards: number; noteIds: string[] }>;
|
|
88
93
|
export declare function mergeRoster(
|
|
89
94
|
project: ProjectRecord,
|
|
90
95
|
state: BoardState,
|
package/src/board/project.js
CHANGED
|
@@ -220,6 +220,42 @@ export function castElsewhere(project, boards, activeBoardId) {
|
|
|
220
220
|
return map;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
/**
|
|
224
|
+
* What lands on a board from the folds of the project's other boards (R58):
|
|
225
|
+
* `paid` — folds that name a scene here, keyed for the paying-off card;
|
|
226
|
+
* `waiting` — folds that name this board and no scene on it yet (or a scene
|
|
227
|
+
* that is gone). The receiving board carries no record of its own; this is
|
|
228
|
+
* composed from the project, the way castElsewhere composes the cast.
|
|
229
|
+
*/
|
|
230
|
+
export function landingsOn(project, boards, boardId) {
|
|
231
|
+
const here = boards[boardId];
|
|
232
|
+
const hereIds = new Set((here?.notes ?? []).map((note) => note.id));
|
|
233
|
+
const paid = [];
|
|
234
|
+
const waiting = [];
|
|
235
|
+
for (const meta of project.boards) {
|
|
236
|
+
if (meta.id === boardId) continue;
|
|
237
|
+
const state = boards[meta.id];
|
|
238
|
+
if (!state) continue;
|
|
239
|
+
for (const note of state.notes) {
|
|
240
|
+
if (!note.plants || note.payoffBoardId !== boardId) continue;
|
|
241
|
+
const landing = { fromBoardId: meta.id, fromBoardName: meta.name, fromNoteId: note.id, fromHeadline: note.headline, fromColor: note.color };
|
|
242
|
+
if (note.payoffNoteId && hereIds.has(note.payoffNoteId)) paid.push({ ...landing, id: note.payoffNoteId });
|
|
243
|
+
else waiting.push({ ...landing, id: note.payoffNoteId ?? null });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return { paid, waiting };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Every board's name, card count and card ids, for the reading to judge a fold's promise by (R58). */
|
|
250
|
+
export function laterBoards(project, boards) {
|
|
251
|
+
const map = {};
|
|
252
|
+
for (const meta of project.boards) {
|
|
253
|
+
const state = boards[meta.id];
|
|
254
|
+
map[meta.id] = { name: meta.name, cards: state?.notes?.length ?? 0, noteIds: (state?.notes ?? []).map((note) => note.id) };
|
|
255
|
+
}
|
|
256
|
+
return map;
|
|
257
|
+
}
|
|
258
|
+
|
|
223
259
|
function touch(project, patch, now) {
|
|
224
260
|
return { ...project, ...patch, updatedAt: now };
|
|
225
261
|
}
|
package/src/board/readWall.d.ts
CHANGED
|
@@ -61,8 +61,10 @@ export type WallReading = {
|
|
|
61
61
|
/** Every planted card: the scene that pays it off (first setup arrow, by wall order), or null while unpaid. */
|
|
62
62
|
/** For each folded card, the cards its setup arrows land on, in wall order; empty when unpaid. */
|
|
63
63
|
payoffs: Record<string, string[]>;
|
|
64
|
-
/** Folded cards that pay off on another board of the project (R50): the card and the board. */
|
|
65
|
-
later: { id: string; boardId: string }[];
|
|
64
|
+
/** 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
|
+
later: { id: string; boardId: string; noteId: string | null }[];
|
|
66
|
+
/** Cards here that pay off a fold of another board (R58), composed by the door from the project. */
|
|
67
|
+
paidBy: Array<{ id: string; fromBoardId: string; fromBoardName: string; fromNoteId: string; fromHeadline: string; fromColor: string }>;
|
|
66
68
|
/** The questions the wall asks now. A left one (R53) is not here while its words hold. */
|
|
67
69
|
findings: Finding[];
|
|
68
70
|
/** Questions the writer has left, for now: the same question, with when it was left. */
|
|
@@ -78,6 +80,10 @@ export declare function readWall(
|
|
|
78
80
|
options?: {
|
|
79
81
|
/** Cast ids on a card of another board of the project (R51): not asked about as uncast here. */
|
|
80
82
|
elsewhere?: string[];
|
|
83
|
+
/** Every board of the project by id (R58): a fold's promise is asked about once that board holds cards and no scene claims it. */
|
|
84
|
+
laterBoards?: Record<string, { name: string; cards: number; noteIds: string[] }>;
|
|
85
|
+
/** Folds of other boards that land on cards here (R58). */
|
|
86
|
+
paidBy?: Array<{ id: string; fromBoardId: string; fromBoardName: string; fromNoteId: string; fromHeadline: string; fromColor: string }>;
|
|
81
87
|
},
|
|
82
88
|
): WallReading;
|
|
83
89
|
export declare function describeRuns(reading: WallReading, state: BoardState): string[];
|
package/src/board/readWall.js
CHANGED
|
@@ -165,6 +165,11 @@ export function readWall(state, options = {}) {
|
|
|
165
165
|
// People on a card of another board of the project (R51) are cast, and
|
|
166
166
|
// are not asked about here.
|
|
167
167
|
const elsewhere = new Set(Array.isArray(options.elsewhere) ? options.elsewhere : []);
|
|
168
|
+
// The project's other boards, for a fold that pays off later (R58): a board
|
|
169
|
+
// that holds cards and no claimed scene is a promise the wall asks about.
|
|
170
|
+
const boardsHeld = options.laterBoards && typeof options.laterBoards === "object" ? options.laterBoards : null;
|
|
171
|
+
// Folds of other boards that land on a card here (R58), composed by the door.
|
|
172
|
+
const paidBy = Array.isArray(options.paidBy) ? options.paidBy : [];
|
|
168
173
|
const order = storyOrder(state);
|
|
169
174
|
const beats = order.filter((note) => note.rank === "beat");
|
|
170
175
|
|
|
@@ -380,7 +385,13 @@ export function readWall(state, options = {}) {
|
|
|
380
385
|
}
|
|
381
386
|
// A fold that pays off on another board (R50) is not unpaid: it is listed
|
|
382
387
|
// under `later`, and the door that knows the project names the board.
|
|
383
|
-
const later = order
|
|
388
|
+
const later = order
|
|
389
|
+
.filter((note) => note.plants && note.payoffBoardId)
|
|
390
|
+
.map((note) => {
|
|
391
|
+
const held = boardsHeld?.[note.payoffBoardId];
|
|
392
|
+
const claimed = Boolean(note.payoffNoteId && (!held || held.noteIds.includes(note.payoffNoteId)));
|
|
393
|
+
return { id: note.id, boardId: note.payoffBoardId, noteId: claimed ? note.payoffNoteId : null };
|
|
394
|
+
});
|
|
384
395
|
for (const note of order) {
|
|
385
396
|
if (note.plants && !paysOff.has(note.id) && !note.payoffBoardId) {
|
|
386
397
|
findings.push({
|
|
@@ -390,6 +401,20 @@ export function readWall(state, options = {}) {
|
|
|
390
401
|
});
|
|
391
402
|
}
|
|
392
403
|
}
|
|
404
|
+
// A board's name is a promise; a scene is the payoff (R58). Once the board
|
|
405
|
+
// has cards, the wall asks which one, until a scene there claims the fold.
|
|
406
|
+
for (const item of later) {
|
|
407
|
+
const held = boardsHeld?.[item.boardId];
|
|
408
|
+
if (!held || held.cards === 0 || item.noteId) continue;
|
|
409
|
+
const note = byId.get(item.id);
|
|
410
|
+
findings.push({
|
|
411
|
+
kind: "unpaid",
|
|
412
|
+
ids: [item.id],
|
|
413
|
+
text: `${quote(note)} pays off later, on "${held.name}", but no scene there claims it yet. Which one?`,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
const hereIds = new Set(order.map((note) => note.id));
|
|
417
|
+
const paidHere = paidBy.filter((item) => hereIds.has(item.id));
|
|
393
418
|
|
|
394
419
|
// The cast (R29): someone who vanishes for a stretch, or never appears.
|
|
395
420
|
const total = boardEighths(state);
|
|
@@ -463,6 +488,7 @@ export function readWall(state, options = {}) {
|
|
|
463
488
|
setups,
|
|
464
489
|
payoffs,
|
|
465
490
|
later,
|
|
491
|
+
paidBy: paidHere,
|
|
466
492
|
findings: asked,
|
|
467
493
|
left,
|
|
468
494
|
};
|
package/src/board/reducer.d.ts
CHANGED
|
@@ -81,6 +81,8 @@ export type BoardNote = {
|
|
|
81
81
|
plants: boolean;
|
|
82
82
|
/** When folded: the id of another board of the project where it pays off (R50), or null. */
|
|
83
83
|
payoffBoardId: string | null;
|
|
84
|
+
/** The scene on that board that pays it off (R58), or null while the board is only a promise. */
|
|
85
|
+
payoffNoteId: string | null;
|
|
84
86
|
/** Where the scene happens (R37): a phrase in the writer's words; empty until set. */
|
|
85
87
|
location: string;
|
|
86
88
|
/** When the scene happens, as the writer says it — "night", "day four, dawn" — printed after the place on the heading (R55). Empty when unsaid. */
|
|
@@ -184,7 +186,7 @@ export type Command =
|
|
|
184
186
|
| ({ type: "update_character"; id: string } & Partial<Record<CharacterField, string>>)
|
|
185
187
|
| { type: "set_cast"; ids: string[]; characterIds: string[] }
|
|
186
188
|
| { type: "set_plant"; ids: string[]; plants: boolean }
|
|
187
|
-
| { type: "set_payoff_board"; ids: string[]; boardId: string | null }
|
|
189
|
+
| { type: "set_payoff_board"; ids: string[]; boardId: string | null; noteId?: string | null }
|
|
188
190
|
| { type: "set_location"; ids: string[]; location: string }
|
|
189
191
|
| { type: "set_when"; ids: string[]; when: string }
|
|
190
192
|
| { type: "apply_template"; template: string; beats?: Array<{ name: string; prompt: string; at: number }> }
|
package/src/board/reducer.js
CHANGED
|
@@ -249,8 +249,10 @@ export function seedState(now = nowIso()) {
|
|
|
249
249
|
text: "",
|
|
250
250
|
plants: false,
|
|
251
251
|
// A fold that pays off on another board — a later episode — names it here;
|
|
252
|
-
// null claims nothing (R50).
|
|
252
|
+
// null claims nothing (R50). The scene there that pays it off, once one
|
|
253
|
+
// does (R58); null while the board is a promise.
|
|
253
254
|
payoffBoardId: null,
|
|
255
|
+
payoffNoteId: null,
|
|
254
256
|
createdAt: now,
|
|
255
257
|
updatedAt: now,
|
|
256
258
|
});
|
|
@@ -346,6 +348,8 @@ export function normalizeState(value) {
|
|
|
346
348
|
const plants = note?.plants === true;
|
|
347
349
|
// Cards written before R50 pay off on their own board or not at all.
|
|
348
350
|
const payoffBoardId = plants && typeof note?.payoffBoardId === "string" && note.payoffBoardId ? note.payoffBoardId : null;
|
|
351
|
+
// Cards written before R58 name a board and no scene on it.
|
|
352
|
+
const payoffNoteId = payoffBoardId && typeof note?.payoffNoteId === "string" && note.payoffNoteId ? note.payoffNoteId : null;
|
|
349
353
|
// Cards written before R37 have no place; a scene is nowhere until it is.
|
|
350
354
|
const location = typeof note?.location === "string" ? note.location : "";
|
|
351
355
|
// Cards written before R55 have no when; a scene is at no time until it is.
|
|
@@ -360,6 +364,7 @@ export function normalizeState(value) {
|
|
|
360
364
|
sameIds(note.characterIds, characterIds) &&
|
|
361
365
|
note.plants === plants &&
|
|
362
366
|
note.payoffBoardId === payoffBoardId &&
|
|
367
|
+
note.payoffNoteId === payoffNoteId &&
|
|
363
368
|
note.location === location &&
|
|
364
369
|
note.when === when &&
|
|
365
370
|
note.text === text
|
|
@@ -367,7 +372,7 @@ export function normalizeState(value) {
|
|
|
367
372
|
return note;
|
|
368
373
|
}
|
|
369
374
|
patched = true;
|
|
370
|
-
return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, location, when, text };
|
|
375
|
+
return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, payoffNoteId, location, when, text };
|
|
371
376
|
});
|
|
372
377
|
|
|
373
378
|
// Boards written before the production half (Roadmap 2, item 8) have no
|
|
@@ -453,6 +458,7 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
453
458
|
characterIds: knownCast(command.characterIds, state.characters ?? []),
|
|
454
459
|
plants: command.plants === true,
|
|
455
460
|
payoffBoardId: null,
|
|
461
|
+
payoffNoteId: null,
|
|
456
462
|
location: cleanPlace(command.location),
|
|
457
463
|
when: cleanWhen(command.when),
|
|
458
464
|
text: typeof command.text === "string" ? command.text : "",
|
|
@@ -624,6 +630,7 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
624
630
|
// says what went should say these (round fifteen, entry 17).
|
|
625
631
|
plants: gone.plants === true,
|
|
626
632
|
payoffBoardId: gone.payoffBoardId ?? null,
|
|
633
|
+
payoffNoteId: gone.payoffNoteId ?? null,
|
|
627
634
|
arrows: taken.map((arrow) => ({ ...arrow, fromHeadline: headlineOf(arrow.from), toHeadline: headlineOf(arrow.to) })),
|
|
628
635
|
joined: joined ? { ...joined, fromHeadline: headlineOf(joined.from), toHeadline: headlineOf(joined.to) } : null,
|
|
629
636
|
groups: left,
|
|
@@ -909,6 +916,7 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
909
916
|
characterIds: [],
|
|
910
917
|
plants: false,
|
|
911
918
|
payoffBoardId: null,
|
|
919
|
+
payoffNoteId: null,
|
|
912
920
|
location: "",
|
|
913
921
|
when: "",
|
|
914
922
|
text: "",
|
|
@@ -1040,8 +1048,9 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
1040
1048
|
if (!ids.has(note.id)) return note;
|
|
1041
1049
|
// Unfolding forgets where it paid off; a claim that no longer stands.
|
|
1042
1050
|
const payoffBoardId = plants ? note.payoffBoardId : null;
|
|
1043
|
-
|
|
1044
|
-
|
|
1051
|
+
const payoffNoteId = plants ? (note.payoffNoteId ?? null) : null;
|
|
1052
|
+
if (note.plants === plants && note.payoffBoardId === payoffBoardId && note.payoffNoteId === payoffNoteId) return note;
|
|
1053
|
+
const next = bump(note, { plants, payoffBoardId, payoffNoteId }, now);
|
|
1045
1054
|
touched.push(next);
|
|
1046
1055
|
return next;
|
|
1047
1056
|
});
|
|
@@ -1053,14 +1062,18 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
1053
1062
|
// stops asking where it comes back, and the reading says where. The
|
|
1054
1063
|
// kernel cannot check the board exists; the door that knows the project
|
|
1055
1064
|
// does. Null takes the claim back.
|
|
1065
|
+
// With noteId, the scene on that board that pays it off (R58): the
|
|
1066
|
+
// receiving end, kept here on the fold so one claim has one owner. A
|
|
1067
|
+
// board alone is a promise; a note is the payoff.
|
|
1056
1068
|
case "set_payoff_board": {
|
|
1057
1069
|
const ids = new Set(command.ids);
|
|
1058
1070
|
if (ids.size === 0) return { state, changed: false };
|
|
1059
1071
|
const payoffBoardId = typeof command.boardId === "string" && command.boardId ? command.boardId : null;
|
|
1072
|
+
const payoffNoteId = payoffBoardId && typeof command.noteId === "string" && command.noteId ? command.noteId : null;
|
|
1060
1073
|
const touched = [];
|
|
1061
1074
|
const notes = state.notes.map((note) => {
|
|
1062
|
-
if (!ids.has(note.id) || !note.plants || note.payoffBoardId === payoffBoardId) return note;
|
|
1063
|
-
const next = bump(note, { payoffBoardId }, now);
|
|
1075
|
+
if (!ids.has(note.id) || !note.plants || (note.payoffBoardId === payoffBoardId && (note.payoffNoteId ?? null) === payoffNoteId)) return note;
|
|
1076
|
+
const next = bump(note, { payoffBoardId, payoffNoteId }, now);
|
|
1064
1077
|
touched.push(next);
|
|
1065
1078
|
return next;
|
|
1066
1079
|
});
|