plotcoder-board 0.1.0
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 +99 -0
- package/package.json +57 -0
- package/scripts/plotcoder-call.mjs +141 -0
- package/scripts/plotcoder-http.mjs +114 -0
- package/scripts/plotcoder-mcp-server.mjs +2663 -0
- package/scripts/plotcoder-mcp.mjs +93 -0
- package/src/board/agents.d.ts +14 -0
- package/src/board/agents.js +78 -0
- package/src/board/fdx.d.ts +26 -0
- package/src/board/fdx.js +206 -0
- package/src/board/fountain.d.ts +48 -0
- package/src/board/fountain.js +246 -0
- package/src/board/numbering.d.ts +13 -0
- package/src/board/numbering.js +89 -0
- package/src/board/organize.d.ts +24 -0
- package/src/board/organize.js +151 -0
- package/src/board/paginate.d.ts +51 -0
- package/src/board/paginate.js +375 -0
- package/src/board/project.d.ts +72 -0
- package/src/board/project.js +236 -0
- package/src/board/projectFile.d.ts +33 -0
- package/src/board/projectFile.js +97 -0
- package/src/board/readWall.d.ts +70 -0
- package/src/board/readWall.js +406 -0
- package/src/board/reducer.d.ts +191 -0
- package/src/board/reducer.js +921 -0
- package/src/board/reminders.d.ts +6 -0
- package/src/board/reminders.js +53 -0
- package/src/board/sync.d.ts +65 -0
- package/src/board/sync.js +198 -0
- package/src/board/templates.d.ts +21 -0
- package/src/board/templates.js +110 -0
- package/src/board/words.d.ts +10 -0
- package/src/board/words.js +201 -0
- package/src/board/workflows.d.ts +24 -0
- package/src/board/workflows.js +116 -0
- package/src/board/zip.d.ts +5 -0
- package/src/board/zip.js +134 -0
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
// PlotCoder board kernel.
|
|
2
|
+
//
|
|
3
|
+
// Pure, DOM-free reducer over the board records. This is the single source of
|
|
4
|
+
// truth for what a "command" does. The browser store, the Vite dev bridge, and
|
|
5
|
+
// the MCP server all apply commands through this module so a human gesture and
|
|
6
|
+
// an agent tool end up on the exact same code path.
|
|
7
|
+
//
|
|
8
|
+
// Authored as plain ESM JavaScript (with a sibling reducer.d.ts) so it runs
|
|
9
|
+
// unchanged in the browser (via Vite) and in Node (the MCP server). Keep it free
|
|
10
|
+
// of `window`, `localStorage`, and `import.meta`.
|
|
11
|
+
|
|
12
|
+
export const NOTE_COLORS = ["yellow", "pink", "blue", "green", "orange"];
|
|
13
|
+
|
|
14
|
+
// A beat is one of the 8-to-15 major turns. Everything else is a scene, which is
|
|
15
|
+
// why "scene" is first: it is the default a card is born with (R20/R21).
|
|
16
|
+
export const NOTE_RANKS = ["scene", "beat"];
|
|
17
|
+
|
|
18
|
+
// An arrow says what kind of link it is (R15, P16). "follows" is what comes
|
|
19
|
+
// after what — the default, and the only kind there was. "setup" says the card
|
|
20
|
+
// at the tail plants something the card at the head pays off. A kind is not a
|
|
21
|
+
// label: R15 keeps free text off arrows on purpose.
|
|
22
|
+
export const ARROW_KINDS = ["follows", "setup"];
|
|
23
|
+
|
|
24
|
+
// Structure templates (R38) are data beside the kernel; applying one is a
|
|
25
|
+
// kernel command so it is one undo step and one tool call.
|
|
26
|
+
import { templateById } from "./templates.js";
|
|
27
|
+
// The same lines the page has, so the panel's count and the print agree (R23 c).
|
|
28
|
+
import { sceneLineCount } from "./paginate.js";
|
|
29
|
+
import { lockFrom, REVISION_COLORS } from "./numbering.js";
|
|
30
|
+
|
|
31
|
+
// Characters are a board-level roster (D26): one record per person, referenced
|
|
32
|
+
// from cards by id, so a name changes in one place and the same person is the
|
|
33
|
+
// same person on every card. Long term the record grows — what they look like,
|
|
34
|
+
// the details a writer needs to pull up — which is why it has an id and
|
|
35
|
+
// timestamps now rather than being a word on a card.
|
|
36
|
+
function isCharacter(value) {
|
|
37
|
+
return Boolean(value) && typeof value.id === "string" && typeof value.name === "string";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A person's page (R36): what they look like, how they sound, what they want,
|
|
41
|
+
// what they need, and the notes a writer pulls up. All text, all optional; a
|
|
42
|
+
// picture waits for file storage. Looks and voice are what the horizon (R28)
|
|
43
|
+
// hands a video agent; wants and needs are the method's two questions about a
|
|
44
|
+
// person (R18).
|
|
45
|
+
export const CHARACTER_FIELDS = ["looks", "voice", "wants", "needs", "notes"];
|
|
46
|
+
|
|
47
|
+
/** A roster record with every page field present, so the page never reads undefined. */
|
|
48
|
+
function fillCharacter(character) {
|
|
49
|
+
let filled = character;
|
|
50
|
+
for (const field of CHARACTER_FIELDS) {
|
|
51
|
+
if (typeof filled[field] !== "string") {
|
|
52
|
+
if (filled === character) filled = { ...character };
|
|
53
|
+
filled[field] = "";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return filled;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The names of the page fields a person has filled in, in page order. */
|
|
60
|
+
export function filledCharacterFields(character) {
|
|
61
|
+
return CHARACTER_FIELDS.filter((field) => typeof character[field] === "string" && character[field].trim());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Where a scene happens (R37): a phrase in the writer's words, not a slug.
|
|
65
|
+
// One string per card, no roster; the wall's places are read off the cards.
|
|
66
|
+
function cleanPlace(value) {
|
|
67
|
+
return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function samePlace(a, b) {
|
|
71
|
+
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The places on a wall, in order of first appearance, each with its card
|
|
76
|
+
* count. Two spellings that differ only in case are one place, spelt the
|
|
77
|
+
* first way. For the lens and for completion on the card.
|
|
78
|
+
*/
|
|
79
|
+
export function boardPlaces(state) {
|
|
80
|
+
const places = [];
|
|
81
|
+
for (const note of state.notes) {
|
|
82
|
+
const name = cleanPlace(note.location);
|
|
83
|
+
if (!name) continue;
|
|
84
|
+
const found = places.find((place) => samePlace(place.name, name));
|
|
85
|
+
if (found) found.cards += 1;
|
|
86
|
+
else places.push({ name, cards: 1 });
|
|
87
|
+
}
|
|
88
|
+
return places;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** True when the card is at this place, spelt any way. */
|
|
92
|
+
export function atPlace(note, place) {
|
|
93
|
+
return Boolean(note.location) && samePlace(note.location, place);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sameName(a, b) {
|
|
97
|
+
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sameIds(a, b) {
|
|
101
|
+
return a.length === b.length && a.every((id, index) => id === b[index]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Keep only ids that name someone in the roster, once each, in the order given. */
|
|
105
|
+
function knownCast(ids, characters) {
|
|
106
|
+
if (!Array.isArray(ids)) return [];
|
|
107
|
+
const known = new Set(characters.map((character) => character.id));
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
const cast = [];
|
|
110
|
+
for (const id of ids) {
|
|
111
|
+
if (typeof id !== "string" || !known.has(id) || seen.has(id)) continue;
|
|
112
|
+
seen.add(id);
|
|
113
|
+
cast.push(id);
|
|
114
|
+
}
|
|
115
|
+
return cast;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Length is measured in eighths of a page (D23) — the unit a production
|
|
119
|
+
// breakdown uses, and the unit real pages will be measured in when PlotCoder
|
|
120
|
+
// holds them (D22). Today's estimate and tomorrow's measurement agree.
|
|
121
|
+
export const EIGHTHS_PER_PAGE = 8;
|
|
122
|
+
export const DEFAULT_NOTE_EIGHTHS = EIGHTHS_PER_PAGE; // a scene is about a page
|
|
123
|
+
export const DEFAULT_TARGET_EIGHTHS = 120 * EIGHTHS_PER_PAGE; // a feature
|
|
124
|
+
const MAX_NOTE_EIGHTHS = 30 * EIGHTHS_PER_PAGE;
|
|
125
|
+
const MAX_TARGET_EIGHTHS = 600 * EIGHTHS_PER_PAGE;
|
|
126
|
+
|
|
127
|
+
function clampEighths(value, fallback, max) {
|
|
128
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
129
|
+
return Math.min(max, Math.max(1, Math.round(value)));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Total estimated length of the board, in eighths. */
|
|
133
|
+
// Pages beside the wall (R23, slice b): a scene's text lives on its card. A
|
|
134
|
+
// card with text is measured — its lines against a page's worth — in the
|
|
135
|
+
// same eighths the estimate uses (D23); a card without keeps the estimate.
|
|
136
|
+
export const LINES_PER_PAGE = 55;
|
|
137
|
+
|
|
138
|
+
/** Eighths of a page the scene's text runs to on the page; 0 when there is no text. */
|
|
139
|
+
export function measuredEighths(text) {
|
|
140
|
+
const lines = sceneLineCount(text);
|
|
141
|
+
if (lines === 0) return 0;
|
|
142
|
+
return Math.max(1, Math.round((lines / LINES_PER_PAGE) * EIGHTHS_PER_PAGE));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The card's length as every reading should take it: measured when written, estimated otherwise. */
|
|
146
|
+
export function noteEighths(note) {
|
|
147
|
+
const measured = measuredEighths(note?.text);
|
|
148
|
+
return measured > 0 ? measured : (note?.lengthEighths ?? DEFAULT_NOTE_EIGHTHS);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** True when the card's length comes from its text rather than the estimate. */
|
|
152
|
+
export function isMeasured(note) {
|
|
153
|
+
return measuredEighths(note?.text) > 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function boardEighths(state) {
|
|
157
|
+
return state.notes.reduce((total, note) => total + noteEighths(note), 0);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Eighths as pages the way a breakdown writes them: "1 3/8", "97", "2 1/2"
|
|
162
|
+
* reduced to "2 4/8"'s plain form. Whole pages lose the fraction entirely.
|
|
163
|
+
*/
|
|
164
|
+
export function formatPages(eighths) {
|
|
165
|
+
const whole = Math.floor(eighths / EIGHTHS_PER_PAGE);
|
|
166
|
+
const part = eighths % EIGHTHS_PER_PAGE;
|
|
167
|
+
if (part === 0) return `${whole}`;
|
|
168
|
+
if (whole === 0) return `${part}/8`;
|
|
169
|
+
return `${whole} ${part}/8`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A page runs about a minute on screen (the industry's rule, an average over a
|
|
174
|
+
* whole script): eighths as whole minutes, "about". Under an hour in minutes,
|
|
175
|
+
* over in hours and minutes.
|
|
176
|
+
*/
|
|
177
|
+
export function formatMinutes(eighths) {
|
|
178
|
+
// Whole minutes, rounded down: "about 17" for seventeen and a half.
|
|
179
|
+
const minutes = Math.floor(eighths / EIGHTHS_PER_PAGE);
|
|
180
|
+
if (minutes < 60) return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
181
|
+
const hours = Math.floor(minutes / 60);
|
|
182
|
+
const rest = minutes % 60;
|
|
183
|
+
if (rest === 0) return `${hours} ${hours === 1 ? "hour" : "hours"}`;
|
|
184
|
+
return `${hours} h ${rest} min`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export const NOTE_WIDTH = 192;
|
|
188
|
+
export const NOTE_HEIGHT = 192;
|
|
189
|
+
|
|
190
|
+
// How far a dropped card's centre may sit outside the *other* members' bounds
|
|
191
|
+
// and still belong to the group. It has to cover a whole neighbouring card:
|
|
192
|
+
// the first card of a row sits a card-width from the rest, and dropping it in
|
|
193
|
+
// place must not eject it. (It did, at 80px, until undo's tests caught it.)
|
|
194
|
+
const SETTLE_MARGIN = NOTE_WIDTH + 40;
|
|
195
|
+
|
|
196
|
+
export function newId() {
|
|
197
|
+
const maybe = globalThis.crypto;
|
|
198
|
+
if (maybe && typeof maybe.randomUUID === "function") return maybe.randomUUID();
|
|
199
|
+
return `id-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function nowIso() {
|
|
203
|
+
return new Date().toISOString();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function emptyState() {
|
|
207
|
+
return {
|
|
208
|
+
logline: "",
|
|
209
|
+
targetEighths: DEFAULT_TARGET_EIGHTHS,
|
|
210
|
+
characters: [],
|
|
211
|
+
notes: [],
|
|
212
|
+
groups: [],
|
|
213
|
+
arrows: [],
|
|
214
|
+
lock: null,
|
|
215
|
+
revision: null,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function seedState(now = nowIso()) {
|
|
220
|
+
const mk = (id, headline, change, color, x, y, rotate, z, characterIds) => ({
|
|
221
|
+
id,
|
|
222
|
+
headline,
|
|
223
|
+
change,
|
|
224
|
+
color,
|
|
225
|
+
x,
|
|
226
|
+
y,
|
|
227
|
+
rotate,
|
|
228
|
+
z,
|
|
229
|
+
rank: "scene",
|
|
230
|
+
// Unsized until someone sizes it: null claims nothing, and reads as about a page (noteEighths).
|
|
231
|
+
lengthEighths: null,
|
|
232
|
+
characterIds,
|
|
233
|
+
location: "",
|
|
234
|
+
text: "",
|
|
235
|
+
plants: false,
|
|
236
|
+
createdAt: now,
|
|
237
|
+
updatedAt: now,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
// Left empty on purpose: the placeholder asks the question, which is how a
|
|
242
|
+
// new writer finds out the logline is there at all.
|
|
243
|
+
logline: "",
|
|
244
|
+
targetEighths: DEFAULT_TARGET_EIGHTHS,
|
|
245
|
+
// Two people, cast on the cards, so a new writer sees what the roster is for.
|
|
246
|
+
characters: [
|
|
247
|
+
fillCharacter({ id: "maya", name: "Maya", createdAt: now, updatedAt: now }),
|
|
248
|
+
fillCharacter({ id: "tom", name: "Tom", createdAt: now, updatedAt: now }),
|
|
249
|
+
],
|
|
250
|
+
notes: [
|
|
251
|
+
mk("maya-letter", "Maya finds the letter", "She decides not to tell Tom.", "yellow", 88, 120, -2.2, 1, ["maya"]),
|
|
252
|
+
mk("tom-lies", "Tom lies about the job", "Maya starts to doubt him.", "pink", 320, 168, 1.6, 2, ["tom", "maya"]),
|
|
253
|
+
mk("letter-aloud", "The letter is read aloud", "The plan dies in the room.", "blue", 196, 340, 0.8, 3, ["maya", "tom"]),
|
|
254
|
+
],
|
|
255
|
+
groups: [],
|
|
256
|
+
arrows: [],
|
|
257
|
+
lock: null,
|
|
258
|
+
revision: null,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Deliberately unchanged by the arrival of `logline`. Validation stays as loose
|
|
263
|
+
// as it was so that no board which was valid yesterday becomes invalid today —
|
|
264
|
+
// a stricter check here would reject saved projects and lose someone's wall.
|
|
265
|
+
// Shape is repaired in normalizeState instead.
|
|
266
|
+
export function isBoardState(value) {
|
|
267
|
+
if (!value || typeof value !== "object") return false;
|
|
268
|
+
return (
|
|
269
|
+
Array.isArray(value.notes) &&
|
|
270
|
+
Array.isArray(value.groups) &&
|
|
271
|
+
Array.isArray(value.arrows)
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Fill in fields added after a board was written. Every load boundary — the
|
|
277
|
+
* browser store, the dev bridge, the MCP server, an opened project file — runs
|
|
278
|
+
* a board through this so the rest of the code can assume the current shape.
|
|
279
|
+
*/
|
|
280
|
+
export function normalizeState(value) {
|
|
281
|
+
if (!isBoardState(value)) return emptyState();
|
|
282
|
+
const logline = typeof value.logline === "string" ? value.logline : "";
|
|
283
|
+
|
|
284
|
+
const targetEighths = clampEighths(
|
|
285
|
+
value.targetEighths,
|
|
286
|
+
DEFAULT_TARGET_EIGHTHS,
|
|
287
|
+
MAX_TARGET_EIGHTHS,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// Arrows written before R30 have no kind. They are "follows": a setup is a
|
|
291
|
+
// claim you make deliberately, so the default has to be the one that claims
|
|
292
|
+
// nothing.
|
|
293
|
+
let arrowsPatched = false;
|
|
294
|
+
const arrows = value.arrows.map((arrow) => {
|
|
295
|
+
if (arrow && ARROW_KINDS.includes(arrow.kind)) return arrow;
|
|
296
|
+
arrowsPatched = true;
|
|
297
|
+
return { ...arrow, kind: "follows" };
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Boards written before R29 have no roster. A card's cast is filtered to the
|
|
301
|
+
// roster, so a dangling id (a character removed by an older build) is dropped
|
|
302
|
+
// rather than left to point at nobody.
|
|
303
|
+
// Rosters written before R36 have no page fields; they are empty until filled.
|
|
304
|
+
const characters = Array.isArray(value.characters)
|
|
305
|
+
? value.characters.filter(isCharacter).map(fillCharacter)
|
|
306
|
+
: [];
|
|
307
|
+
const rosterPatched =
|
|
308
|
+
!Array.isArray(value.characters) ||
|
|
309
|
+
characters.length !== value.characters.length ||
|
|
310
|
+
characters.some((character, index) => character !== value.characters[index]);
|
|
311
|
+
|
|
312
|
+
// Cards written before R20 have no rank. They are scenes: a beat is something
|
|
313
|
+
// you mark deliberately, so the safe default is the one that claims nothing.
|
|
314
|
+
// Cards written before R25 have no length; a scene is about a page. Cards
|
|
315
|
+
// written before R29 have no cast; nobody is in the scene until someone is.
|
|
316
|
+
let patched = false;
|
|
317
|
+
const notes = value.notes.map((note) => {
|
|
318
|
+
const rank = note && NOTE_RANKS.includes(note.rank) ? note.rank : "scene";
|
|
319
|
+
// Unsized stays unsized: null (or no field, before R25) claims nothing and
|
|
320
|
+
// reads as about a page. A number is the writer's estimate, kept in range.
|
|
321
|
+
const lengthEighths =
|
|
322
|
+
note?.lengthEighths === null || note?.lengthEighths === undefined
|
|
323
|
+
? null
|
|
324
|
+
: clampEighths(note.lengthEighths, DEFAULT_NOTE_EIGHTHS, MAX_NOTE_EIGHTHS);
|
|
325
|
+
const characterIds = knownCast(note?.characterIds, characters);
|
|
326
|
+
// Cards written before R31 have no fold; a plant is a claim you make.
|
|
327
|
+
const plants = note?.plants === true;
|
|
328
|
+
// Cards written before R37 have no place; a scene is nowhere until it is.
|
|
329
|
+
const location = typeof note?.location === "string" ? note.location : "";
|
|
330
|
+
// Cards written before pages (R23 b) have no text; a scene is unwritten until it is.
|
|
331
|
+
const text = typeof note?.text === "string" ? note.text : "";
|
|
332
|
+
if (
|
|
333
|
+
note &&
|
|
334
|
+
note.rank === rank &&
|
|
335
|
+
note.lengthEighths === lengthEighths &&
|
|
336
|
+
Array.isArray(note.characterIds) &&
|
|
337
|
+
sameIds(note.characterIds, characterIds) &&
|
|
338
|
+
note.plants === plants &&
|
|
339
|
+
note.location === location &&
|
|
340
|
+
note.text === text
|
|
341
|
+
) {
|
|
342
|
+
return note;
|
|
343
|
+
}
|
|
344
|
+
patched = true;
|
|
345
|
+
return { ...note, rank, lengthEighths, characterIds, plants, location, text };
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// Boards written before the production half (Roadmap 2, item 8) have no
|
|
349
|
+
// lock and no revision; both are null until a draft goes out.
|
|
350
|
+
const lock = value.lock && typeof value.lock === "object" && value.lock.numbers ? value.lock : null;
|
|
351
|
+
const revision = value.revision && typeof value.revision === "object" && typeof value.revision.name === "string" ? value.revision : null;
|
|
352
|
+
if (
|
|
353
|
+
value.logline === logline &&
|
|
354
|
+
value.targetEighths === targetEighths &&
|
|
355
|
+
!rosterPatched &&
|
|
356
|
+
!arrowsPatched &&
|
|
357
|
+
!patched &&
|
|
358
|
+
value.lock === lock &&
|
|
359
|
+
value.revision === revision
|
|
360
|
+
) {
|
|
361
|
+
return value;
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
...value,
|
|
365
|
+
logline,
|
|
366
|
+
targetEighths,
|
|
367
|
+
characters,
|
|
368
|
+
notes: patched ? notes : value.notes,
|
|
369
|
+
arrows: arrowsPatched ? arrows : value.arrows,
|
|
370
|
+
lock,
|
|
371
|
+
revision,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Beats vs scenes. The app shows this number and passes no judgement (D21). */
|
|
376
|
+
export function countRanks(state) {
|
|
377
|
+
let beats = 0;
|
|
378
|
+
for (const note of state.notes) if (note.rank === "beat") beats += 1;
|
|
379
|
+
return { beats, scenes: state.notes.length - beats };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function maxZ(notes) {
|
|
383
|
+
return notes.reduce((top, note) => Math.max(top, note.z), 0);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function bump(note, patch, now) {
|
|
387
|
+
return { ...note, ...patch, updatedAt: now };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function pruneGroups(groups) {
|
|
391
|
+
return groups.filter((group) => group.noteIds.length >= 2);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function applyCommand(state, command, now = nowIso()) {
|
|
395
|
+
switch (command.type) {
|
|
396
|
+
case "set_logline": {
|
|
397
|
+
const logline = typeof command.logline === "string" ? command.logline.trim() : "";
|
|
398
|
+
if (logline === (state.logline ?? "")) return { state, changed: false };
|
|
399
|
+
return { state: { ...state, logline }, changed: true, result: { logline } };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
case "create_note": {
|
|
403
|
+
const n = state.notes.length;
|
|
404
|
+
const note = {
|
|
405
|
+
id: command.id ?? newId(),
|
|
406
|
+
headline: command.headline ?? "New beat",
|
|
407
|
+
change: command.change ?? "What changes?",
|
|
408
|
+
color: command.color ?? NOTE_COLORS[n % NOTE_COLORS.length],
|
|
409
|
+
x: command.x ?? 140 + (n % 5) * 28,
|
|
410
|
+
y: command.y ?? 140 + (n % 4) * 24,
|
|
411
|
+
rotate: command.rotate ?? ((n % 5) - 2) * 1.1,
|
|
412
|
+
rank: NOTE_RANKS.includes(command.rank) ? command.rank : "scene",
|
|
413
|
+
lengthEighths:
|
|
414
|
+
command.lengthEighths === undefined || command.lengthEighths === null
|
|
415
|
+
? null
|
|
416
|
+
: clampEighths(command.lengthEighths, DEFAULT_NOTE_EIGHTHS, MAX_NOTE_EIGHTHS),
|
|
417
|
+
characterIds: knownCast(command.characterIds, state.characters ?? []),
|
|
418
|
+
plants: command.plants === true,
|
|
419
|
+
location: cleanPlace(command.location),
|
|
420
|
+
text: typeof command.text === "string" ? command.text : "",
|
|
421
|
+
z: maxZ(state.notes) + 1,
|
|
422
|
+
createdAt: now,
|
|
423
|
+
updatedAt: now,
|
|
424
|
+
};
|
|
425
|
+
return {
|
|
426
|
+
state: { ...state, notes: [...state.notes, note] },
|
|
427
|
+
changed: true,
|
|
428
|
+
result: note,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
case "update_note": {
|
|
433
|
+
let updated;
|
|
434
|
+
const notes = state.notes.map((note) => {
|
|
435
|
+
if (note.id !== command.id) return note;
|
|
436
|
+
const patch = {};
|
|
437
|
+
if (command.headline !== undefined) patch.headline = command.headline;
|
|
438
|
+
if (command.change !== undefined) patch.change = command.change;
|
|
439
|
+
if (command.location !== undefined) patch.location = cleanPlace(command.location);
|
|
440
|
+
updated = bump(note, patch, now);
|
|
441
|
+
return updated;
|
|
442
|
+
});
|
|
443
|
+
if (!updated) return { state, changed: false };
|
|
444
|
+
return { state: { ...state, notes }, changed: true, result: updated };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
case "move_note": {
|
|
448
|
+
let moved;
|
|
449
|
+
const notes = state.notes.map((note) => {
|
|
450
|
+
if (note.id !== command.id) return note;
|
|
451
|
+
moved = bump(note, { x: command.x, y: command.y }, now);
|
|
452
|
+
return moved;
|
|
453
|
+
});
|
|
454
|
+
if (!moved) return { state, changed: false };
|
|
455
|
+
return { state: { ...state, notes }, changed: true, result: moved };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
case "nudge_notes": {
|
|
459
|
+
const ids = new Set(command.ids);
|
|
460
|
+
if (ids.size === 0) return { state, changed: false };
|
|
461
|
+
const notes = state.notes.map((note) =>
|
|
462
|
+
ids.has(note.id)
|
|
463
|
+
? bump(note, { x: note.x + command.dx, y: note.y + command.dy }, now)
|
|
464
|
+
: note,
|
|
465
|
+
);
|
|
466
|
+
return { state: { ...state, notes }, changed: true };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
case "set_length": {
|
|
470
|
+
const ids = new Set(command.ids);
|
|
471
|
+
if (ids.size === 0) return { state, changed: false };
|
|
472
|
+
const lengthEighths = clampEighths(
|
|
473
|
+
command.lengthEighths,
|
|
474
|
+
DEFAULT_NOTE_EIGHTHS,
|
|
475
|
+
MAX_NOTE_EIGHTHS,
|
|
476
|
+
);
|
|
477
|
+
const touched = [];
|
|
478
|
+
const notes = state.notes.map((note) => {
|
|
479
|
+
if (!ids.has(note.id) || note.lengthEighths === lengthEighths) return note;
|
|
480
|
+
const next = bump(note, { lengthEighths }, now);
|
|
481
|
+
touched.push(next);
|
|
482
|
+
return next;
|
|
483
|
+
});
|
|
484
|
+
if (touched.length === 0) return { state, changed: false };
|
|
485
|
+
return { state: { ...state, notes }, changed: true, result: touched };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
case "set_target": {
|
|
489
|
+
const targetEighths = clampEighths(
|
|
490
|
+
command.targetEighths,
|
|
491
|
+
DEFAULT_TARGET_EIGHTHS,
|
|
492
|
+
MAX_TARGET_EIGHTHS,
|
|
493
|
+
);
|
|
494
|
+
if (targetEighths === (state.targetEighths ?? DEFAULT_TARGET_EIGHTHS)) {
|
|
495
|
+
return { state, changed: false };
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
state: { ...state, targetEighths },
|
|
499
|
+
changed: true,
|
|
500
|
+
result: { targetEighths },
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
case "set_rank": {
|
|
505
|
+
const ids = new Set(command.ids);
|
|
506
|
+
if (ids.size === 0) return { state, changed: false };
|
|
507
|
+
const rank = NOTE_RANKS.includes(command.rank) ? command.rank : "scene";
|
|
508
|
+
const touched = [];
|
|
509
|
+
const notes = state.notes.map((note) => {
|
|
510
|
+
if (!ids.has(note.id) || note.rank === rank) return note;
|
|
511
|
+
const next = bump(note, { rank }, now);
|
|
512
|
+
touched.push(next);
|
|
513
|
+
return next;
|
|
514
|
+
});
|
|
515
|
+
if (touched.length === 0) return { state, changed: false };
|
|
516
|
+
return { state: { ...state, notes }, changed: true, result: touched };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
case "recolor_notes": {
|
|
520
|
+
const ids = new Set(command.ids);
|
|
521
|
+
if (ids.size === 0) return { state, changed: false };
|
|
522
|
+
const changedNotes = [];
|
|
523
|
+
const notes = state.notes.map((note) => {
|
|
524
|
+
if (!ids.has(note.id)) return note;
|
|
525
|
+
const next = bump(note, { color: command.color }, now);
|
|
526
|
+
changedNotes.push(next);
|
|
527
|
+
return next;
|
|
528
|
+
});
|
|
529
|
+
const painted = changedNotes.length > 0;
|
|
530
|
+
return {
|
|
531
|
+
state: painted ? { ...state, notes } : state,
|
|
532
|
+
changed: painted,
|
|
533
|
+
result: changedNotes,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
case "raise_note": {
|
|
538
|
+
const top = maxZ(state.notes) + 1;
|
|
539
|
+
let raised = false;
|
|
540
|
+
const notes = state.notes.map((note) => {
|
|
541
|
+
if (note.id !== command.id) return note;
|
|
542
|
+
raised = true;
|
|
543
|
+
return { ...note, z: top };
|
|
544
|
+
});
|
|
545
|
+
if (!raised) return { state, changed: false };
|
|
546
|
+
return { state: { ...state, notes }, changed: true };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
case "delete_note": {
|
|
550
|
+
if (!state.notes.some((note) => note.id === command.id)) {
|
|
551
|
+
return { state, changed: false };
|
|
552
|
+
}
|
|
553
|
+
const notes = state.notes.filter((note) => note.id !== command.id);
|
|
554
|
+
const arrows = state.arrows.filter(
|
|
555
|
+
(arrow) => arrow.from !== command.id && arrow.to !== command.id,
|
|
556
|
+
);
|
|
557
|
+
const groups = pruneGroups(
|
|
558
|
+
state.groups.map((group) => ({
|
|
559
|
+
...group,
|
|
560
|
+
noteIds: group.noteIds.filter((id) => id !== command.id),
|
|
561
|
+
})),
|
|
562
|
+
);
|
|
563
|
+
return {
|
|
564
|
+
state: { ...state, notes, arrows, groups },
|
|
565
|
+
changed: true,
|
|
566
|
+
result: { id: command.id },
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
case "apply_poses": {
|
|
571
|
+
const byId = new Map(command.poses.map((pose) => [pose.id, pose]));
|
|
572
|
+
if (byId.size === 0) return { state, changed: false };
|
|
573
|
+
const notes = state.notes.map((note) => {
|
|
574
|
+
const pose = byId.get(note.id);
|
|
575
|
+
return pose
|
|
576
|
+
? bump(note, { x: pose.x, y: pose.y, rotate: pose.rotate }, now)
|
|
577
|
+
: note;
|
|
578
|
+
});
|
|
579
|
+
return { state: { ...state, notes }, changed: true };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
case "settle_note": {
|
|
583
|
+
const note = state.notes.find((item) => item.id === command.id);
|
|
584
|
+
if (!note) return { state, changed: false };
|
|
585
|
+
// Only a card that actually left its frame is a change. A drop that
|
|
586
|
+
// changes nothing must say so, or it becomes an empty undo step (R33).
|
|
587
|
+
let touched = false;
|
|
588
|
+
const groups = pruneGroups(
|
|
589
|
+
state.groups.map((group) => {
|
|
590
|
+
if (!group.noteIds.includes(command.id)) return group;
|
|
591
|
+
const others = state.notes.filter(
|
|
592
|
+
(item) => item.id !== command.id && group.noteIds.includes(item.id),
|
|
593
|
+
);
|
|
594
|
+
if (others.length === 0) {
|
|
595
|
+
touched = true;
|
|
596
|
+
return { ...group, noteIds: [] };
|
|
597
|
+
}
|
|
598
|
+
const left = Math.min(...others.map((item) => item.x)) - SETTLE_MARGIN;
|
|
599
|
+
const top = Math.min(...others.map((item) => item.y)) - SETTLE_MARGIN;
|
|
600
|
+
const right = Math.max(...others.map((item) => item.x + NOTE_WIDTH)) + SETTLE_MARGIN;
|
|
601
|
+
const bottom = Math.max(...others.map((item) => item.y + NOTE_HEIGHT)) + SETTLE_MARGIN;
|
|
602
|
+
const cx = note.x + NOTE_WIDTH / 2;
|
|
603
|
+
const cy = note.y + NOTE_HEIGHT / 2;
|
|
604
|
+
const inside = cx > left && cx < right && cy > top && cy < bottom;
|
|
605
|
+
if (inside) return group;
|
|
606
|
+
touched = true;
|
|
607
|
+
return { ...group, noteIds: group.noteIds.filter((id) => id !== command.id) };
|
|
608
|
+
}),
|
|
609
|
+
);
|
|
610
|
+
if (!touched) return { state, changed: false };
|
|
611
|
+
return { state: { ...state, groups }, changed: true };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
case "create_group": {
|
|
615
|
+
const noteIds = command.noteIds.filter((id) =>
|
|
616
|
+
state.notes.some((note) => note.id === id),
|
|
617
|
+
);
|
|
618
|
+
if (noteIds.length < 2) return { state, changed: false };
|
|
619
|
+
const idSet = new Set(noteIds);
|
|
620
|
+
const group = {
|
|
621
|
+
id: newId(),
|
|
622
|
+
title: command.title ?? "Sequence",
|
|
623
|
+
noteIds: [...noteIds],
|
|
624
|
+
};
|
|
625
|
+
const groups = [
|
|
626
|
+
...pruneGroups(
|
|
627
|
+
state.groups.map((existing) => ({
|
|
628
|
+
...existing,
|
|
629
|
+
noteIds: existing.noteIds.filter((id) => !idSet.has(id)),
|
|
630
|
+
})),
|
|
631
|
+
),
|
|
632
|
+
group,
|
|
633
|
+
];
|
|
634
|
+
return { state: { ...state, groups }, changed: true, result: group };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
case "ungroup": {
|
|
638
|
+
if (!state.groups.some((group) => group.id === command.id)) {
|
|
639
|
+
return { state, changed: false };
|
|
640
|
+
}
|
|
641
|
+
return {
|
|
642
|
+
state: { ...state, groups: state.groups.filter((group) => group.id !== command.id) },
|
|
643
|
+
changed: true,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
case "rename_group": {
|
|
648
|
+
let renamed = false;
|
|
649
|
+
const groups = state.groups.map((group) => {
|
|
650
|
+
if (group.id !== command.id) return group;
|
|
651
|
+
renamed = true;
|
|
652
|
+
return { ...group, title: command.title };
|
|
653
|
+
});
|
|
654
|
+
if (!renamed) return { state, changed: false };
|
|
655
|
+
return { state: { ...state, groups }, changed: true };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
case "create_arrow": {
|
|
659
|
+
if (command.from === command.to) return { state, changed: false };
|
|
660
|
+
const knownFrom = state.notes.some((note) => note.id === command.from);
|
|
661
|
+
const knownTo = state.notes.some((note) => note.id === command.to);
|
|
662
|
+
if (!knownFrom || !knownTo) return { state, changed: false };
|
|
663
|
+
if (state.arrows.some((arrow) => arrow.from === command.from && arrow.to === command.to)) {
|
|
664
|
+
return { state, changed: false };
|
|
665
|
+
}
|
|
666
|
+
const arrow = {
|
|
667
|
+
id: newId(),
|
|
668
|
+
from: command.from,
|
|
669
|
+
to: command.to,
|
|
670
|
+
kind: ARROW_KINDS.includes(command.kind) ? command.kind : "follows",
|
|
671
|
+
};
|
|
672
|
+
return {
|
|
673
|
+
state: { ...state, arrows: [...state.arrows, arrow] },
|
|
674
|
+
changed: true,
|
|
675
|
+
result: arrow,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
case "set_arrow_kind": {
|
|
680
|
+
const kind = ARROW_KINDS.includes(command.kind) ? command.kind : "follows";
|
|
681
|
+
let changedArrow;
|
|
682
|
+
const arrows = state.arrows.map((arrow) => {
|
|
683
|
+
if (arrow.id !== command.id || arrow.kind === kind) return arrow;
|
|
684
|
+
changedArrow = { ...arrow, kind };
|
|
685
|
+
return changedArrow;
|
|
686
|
+
});
|
|
687
|
+
if (!changedArrow) return { state, changed: false };
|
|
688
|
+
return { state: { ...state, arrows }, changed: true, result: changedArrow };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// A fresh wall. Everything goes, including the cast and the logline; the
|
|
692
|
+
// target stays, because it belongs to the kind of thing you are writing,
|
|
693
|
+
// not to the cards you had.
|
|
694
|
+
case "new_board": {
|
|
695
|
+
return {
|
|
696
|
+
state: { ...emptyState(), targetEighths: state.targetEighths ?? DEFAULT_TARGET_EIGHTHS },
|
|
697
|
+
changed: true,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// --- Characters (R29): a roster the board maintains ---------------------
|
|
702
|
+
|
|
703
|
+
case "add_character": {
|
|
704
|
+
const name = typeof command.name === "string" ? command.name.trim() : "";
|
|
705
|
+
if (!name) return { state, changed: false };
|
|
706
|
+
const existing = state.characters.find((character) => sameName(character.name, name));
|
|
707
|
+
// The same person twice is the thing a roster exists to prevent. Hand
|
|
708
|
+
// back who it already is so a tool can say so.
|
|
709
|
+
if (existing) return { state, changed: false, result: existing };
|
|
710
|
+
const character = fillCharacter({ id: command.id ?? newId(), name, createdAt: now, updatedAt: now });
|
|
711
|
+
return {
|
|
712
|
+
state: { ...state, characters: [...state.characters, character] },
|
|
713
|
+
changed: true,
|
|
714
|
+
result: character,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
case "rename_character": {
|
|
719
|
+
const name = typeof command.name === "string" ? command.name.trim() : "";
|
|
720
|
+
if (!name) return { state, changed: false };
|
|
721
|
+
const current = state.characters.find((character) => character.id === command.id);
|
|
722
|
+
if (!current || current.name === name) return { state, changed: false };
|
|
723
|
+
const taken = state.characters.find(
|
|
724
|
+
(character) => character.id !== command.id && sameName(character.name, name),
|
|
725
|
+
);
|
|
726
|
+
if (taken) return { state, changed: false, result: taken };
|
|
727
|
+
let renamed;
|
|
728
|
+
const characters = state.characters.map((character) => {
|
|
729
|
+
if (character.id !== command.id) return character;
|
|
730
|
+
renamed = { ...character, name, updatedAt: now };
|
|
731
|
+
return renamed;
|
|
732
|
+
});
|
|
733
|
+
return { state: { ...state, characters }, changed: true, result: renamed };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// The person's page (R36): any of the five lines, by id. Unknown fields
|
|
737
|
+
// are ignored; a patch that changes nothing changes nothing.
|
|
738
|
+
case "update_character": {
|
|
739
|
+
const current = state.characters.find((character) => character.id === command.id);
|
|
740
|
+
if (!current) return { state, changed: false };
|
|
741
|
+
const patch = {};
|
|
742
|
+
for (const field of CHARACTER_FIELDS) {
|
|
743
|
+
if (typeof command[field] === "string" && command[field] !== current[field]) {
|
|
744
|
+
patch[field] = command[field];
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (Object.keys(patch).length === 0) return { state, changed: false, result: current };
|
|
748
|
+
const updated = { ...current, ...patch, updatedAt: now };
|
|
749
|
+
const characters = state.characters.map((character) =>
|
|
750
|
+
character.id === command.id ? updated : character,
|
|
751
|
+
);
|
|
752
|
+
return { state: { ...state, characters }, changed: true, result: updated };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
case "remove_character": {
|
|
756
|
+
if (!state.characters.some((character) => character.id === command.id)) {
|
|
757
|
+
return { state, changed: false };
|
|
758
|
+
}
|
|
759
|
+
const characters = state.characters.filter((character) => character.id !== command.id);
|
|
760
|
+
// Leaving the scene means leaving every card they were in.
|
|
761
|
+
const notes = state.notes.map((note) =>
|
|
762
|
+
note.characterIds.includes(command.id)
|
|
763
|
+
? bump(note, { characterIds: note.characterIds.filter((id) => id !== command.id) }, now)
|
|
764
|
+
: note,
|
|
765
|
+
);
|
|
766
|
+
return { state: { ...state, characters, notes }, changed: true, result: { id: command.id } };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
case "set_cast": {
|
|
770
|
+
const ids = new Set(command.ids);
|
|
771
|
+
if (ids.size === 0) return { state, changed: false };
|
|
772
|
+
const cast = knownCast(command.characterIds, state.characters);
|
|
773
|
+
const touched = [];
|
|
774
|
+
const notes = state.notes.map((note) => {
|
|
775
|
+
if (!ids.has(note.id) || sameIds(note.characterIds, cast)) return note;
|
|
776
|
+
const next = bump(note, { characterIds: [...cast] }, now);
|
|
777
|
+
touched.push(next);
|
|
778
|
+
return next;
|
|
779
|
+
});
|
|
780
|
+
if (touched.length === 0) return { state, changed: false };
|
|
781
|
+
return { state: { ...state, notes }, changed: true, result: touched };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Start from a structure (R38): the template's beats become beat cards in
|
|
785
|
+
// one row above the wall's cards, prompts on their change lines. Nothing
|
|
786
|
+
// remembers the template afterwards; there are only cards.
|
|
787
|
+
case "apply_template": {
|
|
788
|
+
// One of the five by id, or a writer's own beats handed in (Roadmap 2, item 7).
|
|
789
|
+
const template = Array.isArray(command.beats) && command.beats.length
|
|
790
|
+
? { id: command.template, beats: command.beats }
|
|
791
|
+
: templateById(command.template);
|
|
792
|
+
if (!template) return { state, changed: false };
|
|
793
|
+
// Rows read top to bottom, so the block of new rows starts high enough
|
|
794
|
+
// that its last row still clears the wall's top card.
|
|
795
|
+
const rows = Math.ceil(template.beats.length / 5);
|
|
796
|
+
const top = state.notes.length
|
|
797
|
+
? Math.min(...state.notes.map((note) => note.y)) - rows * (NOTE_HEIGHT + 40) - 32
|
|
798
|
+
: 140;
|
|
799
|
+
const left = state.notes.length ? Math.min(...state.notes.map((note) => note.x)) : 140;
|
|
800
|
+
let z = state.notes.reduce((max, note) => Math.max(max, note.z), 0);
|
|
801
|
+
const created = template.beats.map((item, index) => ({
|
|
802
|
+
id: newId(),
|
|
803
|
+
headline: item.name,
|
|
804
|
+
change: item.prompt,
|
|
805
|
+
color: NOTE_COLORS[(state.notes.length + index) % NOTE_COLORS.length],
|
|
806
|
+
x: left + (index % 5) * (NOTE_WIDTH + 28),
|
|
807
|
+
y: top + Math.floor(index / 5) * (NOTE_HEIGHT + 40),
|
|
808
|
+
rotate: ((index % 5) - 2) * 0.8,
|
|
809
|
+
z: (z += 1),
|
|
810
|
+
rank: "beat",
|
|
811
|
+
lengthEighths: null,
|
|
812
|
+
characterIds: [],
|
|
813
|
+
plants: false,
|
|
814
|
+
location: "",
|
|
815
|
+
text: "",
|
|
816
|
+
createdAt: now,
|
|
817
|
+
updatedAt: now,
|
|
818
|
+
}));
|
|
819
|
+
return { state: { ...state, notes: [...state.notes, ...created] }, changed: true, result: created };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Pages (R23 b): the scene's text, on its card. Trailing whitespace is
|
|
823
|
+
// trimmed so a stray newline is not a change.
|
|
824
|
+
case "set_text": {
|
|
825
|
+
const text = typeof command.text === "string" ? command.text.replace(/\s+$/, "") : "";
|
|
826
|
+
let updated;
|
|
827
|
+
const notes = state.notes.map((note) => {
|
|
828
|
+
if (note.id !== command.id || note.text === text) return note;
|
|
829
|
+
updated = bump(note, { text }, now);
|
|
830
|
+
return updated;
|
|
831
|
+
});
|
|
832
|
+
if (!updated) {
|
|
833
|
+
return { state, changed: false, result: state.notes.find((note) => note.id === command.id) };
|
|
834
|
+
}
|
|
835
|
+
return { state: { ...state, notes }, changed: true, result: updated };
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// The production half (Roadmap 2, item 8). Lock the numbers once a draft
|
|
839
|
+
// goes out: every scene keeps its number by the wall's order the caller
|
|
840
|
+
// passes; new scenes take A-numbers; moving never renumbers. A revision is
|
|
841
|
+
// a name and a colour over a snapshot of every card, so changed lines mark.
|
|
842
|
+
case "lock_numbers": {
|
|
843
|
+
const order = Array.isArray(command.order) ? command.order : state.notes.map((note) => note.id);
|
|
844
|
+
const byId = new Map(state.notes.map((note) => [note.id, note]));
|
|
845
|
+
const ordered = order.map((id) => byId.get(id)).filter(Boolean);
|
|
846
|
+
for (const note of state.notes) if (!order.includes(note.id)) ordered.push(note);
|
|
847
|
+
const lock = lockFrom(ordered, state.lock, now);
|
|
848
|
+
return { state: { ...state, lock }, changed: true, result: lock };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
case "unlock_numbers": {
|
|
852
|
+
if (!state.lock) return { state, changed: false };
|
|
853
|
+
return { state: { ...state, lock: null }, changed: true, result: null };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
case "start_revision": {
|
|
857
|
+
const name = typeof command.name === "string" ? command.name.trim() : "";
|
|
858
|
+
if (!name) return { state, changed: false };
|
|
859
|
+
const color = REVISION_COLORS.includes(command.color) ? command.color : "blue";
|
|
860
|
+
const snapshot = {};
|
|
861
|
+
for (const note of state.notes) {
|
|
862
|
+
snapshot[note.id] = { headline: note.headline, change: note.change, location: note.location ?? "", text: note.text ?? "" };
|
|
863
|
+
}
|
|
864
|
+
const revision = { name, color, since: now, snapshot };
|
|
865
|
+
return { state: { ...state, revision }, changed: true, result: revision };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
case "end_revision": {
|
|
869
|
+
if (!state.revision) return { state, changed: false };
|
|
870
|
+
return { state: { ...state, revision: null }, changed: true, result: null };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Where a scene happens (R37): one place on one or more cards; an empty
|
|
874
|
+
// place clears it.
|
|
875
|
+
case "set_location": {
|
|
876
|
+
const ids = new Set(command.ids);
|
|
877
|
+
if (ids.size === 0) return { state, changed: false };
|
|
878
|
+
const location = cleanPlace(command.location);
|
|
879
|
+
const touched = [];
|
|
880
|
+
const notes = state.notes.map((note) => {
|
|
881
|
+
if (!ids.has(note.id) || note.location === location) return note;
|
|
882
|
+
const next = bump(note, { location }, now);
|
|
883
|
+
touched.push(next);
|
|
884
|
+
return next;
|
|
885
|
+
});
|
|
886
|
+
if (touched.length === 0) return { state, changed: false };
|
|
887
|
+
return { state: { ...state, notes }, changed: true, result: touched };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Fold the corner (R31): this card plants something. A claim about the
|
|
891
|
+
// card, like rank, so it never moves it and never touches its arrows.
|
|
892
|
+
case "set_plant": {
|
|
893
|
+
const ids = new Set(command.ids);
|
|
894
|
+
if (ids.size === 0) return { state, changed: false };
|
|
895
|
+
const plants = command.plants === true;
|
|
896
|
+
const touched = [];
|
|
897
|
+
const notes = state.notes.map((note) => {
|
|
898
|
+
if (!ids.has(note.id) || note.plants === plants) return note;
|
|
899
|
+
const next = bump(note, { plants }, now);
|
|
900
|
+
touched.push(next);
|
|
901
|
+
return next;
|
|
902
|
+
});
|
|
903
|
+
if (touched.length === 0) return { state, changed: false };
|
|
904
|
+
return { state: { ...state, notes }, changed: true, result: touched };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
case "delete_arrow": {
|
|
908
|
+
if (!state.arrows.some((arrow) => arrow.id === command.id)) {
|
|
909
|
+
return { state, changed: false };
|
|
910
|
+
}
|
|
911
|
+
return {
|
|
912
|
+
state: { ...state, arrows: state.arrows.filter((arrow) => arrow.id !== command.id) },
|
|
913
|
+
changed: true,
|
|
914
|
+
result: { id: command.id },
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
default:
|
|
919
|
+
return { state, changed: false };
|
|
920
|
+
}
|
|
921
|
+
}
|