brane 0.1.1 → 0.3.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 (348) hide show
  1. package/AGENTS.md +362 -0
  2. package/CHANGELOG.md +33 -0
  3. package/CONTRIBUTING.md +91 -0
  4. package/DESIGN.md +402 -0
  5. package/Dockerfile +61 -0
  6. package/LICENSE +656 -17
  7. package/README.md +174 -2
  8. package/SECURITY.md +205 -0
  9. package/dist/absorb/brane-id.js +11 -0
  10. package/dist/absorb/chatgpt.js +161 -0
  11. package/dist/absorb/claude-code.js +504 -0
  12. package/dist/absorb/index.js +543 -0
  13. package/dist/absorb/organelle.js +345 -0
  14. package/dist/absorb/run.js +520 -0
  15. package/dist/absorb/sources.js +168 -0
  16. package/dist/absorb/types.js +3 -0
  17. package/dist/agent-env.js +45 -0
  18. package/dist/agents.js +1168 -0
  19. package/dist/claude-live-session.js +124 -0
  20. package/dist/cli.js +50 -0
  21. package/dist/codex-tool-log.js +144 -0
  22. package/dist/commands/absorb.js +291 -0
  23. package/dist/commands/attach.js +31 -0
  24. package/dist/commands/certify.js +62 -0
  25. package/dist/commands/config.js +116 -0
  26. package/dist/commands/cost.js +126 -0
  27. package/dist/commands/daemon.js +350 -0
  28. package/dist/commands/doctor.js +262 -0
  29. package/dist/commands/fold.js +95 -0
  30. package/dist/commands/list.js +89 -0
  31. package/dist/commands/mcp.js +84 -0
  32. package/dist/commands/meld.js +332 -0
  33. package/dist/commands/memory.js +215 -0
  34. package/dist/commands/revive.js +74 -0
  35. package/dist/commands/run.js +85 -0
  36. package/dist/commands/spawn.js +1094 -0
  37. package/dist/commands/token.js +153 -0
  38. package/dist/copilot-tool-log.js +145 -0
  39. package/dist/cost/pricing.js +116 -0
  40. package/dist/cost/reprice.js +136 -0
  41. package/dist/crush-tool-log.js +293 -0
  42. package/dist/daemon/agent-scope.js +116 -0
  43. package/dist/daemon/awaiting.js +54 -0
  44. package/dist/daemon/cert-manager.js +349 -0
  45. package/dist/daemon/client.js +262 -0
  46. package/dist/daemon/gated-routes.js +112 -0
  47. package/dist/daemon/keep-awake.js +103 -0
  48. package/dist/daemon/local-socket.js +145 -0
  49. package/dist/daemon/presence-gate.js +206 -0
  50. package/dist/daemon/server.js +6349 -0
  51. package/dist/daemon/signal-source.js +35 -0
  52. package/dist/daemon/tailscale-rebind.js +58 -0
  53. package/dist/daemon/token-ring.js +263 -0
  54. package/dist/daemon/token-routes.js +190 -0
  55. package/dist/db/events.js +152 -0
  56. package/dist/db/mentions.js +179 -0
  57. package/dist/db/schema.js +633 -0
  58. package/dist/db/tokens.js +583 -0
  59. package/dist/db/tool-uses.js +647 -0
  60. package/dist/diagram/index.js +85 -0
  61. package/dist/diagram/label.js +455 -0
  62. package/dist/diagram/layout.js +476 -0
  63. package/dist/diagram/links.js +50 -0
  64. package/dist/diagram/metrics.js +152 -0
  65. package/dist/diagram/parse.js +487 -0
  66. package/dist/diagram/svg.js +389 -0
  67. package/dist/diagram/types.js +9 -0
  68. package/dist/export.js +629 -0
  69. package/dist/files/archive-types.js +2 -0
  70. package/dist/files/archive.js +182 -0
  71. package/dist/files/browse.js +327 -0
  72. package/dist/files/bytes.js +162 -0
  73. package/dist/files/codecs.js +33 -0
  74. package/dist/files/find.js +212 -0
  75. package/dist/files/tar.js +223 -0
  76. package/dist/files/zip.js +209 -0
  77. package/dist/folds.js +72 -0
  78. package/dist/grammars/ebnf.js +92 -0
  79. package/dist/highlight.js +250 -0
  80. package/dist/ids.js +24 -0
  81. package/dist/markdown-diagnostics.js +233 -0
  82. package/dist/math.js +168 -0
  83. package/dist/mcp/server.js +886 -0
  84. package/dist/memory/embedder.js +145 -0
  85. package/dist/memory/embedding-worker.js +71 -0
  86. package/dist/memory/embeddings.js +91 -0
  87. package/dist/memory/index.js +232 -0
  88. package/dist/memory/storage.js +587 -0
  89. package/dist/memory/wiki.js +688 -0
  90. package/dist/memory/wordpiece.js +247 -0
  91. package/dist/mentions-scan.js +40 -0
  92. package/dist/presence.js +259 -0
  93. package/dist/session-affinity.js +185 -0
  94. package/dist/shared/api-request.js +124 -0
  95. package/dist/shared/block-glyphs.js +114 -0
  96. package/dist/shared/diagram-css.js +17 -0
  97. package/dist/shared/export-selection.js +101 -0
  98. package/dist/shared/find-in-text.js +110 -0
  99. package/dist/shared/glow-hues.js +225 -0
  100. package/dist/shared/hex-dump.js +62 -0
  101. package/dist/shared/hex-pages.js +52 -0
  102. package/dist/shared/hit-drum.js +79 -0
  103. package/dist/shared/known-nids.js +58 -0
  104. package/dist/shared/list-scroll-keys.js +56 -0
  105. package/dist/shared/markdown-lists.js +72 -0
  106. package/dist/shared/markdown-math.js +485 -0
  107. package/dist/shared/markdown-mentions.js +79 -0
  108. package/dist/shared/markdown-nids.js +41 -0
  109. package/dist/shared/markdown-protocols.js +53 -0
  110. package/dist/shared/markdown-refs.js +123 -0
  111. package/dist/shared/membrane-invasion.js +342 -0
  112. package/dist/shared/membrane-motion.js +83 -0
  113. package/dist/shared/membrane-palette.js +253 -0
  114. package/dist/shared/membrane-terrain.js +212 -0
  115. package/dist/shared/message-selection.js +123 -0
  116. package/dist/shared/mo-glyph.js +57 -0
  117. package/dist/shared/narrative-format.js +237 -0
  118. package/dist/shared/narrative-menu.js +82 -0
  119. package/dist/shared/narrative-sort.js +47 -0
  120. package/dist/shared/narrative-status.js +52 -0
  121. package/dist/shared/no-pan.js +85 -0
  122. package/dist/shared/pane-layout.js +50 -0
  123. package/dist/shared/payload-inline.js +83 -0
  124. package/dist/shared/speaker-color.js +61 -0
  125. package/dist/shared/token-rotation.js +14 -0
  126. package/dist/shared/view-url.js +197 -0
  127. package/dist/shared/wiki-links.js +70 -0
  128. package/dist/shared/wiki-window.js +4 -0
  129. package/dist/tailscale.js +55 -0
  130. package/dist/test/absorb-claude.test.js +892 -0
  131. package/dist/test/agent-scope.test.js +193 -0
  132. package/dist/test/agents.test.js +55 -0
  133. package/dist/test/api-request.test.js +122 -0
  134. package/dist/test/awaiting-glow.test.js +54 -0
  135. package/dist/test/awaiting.test.js +122 -0
  136. package/dist/test/bind-token.test.js +88 -0
  137. package/dist/test/block-glyphs.test.js +68 -0
  138. package/dist/test/cert-manager.test.js +262 -0
  139. package/dist/test/claude-live-session.test.js +110 -0
  140. package/dist/test/codex-tool-log.test.js +568 -0
  141. package/dist/test/config.test.js +71 -0
  142. package/dist/test/copilot-tool-log.test.js +93 -0
  143. package/dist/test/cors-preflight.test.js +49 -0
  144. package/dist/test/cost-linger.test.js +31 -0
  145. package/dist/test/cost-pricing.test.js +72 -0
  146. package/dist/test/cost-reprice.test.js +139 -0
  147. package/dist/test/cost-sessions.test.js +301 -0
  148. package/dist/test/crush-tool-log.test.js +289 -0
  149. package/dist/test/daemon-client.test.js +109 -0
  150. package/dist/test/daemon-socket.test.js +293 -0
  151. package/dist/test/diagram-arrows.test.js +205 -0
  152. package/dist/test/diagram-export.test.js +210 -0
  153. package/dist/test/diagram-labels.test.js +313 -0
  154. package/dist/test/diagram-parse.test.js +191 -0
  155. package/dist/test/diagram-render.test.js +175 -0
  156. package/dist/test/diagram-review.test.js +288 -0
  157. package/dist/test/diagram-self-loop-labels.test.js +303 -0
  158. package/dist/test/embedder.test.js +84 -0
  159. package/dist/test/events.test.js +123 -0
  160. package/dist/test/export-selection.test.js +102 -0
  161. package/dist/test/export.test.js +505 -0
  162. package/dist/test/files-browse.test.js +393 -0
  163. package/dist/test/files-find.test.js +272 -0
  164. package/dist/test/find-in-text.test.js +86 -0
  165. package/dist/test/first-paint-viewport.test.js +59 -0
  166. package/dist/test/fixtures/embedding-golden.js +23 -0
  167. package/dist/test/fixtures/wordpiece-golden.js +167 -0
  168. package/dist/test/glow-hues.test.js +202 -0
  169. package/dist/test/harness-cost.test.js +423 -0
  170. package/dist/test/helpers.js +21 -0
  171. package/dist/test/hex-pages.test.js +68 -0
  172. package/dist/test/highlight.test.js +69 -0
  173. package/dist/test/hit-drum.test.js +71 -0
  174. package/dist/test/hook-config.test.js +262 -0
  175. package/dist/test/json-gzip.test.js +70 -0
  176. package/dist/test/key-routing.test.js +58 -0
  177. package/dist/test/known-nids.test.js +106 -0
  178. package/dist/test/link-preview.test.js +67 -0
  179. package/dist/test/list-scroll-keys.test.js +66 -0
  180. package/dist/test/listen-own-words.test.js +147 -0
  181. package/dist/test/listen-timeout.test.js +33 -0
  182. package/dist/test/markdown-diagnostics.test.js +121 -0
  183. package/dist/test/markdown-lists.test.js +182 -0
  184. package/dist/test/markdown-math.test.js +256 -0
  185. package/dist/test/markdown-nids.test.js +146 -0
  186. package/dist/test/markdown-protocols.test.js +139 -0
  187. package/dist/test/mcp-bridge-cancel.test.js +98 -0
  188. package/dist/test/mcp-bridge-reconnect.test.js +121 -0
  189. package/dist/test/mcp-narrative.test.js +193 -0
  190. package/dist/test/mcp.test.js +548 -0
  191. package/dist/test/meld.test.js +85 -0
  192. package/dist/test/membrane-invasion.test.js +482 -0
  193. package/dist/test/membrane-motion.test.js +101 -0
  194. package/dist/test/membrane-palette.test.js +166 -0
  195. package/dist/test/membrane-terrain.test.js +170 -0
  196. package/dist/test/memory-log.test.js +202 -0
  197. package/dist/test/mentions-e2e.test.js +261 -0
  198. package/dist/test/mentions-ledger.test.js +265 -0
  199. package/dist/test/mentions-listen.test.js +265 -0
  200. package/dist/test/mentions.test.js +238 -0
  201. package/dist/test/message-selection.test.js +114 -0
  202. package/dist/test/mo-glyph.test.js +22 -0
  203. package/dist/test/model-resolution.test.js +166 -0
  204. package/dist/test/narrate-validate.test.js +77 -0
  205. package/dist/test/narrate-wait.test.js +160 -0
  206. package/dist/test/narrative-format.test.js +219 -0
  207. package/dist/test/narrative-menu.test.js +137 -0
  208. package/dist/test/narrative-sort.test.js +75 -0
  209. package/dist/test/no-pan.test.js +60 -0
  210. package/dist/test/pane-layout.test.js +153 -0
  211. package/dist/test/paths.test.js +37 -0
  212. package/dist/test/payload-inline.test.js +91 -0
  213. package/dist/test/precache-fonts.test.js +72 -0
  214. package/dist/test/presence-gate.test.js +440 -0
  215. package/dist/test/presence.test.js +160 -0
  216. package/dist/test/read-events.test.js +220 -0
  217. package/dist/test/remote-meld.test.js +115 -0
  218. package/dist/test/revive-cli.test.js +93 -0
  219. package/dist/test/search-floor.test.js +206 -0
  220. package/dist/test/search-fusion.test.js +168 -0
  221. package/dist/test/search.test.js +205 -0
  222. package/dist/test/security.test.js +48 -0
  223. package/dist/test/selection-wiring.test.js +61 -0
  224. package/dist/test/session-affinity.test.js +295 -0
  225. package/dist/test/signal-source.test.js +32 -0
  226. package/dist/test/socket-account.test.js +268 -0
  227. package/dist/test/spawn-toml.test.js +72 -0
  228. package/dist/test/speaker-color.test.js +90 -0
  229. package/dist/test/tailscale-rebind.test.js +79 -0
  230. package/dist/test/terminal-cursor.test.js +61 -0
  231. package/dist/test/terminal-resync.test.js +23 -0
  232. package/dist/test/thin-cli.test.js +382 -0
  233. package/dist/test/token-bootstrap.test.js +272 -0
  234. package/dist/test/token-devices.test.js +587 -0
  235. package/dist/test/token-lapse.test.js +94 -0
  236. package/dist/test/token-ring.test.js +188 -0
  237. package/dist/test/token-rotate-cli.test.js +58 -0
  238. package/dist/test/token-routes.test.js +95 -0
  239. package/dist/test/tool-uses.test.js +1006 -0
  240. package/dist/test/usage-ledger.test.js +126 -0
  241. package/dist/test/view-url.test.js +581 -0
  242. package/dist/test/wiki-links.test.js +57 -0
  243. package/dist/test/wiki-sources.test.js +85 -0
  244. package/dist/test/wiki-window.test.js +31 -0
  245. package/dist/test/wiki.test.js +399 -0
  246. package/dist/test/wordpiece.test.js +68 -0
  247. package/dist/tls.js +148 -0
  248. package/dist/tmux.js +202 -0
  249. package/dist/user-agent.js +46 -0
  250. package/docker-entrypoint.sh +141 -0
  251. package/docs/README.md +3 -0
  252. package/docs/absorb.md +231 -0
  253. package/docs/cost-ledger.md +125 -0
  254. package/docs/daemon-api.md +189 -0
  255. package/docs/diagrams.md +163 -0
  256. package/docs/doctor.md +36 -0
  257. package/docs/export-scope.md +69 -0
  258. package/docs/files-view.md +133 -0
  259. package/docs/link-previews.md +38 -0
  260. package/docs/math.md +32 -0
  261. package/docs/mcp.md +69 -0
  262. package/docs/memory-search.md +75 -0
  263. package/docs/mentions.md +101 -0
  264. package/docs/multi-brane.md +14 -0
  265. package/docs/passkeys.md +165 -0
  266. package/docs/presence.md +202 -0
  267. package/docs/releasing.md +53 -0
  268. package/docs/rendering-warnings.md +22 -0
  269. package/docs/request-layer.md +72 -0
  270. package/docs/selection-store.md +50 -0
  271. package/docs/session-affinity.md +61 -0
  272. package/docs/terminal-mirroring.md +211 -0
  273. package/docs/token-rotation.md +413 -0
  274. package/docs/view-urls.md +114 -0
  275. package/docs/web-app.md +23 -0
  276. package/docs/wiki.md +43 -0
  277. package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -0
  278. package/expo/dist/apple-touch-icon.png +0 -0
  279. package/expo/dist/favicon.svg +3 -0
  280. package/expo/dist/index.html +177 -0
  281. package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  282. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  283. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  284. package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  285. package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  286. package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  287. package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  288. package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  289. package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  290. package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  291. package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  292. package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  293. package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  294. package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  295. package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  296. package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  297. package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  298. package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  299. package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  300. package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  301. package/expo/dist/katex/katex.min.css +1 -0
  302. package/expo/dist/manifest.json +16 -0
  303. package/expo/dist/metadata.json +1 -0
  304. package/expo/dist/og/membrane.v1.png +0 -0
  305. package/expo/dist/pdf.min.mjs +28 -0
  306. package/expo/dist/pdf.worker.min.mjs +28 -0
  307. package/expo/dist/sw.js +154 -0
  308. package/package.json +79 -27
  309. package/protocols/LIVE_loop.md +407 -0
  310. package/protocols/bebrief.md +41 -0
  311. package/protocols/digest.md +231 -0
  312. package/protocols/fly.md +266 -0
  313. package/protocols/idle.md +81 -0
  314. package/protocols/keeper.md +829 -0
  315. package/protocols/markdown.md +80 -0
  316. package/protocols/memory.md +419 -0
  317. package/protocols/mentions.md +93 -0
  318. package/protocols/payloads.md +53 -0
  319. package/protocols/prelude.md +91 -0
  320. package/protocols/ramify.md +105 -0
  321. package/protocols/review.md +356 -0
  322. package/protocols/rrr.md +232 -0
  323. package/protocols/scribe.md +429 -0
  324. package/protocols/summarize.md +203 -0
  325. package/protocols/twin.md +174 -0
  326. package/protocols/wiki.md +438 -0
  327. package/protocols/worker.md +38 -0
  328. package/wiki/shared/append-only-event-log.md +148 -0
  329. package/wiki/shared/index.md +48 -0
  330. package/wiki/shared/owning-your-narratives.md +161 -0
  331. package/wiki/shared/premature-ontologies.md +435 -0
  332. package/wiki/shared/sedimentary-superintelligence.md +128 -0
  333. package/wiki/shared/sequestering-the-narrative.md +143 -0
  334. package/wiki/shared/why-wikis-work.md +216 -0
  335. package/.travis.yml +0 -7
  336. package/lib/brane.cjs.js +0 -12
  337. package/lib/brane.cjs.js.map +0 -1
  338. package/lib/brane.d.ts +0 -11
  339. package/lib/brane.js +0 -6
  340. package/lib/brane.js.map +0 -1
  341. package/lib/tests.d.ts +0 -1
  342. package/lib/tests.js +0 -8
  343. package/lib/tests.js.map +0 -1
  344. package/rollup.config.js +0 -21
  345. package/scripts/test.sh +0 -8
  346. package/src/brane.ts +0 -21
  347. package/src/tests.ts +0 -8
  348. package/tsconfig.json +0 -16
@@ -0,0 +1,482 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { BURST_S, COVER_MARGIN, TAP_EDGE, CRITICAL_PRESSURE, MAX_SPEED, MAX_SPLOTCHES, MAX_SPLOTCH_RADIUS, RISE, SPLOTCH_TEXELS, TAP_RADIUS, WASH_DELAY_S, WASH_SPEED, WOBBLE, adopt, autoDue, covers, emptyFilm, frontSpeed, invade, packSplotches, reach, sdAt, shift, step, tap, touching, washing, } from "../shared/membrane-invasion.js";
4
+ // What the operator asked for: a tap's color spreads "much more slowly and
5
+ // (seemingly) with struggle" (claude-1cwe8u9p:49); "one splotch should die
6
+ // out, and two that touch should prevail" (:58, :62); splotches oblong and
7
+ // irregular, with no limit a hand runs into (:61); and what prevails washes
8
+ // back into the slow upward flow, in its colors, rather than freezing into
9
+ // a patchwork (:73, :74, :77). These pin outcomes — who heals, who washes
10
+ // in, what never happens — not the exact curves, which are tuning (:63).
11
+ function mulberry32(a) {
12
+ return () => {
13
+ a |= 0;
14
+ a = (a + 0x6d2b79f5) | 0;
15
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
16
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
17
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
18
+ };
19
+ }
20
+ const BOX = [];
21
+ for (let i = 0; i < 20; i++)
22
+ for (let j = 0; j < 20; j++)
23
+ BOX.push([-0.45 + (0.9 * i) / 19, -0.45 + (0.9 * j) / 19]);
24
+ const DT = 1 / 30;
25
+ /** Just beside the latest splotch, in direction (dx, dy): outside its outline
26
+ * by a little, and near enough that the tap's own spot overlaps it. A tap
27
+ * inside the outline would recolor it instead (claude-u6qlei0j:4). */
28
+ const beside = (dx, dy, s) => (film) => {
29
+ const at = s ?? film.splotches.at(-1);
30
+ const n = Math.hypot(dx, dy), d = reach(at, at.x + dx, at.y + dy) + 0.04;
31
+ return [at.x + (dx / n) * d, at.y + (dy / n) * d];
32
+ };
33
+ /** Run taps [time, x, y] (or [time, where]) at 30 fps for up to `seconds`,
34
+ * the way the engine does — adopting a wash once it covers the glyph — and
35
+ * check every step that no front jumps and no splotch outgrows its cap
36
+ * until it washes. */
37
+ function run(taps, seconds, seed = 1, similar) {
38
+ const rng = mulberry32(seed);
39
+ let film = emptyFilm(), next = 0, washedIn = null, healedAt = null;
40
+ for (let t = 0; t < seconds; t += DT) {
41
+ while (next < taps.length && taps[next][0] <= t) {
42
+ const spec = taps[next];
43
+ const [x, y] = spec.length === 3 ? [spec[1], spec[2]] : spec[1](film);
44
+ film = tap(film, x, y, rng).film;
45
+ next++;
46
+ }
47
+ const prev = film;
48
+ film = step(film, DT, similar);
49
+ for (const s of film.splotches) {
50
+ const was = prev.splotches.find((p) => p.phase === s.phase);
51
+ const g = film.groups.find((g) => g.id === s.group);
52
+ assert.ok(Math.abs(s.r - was.r) <= (Math.max(MAX_SPEED, WASH_SPEED) + WOBBLE) * DT + 1e-12, "a front never jumps");
53
+ if (!washing(g, film.clock))
54
+ assert.ok(s.r <= MAX_SPLOTCH_RADIUS + 1e-12, "a splotch outgrows its cap only by washing");
55
+ }
56
+ const won = film.groups.find((g) => washing(g, film.clock) && covers(film, g.id, BOX));
57
+ if (won) {
58
+ washedIn ??= t;
59
+ film = adopt(film, won.id);
60
+ }
61
+ if (next >= taps.length && film.splotches.length === 0 && healedAt === null && washedIn === null)
62
+ healedAt = t;
63
+ }
64
+ return { film, washedIn, healedAt };
65
+ }
66
+ /** Over several seeds, since every splotch's outline is random. */
67
+ const SEEDS = [1, 2, 3, 4, 5];
68
+ describe("a single tap", () => {
69
+ it("turns a spot at once where it lands", () => {
70
+ const { film, started } = tap(emptyFilm(), 0.1, -0.2, mulberry32(1));
71
+ assert.ok(started, "a first tap starts a group, for which the caller draws a scheme");
72
+ assert.deepEqual([film.splotches[0].x, film.splotches[0].y, film.splotches[0].r], [0.1, -0.2, TAP_RADIUS]);
73
+ assert.ok(sdAt(film.splotches[0], 0.1, -0.2) < 0, "its own centre is inside it");
74
+ });
75
+ it("dies out when it touches nothing, and never washes in", () => {
76
+ for (const seed of SEEDS) {
77
+ const o = run([[0, 0, 0]], 150, seed);
78
+ assert.equal(o.washedIn, null, `seed ${seed}`);
79
+ assert.ok(o.healedAt !== null && o.healedAt < 90, `seed ${seed}: healed at ${o.healedAt}`);
80
+ }
81
+ });
82
+ });
83
+ describe("two that touch prevail, and wash in", () => {
84
+ it("when a burst's taps overlap: the film becomes their colors in tens of seconds", () => {
85
+ for (const seed of SEEDS) {
86
+ const o = run([[0, 0, 0], [0.5, beside(1, 0)]], 120, seed);
87
+ assert.ok(o.washedIn !== null && o.washedIn > WASH_DELAY_S && o.washedIn < 30, `seed ${seed}: ${o.washedIn}`);
88
+ assert.equal(o.film.splotches.length, 0, "and then it is just the film again");
89
+ }
90
+ });
91
+ it("from anywhere in the glyph, top or bottom", () => {
92
+ for (const y of [-0.35, 0.35]) {
93
+ const o = run([[0, -0.05, y], [0.5, beside(1, 0)]], 120);
94
+ assert.ok(o.washedIn !== null && o.washedIn < 40, `from y ${y}: ${o.washedIn}`);
95
+ }
96
+ });
97
+ it("but not when a burst's taps are too far apart ever to meet", () => {
98
+ for (const seed of SEEDS) {
99
+ const o = run([[0, -0.4, -0.4], [0.5, 0.4, 0.4]], 300, seed);
100
+ assert.equal(o.washedIn, null, `seed ${seed}`);
101
+ assert.equal(o.film.splotches.length, 0);
102
+ }
103
+ });
104
+ it("nor when they overlap but came from bursts with different colors", () => {
105
+ for (const seed of SEEDS) {
106
+ const rng = mulberry32(seed);
107
+ let film = tap(emptyFilm(), 0, 0, rng).film;
108
+ for (let t = 0; t < BURST_S + 2; t += DT)
109
+ film = step(film, DT);
110
+ // A second color, tapped elsewhere and then moved onto the first: no
111
+ // tap can put two colors together (beside joins), but a front can grow
112
+ // into a neighbor's ground.
113
+ const b = tap(film, 0.4, 0.4, rng);
114
+ film = { ...b.film, splotches: b.film.splotches.map((s) => (s.group === b.started.id ? { ...s, x: 0.06, y: 0 } : s)) };
115
+ assert.equal(film.groups.length, 2, `seed ${seed}: a second color`);
116
+ assert.ok(touching(film.splotches[0], film.splotches[1]), `seed ${seed}: they overlap`);
117
+ let washed = false;
118
+ for (let t = 0; t < 300; t += DT) {
119
+ film = step(film, DT);
120
+ washed ||= film.groups.some((g) => washing(g, film.clock));
121
+ }
122
+ assert.equal(washed, false, `seed ${seed}`);
123
+ assert.equal(film.splotches.length, 0);
124
+ }
125
+ });
126
+ it("across bursts, when their colors are similar", () => {
127
+ assert.notEqual(run([[0, 0, 0], [BURST_S + 2, 0.1, 0]], 120, 1, () => true).washedIn, null);
128
+ });
129
+ it("struggles first: the wash starts WASH_DELAY_S after the splotches connect", () => {
130
+ const rng = mulberry32(1);
131
+ let film = tap(emptyFilm(), 0, 0, rng).film;
132
+ film = tap(film, 0.1, 0, rng).film;
133
+ film = step(film, DT);
134
+ const g = film.groups[0];
135
+ assert.ok(g.washAt !== null);
136
+ assert.ok(Math.abs(g.washAt - (DT + WASH_DELAY_S)) < 1e-9);
137
+ assert.equal(washing(g, film.clock), false);
138
+ assert.equal(washing(g, g.washAt), true);
139
+ });
140
+ it("rises: a washing outline reaches farther up than down", () => {
141
+ const rng = mulberry32(3);
142
+ let film = tap(emptyFilm(), 0, 0, rng).film;
143
+ film = tap(film, ...beside(1, 0)(film), rng).film;
144
+ for (let t = 0; t < WASH_DELAY_S + 3; t += DT)
145
+ film = step(film, DT);
146
+ const s = film.splotches[0];
147
+ assert.equal(s.rise, RISE);
148
+ const round = { ...s, shape: { angle: 0, aspect: 1, lobes: [0, 0, 0], phases: [0, 0, 0] } };
149
+ assert.ok(Math.abs(reach(round, 0, -1) / reach(round, 0, 1) - RISE) < 1e-9, "y is down: up is -y");
150
+ });
151
+ });
152
+ describe("preview, then commit (claude-1cwe8u9p:86)", () => {
153
+ it("a tap beside a splotch, however much later, adds to its color and washes it in", () => {
154
+ for (const seed of SEEDS) {
155
+ const rng = mulberry32(seed);
156
+ let r = tap(emptyFilm(), 0, 0, rng);
157
+ let film = r.film;
158
+ const liked = r.film.groups[0].id;
159
+ for (let t = 0; t < 8; t += DT)
160
+ film = step(film, DT); // look at it a while
161
+ const s = film.splotches[0];
162
+ r = tap(film, s.x + reach(s, 1, 0) + 0.04, s.y, rng); // like it: tap just beside it
163
+ assert.equal(r.started, null, `seed ${seed}: no new color`);
164
+ assert.equal(r.recolored, null, `seed ${seed}: not a recolor`);
165
+ film = r.film;
166
+ assert.equal(film.splotches.length, 2, `seed ${seed}: a second splotch`);
167
+ assert.equal(film.splotches.at(-1).group, liked);
168
+ let washedIn = null;
169
+ for (let t = 0; t < 60 && washedIn === null; t += DT) {
170
+ film = step(film, DT);
171
+ if (film.groups.some((g) => g.id === liked && washing(g, film.clock) && covers(film, g.id, BOX)))
172
+ washedIn = t;
173
+ }
174
+ assert.ok(washedIn !== null && washedIn < 40, `seed ${seed}: ${washedIn}`);
175
+ }
176
+ });
177
+ it("a later tap on bare film is a new color to preview", () => {
178
+ const rng = mulberry32(1);
179
+ let film = tap(emptyFilm(), -0.3, -0.3, rng).film;
180
+ for (let t = 0; t < BURST_S + 1; t += DT)
181
+ film = step(film, DT);
182
+ assert.ok(tap(film, 0.35, 0.35, rng).started);
183
+ });
184
+ it("the topmost splotch under the finger decides", () => {
185
+ const rng = mulberry32(2);
186
+ let film = tap(emptyFilm(), 0, 0, rng).film; // A, beneath
187
+ for (let t = 0; t < BURST_S + 1; t += DT)
188
+ film = step(film, DT);
189
+ const b = tap(film, 0.4, 0.4, rng); // B, elsewhere
190
+ film = b.film;
191
+ film = { ...film, splotches: film.splotches.map((s) => (s.group === b.started.id ? { ...s, x: 0, y: 0 } : s)) };
192
+ for (let t = 0; t < BURST_S + 1; t += DT)
193
+ film = step(film, DT);
194
+ const r = tap(film, 0, 0, rng);
195
+ assert.equal(r.recolored?.id, b.started.id, "B paints over A there, so the tap is B's");
196
+ assert.equal(r.started, null);
197
+ });
198
+ });
199
+ describe("on a splotch recolors it; beside it joins it (claude-u6qlei0j:4, :7)", () => {
200
+ it("a tap on the spot just tapped: the same splotches, another color, no new area or pressure", () => {
201
+ for (const seed of SEEDS) {
202
+ const rng = mulberry32(seed);
203
+ let film = tap(emptyFilm(), 0.1, 0.1, rng).film;
204
+ const id = film.groups[0].id;
205
+ film = step(film, 1);
206
+ const r = tap(film, 0.1, 0.1, rng);
207
+ assert.equal(r.recolored?.id, id, `seed ${seed}`);
208
+ assert.equal(r.started, null);
209
+ assert.equal(r.film.splotches.length, 1);
210
+ assert.equal(r.film.groups[0].pressure, film.groups[0].pressure);
211
+ }
212
+ });
213
+ it("can be repeated at any pace, and the splotch still heals alone", () => {
214
+ for (const seed of SEEDS) {
215
+ const o = run([[0, 0, 0], [0.5, 0, 0], [3, 0, 0], [7, 0, 0]], 150, seed);
216
+ assert.equal(o.washedIn, null, `seed ${seed}`);
217
+ assert.ok(o.healedAt !== null, `seed ${seed}`);
218
+ }
219
+ });
220
+ it("a tap on the edge is beside, not on: a finger meaning more of this lands there too", () => {
221
+ for (const seed of SEEDS) {
222
+ const rng = mulberry32(seed);
223
+ let film = tap(emptyFilm(), 0, 0, rng).film;
224
+ film = step(film, 1);
225
+ const s = film.splotches[0];
226
+ const edge = reach(s, 1, 0);
227
+ const inside = tap(film, edge - TAP_EDGE / 2, 0, rng);
228
+ assert.equal(inside.recolored, null, `seed ${seed}`);
229
+ assert.equal(inside.started, null);
230
+ assert.equal(inside.film.splotches.length, 2, "joined");
231
+ const deeper = tap(film, edge - TAP_EDGE * 2, 0, rng);
232
+ assert.equal(deeper.recolored?.id, s.group, `seed ${seed}: recolored`);
233
+ }
234
+ });
235
+ });
236
+ describe("a wash is closed (claude-u6qlei0j:3)", () => {
237
+ /** Tap, then commit beside it, then step until the wash is under way and big. */
238
+ function midWash(rng) {
239
+ let film = tap(emptyFilm(), 0, 0, rng).film;
240
+ film = step(film, 1);
241
+ const s = film.splotches[0];
242
+ film = tap(film, s.x + reach(s, 1, 0) + 0.04, s.y, rng).film;
243
+ for (let t = 0; t < 60 && !film.groups.some((g) => washing(g, film.clock)); t += DT)
244
+ film = step(film, DT);
245
+ for (let t = 0; t < 3; t += DT)
246
+ film = step(film, DT);
247
+ assert.ok(film.groups.some((g) => washing(g, film.clock)), "washing");
248
+ assert.ok(film.splotches.some((s) => sdAt(s, 0, 0) < -TAP_EDGE), "and the tap point is well inside it");
249
+ return film;
250
+ }
251
+ it("a tap during a wash is a new color over it, not a recolor or a join, and outlives the wash", () => {
252
+ for (const seed of SEEDS) {
253
+ const rng = mulberry32(seed);
254
+ let film = midWash(rng);
255
+ const wash = film.groups.find((g) => washing(g, film.clock));
256
+ const r = tap(film, 0, 0, rng);
257
+ assert.ok(r.started, `seed ${seed}: a new color`);
258
+ assert.equal(r.recolored, null);
259
+ assert.notEqual(r.started.id, wash.id);
260
+ film = r.film;
261
+ let t = 0;
262
+ for (; t < 60 && !covers(film, wash.id, BOX); t += DT)
263
+ film = step(film, DT);
264
+ assert.ok(t < 60, `seed ${seed}: the wash still finishes`);
265
+ assert.ok(film.groups.some((g) => g.id === r.started.id), `seed ${seed}: the new color is still there when it does`);
266
+ film = adopt(film, wash.id);
267
+ assert.ok(film.groups.some((g) => g.id === r.started.id), `seed ${seed}: and after`);
268
+ }
269
+ });
270
+ it("a quick tap after the one that committed does not join the wash either", () => {
271
+ const rng = mulberry32(3);
272
+ const film = midWash(rng);
273
+ // The committing tap was seconds ago, but make sure the burst rule alone
274
+ // would have joined: pretend it was just now.
275
+ const wash = film.groups.find((g) => washing(g, film.clock));
276
+ const recent = { ...film, groups: film.groups.map((g) => (g.id === wash.id ? { ...g, lastTap: film.clock } : g)) };
277
+ assert.ok(tap(recent, 0.4, 0.4, rng).started, "a new group, not the wash's");
278
+ });
279
+ it("the automatic change is closed too", () => {
280
+ const rng = mulberry32(1);
281
+ let film = invade(emptyFilm(), 0, 0, rng).film;
282
+ for (let t = 0; t < 5; t += DT)
283
+ film = step(film, DT);
284
+ assert.ok(sdAt(film.splotches[0], 0, 0) < -TAP_EDGE, "the tap lands inside it");
285
+ const r = tap(film, 0, 0, rng);
286
+ assert.ok(r.started, "a new color over it");
287
+ assert.equal(r.film.groups.length, 2);
288
+ });
289
+ });
290
+ describe("splotches are not circles", () => {
291
+ it("each has its own stretched, lobed outline", () => {
292
+ const rng = mulberry32(4);
293
+ let film = emptyFilm();
294
+ for (let i = 0; i < 6; i++)
295
+ film = tap(film, 0, 0, rng).film;
296
+ const shapes = film.splotches.map((s) => s.shape);
297
+ assert.ok(shapes.every((sh) => sh.aspect >= 1.2 && sh.aspect <= 2.4));
298
+ assert.equal(new Set(shapes.map((sh) => sh.angle)).size, shapes.length, "no two alike");
299
+ // Reach differs by direction: long one way, short the other.
300
+ const s = film.splotches[0];
301
+ const reaches = Array.from({ length: 36 }, (_, k) => reach(s, s.x + Math.cos(k / 36 * 2 * Math.PI), s.y + Math.sin(k / 36 * 2 * Math.PI)));
302
+ assert.ok(Math.max(...reaches) > 1.3 * Math.min(...reaches), `${Math.min(...reaches)}..${Math.max(...reaches)}`);
303
+ });
304
+ it("reach and sdAt agree: the boundary point in any direction is on the outline, risen or not", () => {
305
+ const rng = mulberry32(8);
306
+ const flat = tap(emptyFilm(), 0.1, 0.05, rng).film.splotches[0];
307
+ for (const s of [flat, { ...flat, rise: 2.4 }]) {
308
+ for (let k = 0; k < 24; k++) {
309
+ const a = (k / 24) * 2 * Math.PI, dx = Math.cos(a), dy = Math.sin(a);
310
+ const rr = reach(s, s.x + dx, s.y + dy);
311
+ assert.ok(Math.abs(sdAt(s, s.x + dx * rr, s.y + dy * rr)) < 1e-9);
312
+ }
313
+ }
314
+ });
315
+ it("touching follows the outlines, not the centres' distance alone", () => {
316
+ const rng = mulberry32(2);
317
+ const base = tap(emptyFilm(), 0, 0, rng).film.splotches[0];
318
+ const long = { ...base, r: 0.15, shape: { angle: 0, aspect: 2.4, lobes: [0, 0, 0], phases: [0, 0, 0] } };
319
+ const other = { ...long, phase: 7 };
320
+ // Along the long axis they reach ~0.23 each; across it, ~0.10.
321
+ assert.equal(touching(long, { ...other, x: 0.4, y: 0 }), true);
322
+ assert.equal(touching(long, { ...other, x: 0, y: 0.4 }), false);
323
+ });
324
+ });
325
+ describe("no patchwork", () => {
326
+ it("bursts that prevail one after another each become the film in turn, leaving nothing behind", () => {
327
+ const o = run([[0, -0.2, 0], [0.4, -0.1, 0], [8, 0.2, 0.2], [8.4, 0.3, 0.2]], 200);
328
+ assert.notEqual(o.washedIn, null);
329
+ assert.equal(o.film.splotches.length, 0, "no stain outlives its wash");
330
+ });
331
+ });
332
+ describe("no limits a hand will meet", () => {
333
+ it("a tap always lands, even at the table's limit", () => {
334
+ const rng = mulberry32(1);
335
+ let film = emptyFilm();
336
+ for (let i = 0; i < MAX_SPLOTCHES + 10; i++) {
337
+ film = tap(film, (i % 8) * 0.1 - 0.4, Math.floor(i / 8) * 0.1 - 0.4, rng).film;
338
+ assert.equal(film.splotches.at(-1).x, (i % 8) * 0.1 - 0.4, `tap ${i} landed`);
339
+ // Shrunk, so the next tap on the grid is never on one of these.
340
+ film = { ...film, splotches: film.splotches.map((s) => ({ ...s, r: 0.001 })) };
341
+ }
342
+ assert.equal(film.splotches.length, MAX_SPLOTCHES);
343
+ });
344
+ it("at the limit, the smallest unanchored splotch gives way", () => {
345
+ const rng = mulberry32(1);
346
+ let film = emptyFilm();
347
+ for (let i = 0; i < MAX_SPLOTCHES; i++) {
348
+ film = tap(film, 0, 0, rng).film;
349
+ film = { ...film, splotches: film.splotches.map((s) => ({ ...s, r: 0.001 })) }; // never on the last
350
+ }
351
+ film = { ...film, splotches: film.splotches.map((s, i) => ({ ...s, r: 0.1 + i * 0.001, anchoredAt: i === 0 ? 0.1 : null })) };
352
+ const anchored = film.splotches[0], smallestFree = film.splotches[1];
353
+ film = tap(film, 0.45, -0.45, rng).film; // far from all of them
354
+ assert.ok(film.splotches.some((s) => s.phase === anchored.phase), "the anchored one, though smallest, stays");
355
+ assert.ok(!film.splotches.some((s) => s.phase === smallestFree.phase), "the smallest unanchored one gave way");
356
+ });
357
+ it("taps within BURST_S join one group, one scheme; a later tap starts another", () => {
358
+ const rng = mulberry32(1);
359
+ let r = tap(emptyFilm(), 0, 0, rng);
360
+ let film = step(r.film, 1);
361
+ r = tap(film, 0.1, 0.1, rng);
362
+ assert.equal(r.started, null);
363
+ assert.equal(r.film.groups.length, 1);
364
+ film = r.film;
365
+ for (let t = 0; t < BURST_S + 0.5; t += DT)
366
+ film = step(film, DT);
367
+ assert.ok(tap(film, 0.3, 0.3, rng).started);
368
+ });
369
+ });
370
+ describe("no global discontinuities (claude-1cwe8u9p:81)", () => {
371
+ it("adopting a wash drops the groups beneath it, which it hides, and keeps those above", () => {
372
+ const rng = mulberry32(1);
373
+ let film = tap(emptyFilm(), -0.3, 0, rng).film; // older: beneath
374
+ for (let t = 0; t < BURST_S + 0.5; t += DT)
375
+ film = step(film, DT);
376
+ const r = invade(film, 0, 0, rng); // the wash
377
+ film = r.film;
378
+ for (let t = 0; t < BURST_S + 0.5; t += DT)
379
+ film = step(film, DT);
380
+ film = tap(film, 0.3, 0, rng).film; // newer: above
381
+ const [older, , newer] = film.groups;
382
+ const after = adopt(film, r.started.id);
383
+ assert.deepEqual(after.groups.map((g) => g.id), [newer.id]);
384
+ assert.ok(after.splotches.every((s) => s.group === newer.id));
385
+ assert.ok(!after.groups.some((g) => g.id === older.id));
386
+ });
387
+ it("composites by group, oldest at the bottom, whatever order the splotches came in", () => {
388
+ const rng = mulberry32(2);
389
+ let film = tap(emptyFilm(), 0, 0, rng).film; // group A
390
+ for (let t = 0; t < BURST_S + 0.5; t += DT)
391
+ film = step(film, DT);
392
+ film = tap(film, 0.2, 0, rng).film; // group B
393
+ const [a] = film.groups;
394
+ // A later tap joining the older group A (as a similar-scheme burst might).
395
+ film = { ...film, splotches: [...film.splotches, { ...film.splotches[0], x: 0.4, phase: 99 }] };
396
+ const out = packSplotches(film, (id) => (id === a.id ? 1 : 2));
397
+ const rowsInOrder = [0, 1, 2].map((i) => out[i * SPLOTCH_TEXELS * 4 + 3]);
398
+ assert.deepEqual(rowsInOrder, [1, 1, 2], "both of A's splotches beneath B's");
399
+ });
400
+ it("a wash is only adopted once it covers the glyph by more than its fronts can wander", () => {
401
+ assert.ok(COVER_MARGIN >= 0.16 * 0.4 + 0.012, "dithering plus edge");
402
+ });
403
+ it("the automatic change grows from nothing, with no tap to explain a spot", () => {
404
+ const r = invade(emptyFilm(), 0.1, 0.1, mulberry32(3));
405
+ assert.ok(r.film.splotches[0].r <= 0.01);
406
+ });
407
+ });
408
+ describe("the automatic change", () => {
409
+ it("wins from anywhere in the glyph, in tens of seconds, and then leaves the film", () => {
410
+ for (const [x, y] of [[0, 0], [0.4, 0.4], [-0.4, 0.3]]) {
411
+ const rng = mulberry32(7);
412
+ let film = invade(emptyFilm(), x, y, rng).film;
413
+ const id = film.groups[0].id;
414
+ let t = 0;
415
+ for (; t < 90 && !covers(film, id, BOX); t += DT)
416
+ film = step(film, DT);
417
+ assert.ok(t > 5 && t < 45, `from (${x}, ${y}): ${t.toFixed(1)} s`);
418
+ assert.equal(adopt(film, id).splotches.length, 0);
419
+ }
420
+ });
421
+ it("happens once at a time", () => {
422
+ const rng = mulberry32(1);
423
+ const film = invade(emptyFilm(), 0, 0, rng).film;
424
+ assert.equal(invade(film, 0.2, 0.2, rng), null);
425
+ });
426
+ });
427
+ describe("returning after an absence (claude-xaj5pb9b:18)", () => {
428
+ it("the automatic change is due on the first frame back, however still the film's clock stood", () => {
429
+ // Ten seconds of frames, then the tab is hidden: no frames are drawn, so
430
+ // the film's clock stands at ten while the wall clock runs on past the
431
+ // hold. What the operator sees on return is the invasion planting at once.
432
+ let film = emptyFilm();
433
+ for (let t = 0; t < 10; t += DT)
434
+ film = step(film, DT);
435
+ const holdUntil = 60;
436
+ assert.ok(film.clock < holdUntil);
437
+ assert.ok(!autoDue(film, holdUntil, 10));
438
+ assert.ok(autoDue(film, holdUntil, 95));
439
+ // Planted, it is under way, and no second one is due until it is done.
440
+ const r = invade(film, 0, 0, mulberry32(1));
441
+ assert.ok(r);
442
+ assert.ok(!autoDue(r.film, holdUntil, 95));
443
+ assert.ok(!autoDue(r.film, holdUntil, 1e6));
444
+ });
445
+ });
446
+ describe("frontSpeed", () => {
447
+ it("holds still at critical pressure, advances above it, retreats more slowly below", () => {
448
+ assert.equal(frontSpeed(CRITICAL_PRESSURE), 0);
449
+ assert.equal(frontSpeed(10), MAX_SPEED);
450
+ assert.ok(frontSpeed(0) < 0 && -frontSpeed(0) < MAX_SPEED);
451
+ });
452
+ });
453
+ describe("packSplotches", () => {
454
+ it("lays each splotch out as the shader reads it, padded with zero radius", () => {
455
+ const { film } = tap(emptyFilm(), 0.25, -0.125, mulberry32(3));
456
+ const s = film.splotches[0];
457
+ const out = packSplotches(film, () => 5);
458
+ assert.equal(out.length, MAX_SPLOTCHES * SPLOTCH_TEXELS * 4);
459
+ const f = Math.fround;
460
+ assert.deepEqual([...out.slice(0, 16)], [
461
+ 0.25, -0.125, f(TAP_RADIUS), 5,
462
+ f(s.shape.angle), f(s.shape.aspect), f(s.shape.lobes[0]), f(s.shape.lobes[1]),
463
+ f(s.shape.lobes[2]), f(s.shape.phases[0]), f(s.shape.phases[1]), f(s.shape.phases[2]),
464
+ 1, 0, 0, 0,
465
+ ]);
466
+ assert.ok(out.slice(16).every((v) => v === 0));
467
+ });
468
+ });
469
+ describe("a drag (claude-u6qlei0j:23)", () => {
470
+ it("moves every splotch by the same distance and changes nothing else", () => {
471
+ const rng = mulberry32(1);
472
+ let film = tap(emptyFilm(), 0, 0, rng).film;
473
+ film = step(film, 1);
474
+ film = tap(film, 0.3, -0.2, rng).film;
475
+ const moved = shift(film, 0.1, -0.05);
476
+ assert.deepEqual(moved.splotches.map((s) => [s.x, s.y]), film.splotches.map((s) => [s.x + 0.1, s.y - 0.05]));
477
+ assert.deepEqual(moved.splotches.map(({ x, y, ...rest }) => rest), film.splotches.map(({ x, y, ...rest }) => rest));
478
+ assert.deepEqual({ ...moved, splotches: [] }, { ...film, splotches: [] });
479
+ assert.equal(shift(film, 0, 0), film);
480
+ });
481
+ });
482
+ //# sourceMappingURL=membrane-invasion.test.js.map
@@ -0,0 +1,101 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { MEMBRANE_MOTION, MOTION_MAX_DT_S, NOISE_SCALE, dragMotion, initialMotionState, membraneMotion, stepMotion, } from "../shared/membrane-motion.js";
4
+ // The failure this module exists to prevent is a jump: the shader used to
5
+ // compute the film's position as `rate * t`, where changing the rate with t in
6
+ // the hundreds of seconds teleports the whole field. Integrating the position
7
+ // makes a jump impossible by construction, and these pin that — plus the
8
+ // defaults the operator chose, since they are a decision, not a derivation.
9
+ function run(state, target, seconds, dt = 1 / 60) {
10
+ for (let t = 0; t < seconds; t += dt)
11
+ state = stepMotion(state, target, dt);
12
+ return state;
13
+ }
14
+ describe("membraneMotion", () => {
15
+ it("defaults to awake, rising at half the lift #465 shipped with", () => {
16
+ assert.deepEqual(membraneMotion(), MEMBRANE_MOTION.awake);
17
+ assert.equal(membraneMotion().lift, 0.14 / 2);
18
+ });
19
+ it("does not rise when dormant, and still churns", () => {
20
+ const m = membraneMotion("dormant");
21
+ assert.equal(m.lift, 0);
22
+ assert.ok(m.churn > 0);
23
+ });
24
+ it("lets an explicit rate override the preset, and only that rate", () => {
25
+ const m = membraneMotion("dormant", { lift: 0.2 });
26
+ assert.equal(m.lift, 0.2);
27
+ assert.equal(m.churn, MEMBRANE_MOTION.dormant.churn);
28
+ assert.equal(m.sway, MEMBRANE_MOTION.dormant.sway);
29
+ // An override that is undefined (a prop left off) is not an override.
30
+ assert.deepEqual(membraneMotion("awake", { lift: undefined }), MEMBRANE_MOTION.awake);
31
+ });
32
+ });
33
+ describe("stepMotion", () => {
34
+ it("never moves the film by more than the fastest rate times the step", () => {
35
+ const fast = { lift: 5, sway: 5, churn: 50 };
36
+ let s = run(initialMotionState(MEMBRANE_MOTION.awake), MEMBRANE_MOTION.awake, 300);
37
+ // Retarget from a film that has been running five minutes: under `rate * t`
38
+ // this is where the field would leap by ~1500 units.
39
+ for (const target of [fast, MEMBRANE_MOTION.dormant, MEMBRANE_MOTION.awake]) {
40
+ const next = stepMotion(s, target, 1 / 60);
41
+ assert.ok(Math.abs(next.offset[1] - s.offset[1]) <= 5 / 60);
42
+ assert.ok(Math.abs(next.offset[0] - s.offset[0]) <= 5 / 60);
43
+ assert.ok(Math.abs(next.phase - s.phase) <= 50 / 60);
44
+ s = next;
45
+ }
46
+ });
47
+ it("eases to a new motion within about a second", () => {
48
+ let s = initialMotionState(MEMBRANE_MOTION.dormant);
49
+ s = run(s, MEMBRANE_MOTION.awake, 0.1);
50
+ assert.ok(s.motion.lift > 0 && s.motion.lift < MEMBRANE_MOTION.awake.lift, "partway after 0.1 s");
51
+ s = run(s, MEMBRANE_MOTION.awake, 1.1);
52
+ assert.ok(Math.abs(s.motion.lift - MEMBRANE_MOTION.awake.lift) < 0.05 * MEMBRANE_MOTION.awake.lift);
53
+ assert.ok(Math.abs(s.motion.churn - MEMBRANE_MOTION.awake.churn) < 0.05 * MEMBRANE_MOTION.awake.churn);
54
+ });
55
+ it("rises while awake and holds its height while dormant", () => {
56
+ let s = run(initialMotionState(MEMBRANE_MOTION.awake), MEMBRANE_MOTION.awake, 10);
57
+ assert.ok(Math.abs(s.offset[1] - 0.7) < 0.01, `10 s awake rises ~0.7, got ${s.offset[1]}`);
58
+ s = run(s, MEMBRANE_MOTION.dormant, 3); // ease down
59
+ const settled = s.offset[1];
60
+ s = run(s, MEMBRANE_MOTION.dormant, 10);
61
+ assert.ok(s.offset[1] - settled < 1e-3, "no further rise once dormant");
62
+ assert.ok(s.phase > 0, "the swirl keeps turning");
63
+ });
64
+ it("steps a long frame as at most MOTION_MAX_DT_S", () => {
65
+ const s0 = initialMotionState(MEMBRANE_MOTION.awake);
66
+ const long = stepMotion(s0, MEMBRANE_MOTION.awake, 30);
67
+ const capped = stepMotion(s0, MEMBRANE_MOTION.awake, MOTION_MAX_DT_S);
68
+ assert.deepEqual(long, capped);
69
+ // And a negative dt (a clock that went backwards) moves nothing.
70
+ assert.deepEqual(stepMotion(s0, MEMBRANE_MOTION.awake, -1).offset, [0, 0]);
71
+ });
72
+ });
73
+ describe("a hand on the film (claude-u6qlei0j:6)", () => {
74
+ it("stops the drift at once while held, keeps churning, and eases back in on release", () => {
75
+ let s = run(initialMotionState(MEMBRANE_MOTION.awake), MEMBRANE_MOTION.awake, 5);
76
+ const heldFrom = s.offset;
77
+ const phase = s.phase;
78
+ s = stepMotion(s, MEMBRANE_MOTION.awake, 1 / 60, true);
79
+ assert.deepEqual(s.offset, heldFrom, "the first held frame moves nothing");
80
+ assert.deepEqual([s.motion.lift, s.motion.sway], [0, 0]);
81
+ for (let t = 0; t < 3; t += 1 / 60)
82
+ s = stepMotion(s, MEMBRANE_MOTION.awake, 1 / 60, true);
83
+ assert.deepEqual(s.offset, heldFrom, "nor any later one");
84
+ assert.ok(s.phase > phase, "the swirl keeps turning under the hand");
85
+ // Released: the rise comes back the way an awakening does, not all at once.
86
+ const first = stepMotion(s, MEMBRANE_MOTION.awake, 1 / 60);
87
+ assert.ok(first.motion.lift > 0 && first.motion.lift < MEMBRANE_MOTION.awake.lift * 0.2);
88
+ s = run(s, MEMBRANE_MOTION.awake, 1.2);
89
+ assert.ok(Math.abs(s.motion.lift - MEMBRANE_MOTION.awake.lift) < 0.05 * MEMBRANE_MOTION.awake.lift);
90
+ assert.ok(s.offset[1] > heldFrom[1], "and it rises again");
91
+ });
92
+ it("a drag moves the window against the finger, scaled to the noise, and nothing else", () => {
93
+ const s = run(initialMotionState(MEMBRANE_MOTION.awake), MEMBRANE_MOTION.awake, 2);
94
+ const d = dragMotion(s, 0.1, -0.25);
95
+ assert.deepEqual(d.offset, [s.offset[0] - NOISE_SCALE * 0.1, s.offset[1] + NOISE_SCALE * 0.25]);
96
+ assert.equal(d.phase, s.phase);
97
+ assert.deepEqual(d.motion, s.motion);
98
+ assert.deepEqual(dragMotion(s, 0, 0), s);
99
+ });
100
+ });
101
+ //# sourceMappingURL=membrane-motion.test.js.map