plotcoder-board 0.1.16 → 0.1.18

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.
@@ -83,6 +83,8 @@ export type BoardNote = {
83
83
  payoffBoardId: string | null;
84
84
  /** Where the scene happens (R37): a phrase in the writer's words; empty until set. */
85
85
  location: string;
86
+ /** When the scene happens, as the writer says it — "night", "day four, dawn" — printed after the place on the heading (R55). Empty when unsaid. */
87
+ when: string;
86
88
  /** The scene's text in Fountain (R23 b): action, cues, dialogue; empty until written. */
87
89
  text: string;
88
90
  createdAt: string;
@@ -128,6 +130,8 @@ export type LeftQuestion = {
128
130
  /** The question's words when it was left; it comes back when they would differ. */
129
131
  text: string;
130
132
  since: string;
133
+ /** The writer's reason, when they gave one. */
134
+ why?: string;
131
135
  };
132
136
 
133
137
  export type Pose = { id: string; x: number; y: number; rotate: number };
@@ -155,9 +159,10 @@ export type Command =
155
159
  characterIds?: string[];
156
160
  plants?: boolean;
157
161
  location?: string;
162
+ when?: string;
158
163
  text?: string;
159
164
  }
160
- | { type: "update_note"; id: string; headline?: string; change?: string; location?: string }
165
+ | { type: "update_note"; id: string; headline?: string; change?: string; location?: string; when?: string }
161
166
  | { type: "move_note"; id: string; x: number; y: number }
162
167
  | { type: "nudge_notes"; ids: string[]; dx: number; dy: number }
163
168
  | { type: "recolor_notes"; ids: string[]; color: NoteColor }
@@ -181,13 +186,14 @@ export type Command =
181
186
  | { type: "set_plant"; ids: string[]; plants: boolean }
182
187
  | { type: "set_payoff_board"; ids: string[]; boardId: string | null }
183
188
  | { type: "set_location"; ids: string[]; location: string }
189
+ | { type: "set_when"; ids: string[]; when: string }
184
190
  | { type: "apply_template"; template: string; beats?: Array<{ name: string; prompt: string; at: number }> }
185
191
  | { type: "set_text"; id: string; text: string }
186
192
  | { type: "lock_numbers"; order?: string[] }
187
193
  | { type: "unlock_numbers" }
188
194
  | { type: "start_revision"; name: string; color?: string }
189
195
  | { type: "end_revision" }
190
- | { type: "leave_question"; kind: string; ids: string[]; text: string }
196
+ | { type: "leave_question"; kind: string; ids: string[]; text: string; why?: string }
191
197
  | { type: "ask_again"; kind: string; ids?: string[] };
192
198
 
193
199
  export type CommandResult = {
@@ -245,6 +245,7 @@ export function seedState(now = nowIso()) {
245
245
  lengthEighths: null,
246
246
  characterIds,
247
247
  location: "",
248
+ when: "",
248
249
  text: "",
249
250
  plants: false,
250
251
  // A fold that pays off on another board — a later episode — names it here;
@@ -347,6 +348,8 @@ export function normalizeState(value) {
347
348
  const payoffBoardId = plants && typeof note?.payoffBoardId === "string" && note.payoffBoardId ? note.payoffBoardId : null;
348
349
  // Cards written before R37 have no place; a scene is nowhere until it is.
349
350
  const location = typeof note?.location === "string" ? note.location : "";
351
+ // Cards written before R55 have no when; a scene is at no time until it is.
352
+ const when = typeof note?.when === "string" ? note.when : "";
350
353
  // Cards written before pages (R23 b) have no text; a scene is unwritten until it is.
351
354
  const text = typeof note?.text === "string" ? note.text : "";
352
355
  if (
@@ -358,12 +361,13 @@ export function normalizeState(value) {
358
361
  note.plants === plants &&
359
362
  note.payoffBoardId === payoffBoardId &&
360
363
  note.location === location &&
364
+ note.when === when &&
361
365
  note.text === text
362
366
  ) {
363
367
  return note;
364
368
  }
365
369
  patched = true;
366
- return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, location, text };
370
+ return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, location, when, text };
367
371
  });
368
372
 
369
373
  // Boards written before the production half (Roadmap 2, item 8) have no
@@ -414,6 +418,11 @@ function bump(note, patch, now) {
414
418
  return { ...note, ...patch, updatedAt: now };
415
419
  }
416
420
 
421
+ /** A when as the writer typed it, one line, spaces collapsed. */
422
+ function cleanWhen(value) {
423
+ return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
424
+ }
425
+
417
426
  function pruneGroups(groups) {
418
427
  return groups.filter((group) => group.noteIds.length >= 2);
419
428
  }
@@ -445,6 +454,7 @@ export function applyCommand(state, command, now = nowIso()) {
445
454
  plants: command.plants === true,
446
455
  payoffBoardId: null,
447
456
  location: cleanPlace(command.location),
457
+ when: cleanWhen(command.when),
448
458
  text: typeof command.text === "string" ? command.text : "",
449
459
  z: maxZ(state.notes) + 1,
450
460
  createdAt: now,
@@ -465,6 +475,7 @@ export function applyCommand(state, command, now = nowIso()) {
465
475
  if (command.headline !== undefined) patch.headline = command.headline;
466
476
  if (command.change !== undefined) patch.change = command.change;
467
477
  if (command.location !== undefined) patch.location = cleanPlace(command.location);
478
+ if (command.when !== undefined) patch.when = cleanWhen(command.when);
468
479
  updated = bump(note, patch, now);
469
480
  return updated;
470
481
  });
@@ -581,7 +592,17 @@ export function applyCommand(state, command, now = nowIso()) {
581
592
  const headlineOf = (id) => state.notes.find((note) => note.id === id)?.headline ?? id;
582
593
  const notes = state.notes.filter((note) => note.id !== command.id);
583
594
  const taken = state.arrows.filter((arrow) => arrow.from === command.id || arrow.to === command.id);
584
- const arrows = state.arrows.filter((arrow) => !taken.includes(arrow));
595
+ let arrows = state.arrows.filter((arrow) => !taken.includes(arrow));
596
+ // A card wired into a chain — one follows in, one follows out — leaves
597
+ // the chain joined behind it, as move_scene does; undo takes the join
598
+ // back with the card (round fourteen, entry 20).
599
+ const ins = taken.filter((arrow) => arrow.kind !== "setup" && arrow.to === command.id);
600
+ const outs = taken.filter((arrow) => arrow.kind !== "setup" && arrow.from === command.id);
601
+ let joined = null;
602
+ if (ins.length === 1 && outs.length === 1 && ins[0].from !== outs[0].to && !arrows.some((arrow) => arrow.from === ins[0].from && arrow.to === outs[0].to)) {
603
+ joined = { id: newId(), from: ins[0].from, to: outs[0].to, kind: "follows" };
604
+ arrows = [...arrows, joined];
605
+ }
585
606
  const left = [];
586
607
  const groups = pruneGroups(
587
608
  state.groups.map((group) => {
@@ -600,6 +621,7 @@ export function applyCommand(state, command, now = nowIso()) {
600
621
  id: command.id,
601
622
  headline: gone.headline,
602
623
  arrows: taken.map((arrow) => ({ ...arrow, fromHeadline: headlineOf(arrow.from), toHeadline: headlineOf(arrow.to) })),
624
+ joined: joined ? { ...joined, fromHeadline: headlineOf(joined.from), toHeadline: headlineOf(joined.to) } : null,
603
625
  groups: left,
604
626
  },
605
627
  };
@@ -608,13 +630,17 @@ export function applyCommand(state, command, now = nowIso()) {
608
630
  case "apply_poses": {
609
631
  const byId = new Map(command.poses.map((pose) => [pose.id, pose]));
610
632
  if (byId.size === 0) return { state, changed: false };
633
+ // Only a card that actually moves is touched, so a tidy that leaves a
634
+ // card where it was does not stamp it (round fourteen, entry 43).
635
+ let moved = 0;
611
636
  const notes = state.notes.map((note) => {
612
637
  const pose = byId.get(note.id);
613
- return pose
614
- ? bump(note, { x: pose.x, y: pose.y, rotate: pose.rotate }, now)
615
- : note;
638
+ if (!pose || (note.x === pose.x && note.y === pose.y && note.rotate === pose.rotate)) return note;
639
+ moved += 1;
640
+ return bump(note, { x: pose.x, y: pose.y, rotate: pose.rotate }, now);
616
641
  });
617
- return { state: { ...state, notes }, changed: true };
642
+ if (moved === 0) return { state, changed: false };
643
+ return { state: { ...state, notes }, changed: true, result: { moved } };
618
644
  }
619
645
 
620
646
  case "settle_note": {
@@ -880,6 +906,7 @@ export function applyCommand(state, command, now = nowIso()) {
880
906
  plants: false,
881
907
  payoffBoardId: null,
882
908
  location: "",
909
+ when: "",
883
910
  text: "",
884
911
  createdAt: now,
885
912
  updatedAt: now,
@@ -949,7 +976,8 @@ export function applyCommand(state, command, now = nowIso()) {
949
976
  const ids = Array.isArray(command.ids) ? command.ids.filter((id) => typeof id === "string") : [];
950
977
  const text = typeof command.text === "string" ? command.text : "";
951
978
  if (!kind || !text) return { state, changed: false };
952
- const entry = { kind, ids, text, since: now };
979
+ const why = typeof command.why === "string" ? command.why.trim() : "";
980
+ const entry = why ? { kind, ids, text, since: now, why } : { kind, ids, text, since: now };
953
981
  const rest = (state.left ?? []).filter((item) => !(item.kind === kind && sameIds(item.ids, ids)));
954
982
  return { state: { ...state, left: [...rest, entry] }, changed: true, result: entry };
955
983
  }
@@ -980,6 +1008,23 @@ export function applyCommand(state, command, now = nowIso()) {
980
1008
  return { state: { ...state, notes }, changed: true, result: touched };
981
1009
  }
982
1010
 
1011
+ // When a scene happens (R55): "night", "day four, dawn" — the writer's
1012
+ // phrase, printed after the place on the scene heading; empty clears it.
1013
+ case "set_when": {
1014
+ const ids = new Set(command.ids);
1015
+ if (ids.size === 0) return { state, changed: false };
1016
+ const when = cleanWhen(command.when);
1017
+ const touched = [];
1018
+ const notes = state.notes.map((note) => {
1019
+ if (!ids.has(note.id) || note.when === when) return note;
1020
+ const next = bump(note, { when }, now);
1021
+ touched.push(next);
1022
+ return next;
1023
+ });
1024
+ if (touched.length === 0) return { state, changed: false };
1025
+ return { state: { ...state, notes }, changed: true, result: touched };
1026
+ }
1027
+
983
1028
  // Fold the corner (R31): this card plants something. A claim about the
984
1029
  // card, like rank, so it never moves it and never touches its arrows.
985
1030
  case "set_plant": {
@@ -165,7 +165,7 @@ export const WORD_GROUPS = [
165
165
  {
166
166
  id: "revisions",
167
167
  name: "Locked numbers, revisions",
168
- sentence: "Once a draft goes out, scene numbers stop moving and changes print in a colour; a scene added after the lock keeps its own letter, 2A after 2. For after the writing.",
168
+ sentence: "Once a draft goes out, scene numbers stop moving and changes print in a colour with a star, on the page and in every export; a scene added after the lock has a letter, 2A after 2, that is its place between locked scenes and follows it if it moves. For after the writing.",
169
169
  },
170
170
  ],
171
171
  },