plotcoder-board 0.1.25 → 0.1.27
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 +36 -2
- package/src/board/readWall.d.ts +2 -0
- package/src/board/readWall.js +12 -3
- package/src/board/reducer.d.ts +4 -0
- package/src/board/reducer.js +32 -1
- package/src/board/words.js +5 -0
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.
|
|
@@ -100,4 +100,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
|
|
|
100
100
|
|
|
101
101
|
## Status
|
|
102
102
|
|
|
103
|
-
Version 0.1.
|
|
103
|
+
Version 0.1.27. 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
|
@@ -1039,11 +1039,12 @@ function summarize(state) {
|
|
|
1039
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}` : "";
|
|
1040
1040
|
const place = note.location ? `, at: ${note.location}` : "";
|
|
1041
1041
|
const when = note.when ? `, when: ${note.when}` : "";
|
|
1042
|
+
const openWord = note.open ? `, open: ${note.open}` : "";
|
|
1042
1043
|
const count = formatPages(noteEighths(note));
|
|
1043
1044
|
// A written card's estimate is kept underneath for when the text goes; say it, or it is invisible (round sixteen, entry 44).
|
|
1044
1045
|
const underneath = isMeasured(note) && note.lengthEighths !== null ? `; the writer's estimate underneath: ${formatPages(note.lengthEighths)}` : "";
|
|
1045
1046
|
const pages = isMeasured(note) ? `${count} ${count === "1" ? "page" : "pages"}, written${underneath}` : note.lengthEighths === null ? "about a page, unsized" : `${count} ${count === "1" ? "page" : "pages"}`;
|
|
1046
|
-
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)}`;
|
|
1047
1048
|
})
|
|
1048
1049
|
.join("\n");
|
|
1049
1050
|
const cast = state.characters
|
|
@@ -1311,6 +1312,7 @@ server.registerTool(
|
|
|
1311
1312
|
before: z.string().optional().describe("Or before this card (id or headline)."),
|
|
1312
1313
|
location: z.string().optional(),
|
|
1313
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."),
|
|
1314
1316
|
characters: z.array(z.string().min(1)).optional(),
|
|
1315
1317
|
x: z.number().optional(),
|
|
1316
1318
|
y: z.number().optional(),
|
|
@@ -1345,6 +1347,7 @@ server.registerTool(
|
|
|
1345
1347
|
plants: args.plants,
|
|
1346
1348
|
location: args.location,
|
|
1347
1349
|
when: args.when,
|
|
1350
|
+
open: args.open,
|
|
1348
1351
|
x: landing.x,
|
|
1349
1352
|
y: landing.y,
|
|
1350
1353
|
}).result;
|
|
@@ -1381,6 +1384,7 @@ server.registerTool(
|
|
|
1381
1384
|
result?.plants ? "corner folded" : null,
|
|
1382
1385
|
result?.location ? `at ${result.location}` : `no place yet${once("place", " (location here, or set_location)")}`,
|
|
1383
1386
|
result?.when ? `when: ${result.when}` : null,
|
|
1387
|
+
result?.open ? `open: "${result.open}" (listed, not asked about)` : null,
|
|
1384
1388
|
].filter(Boolean).join(", ");
|
|
1385
1389
|
// Where it landed matters only until the tidy, so the reply says the rule once and never the coordinates (round fourteen, entry 11).
|
|
1386
1390
|
const placed = beside
|
|
@@ -1551,6 +1555,9 @@ server.registerTool(
|
|
|
1551
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)"]),
|
|
1552
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"}`),
|
|
1553
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
|
+
: []),
|
|
1554
1561
|
"questions the wall raises:",
|
|
1555
1562
|
...(reading.findings.length
|
|
1556
1563
|
? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}${finding.ids.length ? ` (ids: ${finding.ids.join(", ")})` : ""}`)
|
|
@@ -1568,7 +1575,7 @@ server.registerTool(
|
|
|
1568
1575
|
const counts = new Map();
|
|
1569
1576
|
for (const finding of asked) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
|
|
1570
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}`;
|
|
1571
|
-
})()}${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) => {
|
|
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) => {
|
|
1572
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)";
|
|
1573
1580
|
if (kind === "unlinked") {
|
|
1574
1581
|
const linked = new Set(state.arrows.flatMap((arrow) => [arrow.from, arrow.to]));
|
|
@@ -2663,6 +2670,33 @@ server.registerTool(
|
|
|
2663
2670
|
},
|
|
2664
2671
|
);
|
|
2665
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
|
+
|
|
2666
2700
|
server.registerTool(
|
|
2667
2701
|
"set_plant",
|
|
2668
2702
|
{
|
package/src/board/readWall.d.ts
CHANGED
|
@@ -67,6 +67,8 @@ export type WallReading = {
|
|
|
67
67
|
payoffs: Record<string, string[]>;
|
|
68
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. */
|
|
69
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 }>;
|
|
70
72
|
/** Cards here that pay off a fold of another board (R58), composed by the door from the project. */
|
|
71
73
|
paidBy: Array<{ id: string; fromBoardId: string; fromBoardName: string; fromNoteId: string; fromHeadline: string; fromColor: string }>;
|
|
72
74
|
/** The questions the wall asks now. A left one (R53) is not here while its words hold. */
|
package/src/board/readWall.js
CHANGED
|
@@ -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",
|
|
@@ -433,7 +438,7 @@ export function readWall(state, options = {}) {
|
|
|
433
438
|
// A card with nobody in it, once the wall has a cast (round seventeen,
|
|
434
439
|
// entry 28): a scene nobody is in passed every check.
|
|
435
440
|
if ((state.characters ?? []).length > 0) {
|
|
436
|
-
const empty = order.filter((note) => !(note.characterIds ?? []).length);
|
|
441
|
+
const empty = order.filter((note) => askable(note) && !(note.characterIds ?? []).length);
|
|
437
442
|
if (empty.length) {
|
|
438
443
|
findings.push({
|
|
439
444
|
kind: "nobody",
|
|
@@ -499,7 +504,10 @@ export function readWall(state, options = {}) {
|
|
|
499
504
|
// it is a new question and is asked. The kernel never decides this; the
|
|
500
505
|
// reading does, on every read.
|
|
501
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))) : [];
|
|
502
509
|
const asked = findings.filter((finding) => {
|
|
510
|
+
if (openCards.includes(finding)) return false;
|
|
503
511
|
const entry = (state.left ?? []).find(
|
|
504
512
|
(item) => item.kind === finding.kind && sameList(item.ids, finding.ids) && item.text === finding.text,
|
|
505
513
|
);
|
|
@@ -516,6 +524,7 @@ export function readWall(state, options = {}) {
|
|
|
516
524
|
payoffs,
|
|
517
525
|
later,
|
|
518
526
|
paidBy: paidHere,
|
|
527
|
+
open: order.filter((note) => openIds.has(note.id)).map((note) => ({ id: note.id, words: note.open.trim() })),
|
|
519
528
|
findings: asked,
|
|
520
529
|
left,
|
|
521
530
|
};
|
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
|
@@ -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",
|