@nodaro/prompts 1.19.0 → 1.21.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.
Files changed (55) hide show
  1. package/dist/index.cjs +11175 -1797
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +280 -41
  4. package/dist/index.d.ts +280 -41
  5. package/dist/index.js +10049 -686
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/adult-only-ratchet.test.ts +26 -1
  9. package/src/__tests__/character-motion-audit.test.ts +77 -0
  10. package/src/__tests__/character-motion-docs-example.test.ts +26 -0
  11. package/src/__tests__/character-motion-exit-visibility.test.ts +183 -0
  12. package/src/__tests__/character-motion-partner-referent.test.ts +225 -0
  13. package/src/__tests__/character-motion-timing-catalogs.test.ts +76 -0
  14. package/src/__tests__/character-motion.test.ts +214 -0
  15. package/src/__tests__/fixtures/parameter-hint-golden.json +136 -0
  16. package/src/__tests__/frame-delivery.test.ts +93 -0
  17. package/src/__tests__/graph-composed-unwired-identity.test.ts +166 -0
  18. package/src/__tests__/parameter-hint-mode.test.ts +28 -0
  19. package/src/__tests__/parameter-prompt-hint.test.ts +165 -0
  20. package/src/__tests__/parameter-registry-sync.test.ts +2 -0
  21. package/src/__tests__/picker-analyzer-registry.test.ts +1 -1
  22. package/src/__tests__/transition-timing-catalogs.test.ts +1 -1
  23. package/src/__tests__/video-reference-features.test.ts +10 -1
  24. package/src/age-floor.ts +1 -1
  25. package/src/character-motion/animals-pets.ts +116 -0
  26. package/src/character-motion/athletic-stunts.ts +159 -0
  27. package/src/character-motion/camera-interaction.ts +96 -0
  28. package/src/character-motion/combat-weapons.ts +147 -0
  29. package/src/character-motion/dance.ts +125 -0
  30. package/src/character-motion/entrances-exits.ts +92 -0
  31. package/src/character-motion/evasive-falls.ts +92 -0
  32. package/src/character-motion/everyday-actions.ts +237 -0
  33. package/src/character-motion/face-expression.ts +129 -0
  34. package/src/character-motion/gestures.ts +215 -0
  35. package/src/character-motion/head-gestures.ts +70 -0
  36. package/src/character-motion/idle-ambient.ts +89 -0
  37. package/src/character-motion/posture-shifts.ts +120 -0
  38. package/src/character-motion/runway.ts +77 -0
  39. package/src/character-motion/stage-performance.ts +96 -0
  40. package/src/character-motion/turns-looks.ts +90 -0
  41. package/src/character-motion/two-person.ts +245 -0
  42. package/src/character-motion/types.ts +45 -0
  43. package/src/character-motion/unnatural-horror.ts +57 -0
  44. package/src/character-motion/vehicles-mounts.ts +105 -0
  45. package/src/character-motion/walks-runs.ts +97 -0
  46. package/src/character-motion-diagnostics.ts +49 -0
  47. package/src/character-motion.ts +362 -0
  48. package/src/frame-delivery.ts +106 -0
  49. package/src/index.ts +5 -0
  50. package/src/parameter-prompt-hint.ts +71 -0
  51. package/src/picker-analyzer-registry.ts +9 -5
  52. package/src/picker-catalogs.ts +33 -1
  53. package/src/picker-wiring.ts +2 -0
  54. package/src/prompt-wizard-categories.ts +0 -2
  55. package/src/provider-prompt-doctrine.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/prompts",
3
- "version": "1.19.0",
3
+ "version": "1.21.0",
4
4
  "description": "Nodaro's prompt-engineering layer — person/picker catalogs with prompt hints, identity-lock clauses, entity prompt builders, brand presets, and prompt/reference assembly shared by the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -20,7 +20,7 @@
20
20
  "test": "vitest run"
21
21
  },
22
22
  "dependencies": {
23
- "@nodaro/shared": "^3.9.0"
23
+ "@nodaro/shared": "^3.11.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "tsup": "^8.5.0",
@@ -4,6 +4,7 @@ import { STYLINGS } from "../styling.js"
4
4
  import { MOODS } from "../mood.js"
5
5
  import { POSES } from "../pose.js"
6
6
  import { PHOTO_GENRES } from "../photo-genre.js"
7
+ import { CHARACTER_MOTIONS } from "../character-motion.js"
7
8
  import { getAdultOnlyEntries } from "../age-floor.js"
8
9
 
9
10
  /**
@@ -21,6 +22,14 @@ const RISKY =
21
22
 
22
23
  /** Entries the regex trips on that are NOT body-exposure content. */
23
24
  const EXEMPT: Readonly<Record<string, string>> = {
25
+ "sashay-forward": "Neutral movement or affection; no sensual framing or exposure in the authored fragment.",
26
+ "sway-hips-walk": "Neutral movement or affection; no sensual framing or exposure in the authored fragment.",
27
+ "drop-coat-off-shoulders": "Neutral movement or affection; no sensual framing or exposure in the authored fragment.",
28
+ "shimmy-the-hips": "Neutral movement or affection; no sensual framing or exposure in the authored fragment.",
29
+ "roll-the-body": "Neutral movement or affection; no sensual framing or exposure in the authored fragment.",
30
+ "hug-partner-from-behind": "Neutral movement or affection; no sensual framing or exposure in the authored fragment.",
31
+ "pull-partner-close": "Neutral movement or affection; no sensual framing or exposure in the authored fragment.",
32
+
24
33
  "nun": "rosary at the waist — garment placement",
25
34
  "construction-worker": "tool belt at the waist",
26
35
  "karate-fighter": "barefoot in a fighting stance — martial arts",
@@ -45,6 +54,20 @@ const EXEMPT: Readonly<Record<string, string>> = {
45
54
  "caught-off-guard": "lips parted from a startled reaction, not a seductive expression",
46
55
  "hands-on-hips": "neutral confident stance — hips referenced as a body part, no exposure or sexual content",
47
56
  "hand-position-hands-on-hips": "neutral confident stance, hand-position variant of hands-on-hips",
57
+ "sway-to-music": "character-motion: sway to the music — dance mechanics, hips as a body part, no exposure",
58
+ "sway-arms-raised": "character-motion: arms-up sway — dance mechanics, hips as a body part, no exposure",
59
+ "do-the-worm": "character-motion: the worm — dance mechanics, hips as a body part, no exposure",
60
+ "strike-vogue-poses": "character-motion: vogue — dance mechanics, hips as a body part, no exposure",
61
+ "spin-salsa-turn": "character-motion: salsa turn — dance mechanics, hips as a body part, no exposure",
62
+ "sway-bachata-hips": "character-motion: bachata sway — dance mechanics, hips as a body part, no exposure",
63
+ "flourish-bollywood-hands": "character-motion: bollywood flourish — dance mechanics, hips as a body part, no exposure",
64
+ "do-the-twist": "character-motion: the twist — dance mechanics, hips as a body part, no exposure",
65
+ "throw-disco-point": "character-motion: disco point — dance mechanics, hips as a body part, no exposure",
66
+ "do-the-floss": "character-motion: the floss — dance mechanics, hips as a body part, no exposure",
67
+ "tears-well-up": "character-motion: tear-filled glistening eyes — emotion, not skin",
68
+ "pout-sulkily": "character-motion: a sulky pout — emotional expression, not a seductive one",
69
+ "plant-hands-on-hips": "character-motion: neutral confident stance — hips as a body part, no exposure",
70
+ "freeze-on-the-drop": "character-motion: freeze on the drop — dance mechanics, hips as a body part, no exposure",
48
71
  }
49
72
 
50
73
  /** The hand-curated set. The test fails if any of these loses the flag. */
@@ -71,9 +94,11 @@ const CURATED = [
71
94
  "seductive", "sultry", "smoldering", "flirty",
72
95
  "biting-lip", "arched-back", "sitting-edge-of-bed", "lounging", "lying-down",
73
96
  "brand-helmut-newton", "glamour-portrait",
97
+ // character-motion
98
+ "sultry-look-over-shoulder", "sultry-look-back", "bite-lip-teasingly", "lick-lips-suggestively", "smolder-heavy-lidded", "smolder-at-camera", "lean-in-almost-kiss", "kiss-partner", "rise-on-tiptoes-to-kiss-partner",
74
99
  ] as const
75
100
 
76
- const ALL = [...PEOPLE, ...STYLINGS, ...MOODS, ...POSES, ...PHOTO_GENRES]
101
+ const ALL = [...PEOPLE, ...STYLINGS, ...MOODS, ...POSES, ...PHOTO_GENRES, ...CHARACTER_MOTIONS]
77
102
 
78
103
  describe("adultOnly ratchet", () => {
79
104
  it("every id in CURATED exists and carries the flag", () => {
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { CHARACTER_MOTIONS, composeCharacterMotionHintFromConnections as compose, getCharacterMotion } from "../character-motion.js"
3
+ import { getCharacterMotionDiagnostics as diagnose } from "../character-motion-diagnostics.js"
4
+ import { getParameterPromptHint, getCharacterMotionBindings } from "../parameter-prompt-hint.js"
5
+ import { getPickerCatalog } from "../picker-catalogs.js"
6
+
7
+ describe("motion composition regression coverage", () => {
8
+ it("treats replacement syntax and template words inside names literally", () => {
9
+ const output = compose("hug-partner", ["$& the partner"], ["$` Theo"])
10
+ expect(output).toContain("$& the partner steps toward $` Theo")
11
+ expect(output).not.toContain("another person")
12
+ })
13
+ it("composes separate singular clauses for multiple named targets", () => {
14
+ expect(compose("wave-hello", ["Mira", "Theo"], [])).toBe(`${compose("wave-hello", ["Mira"], [])}; separately, ${compose("wave-hello", ["Theo"], [])}`)
15
+ })
16
+ it("keeps multiple-target semantics identical in compact mode", () => {
17
+ expect(compose("wave-hello", ["Mira", "Theo"], [], undefined, "compact")).toBe("Mira: waves hello; separately, Theo: waves hello")
18
+ })
19
+ it("binds an animal recipient in both modes through the shared graph path", () => {
20
+ const node = { id: "motion", type: "character-motion", data: { characterMotion: "signal-dog-to-sit" } }
21
+ const graph = { nodes: [node, { id: "dog", type: "creature", data: { creatureName: "Rex" } }], edges: [{ source: "dog", target: "motion", targetHandle: "partner" }] }
22
+ for (const hintMode of ["full", "compact"]) {
23
+ const text = getParameterPromptHint({ ...node, data: { ...node.data, hintMode } }, graph)
24
+ expect(text).toContain("Rex")
25
+ expect(text).not.toContain("the counterpart")
26
+ }
27
+ expect(getCharacterMotionBindings(node, graph).partnerNames).toEqual(["Rex"])
28
+ })
29
+ it("resolves saved deprecated ids and advertises a valid replacement", () => {
30
+ expect(compose("mount-the-horse", [], [])).not.toBe("")
31
+ for (const entry of CHARACTER_MOTIONS.filter(e => e.replacementId)) {
32
+ expect(getCharacterMotion(entry.replacementId)?.deprecated).not.toBe(true)
33
+ expect(getCharacterMotion(entry.replacementId)?.promptHint).toBeTruthy()
34
+ }
35
+ })
36
+ it("binds counterpart tokens in both modes with an explicit fallback", () => {
37
+ for (const entry of CHARACTER_MOTIONS.filter(e => e.counterpart)) {
38
+ expect(entry.promptHint, entry.id).toContain("the counterpart")
39
+ expect(entry.term, entry.id).toContain("the counterpart")
40
+ for (const mode of ["full", "compact"] as const) {
41
+ expect(compose(entry.id, [], [], undefined, mode), entry.id).not.toContain("the counterpart")
42
+ expect(compose(entry.id, [], ["Named recipient"], undefined, mode), entry.id).toContain("Named recipient")
43
+ }
44
+ }
45
+ })
46
+ })
47
+ describe("authored sequence diagnostics", () => {
48
+ it("flags exits, missing transitions, occupied hands and contradictory pace", () => {
49
+ expect(diagnose(["walk-out-left", "wave-hello"]).map(x => x.code)).toContain("visibility")
50
+ expect(diagnose(["crawl-head-raised", "wave-hello"]).map(x => x.code)).toContain("pose")
51
+ expect(diagnose(["hug-partner-from-behind", "wave-hello"]).map(x => x.code)).toContain("hands")
52
+ expect(diagnose("give-slow-blink", { pace: "explosive" }).map(x => x.code)).toContain("pace")
53
+ expect(diagnose("sun-salutation").map(x => x.code)).toContain("compound")
54
+ })
55
+ it("reports unknown and completely filtered selections without certifying unknown metadata", () => {
56
+ expect(diagnose("missing")[0]?.severity).toBe("error")
57
+ expect(diagnose("kiss-partner", undefined, { subjectMinor: true })[0]?.severity).toBe("error")
58
+ expect(diagnose(["hold-current-pose", "wave-hello"]).some(x => x.code === "pose")).toBe(false)
59
+ })
60
+ it("identifies the same graph reference in both roles without confusing equal display names", () => {
61
+ const node = { id: "motion", type: "character-motion", data: { characterMotion: "hug-partner" } }
62
+ const nodes = [node, { id: "a", type: "character", data: { characterName: "Mira" } }, { id: "b", type: "character", data: { characterName: "Mira" } }]
63
+ const edges = [{ source: "a", target: "motion", targetHandle: "target" }, { source: "a", target: "motion", targetHandle: "partner" }]
64
+ const bindings = getCharacterMotionBindings(node, { nodes, edges })
65
+ expect(diagnose("hug-partner", undefined, bindings).some(e => e.code === "roles" && e.severity === "error")).toBe(true)
66
+ expect(getCharacterMotionBindings(node, { nodes, edges: [edges[0]!, { ...edges[1]!, source: "b" }] }).selfPairing).toBe(false)
67
+ })
68
+ it("passes authored metadata through catalog discovery", () => {
69
+ const option = getPickerCatalog("character-motion")?.options?.find(e => e.id === "sun-salutation")
70
+ expect(option?.motion?.kind).toBe("compound")
71
+ expect(option?.motion?.requires).toContain("floor space")
72
+ })
73
+ it("keeps neutral dance and affection available while retaining sensual flags", () => {
74
+ for (const id of ["sashay-forward", "roll-the-body", "hug-partner-from-behind", "drop-coat-off-shoulders"]) expect(getCharacterMotion(id)?.adultOnly).toBeUndefined()
75
+ for (const id of ["sultry-look-over-shoulder", "kiss-partner", "bite-lip-teasingly"]) expect(getCharacterMotion(id)?.adultOnly).toBe(true)
76
+ })
77
+ })
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Public Docs Maintenance Rule: worked examples in docs MUST match the code.
3
+ * The Character Motion page quotes the composer's exact output for one sequence
4
+ * in both hint modes; this reads the page and fails if either quote drifts.
5
+ */
6
+ import { describe, it, expect } from "vitest"
7
+ import { readFileSync } from "node:fs"
8
+ import { dirname, join } from "node:path"
9
+ import { fileURLToPath } from "node:url"
10
+ import { composeCharacterMotionHintFromConnections as compose } from "../character-motion.js"
11
+
12
+ const DOC = readFileSync(
13
+ join(dirname(fileURLToPath(import.meta.url)), "../../../../docs/nodes/parameters/character-motion.md"),
14
+ "utf8",
15
+ )
16
+ const IDS = ["walk-in-from-left", "wave-hello", "hug-partner"]
17
+ const TIMING = { position: "start", pace: "slow" } as const
18
+
19
+ describe("docs/nodes/parameters/character-motion.md worked example", () => {
20
+ it("quotes the full-mode output verbatim", () => {
21
+ expect(DOC).toContain(compose(IDS, ["Mira"], ["Theo"], TIMING))
22
+ })
23
+ it("quotes the compact-mode output verbatim", () => {
24
+ expect(DOC).toContain(compose(IDS, ["Mira"], ["Theo"], TIMING, "compact"))
25
+ })
26
+ })
@@ -0,0 +1,183 @@
1
+ /**
2
+ * `endVisibility: "out-of-frame"` coverage.
3
+ *
4
+ * The annotation is ADVISORY input to `getCharacterMotionDiagnostics`: present,
5
+ * it raises a "visibility" warning when a later move follows; absent, it raises
6
+ * nothing. Absence is therefore missing information, never a claim that the
7
+ * performer stays in shot — these tests assert what each entry DECLARES and
8
+ * what the diagnostic reports, and nothing about what a model renders.
9
+ */
10
+ import { describe, expect, it } from "vitest"
11
+ import { CHARACTER_MOTIONS, getCharacterMotion } from "../character-motion.js"
12
+ import { getCharacterMotionDiagnostics as diagnose } from "../character-motion-diagnostics.js"
13
+
14
+ /** Every entry whose authored text ends with the subject out of view. */
15
+ const DECLARED_EXITS = [
16
+ // annotated from the start
17
+ "walk-out-left",
18
+ "walk-out-right",
19
+ "run-out-of-frame",
20
+ "back-out-of-frame",
21
+ "exit-past-camera",
22
+ "duck-out-of-frame",
23
+ "exit-through-doorway",
24
+ // added after an audit found their own text already said so
25
+ "dragged-out-of-frame",
26
+ "dash-across-frame",
27
+ "walk-off-waving",
28
+ "walk-past-camera",
29
+ "lean-into-frame",
30
+ ] as const
31
+
32
+ /**
33
+ * Phrases that describe the performer leaving the frame. Used as a fail-closed
34
+ * guard, not as a classifier: a NEW entry whose text matches has to either
35
+ * declare the annotation or be added to EXIT_PHRASE_EXEMPT on purpose.
36
+ *
37
+ * WHAT IT DEFENDS. Every way the catalog and plausible new entries spell an
38
+ * exit with a frame word: "off screen" / "off-screen" / "off camera";
39
+ * "out of frame / shot / view / sight", with or without an article, which is
40
+ * what defeats a bare /out of (frame|shot)/ pattern ("runs out of THE shot");
41
+ * "leaves / exits the frame"; "disappears from view"; crossing an edge
42
+ * ("below the bottom edge of the frame" — how `duck-out-of-frame` says it);
43
+ * a trailing ", and is gone"; and the flat statements "no longer visible" and
44
+ * "lost from sight". The positive table below pins each of those spellings, so
45
+ * narrowing the pattern breaks a test rather than silently un-covering a class.
46
+ *
47
+ * WHAT IT DOES NOT DEFEND, and cannot without false positives here: an exit
48
+ * stated only by its CONSEQUENCE, with no frame word at all — "steps behind the
49
+ * pillar and is not seen again", "melts into the crowd", "recedes into the
50
+ * darkness". The obvious pattern for those, /disappears into|is gone/, fires on
51
+ * `apply-skincare` ("until it disappears into the skin") and `reel-in-leash`
52
+ * ("until the slack is gone"), both of which stay in full view. An entry that
53
+ * ends out of view and never says so in frame terms still needs a human to
54
+ * annotate it; this scan will not catch it. The negative table pins those two
55
+ * so a future widening cannot quietly re-break them.
56
+ */
57
+ const EXIT_PHRASE = new RegExp([
58
+ String.raw`\boff[ -]?(?:screen|frame|camera)\b`,
59
+ String.raw`\bout[ -]of[ -](?:the[ -])?(?:frame|shot|screen|view|sight|camera)\b`,
60
+ String.raw`\b(?:leave|leaves|leaving|exit|exits|exiting)\s+(?:the\s+)?(?:frame|shot|screen|view)\b`,
61
+ String.raw`\b(?:disappear|vanish)(?:e?s|ing|ed)?\s+(?:from|off|out[ -]of)\s+(?:the\s+)?(?:frame|shot|screen|view|sight)\b`,
62
+ String.raw`\b(?:below|past|beyond|through)\s+(?:the\s+)?(?:[\w-]+\s+){0,2}(?:edge|bottom|top|side)\s+of\s+(?:the\s+)?frame\b`,
63
+ String.raw`\b(?:and|then)\s+is\s+gone\b`,
64
+ String.raw`\b(?:no longer (?:visible|in (?:the )?(?:frame|shot|view))|(?:lost|hidden) from (?:view|sight))\b`,
65
+ String.raw`\bgone out\b`,
66
+ String.raw`\bexiting behind\b`,
67
+ String.raw`\bout the far side\b`,
68
+ ].join("|"), "i")
69
+
70
+ /**
71
+ * "Off screen" as the thing LOOKED AT, not the place the performer went:
72
+ * "an off-screen sound", "toward something off-frame", "gaze from off-frame".
73
+ * Stripped before the scan rather than exempted by id, so the four entries that
74
+ * use it — and any future one — pass without an exemption, while an entry that
75
+ * says BOTH ("hears an off-screen shout and walks off screen") still trips the
76
+ * guard on its second phrase. Deliberately narrow: only the attributive and
77
+ * "from" forms, never a verb-distance heuristic, which would suppress
78
+ * "looks back, then walks off screen".
79
+ */
80
+ const LOOKED_AT = new RegExp([
81
+ String.raw`\b(?:something|someone|somewhere|anything)\s+off[ -]?(?:screen|frame|camera)\b`,
82
+ String.raw`\boff[ -]?(?:screen|frame|camera)\s+(?:sound|noise|voice|light|source|object|person|movement|motion|thing)\b`,
83
+ String.raw`\bfrom\s+off[ -]?(?:screen|frame|camera)\b`,
84
+ ].join("|"), "gi")
85
+
86
+ /** True when the text says the PERFORMER ends out of view. */
87
+ const scansAsExit = (text: string): boolean => EXIT_PHRASE.test(text.replace(LOOKED_AT, " "))
88
+
89
+ /**
90
+ * Entries the phrase scan still matches although the SUBJECT ends in view. Each
91
+ * is a genuine exception, not an oversight:
92
+ * - peek-into-frame — the head holds in view; "the rest of the body" is
93
+ * what stays out of frame.
94
+ * - look-off-screen-* — "something out of frame" is the thing looked AT.
95
+ * (The "off-screen" spelling of the same idea needs no
96
+ * exemption — LOOKED_AT strips it — but these two say
97
+ * "out of frame", which an exit says too.)
98
+ */
99
+ const EXIT_PHRASE_EXEMPT = new Set(["peek-into-frame", "look-off-screen-left", "look-off-screen-right"])
100
+
101
+ describe("character motion — entries that declare an out-of-frame ending", () => {
102
+ it("annotates every move whose text ends with the subject out of view", () => {
103
+ for (const id of DECLARED_EXITS) {
104
+ expect(getCharacterMotion(id)?.endVisibility, id).toBe("out-of-frame")
105
+ }
106
+ })
107
+
108
+ // Deliberately NOT asserting that these twelve are the WHOLE set: a thirteenth
109
+ // entry annotating itself correctly is the outcome we want, not a test failure.
110
+ // The fail-closed guard below is what catches an entry that SHOULD annotate.
111
+
112
+ it("fails closed on a new entry whose text uses an exit phrase", () => {
113
+ const unannotated = CHARACTER_MOTIONS.filter(
114
+ (m) =>
115
+ (scansAsExit(m.promptHint) || scansAsExit(m.term ?? "")) &&
116
+ m.endVisibility !== "out-of-frame" &&
117
+ !EXIT_PHRASE_EXEMPT.has(m.id),
118
+ ).map((m) => m.id)
119
+ expect(unannotated, "annotate endVisibility, or add to EXIT_PHRASE_EXEMPT with a reason").toEqual([])
120
+ })
121
+
122
+ it("scans all twelve declared exits by phrase, not just by the pin above", () => {
123
+ // The explicit list is a pin; this asserts the GUARD would have caught each
124
+ // of them on its own, so the scan is demonstrably wide enough for the
125
+ // phrasings the catalog actually uses.
126
+ for (const id of DECLARED_EXITS) {
127
+ const entry = getCharacterMotion(id)!
128
+ expect(scansAsExit(`${entry.promptHint} ${entry.term ?? ""}`), id).toBe(true)
129
+ }
130
+ })
131
+ })
132
+
133
+ describe("character motion — what the exit-phrase scan does and does not catch", () => {
134
+ // A heuristic that looks broad and is not is worse than a narrow one: these
135
+ // two tables ARE the scan's contract. Shrink the pattern and the first fails;
136
+ // widen it carelessly and the second does.
137
+ it.each([
138
+ "the subject walks off screen to the left",
139
+ "the subject walks off-screen without looking back",
140
+ "the subject leaves the frame at a jog",
141
+ "the subject exits the shot",
142
+ "the subject strides away until out of sight",
143
+ "the subject runs out of the shot",
144
+ "the subject backs out of frame",
145
+ "the subject walks off camera",
146
+ "the subject disappears from view behind the pillar",
147
+ "the subject ducks below the bottom edge of the frame",
148
+ "the subject edges sideways along the wall and slips off screen at the left, never looking back, and is gone",
149
+ "the subject steps out the far side of the doorway",
150
+ "the subject walks away and is no longer visible",
151
+ "the subject slips into the alley, lost from sight",
152
+ ])("catches an exit phrased as %j", (text) => {
153
+ expect(scansAsExit(text)).toBe(true)
154
+ })
155
+
156
+ it.each([
157
+ // The performer stays in view; something ELSE is off screen or gone.
158
+ "the subject snaps the head toward an off-screen sound in one sharp turn",
159
+ "slides the eyes hard to one side toward something off-frame without turning the head",
160
+ "the subject lifts their gaze from off-frame and locks eyes straight into the lens",
161
+ "the subject reacts to something off-screen then swings the gaze back to the lens",
162
+ "walks a few paces forward to stop in clear view",
163
+ "rubs it in with upward circles of the fingers until it disappears into the skin",
164
+ "drawing it back to the hip until the slack is gone",
165
+ "the subject leans the head and one shoulder into view from the right edge of the frame",
166
+ ])("does not fire on %j", (text) => {
167
+ expect(scansAsExit(text)).toBe(false)
168
+ })
169
+ })
170
+
171
+ describe("character motion — the advisory visibility diagnostic", () => {
172
+ it("warns when a declared exit is sequenced before another move", () => {
173
+ for (const id of ["dragged-out-of-frame", "walk-off-waving", "walk-past-camera"]) {
174
+ const warning = diagnose([id, "wave-hello"]).find((d) => d.code === "visibility")
175
+ expect(warning?.severity, id).toBe("warning")
176
+ expect(warning?.ids, id).toEqual([id, "wave-hello"])
177
+ }
178
+ })
179
+
180
+ it("stays silent when the declared exit is last — nothing follows it", () => {
181
+ expect(diagnose(["wave-hello", "walk-off-waving"]).some((d) => d.code === "visibility")).toBe(false)
182
+ })
183
+ })
@@ -0,0 +1,225 @@
1
+ /**
2
+ * The unwired partner / counterpart referent.
3
+ *
4
+ * A two-person hint can name "the partner" up to five times, and 74 of the 100
5
+ * two-person hints name it more than once. Substituting the same INDEFINITE
6
+ * fallback at every occurrence ("another person … another person's back … as
7
+ * another person arches") reads as several different people. The composer now
8
+ * introduces the referent at its first occurrence in the composed string and
9
+ * refers back to it afterwards ("that same person"); the wired path, where the
10
+ * name is already unambiguous, is a plain global replace exactly as before.
11
+ */
12
+ import { describe, expect, it } from "vitest"
13
+ import {
14
+ CHARACTER_MOTIONS,
15
+ composeCharacterMotionHintFromConnections as compose,
16
+ getCharacterMotionPromptHint as hintOf,
17
+ getCharacterMotionTerm as termOf,
18
+ } from "../character-motion.js"
19
+
20
+ const RECIPIENT = /\bthe (partner|counterpart)\b/g
21
+ const mentions = (text: string) => (text.match(RECIPIENT) ?? []).length
22
+ /** Every entry whose text addresses a second participant, in either mode. */
23
+ const recipientEntries = CHARACTER_MOTIONS.filter(
24
+ (m) => mentions(m.promptHint) > 0 || mentions(m.term ?? "") > 0,
25
+ )
26
+
27
+ describe("character motion — the wired path is a plain global replace", () => {
28
+ it("has entries to check", () => {
29
+ expect(recipientEntries.length).toBeGreaterThan(100)
30
+ })
31
+
32
+ it("substitutes a wired name at EVERY occurrence, in both modes", () => {
33
+ for (const entry of recipientEntries) {
34
+ const named = (text: string) =>
35
+ text.replace(/\bthe subject\b/g, "Aria").replace(RECIPIENT, "Ben")
36
+ expect(compose(entry.id, ["Aria"], ["Ben"]), `${entry.id} full`).toBe(named(hintOf(entry.id)))
37
+ expect(compose(entry.id, ["Aria"], ["Ben"], undefined, "compact"), `${entry.id} compact`).toBe(
38
+ `Aria: ${named(termOf(entry.id))}`,
39
+ )
40
+ }
41
+ })
42
+
43
+ it("substitutes a wired name at every occurrence of EVERY target clause too", () => {
44
+ // Multiple targets are what the per-target referent scope touches; the wired
45
+ // path must stay a plain global replace there as well, byte for byte.
46
+ for (const entry of recipientEntries) {
47
+ const named = (text: string, subject: string) =>
48
+ text.replace(/\bthe subject\b/g, subject).replace(RECIPIENT, "Ben")
49
+ expect(compose(entry.id, ["Mira", "Ada"], ["Ben"]), `${entry.id} full`).toBe(
50
+ `${named(hintOf(entry.id), "Mira")}; separately, ${named(hintOf(entry.id), "Ada")}`,
51
+ )
52
+ expect(compose(entry.id, ["Mira", "Ada"], ["Ben"], undefined, "compact"), `${entry.id} compact`).toBe(
53
+ `Mira: ${named(termOf(entry.id), "Mira")}; separately, Ada: ${named(termOf(entry.id), "Ada")}`,
54
+ )
55
+ }
56
+ })
57
+
58
+ it("never rewrites a repeated wired name — it is already unambiguous", () => {
59
+ const repeated = recipientEntries.filter((m) => mentions(m.promptHint) > 1)
60
+ expect(repeated.length).toBeGreaterThan(70)
61
+ for (const entry of repeated) {
62
+ const out = compose(entry.id, ["Aria"], ["Ben"])
63
+ expect(out.match(/\bBen\b/g)?.length, entry.id).toBe(mentions(entry.promptHint))
64
+ expect(out, entry.id).not.toContain("that same")
65
+ }
66
+ })
67
+ })
68
+
69
+ describe("character motion — an unwired recipient is one referent", () => {
70
+ it("no authored text already contains the composed referent phrases", () => {
71
+ // Guards the counting assertions below against a hint that happens to
72
+ // spell "another person" or "that same …" on its own.
73
+ for (const entry of CHARACTER_MOTIONS) {
74
+ expect(`${entry.promptHint} ${entry.term ?? ""}`, entry.id).not.toMatch(/another person|that same/)
75
+ }
76
+ })
77
+
78
+ it("introduces 'another person' exactly once per entry and refers back after", () => {
79
+ const twoPerson = CHARACTER_MOTIONS.filter((m) => mentions(m.promptHint) > 0 && m.twoPerson)
80
+ expect(twoPerson.length).toBeGreaterThan(90)
81
+ for (const entry of twoPerson) {
82
+ const out = compose(entry.id, ["Aria"], [])
83
+ expect(out, entry.id).not.toMatch(/\bthe partner\b/)
84
+ expect(out.match(/another person/g)?.length, entry.id).toBe(1)
85
+ expect(out.match(/that same person/g)?.length ?? 0, entry.id).toBe(mentions(entry.promptHint) - 1)
86
+ }
87
+ })
88
+
89
+ it("renders possessives on both mentions", () => {
90
+ expect(compose("dip-the-partner", ["Mira"], [], undefined, "full")).toBe(
91
+ "Mira steps forward and dips another person backward, one arm supporting that same person's back " +
92
+ "as that same person arches the spine and extends one leg, holding the dip then lifting them upright",
93
+ )
94
+ })
95
+
96
+ it("shares ONE referent across the picks of a sequence", () => {
97
+ const out = compose(["dip-the-partner", "spin-partner"], ["Mira"], [])
98
+ expect(out.match(/another person/g)).toHaveLength(1)
99
+ expect(out.indexOf("another person")).toBeLessThan(out.indexOf("that same person"))
100
+ // The second pick refers back to the person the first pick introduced.
101
+ expect(out).toContain("then Mira raises that same person's hand overhead")
102
+ })
103
+
104
+ it("shares that referent in compact mode too — same rule, same scope", () => {
105
+ expect(compose(["dip-the-partner", "spin-partner"], ["Mira"], [], undefined, "compact")).toBe(
106
+ "Mira: dips another person backward, then spins that same person under the arm",
107
+ )
108
+ })
109
+
110
+ /**
111
+ * Each target performs a SEPARATE COPY of the sequence — the promise the
112
+ * `multiple-targets` diagnostic makes to the user — so the referent is scoped
113
+ * to one target's clauses, NOT to the whole composed string. Sharing it across
114
+ * the "; separately, " join made the second performer act on the first
115
+ * performer's partner: one person bridal-carried by two people at once.
116
+ */
117
+ it("gives each target its OWN partner — a separate copy of the sequence", () => {
118
+ const out = compose("carry-partner-bridal", ["Mira", "Ada"], [])
119
+ expect(out.match(/another person/g), "one introduction per target").toHaveLength(2)
120
+ expect(out).toBe(
121
+ "Mira scoops another person up in a bridal carry, one arm under that same person's knees " +
122
+ "and the other behind that same person's back, that same person's arm around Mira's neck, " +
123
+ "and walks forward carrying that same person" +
124
+ "; separately, " +
125
+ "Ada scoops another person up in a bridal carry, one arm under that same person's knees " +
126
+ "and the other behind that same person's back, that same person's arm around Ada's neck, " +
127
+ "and walks forward carrying that same person",
128
+ )
129
+ })
130
+
131
+ it("keeps BOTH scopes at once: per target, shared across that target's picks", () => {
132
+ const out = compose(["dip-the-partner", "spin-partner"], ["Mira", "Ada"], [])
133
+ // Two targets ⇒ two introductions; each target's second pick refers back to
134
+ // its own, across the other target's clause interleaved between them.
135
+ expect(out.match(/another person/g)).toHaveLength(2)
136
+ expect(out).toContain("Mira steps forward and dips another person backward")
137
+ expect(out).toContain("; separately, Ada steps forward and dips another person backward")
138
+ expect(out).toContain("then Mira raises that same person's hand overhead")
139
+ expect(out).toContain("; separately, Ada raises that same person's hand overhead")
140
+ })
141
+
142
+ it("scopes per target in compact mode on the same rule", () => {
143
+ expect(compose(["dip-the-partner", "spin-partner"], ["Mira", "Ada"], [], undefined, "compact")).toBe(
144
+ "Mira: dips another person backward; separately, Ada: dips another person backward, " +
145
+ "then Mira: spins that same person under the arm; separately, Ada: spins that same person under the arm",
146
+ )
147
+ })
148
+ })
149
+
150
+ describe("character motion — counterpart nouns are tracked per referent", () => {
151
+ it("introduces an indefinite animal noun once, then refers back", () => {
152
+ expect(compose("stroke-horse-neck", ["Mira"], [])).toBe(
153
+ "Mira lays a flat palm high on a horse's neck and sweeps it down the length of the crest " +
154
+ "in long full strokes, the other hand resting at that same horse's shoulder",
155
+ )
156
+ })
157
+
158
+ it("carries one referent across picks that declare the SAME noun", () => {
159
+ expect(compose(["pet-a-dog", "offer-a-treat"], ["Mira"], [])).toContain(
160
+ "extends an open palm holding a treat toward that same dog",
161
+ )
162
+ })
163
+
164
+ it("gives picks with DIFFERENT nouns their own introductions", () => {
165
+ const out = compose(["pet-a-dog", "stroke-horse-neck"], ["Mira"], [])
166
+ expect(out).toContain("onto a dog's head")
167
+ expect(out).toContain("high on a horse's neck")
168
+ expect(out).not.toContain("that same dog")
169
+ })
170
+
171
+ /**
172
+ * The definite-noun exemption is only REACHABLE on a second mention, and no
173
+ * single entry mentions its definite counterpart twice — so a per-entry loop
174
+ * of single picks asserts nothing: it passes with or without the branch.
175
+ * These cases compose two picks that declare the SAME definite noun, which is
176
+ * what puts a second mention in front of `laterReferenceTo`. Verified by
177
+ * mutation: widen its regex to /^(?:another|an|a|the) (.+)$/ and this block
178
+ * fails with "that same held object".
179
+ */
180
+ describe("a DEFINITE recipient noun repeats verbatim — it already names one referent", () => {
181
+ /** Definite nouns declared by two or more entries — the only way to reach a second mention. */
182
+ const definitePairs = [
183
+ ...CHARACTER_MOTIONS.filter((m) => m.counterpart?.startsWith("the ")).reduce((byNoun, m) => {
184
+ byNoun.set(m.counterpart!, [...(byNoun.get(m.counterpart!) ?? []), m.id])
185
+ return byNoun
186
+ }, new Map<string, string[]>()),
187
+ ].filter(([, ids]) => ids.length > 1)
188
+
189
+ it("has a repeatable definite noun to test — otherwise the cases below are vacuous", () => {
190
+ expect(definitePairs.length).toBeGreaterThan(0)
191
+ })
192
+
193
+ it.each(definitePairs)("repeats %j on every mention across the picks that share it", (noun, ids) => {
194
+ const picks = ids.slice(0, 3)
195
+ const out = compose(picks, ["Mira"], [])
196
+ const mentioned = picks.reduce((n, id) => n + mentions(hintOf(id)), 0)
197
+ // Non-vacuity, asserted rather than assumed: this composition really does
198
+ // put the noun in front of the composer more than once.
199
+ expect(mentioned, `${noun} is mentioned once — the exemption is not exercised`).toBeGreaterThan(1)
200
+ expect(out.match(new RegExp(noun, "g")), noun).toHaveLength(mentioned)
201
+ expect(out, noun).not.toContain("that same")
202
+ })
203
+
204
+ it("spells the two-pick held-object case out in full", () => {
205
+ expect(compose(["set-object-down", "hold-object-still"], ["Mira"], [])).toBe(
206
+ "Mira lowers the held object onto the nearby supporting surface, releases it and draws both hands away, " +
207
+ "then Mira keeps the held object steady in both hands without changing its orientation",
208
+ )
209
+ })
210
+
211
+ it("rewrites an INDEFINITE noun in the very same two-pick shape", () => {
212
+ // The contrast that proves the exemption is a real branch and not a
213
+ // property of two-pick composition: same shape, indefinite noun, rewritten.
214
+ expect(compose(["pet-a-dog", "offer-a-treat"], ["Mira"], [])).toContain("that same dog")
215
+ })
216
+
217
+ it("still repeats a definite noun verbatim in a single pick", () => {
218
+ for (const entry of CHARACTER_MOTIONS.filter((m) => m.counterpart?.startsWith("the "))) {
219
+ const out = compose(entry.id, ["Mira"], [])
220
+ expect(out, entry.id).toContain(entry.counterpart!)
221
+ expect(out, entry.id).not.toContain("that same")
222
+ }
223
+ })
224
+ })
225
+ })