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.
@@ -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
+ }