@cogcoin/client 0.5.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 (289) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/dist/app-paths.d.ts +38 -0
  4. package/dist/app-paths.js +121 -0
  5. package/dist/art/banner.txt +13 -0
  6. package/dist/art/scroll.txt +13 -0
  7. package/dist/art/train-car.txt +6 -0
  8. package/dist/art/train-smoke.txt +6 -0
  9. package/dist/art/train.txt +6 -0
  10. package/dist/bitcoind/bootstrap/chainstate.d.ts +4 -0
  11. package/dist/bitcoind/bootstrap/chainstate.js +13 -0
  12. package/dist/bitcoind/bootstrap/constants.d.ts +7 -0
  13. package/dist/bitcoind/bootstrap/constants.js +12 -0
  14. package/dist/bitcoind/bootstrap/controller.d.ts +29 -0
  15. package/dist/bitcoind/bootstrap/controller.js +101 -0
  16. package/dist/bitcoind/bootstrap/download.d.ts +2 -0
  17. package/dist/bitcoind/bootstrap/download.js +196 -0
  18. package/dist/bitcoind/bootstrap/headers.d.ts +13 -0
  19. package/dist/bitcoind/bootstrap/headers.js +61 -0
  20. package/dist/bitcoind/bootstrap/paths.d.ts +4 -0
  21. package/dist/bitcoind/bootstrap/paths.js +15 -0
  22. package/dist/bitcoind/bootstrap/snapshot-file.d.ts +7 -0
  23. package/dist/bitcoind/bootstrap/snapshot-file.js +42 -0
  24. package/dist/bitcoind/bootstrap/state.d.ts +40 -0
  25. package/dist/bitcoind/bootstrap/state.js +70 -0
  26. package/dist/bitcoind/bootstrap/types.d.ts +28 -0
  27. package/dist/bitcoind/bootstrap/types.js +1 -0
  28. package/dist/bitcoind/bootstrap.d.ts +8 -0
  29. package/dist/bitcoind/bootstrap.js +7 -0
  30. package/dist/bitcoind/client/factory.d.ts +3 -0
  31. package/dist/bitcoind/client/factory.js +57 -0
  32. package/dist/bitcoind/client/follow-block-times.d.ts +8 -0
  33. package/dist/bitcoind/client/follow-block-times.js +25 -0
  34. package/dist/bitcoind/client/follow-loop.d.ts +10 -0
  35. package/dist/bitcoind/client/follow-loop.js +57 -0
  36. package/dist/bitcoind/client/internal-types.d.ts +63 -0
  37. package/dist/bitcoind/client/internal-types.js +18 -0
  38. package/dist/bitcoind/client/managed-client.d.ts +20 -0
  39. package/dist/bitcoind/client/managed-client.js +197 -0
  40. package/dist/bitcoind/client/rate-tracker.d.ts +2 -0
  41. package/dist/bitcoind/client/rate-tracker.js +24 -0
  42. package/dist/bitcoind/client/sync-engine.d.ts +3 -0
  43. package/dist/bitcoind/client/sync-engine.js +143 -0
  44. package/dist/bitcoind/client.d.ts +1 -0
  45. package/dist/bitcoind/client.js +1 -0
  46. package/dist/bitcoind/errors.d.ts +1 -0
  47. package/dist/bitcoind/errors.js +49 -0
  48. package/dist/bitcoind/index.d.ts +2 -0
  49. package/dist/bitcoind/index.js +1 -0
  50. package/dist/bitcoind/indexer-daemon-main.d.ts +1 -0
  51. package/dist/bitcoind/indexer-daemon-main.js +472 -0
  52. package/dist/bitcoind/indexer-daemon.d.ts +107 -0
  53. package/dist/bitcoind/indexer-daemon.js +391 -0
  54. package/dist/bitcoind/node.d.ts +8 -0
  55. package/dist/bitcoind/node.js +219 -0
  56. package/dist/bitcoind/normalize.d.ts +3 -0
  57. package/dist/bitcoind/normalize.js +47 -0
  58. package/dist/bitcoind/progress/assets.d.ts +10 -0
  59. package/dist/bitcoind/progress/assets.js +90 -0
  60. package/dist/bitcoind/progress/constants.d.ts +48 -0
  61. package/dist/bitcoind/progress/constants.js +53 -0
  62. package/dist/bitcoind/progress/controller.d.ts +28 -0
  63. package/dist/bitcoind/progress/controller.js +188 -0
  64. package/dist/bitcoind/progress/follow-scene.d.ts +40 -0
  65. package/dist/bitcoind/progress/follow-scene.js +367 -0
  66. package/dist/bitcoind/progress/formatting.d.ts +23 -0
  67. package/dist/bitcoind/progress/formatting.js +227 -0
  68. package/dist/bitcoind/progress/quote-scene.d.ts +4 -0
  69. package/dist/bitcoind/progress/quote-scene.js +137 -0
  70. package/dist/bitcoind/progress/train-scene.d.ts +9 -0
  71. package/dist/bitcoind/progress/train-scene.js +92 -0
  72. package/dist/bitcoind/progress/tty-renderer.d.ts +18 -0
  73. package/dist/bitcoind/progress/tty-renderer.js +150 -0
  74. package/dist/bitcoind/progress.d.ts +7 -0
  75. package/dist/bitcoind/progress.js +7 -0
  76. package/dist/bitcoind/quotes.d.ts +24 -0
  77. package/dist/bitcoind/quotes.js +195 -0
  78. package/dist/bitcoind/rpc.d.ts +71 -0
  79. package/dist/bitcoind/rpc.js +322 -0
  80. package/dist/bitcoind/service-paths.d.ts +19 -0
  81. package/dist/bitcoind/service-paths.js +49 -0
  82. package/dist/bitcoind/service.d.ts +40 -0
  83. package/dist/bitcoind/service.js +735 -0
  84. package/dist/bitcoind/testing.d.ts +9 -0
  85. package/dist/bitcoind/testing.js +9 -0
  86. package/dist/bitcoind/types.d.ts +396 -0
  87. package/dist/bitcoind/types.js +3 -0
  88. package/dist/bytes.d.ts +9 -0
  89. package/dist/bytes.js +36 -0
  90. package/dist/cli/commands/follow.d.ts +2 -0
  91. package/dist/cli/commands/follow.js +43 -0
  92. package/dist/cli/commands/mining-admin.d.ts +2 -0
  93. package/dist/cli/commands/mining-admin.js +92 -0
  94. package/dist/cli/commands/mining-read.d.ts +2 -0
  95. package/dist/cli/commands/mining-read.js +173 -0
  96. package/dist/cli/commands/mining-runtime.d.ts +2 -0
  97. package/dist/cli/commands/mining-runtime.js +108 -0
  98. package/dist/cli/commands/status.d.ts +2 -0
  99. package/dist/cli/commands/status.js +31 -0
  100. package/dist/cli/commands/sync.d.ts +2 -0
  101. package/dist/cli/commands/sync.js +52 -0
  102. package/dist/cli/commands/wallet-admin.d.ts +2 -0
  103. package/dist/cli/commands/wallet-admin.js +175 -0
  104. package/dist/cli/commands/wallet-mutation.d.ts +2 -0
  105. package/dist/cli/commands/wallet-mutation.js +681 -0
  106. package/dist/cli/commands/wallet-read.d.ts +2 -0
  107. package/dist/cli/commands/wallet-read.js +265 -0
  108. package/dist/cli/context.d.ts +3 -0
  109. package/dist/cli/context.js +75 -0
  110. package/dist/cli/io.d.ts +3 -0
  111. package/dist/cli/io.js +12 -0
  112. package/dist/cli/mining-format.d.ts +5 -0
  113. package/dist/cli/mining-format.js +156 -0
  114. package/dist/cli/mining-json.d.ts +49 -0
  115. package/dist/cli/mining-json.js +89 -0
  116. package/dist/cli/mutation-command-groups.d.ts +15 -0
  117. package/dist/cli/mutation-command-groups.js +71 -0
  118. package/dist/cli/mutation-json.d.ts +430 -0
  119. package/dist/cli/mutation-json.js +311 -0
  120. package/dist/cli/mutation-resolved-json.d.ts +124 -0
  121. package/dist/cli/mutation-resolved-json.js +129 -0
  122. package/dist/cli/mutation-success.d.ts +20 -0
  123. package/dist/cli/mutation-success.js +47 -0
  124. package/dist/cli/mutation-text-format.d.ts +22 -0
  125. package/dist/cli/mutation-text-format.js +171 -0
  126. package/dist/cli/mutation-text-write.d.ts +13 -0
  127. package/dist/cli/mutation-text-write.js +16 -0
  128. package/dist/cli/output.d.ts +185 -0
  129. package/dist/cli/output.js +1085 -0
  130. package/dist/cli/parse.d.ts +3 -0
  131. package/dist/cli/parse.js +971 -0
  132. package/dist/cli/preview-json.d.ts +416 -0
  133. package/dist/cli/preview-json.js +293 -0
  134. package/dist/cli/prompt.d.ts +3 -0
  135. package/dist/cli/prompt.js +33 -0
  136. package/dist/cli/read-json.d.ts +187 -0
  137. package/dist/cli/read-json.js +675 -0
  138. package/dist/cli/runner.d.ts +2 -0
  139. package/dist/cli/runner.js +129 -0
  140. package/dist/cli/signals.d.ts +3 -0
  141. package/dist/cli/signals.js +63 -0
  142. package/dist/cli/status-format.d.ts +2 -0
  143. package/dist/cli/status-format.js +48 -0
  144. package/dist/cli/types.d.ts +148 -0
  145. package/dist/cli/types.js +2 -0
  146. package/dist/cli/wallet-format.d.ts +29 -0
  147. package/dist/cli/wallet-format.js +637 -0
  148. package/dist/cli/workflow-hints.d.ts +13 -0
  149. package/dist/cli/workflow-hints.js +94 -0
  150. package/dist/cli-runner.d.ts +3 -0
  151. package/dist/cli-runner.js +3 -0
  152. package/dist/cli.d.ts +2 -0
  153. package/dist/cli.js +6 -0
  154. package/dist/client/default-client.d.ts +11 -0
  155. package/dist/client/default-client.js +118 -0
  156. package/dist/client/factory.d.ts +2 -0
  157. package/dist/client/factory.js +15 -0
  158. package/dist/client/initialization.d.ts +6 -0
  159. package/dist/client/initialization.js +30 -0
  160. package/dist/client/persistence.d.ts +5 -0
  161. package/dist/client/persistence.js +28 -0
  162. package/dist/client/store-adapter.d.ts +3 -0
  163. package/dist/client/store-adapter.js +20 -0
  164. package/dist/client.d.ts +2 -0
  165. package/dist/client.js +2 -0
  166. package/dist/index.d.ts +2 -0
  167. package/dist/index.js +1 -0
  168. package/dist/passive-status.d.ts +36 -0
  169. package/dist/passive-status.js +100 -0
  170. package/dist/sqlite/better-sqlite3.d.ts +26 -0
  171. package/dist/sqlite/better-sqlite3.js +4 -0
  172. package/dist/sqlite/checkpoints.d.ts +11 -0
  173. package/dist/sqlite/checkpoints.js +27 -0
  174. package/dist/sqlite/driver.d.ts +17 -0
  175. package/dist/sqlite/driver.js +98 -0
  176. package/dist/sqlite/index.d.ts +4 -0
  177. package/dist/sqlite/index.js +9 -0
  178. package/dist/sqlite/migrate.d.ts +2 -0
  179. package/dist/sqlite/migrate.js +37 -0
  180. package/dist/sqlite/store.d.ts +3 -0
  181. package/dist/sqlite/store.js +122 -0
  182. package/dist/sqlite/tip-meta.d.ts +26 -0
  183. package/dist/sqlite/tip-meta.js +97 -0
  184. package/dist/sqlite/types.d.ts +10 -0
  185. package/dist/sqlite/types.js +1 -0
  186. package/dist/types.d.ts +55 -0
  187. package/dist/types.js +1 -0
  188. package/dist/wallet/archive.d.ts +4 -0
  189. package/dist/wallet/archive.js +39 -0
  190. package/dist/wallet/cogop/constants.d.ts +32 -0
  191. package/dist/wallet/cogop/constants.js +32 -0
  192. package/dist/wallet/cogop/index.d.ts +32 -0
  193. package/dist/wallet/cogop/index.js +213 -0
  194. package/dist/wallet/cogop/numeric.d.ts +3 -0
  195. package/dist/wallet/cogop/numeric.js +24 -0
  196. package/dist/wallet/cogop/scriptpubkey.d.ts +2 -0
  197. package/dist/wallet/cogop/scriptpubkey.js +13 -0
  198. package/dist/wallet/cogop/validate-name.d.ts +2 -0
  199. package/dist/wallet/cogop/validate-name.js +18 -0
  200. package/dist/wallet/fs/atomic.d.ts +6 -0
  201. package/dist/wallet/fs/atomic.js +46 -0
  202. package/dist/wallet/fs/lock.d.ts +19 -0
  203. package/dist/wallet/fs/lock.js +61 -0
  204. package/dist/wallet/fs/status-file.d.ts +1 -0
  205. package/dist/wallet/fs/status-file.js +4 -0
  206. package/dist/wallet/lifecycle.d.ts +193 -0
  207. package/dist/wallet/lifecycle.js +1475 -0
  208. package/dist/wallet/material.d.ts +45 -0
  209. package/dist/wallet/material.js +118 -0
  210. package/dist/wallet/mining/config.d.ts +18 -0
  211. package/dist/wallet/mining/config.js +44 -0
  212. package/dist/wallet/mining/constants.d.ts +24 -0
  213. package/dist/wallet/mining/constants.js +24 -0
  214. package/dist/wallet/mining/control.d.ts +53 -0
  215. package/dist/wallet/mining/control.js +758 -0
  216. package/dist/wallet/mining/coordination.d.ts +40 -0
  217. package/dist/wallet/mining/coordination.js +121 -0
  218. package/dist/wallet/mining/hook-protocol.d.ts +47 -0
  219. package/dist/wallet/mining/hook-protocol.js +161 -0
  220. package/dist/wallet/mining/hook-runner.d.ts +1 -0
  221. package/dist/wallet/mining/hook-runner.js +52 -0
  222. package/dist/wallet/mining/hooks.d.ts +38 -0
  223. package/dist/wallet/mining/hooks.js +520 -0
  224. package/dist/wallet/mining/index.d.ts +8 -0
  225. package/dist/wallet/mining/index.js +6 -0
  226. package/dist/wallet/mining/runner.d.ts +155 -0
  227. package/dist/wallet/mining/runner.js +2574 -0
  228. package/dist/wallet/mining/runtime-artifacts.d.ts +17 -0
  229. package/dist/wallet/mining/runtime-artifacts.js +166 -0
  230. package/dist/wallet/mining/sentences.d.ts +23 -0
  231. package/dist/wallet/mining/sentences.js +281 -0
  232. package/dist/wallet/mining/state.d.ts +9 -0
  233. package/dist/wallet/mining/state.js +75 -0
  234. package/dist/wallet/mining/types.d.ts +141 -0
  235. package/dist/wallet/mining/types.js +1 -0
  236. package/dist/wallet/mining/visualizer.d.ts +19 -0
  237. package/dist/wallet/mining/visualizer.js +134 -0
  238. package/dist/wallet/mining/worker-main.d.ts +1 -0
  239. package/dist/wallet/mining/worker-main.js +17 -0
  240. package/dist/wallet/read/context.d.ts +20 -0
  241. package/dist/wallet/read/context.js +532 -0
  242. package/dist/wallet/read/filter.d.ts +9 -0
  243. package/dist/wallet/read/filter.js +42 -0
  244. package/dist/wallet/read/index.d.ts +4 -0
  245. package/dist/wallet/read/index.js +3 -0
  246. package/dist/wallet/read/project.d.ts +11 -0
  247. package/dist/wallet/read/project.js +300 -0
  248. package/dist/wallet/read/types.d.ts +144 -0
  249. package/dist/wallet/read/types.js +1 -0
  250. package/dist/wallet/runtime.d.ts +26 -0
  251. package/dist/wallet/runtime.js +28 -0
  252. package/dist/wallet/state/crypto.d.ts +31 -0
  253. package/dist/wallet/state/crypto.js +127 -0
  254. package/dist/wallet/state/provider.d.ts +37 -0
  255. package/dist/wallet/state/provider.js +312 -0
  256. package/dist/wallet/state/session.d.ts +12 -0
  257. package/dist/wallet/state/session.js +23 -0
  258. package/dist/wallet/state/storage.d.ts +19 -0
  259. package/dist/wallet/state/storage.js +55 -0
  260. package/dist/wallet/tx/anchor.d.ts +40 -0
  261. package/dist/wallet/tx/anchor.js +1210 -0
  262. package/dist/wallet/tx/cog.d.ts +92 -0
  263. package/dist/wallet/tx/cog.js +1055 -0
  264. package/dist/wallet/tx/common.d.ts +89 -0
  265. package/dist/wallet/tx/common.js +156 -0
  266. package/dist/wallet/tx/confirm.d.ts +15 -0
  267. package/dist/wallet/tx/confirm.js +24 -0
  268. package/dist/wallet/tx/domain-admin.d.ts +105 -0
  269. package/dist/wallet/tx/domain-admin.js +869 -0
  270. package/dist/wallet/tx/domain-market.d.ts +112 -0
  271. package/dist/wallet/tx/domain-market.js +1365 -0
  272. package/dist/wallet/tx/field.d.ts +101 -0
  273. package/dist/wallet/tx/field.js +1853 -0
  274. package/dist/wallet/tx/identity-selector.d.ts +12 -0
  275. package/dist/wallet/tx/identity-selector.js +52 -0
  276. package/dist/wallet/tx/index.d.ts +7 -0
  277. package/dist/wallet/tx/index.js +7 -0
  278. package/dist/wallet/tx/journal.d.ts +5 -0
  279. package/dist/wallet/tx/journal.js +31 -0
  280. package/dist/wallet/tx/register.d.ts +68 -0
  281. package/dist/wallet/tx/register.js +952 -0
  282. package/dist/wallet/tx/reputation.d.ts +72 -0
  283. package/dist/wallet/tx/reputation.js +693 -0
  284. package/dist/wallet/tx/targets.d.ts +7 -0
  285. package/dist/wallet/tx/targets.js +122 -0
  286. package/dist/wallet/types.d.ts +249 -0
  287. package/dist/wallet/types.js +1 -0
  288. package/dist/writing_quotes.json +1654 -0
  289. package/package.json +78 -0
@@ -0,0 +1,137 @@
1
+ import { loadArtTemplate } from "./assets.js";
2
+ import { MAX_QUOTE_LINES, MESSAGE_FIELD_ROW, NEUTRAL_MESSAGE_TITLE, SCROLL_WINDOW_HEIGHT, SCROLL_WINDOW_TOP, SCROLL_WINDOW_WIDTH, STATUS_FIELD_ROW, } from "./constants.js";
3
+ import { centerLine, computeCenteredLeftPadding, normalizeInlineText, overlayCenteredField, positionLine, replaceWindowSegment, truncateLine, } from "./formatting.js";
4
+ import { renderIntroFrame } from "./train-scene.js";
5
+ function consumeWrappedLine(text, capacity) {
6
+ const remaining = text.trimStart();
7
+ if (remaining.length <= capacity) {
8
+ return {
9
+ line: remaining,
10
+ remaining: "",
11
+ };
12
+ }
13
+ const candidate = remaining.slice(0, capacity + 1);
14
+ const breakIndex = candidate.lastIndexOf(" ");
15
+ if (breakIndex > 0) {
16
+ return {
17
+ line: remaining.slice(0, breakIndex).trimEnd(),
18
+ remaining: remaining.slice(breakIndex + 1).trimStart(),
19
+ };
20
+ }
21
+ return {
22
+ line: remaining.slice(0, capacity),
23
+ remaining: remaining.slice(capacity).trimStart(),
24
+ };
25
+ }
26
+ function wrapTextToCapacities(text, capacities) {
27
+ let remaining = normalizeInlineText(text);
28
+ const lines = [];
29
+ for (const capacity of capacities) {
30
+ if (remaining.length === 0) {
31
+ break;
32
+ }
33
+ const wrapped = consumeWrappedLine(remaining, capacity);
34
+ lines.push(wrapped.line);
35
+ remaining = wrapped.remaining;
36
+ }
37
+ return { lines, remaining };
38
+ }
39
+ function decorateWrappedQuote(lines, truncated) {
40
+ const decorated = [...lines];
41
+ const lastIndex = decorated.length - 1;
42
+ decorated[0] = `"${decorated[0]}`;
43
+ decorated[lastIndex] = truncated
44
+ ? `${decorated[lastIndex]}\u2026"`
45
+ : `${decorated[lastIndex]}"`;
46
+ return decorated;
47
+ }
48
+ function wrapQuoteText(text) {
49
+ const normalized = normalizeInlineText(text);
50
+ if (normalized.length === 0) {
51
+ return {
52
+ lines: ["\"\""],
53
+ truncated: false,
54
+ };
55
+ }
56
+ for (let lineCount = 1; lineCount <= MAX_QUOTE_LINES; lineCount += 1) {
57
+ const capacities = lineCount === 1
58
+ ? [SCROLL_WINDOW_WIDTH - 2]
59
+ : [
60
+ SCROLL_WINDOW_WIDTH - 1,
61
+ ...Array.from({ length: Math.max(0, lineCount - 2) }, () => SCROLL_WINDOW_WIDTH),
62
+ SCROLL_WINDOW_WIDTH - 1,
63
+ ];
64
+ const wrapped = wrapTextToCapacities(normalized, capacities);
65
+ if (wrapped.remaining.length === 0) {
66
+ return {
67
+ lines: decorateWrappedQuote(wrapped.lines, false),
68
+ truncated: false,
69
+ };
70
+ }
71
+ }
72
+ const prefixCapacities = [
73
+ SCROLL_WINDOW_WIDTH - 1,
74
+ ...Array.from({ length: MAX_QUOTE_LINES - 2 }, () => SCROLL_WINDOW_WIDTH),
75
+ ];
76
+ const wrapped = wrapTextToCapacities(normalized, prefixCapacities);
77
+ const lastLine = consumeWrappedLine(wrapped.remaining, SCROLL_WINDOW_WIDTH - 2).line;
78
+ return {
79
+ lines: decorateWrappedQuote([...wrapped.lines, lastLine], true),
80
+ truncated: true,
81
+ };
82
+ }
83
+ function buildQuoteWindowLines(quoteLines) {
84
+ if (quoteLines.length <= 1) {
85
+ return quoteLines.map((line) => centerLine(line, SCROLL_WINDOW_WIDTH));
86
+ }
87
+ const centeredPaddings = quoteLines.map((line) => computeCenteredLeftPadding(line, SCROLL_WINDOW_WIDTH));
88
+ const lastLine = truncateLine(quoteLines[quoteLines.length - 1] ?? "", SCROLL_WINDOW_WIDTH);
89
+ const maxLastPadding = Math.max(0, SCROLL_WINDOW_WIDTH - lastLine.length);
90
+ const anchoredLastPadding = centeredPaddings
91
+ .slice(0, -1)
92
+ .sort((left, right) => left - right)
93
+ .find((padding) => padding <= maxLastPadding)
94
+ ?? computeCenteredLeftPadding(lastLine, SCROLL_WINDOW_WIDTH);
95
+ return quoteLines.map((line, index) => {
96
+ if (index === quoteLines.length - 1) {
97
+ return positionLine(line, SCROLL_WINDOW_WIDTH, anchoredLastPadding);
98
+ }
99
+ return centerLine(line, SCROLL_WINDOW_WIDTH);
100
+ });
101
+ }
102
+ function buildScrollWindow(quote) {
103
+ const lines = Array.from({ length: SCROLL_WINDOW_HEIGHT }, () => " ".repeat(SCROLL_WINDOW_WIDTH));
104
+ if (quote === null) {
105
+ return lines;
106
+ }
107
+ const wrappedQuote = wrapQuoteText(quote.quote);
108
+ const byline = truncateLine(`- ${normalizeInlineText(quote.author)}`, SCROLL_WINDOW_WIDTH);
109
+ const quoteWindowLines = buildQuoteWindowLines(wrappedQuote.lines);
110
+ const block = [...quoteWindowLines, " ".repeat(SCROLL_WINDOW_WIDTH), centerLine(byline, SCROLL_WINDOW_WIDTH)];
111
+ const topPadding = Math.max(0, Math.ceil((SCROLL_WINDOW_HEIGHT - block.length) / 2));
112
+ for (const [index, line] of block.entries()) {
113
+ const targetRow = topPadding + index;
114
+ if (targetRow >= SCROLL_WINDOW_HEIGHT) {
115
+ break;
116
+ }
117
+ lines[targetRow] = line;
118
+ }
119
+ return lines;
120
+ }
121
+ export function renderArtFrame(displayPhase, quote, statusFieldText, introElapsedMs = 0) {
122
+ if (displayPhase === "banner") {
123
+ return renderIntroFrame(introElapsedMs, statusFieldText);
124
+ }
125
+ const frame = [...loadArtTemplate("scroll")];
126
+ const windowLines = buildScrollWindow(quote);
127
+ for (const [rowOffset, windowLine] of windowLines.entries()) {
128
+ const rowIndex = SCROLL_WINDOW_TOP + rowOffset;
129
+ frame[rowIndex] = replaceWindowSegment(frame[rowIndex] ?? "", windowLine);
130
+ }
131
+ overlayCenteredField(frame, MESSAGE_FIELD_ROW, NEUTRAL_MESSAGE_TITLE);
132
+ overlayCenteredField(frame, STATUS_FIELD_ROW, statusFieldText);
133
+ return frame;
134
+ }
135
+ export function renderArtFrameForTesting(displayPhase, quote, statusFieldText = "", introElapsedMs = 0) {
136
+ return renderArtFrame(displayPhase, quote, statusFieldText, introElapsedMs);
137
+ }
@@ -0,0 +1,9 @@
1
+ export type TrainSceneKind = "intro" | "completion";
2
+ export declare function resolveTrainSceneMessage(kind: TrainSceneKind, elapsedMs: number): string;
3
+ export declare function resolveIntroMessageForTesting(introElapsedMs: number): string;
4
+ export declare function resolveCompletionMessageForTesting(completionElapsedMs: number): string;
5
+ export declare function renderTrainSceneFrame(kind: TrainSceneKind, elapsedMs: number, statusFieldText: string): string[];
6
+ export declare function renderIntroFrame(introElapsedMs: number, statusFieldText: string): string[];
7
+ export declare function renderCompletionFrame(completionElapsedMs: number, statusFieldText: string): string[];
8
+ export declare function renderIntroFrameForTesting(introElapsedMs: number, statusFieldText?: string): string[];
9
+ export declare function renderCompletionFrameForTesting(completionElapsedMs: number, statusFieldText?: string): string[];
@@ -0,0 +1,92 @@
1
+ import { INTRO_ENTRY_MS, INTRO_EXIT_MS, INTRO_PAUSE_MS, INTRO_TOTAL_MS, MESSAGE_FIELD_ROW, NEUTRAL_MESSAGE_TITLE, SCROLL_WINDOW_LEFT, STATUS_FIELD_ROW, TRAIN_CENTER_X, TRAIN_CLIP_MAX_COLUMN, TRAIN_CLIP_MIN_COLUMN, TRAIN_OFFSCREEN_LEFT_X, TRAIN_OFFSCREEN_RIGHT_X, TRAIN_SPRITE_TOP, } from "./constants.js";
2
+ import { loadArtTemplate, loadSprite } from "./assets.js";
3
+ import { overlayCenteredField } from "./formatting.js";
4
+ export function resolveTrainSceneMessage(kind, elapsedMs) {
5
+ if (elapsedMs < INTRO_ENTRY_MS) {
6
+ return kind === "intro"
7
+ ? "Here comes the mining train!"
8
+ : "Congratuations, you are synced!";
9
+ }
10
+ if (elapsedMs < INTRO_ENTRY_MS + INTRO_PAUSE_MS) {
11
+ return kind === "intro"
12
+ ? "Welcome to Cogcoin!"
13
+ : "You shape your own future.";
14
+ }
15
+ if (elapsedMs < INTRO_TOTAL_MS) {
16
+ return kind === "intro"
17
+ ? "How many sentences will you mine?"
18
+ : "Your Cogcoin story begins...";
19
+ }
20
+ return "";
21
+ }
22
+ export function resolveIntroMessageForTesting(introElapsedMs) {
23
+ return resolveTrainSceneMessage("intro", introElapsedMs);
24
+ }
25
+ export function resolveCompletionMessageForTesting(completionElapsedMs) {
26
+ return resolveTrainSceneMessage("completion", completionElapsedMs);
27
+ }
28
+ function resolveIntroSpriteName(introElapsedMs) {
29
+ if (introElapsedMs >= INTRO_ENTRY_MS && introElapsedMs < INTRO_ENTRY_MS + INTRO_PAUSE_MS) {
30
+ return "train";
31
+ }
32
+ return "train-smoke";
33
+ }
34
+ function resolveIntroSpriteX(introElapsedMs) {
35
+ if (introElapsedMs <= 0) {
36
+ return TRAIN_OFFSCREEN_RIGHT_X;
37
+ }
38
+ if (introElapsedMs < INTRO_ENTRY_MS) {
39
+ const progress = introElapsedMs / INTRO_ENTRY_MS;
40
+ return Math.round(TRAIN_OFFSCREEN_RIGHT_X + ((TRAIN_CENTER_X - TRAIN_OFFSCREEN_RIGHT_X) * progress));
41
+ }
42
+ if (introElapsedMs < INTRO_ENTRY_MS + INTRO_PAUSE_MS) {
43
+ return TRAIN_CENTER_X;
44
+ }
45
+ if (introElapsedMs < INTRO_TOTAL_MS) {
46
+ const progress = (introElapsedMs - INTRO_ENTRY_MS - INTRO_PAUSE_MS) / INTRO_EXIT_MS;
47
+ return Math.round(TRAIN_CENTER_X + ((TRAIN_OFFSCREEN_LEFT_X - TRAIN_CENTER_X) * progress));
48
+ }
49
+ return TRAIN_OFFSCREEN_LEFT_X;
50
+ }
51
+ function overlaySpriteOnFrame(frame, sprite, spriteX) {
52
+ const rows = frame.map((line) => [...line]);
53
+ for (const [rowOffset, spriteLine] of sprite.entries()) {
54
+ const targetRow = TRAIN_SPRITE_TOP + rowOffset;
55
+ const targetChars = rows[targetRow];
56
+ if (targetChars === undefined) {
57
+ continue;
58
+ }
59
+ for (const [columnOffset, character] of [...spriteLine].entries()) {
60
+ const targetColumn = spriteX + columnOffset;
61
+ if (character === " "
62
+ || targetColumn < TRAIN_CLIP_MIN_COLUMN
63
+ || targetColumn > TRAIN_CLIP_MAX_COLUMN) {
64
+ continue;
65
+ }
66
+ targetChars[SCROLL_WINDOW_LEFT + targetColumn] = character;
67
+ }
68
+ }
69
+ return rows.map((chars) => chars.join(""));
70
+ }
71
+ export function renderTrainSceneFrame(kind, elapsedMs, statusFieldText) {
72
+ const frame = [...loadArtTemplate("scroll")];
73
+ const introFrame = elapsedMs >= INTRO_TOTAL_MS
74
+ ? frame
75
+ : overlaySpriteOnFrame(frame, loadSprite(resolveIntroSpriteName(elapsedMs)), resolveIntroSpriteX(elapsedMs));
76
+ const message = resolveTrainSceneMessage(kind, elapsedMs);
77
+ overlayCenteredField(introFrame, MESSAGE_FIELD_ROW, message.length > 0 ? message : NEUTRAL_MESSAGE_TITLE);
78
+ overlayCenteredField(introFrame, STATUS_FIELD_ROW, statusFieldText);
79
+ return introFrame;
80
+ }
81
+ export function renderIntroFrame(introElapsedMs, statusFieldText) {
82
+ return renderTrainSceneFrame("intro", introElapsedMs, statusFieldText);
83
+ }
84
+ export function renderCompletionFrame(completionElapsedMs, statusFieldText) {
85
+ return renderTrainSceneFrame("completion", completionElapsedMs, statusFieldText);
86
+ }
87
+ export function renderIntroFrameForTesting(introElapsedMs, statusFieldText = "") {
88
+ return renderIntroFrame(introElapsedMs, statusFieldText);
89
+ }
90
+ export function renderCompletionFrameForTesting(completionElapsedMs, statusFieldText = "") {
91
+ return renderCompletionFrame(completionElapsedMs, statusFieldText);
92
+ }
@@ -0,0 +1,18 @@
1
+ import type { QuoteDisplayPhase } from "../quotes.js";
2
+ import type { BootstrapProgress, WritingQuote } from "../types.js";
3
+ import type { FollowSceneStateForTesting } from "./follow-scene.js";
4
+ import { type TrainSceneKind } from "./train-scene.js";
5
+ interface RenderStream {
6
+ isTTY?: boolean;
7
+ columns?: number;
8
+ write(chunk: string): boolean | void;
9
+ }
10
+ export declare class TtyProgressRenderer {
11
+ #private;
12
+ constructor(stream?: RenderStream);
13
+ render(displayPhase: QuoteDisplayPhase, quote: WritingQuote | null, progress: BootstrapProgress, cogcoinSyncHeight: number | null, cogcoinSyncTargetHeight: number | null, introElapsedMs?: number, statusFieldText?: string): void;
14
+ renderTrainScene(kind: TrainSceneKind, progress: BootstrapProgress, cogcoinSyncHeight: number | null, cogcoinSyncTargetHeight: number | null, elapsedMs: number, statusFieldText?: string): void;
15
+ renderFollowScene(progress: BootstrapProgress, cogcoinSyncHeight: number | null, cogcoinSyncTargetHeight: number | null, followScene: FollowSceneStateForTesting, statusFieldText?: string): void;
16
+ close(): void;
17
+ }
18
+ export {};
@@ -0,0 +1,150 @@
1
+ import { ART_WIDTH, INTRO_TOTAL_MS, NEUTRAL_MESSAGE_TITLE } from "./constants.js";
2
+ import { renderFollowFrame } from "./follow-scene.js";
3
+ import { formatProgressLine, formatQuoteLine, truncateLine } from "./formatting.js";
4
+ import { renderArtFrame } from "./quote-scene.js";
5
+ import { renderTrainSceneFrame, resolveTrainSceneMessage } from "./train-scene.js";
6
+ const STREAM_WRITE_OBSERVERS = new WeakMap();
7
+ function getStreamWriteObserver(stream) {
8
+ const existing = STREAM_WRITE_OBSERVERS.get(stream);
9
+ if (existing !== undefined) {
10
+ existing.refCount += 1;
11
+ return existing;
12
+ }
13
+ const observer = {
14
+ externalWriteCount: 0,
15
+ internalWriteDepth: 0,
16
+ refCount: 1,
17
+ originalWrite: stream.write,
18
+ };
19
+ stream.write = ((chunk) => {
20
+ if (observer.internalWriteDepth === 0) {
21
+ observer.externalWriteCount += 1;
22
+ }
23
+ const result = observer.originalWrite.call(stream, chunk);
24
+ return result ?? true;
25
+ });
26
+ STREAM_WRITE_OBSERVERS.set(stream, observer);
27
+ return observer;
28
+ }
29
+ function releaseStreamWriteObserver(stream, observer) {
30
+ observer.refCount -= 1;
31
+ if (observer.refCount > 0) {
32
+ return;
33
+ }
34
+ stream.write = observer.originalWrite;
35
+ STREAM_WRITE_OBSERVERS.delete(stream);
36
+ }
37
+ export class TtyProgressRenderer {
38
+ #stream;
39
+ #streamWriteObserver;
40
+ #rendered = false;
41
+ #observerReleased = false;
42
+ #lastExternalWriteCount = 0;
43
+ #previousFrameHeight = 0;
44
+ constructor(stream = process.stderr) {
45
+ this.#stream = stream;
46
+ this.#streamWriteObserver = getStreamWriteObserver(stream);
47
+ this.#lastExternalWriteCount = this.#streamWriteObserver.externalWriteCount;
48
+ }
49
+ render(displayPhase, quote, progress, cogcoinSyncHeight, cogcoinSyncTargetHeight, introElapsedMs = 0, statusFieldText = "") {
50
+ const now = Date.now();
51
+ const width = Math.max(20, this.#stream.columns ?? 120);
52
+ const progressLine = formatProgressLine(progress, cogcoinSyncHeight, cogcoinSyncTargetHeight, width, now);
53
+ const lines = width >= ART_WIDTH
54
+ ? [...renderArtFrame(displayPhase, quote, statusFieldText, introElapsedMs), "", progressLine, ""]
55
+ : [formatQuoteLine(quote, width), progressLine, ""];
56
+ const frame = lines.join("\n");
57
+ this.#resetFrameIfExternalWritesDetected();
58
+ if (!this.#rendered) {
59
+ this.#writeChunk(frame);
60
+ this.#rendered = true;
61
+ this.#previousFrameHeight = lines.length;
62
+ return;
63
+ }
64
+ this.#writeChunk(this.#clearPreviousFrame());
65
+ this.#writeChunk(frame);
66
+ this.#previousFrameHeight = lines.length;
67
+ }
68
+ renderTrainScene(kind, progress, cogcoinSyncHeight, cogcoinSyncTargetHeight, elapsedMs, statusFieldText = "") {
69
+ const now = Date.now();
70
+ const width = Math.max(20, this.#stream.columns ?? 120);
71
+ const progressLine = formatProgressLine(progress, cogcoinSyncHeight, cogcoinSyncTargetHeight, width, now);
72
+ const sceneMessage = resolveTrainSceneMessage(kind, elapsedMs);
73
+ const lines = width >= ART_WIDTH
74
+ ? [...renderTrainSceneFrame(kind, elapsedMs, statusFieldText), "", progressLine, ""]
75
+ : [truncateLine(sceneMessage, width), progressLine, ""];
76
+ const frame = lines.join("\n");
77
+ this.#resetFrameIfExternalWritesDetected();
78
+ if (!this.#rendered) {
79
+ this.#writeChunk(frame);
80
+ this.#rendered = true;
81
+ this.#previousFrameHeight = lines.length;
82
+ return;
83
+ }
84
+ this.#writeChunk(this.#clearPreviousFrame());
85
+ this.#writeChunk(frame);
86
+ this.#previousFrameHeight = lines.length;
87
+ }
88
+ renderFollowScene(progress, cogcoinSyncHeight, cogcoinSyncTargetHeight, followScene, statusFieldText = "") {
89
+ const now = Date.now();
90
+ const width = Math.max(20, this.#stream.columns ?? 120);
91
+ const progressLine = formatProgressLine(progress, cogcoinSyncHeight, cogcoinSyncTargetHeight, width, now);
92
+ const lines = width >= ART_WIDTH
93
+ ? [...renderFollowFrame(followScene, statusFieldText, now), "", progressLine, ""]
94
+ : [truncateLine(NEUTRAL_MESSAGE_TITLE, width), progressLine, ""];
95
+ const frame = lines.join("\n");
96
+ this.#resetFrameIfExternalWritesDetected();
97
+ if (!this.#rendered) {
98
+ this.#writeChunk(frame);
99
+ this.#rendered = true;
100
+ this.#previousFrameHeight = lines.length;
101
+ return;
102
+ }
103
+ this.#writeChunk(this.#clearPreviousFrame());
104
+ this.#writeChunk(frame);
105
+ this.#previousFrameHeight = lines.length;
106
+ }
107
+ close() {
108
+ if (this.#rendered) {
109
+ this.#writeChunk("\n");
110
+ this.#rendered = false;
111
+ this.#previousFrameHeight = 0;
112
+ }
113
+ if (this.#observerReleased) {
114
+ return;
115
+ }
116
+ releaseStreamWriteObserver(this.#stream, this.#streamWriteObserver);
117
+ this.#observerReleased = true;
118
+ }
119
+ #writeChunk(chunk) {
120
+ this.#streamWriteObserver.internalWriteDepth += 1;
121
+ try {
122
+ this.#stream.write(chunk);
123
+ }
124
+ finally {
125
+ this.#streamWriteObserver.internalWriteDepth = Math.max(0, this.#streamWriteObserver.internalWriteDepth - 1);
126
+ this.#lastExternalWriteCount = this.#streamWriteObserver.externalWriteCount;
127
+ }
128
+ }
129
+ #resetFrameIfExternalWritesDetected() {
130
+ if (!this.#rendered) {
131
+ return;
132
+ }
133
+ if (this.#streamWriteObserver.externalWriteCount === this.#lastExternalWriteCount) {
134
+ return;
135
+ }
136
+ this.#rendered = false;
137
+ this.#previousFrameHeight = 0;
138
+ this.#lastExternalWriteCount = this.#streamWriteObserver.externalWriteCount;
139
+ }
140
+ #clearPreviousFrame() {
141
+ let clear = "";
142
+ for (let index = 0; index < this.#previousFrameHeight; index += 1) {
143
+ clear += "\r\u001B[2K";
144
+ if (index < this.#previousFrameHeight - 1) {
145
+ clear += "\u001B[1A";
146
+ }
147
+ }
148
+ return clear;
149
+ }
150
+ }
@@ -0,0 +1,7 @@
1
+ export { ManagedProgressController } from "./progress/controller.js";
2
+ export { type FollowAnimation, type FollowAnimationKind, type FollowSceneStateForTesting, advanceFollowSceneStateForTesting, createFollowSceneStateForTesting, formatCompactFollowAgeLabelForTesting, renderFollowFrameForTesting, setFollowBlockTimeForTesting, setFollowBlockTimesForTesting, syncFollowSceneStateForTesting, } from "./progress/follow-scene.js";
3
+ export { createBootstrapProgressForTesting, formatProgressLineForTesting, formatQuoteLineForTesting, resolveStatusFieldTextForTesting, } from "./progress/formatting.js";
4
+ export { renderArtFrameForTesting } from "./progress/quote-scene.js";
5
+ export { renderCompletionFrameForTesting, renderIntroFrameForTesting, resolveCompletionMessageForTesting, resolveIntroMessageForTesting, } from "./progress/train-scene.js";
6
+ export { TtyProgressRenderer } from "./progress/tty-renderer.js";
7
+ export { loadBannerArtForTesting, loadScrollArtForTesting, loadTrainArtForTesting, loadTrainCarArtForTesting, loadTrainSmokeArtForTesting, } from "./progress/assets.js";
@@ -0,0 +1,7 @@
1
+ export { ManagedProgressController } from "./progress/controller.js";
2
+ export { advanceFollowSceneStateForTesting, createFollowSceneStateForTesting, formatCompactFollowAgeLabelForTesting, renderFollowFrameForTesting, setFollowBlockTimeForTesting, setFollowBlockTimesForTesting, syncFollowSceneStateForTesting, } from "./progress/follow-scene.js";
3
+ export { createBootstrapProgressForTesting, formatProgressLineForTesting, formatQuoteLineForTesting, resolveStatusFieldTextForTesting, } from "./progress/formatting.js";
4
+ export { renderArtFrameForTesting } from "./progress/quote-scene.js";
5
+ export { renderCompletionFrameForTesting, renderIntroFrameForTesting, resolveCompletionMessageForTesting, resolveIntroMessageForTesting, } from "./progress/train-scene.js";
6
+ export { TtyProgressRenderer } from "./progress/tty-renderer.js";
7
+ export { loadBannerArtForTesting, loadScrollArtForTesting, loadTrainArtForTesting, loadTrainCarArtForTesting, loadTrainSmokeArtForTesting, } from "./progress/assets.js";
@@ -0,0 +1,24 @@
1
+ import type { WritingQuote } from "./types.js";
2
+ export type QuoteDisplayPhase = "banner" | "scroll";
3
+ export interface QuoteStateSnapshot {
4
+ currentQuote: WritingQuote;
5
+ completedCycles: number;
6
+ index: number;
7
+ permutation: number[];
8
+ displayPhase: QuoteDisplayPhase;
9
+ displayStartedAt: number;
10
+ quoteStartedAt: number;
11
+ }
12
+ export declare function shuffleIndicesForTesting(length: number, random?: () => number): number[];
13
+ export declare function loadWritingQuotesForTesting(): Promise<{
14
+ datasetHash: string;
15
+ quotes: WritingQuote[];
16
+ }>;
17
+ export declare class WritingQuoteRotator {
18
+ #private;
19
+ private constructor();
20
+ static create(statePath: string, random?: () => number): Promise<WritingQuoteRotator>;
21
+ current(now?: number): Promise<QuoteStateSnapshot>;
22
+ forceAdvance(now?: number): Promise<QuoteStateSnapshot>;
23
+ getPersistedStateForTesting(): Promise<QuoteStateSnapshot>;
24
+ }
@@ -0,0 +1,195 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ const BANNER_DISPLAY_MS = 15_000;
5
+ const QUOTE_ROTATION_MS = 7_000;
6
+ async function writeJsonAtomic(path, payload) {
7
+ await mkdir(dirname(path), { recursive: true });
8
+ const tempPath = `${path}.tmp`;
9
+ await writeFile(tempPath, JSON.stringify(payload, null, 2));
10
+ await rename(tempPath, path);
11
+ }
12
+ function isPermutation(indices, length) {
13
+ if (indices.length !== length) {
14
+ return false;
15
+ }
16
+ const seen = new Set(indices);
17
+ if (seen.size !== length) {
18
+ return false;
19
+ }
20
+ for (let index = 0; index < length; index += 1) {
21
+ if (!seen.has(index)) {
22
+ return false;
23
+ }
24
+ }
25
+ return true;
26
+ }
27
+ export function shuffleIndicesForTesting(length, random = Math.random) {
28
+ const indices = Array.from({ length }, (_value, index) => index);
29
+ for (let index = indices.length - 1; index > 0; index -= 1) {
30
+ const swapIndex = Math.floor(random() * (index + 1));
31
+ [indices[index], indices[swapIndex]] = [indices[swapIndex] ?? 0, indices[index] ?? 0];
32
+ }
33
+ return indices;
34
+ }
35
+ async function loadWritingQuotes() {
36
+ const raw = await readFile(new URL("../writing_quotes.json", import.meta.url));
37
+ const datasetHash = createHash("sha256").update(raw).digest("hex");
38
+ const quotes = JSON.parse(raw.toString("utf8"));
39
+ if (!Array.isArray(quotes) || quotes.length === 0) {
40
+ throw new Error("writing_quotes_invalid");
41
+ }
42
+ return { datasetHash, quotes };
43
+ }
44
+ export async function loadWritingQuotesForTesting() {
45
+ return loadWritingQuotes();
46
+ }
47
+ export class WritingQuoteRotator {
48
+ #quotes;
49
+ #datasetHash;
50
+ #statePath;
51
+ #random;
52
+ #state;
53
+ #queue = Promise.resolve();
54
+ constructor(quotes, datasetHash, statePath, random, state) {
55
+ this.#quotes = quotes;
56
+ this.#datasetHash = datasetHash;
57
+ this.#statePath = statePath;
58
+ this.#random = random;
59
+ this.#state = state;
60
+ }
61
+ static async create(statePath, random = Math.random) {
62
+ const { datasetHash, quotes } = await loadWritingQuotes();
63
+ const state = await WritingQuoteRotator.#loadState(statePath, quotes.length, datasetHash, random);
64
+ return new WritingQuoteRotator(quotes, datasetHash, statePath, random, state);
65
+ }
66
+ async current(now = Date.now()) {
67
+ await this.#advanceIfNeeded(now);
68
+ return this.#snapshot(now);
69
+ }
70
+ async forceAdvance(now = Date.now()) {
71
+ await this.#enqueue(async () => {
72
+ this.#advanceState(now, 1);
73
+ await writeJsonAtomic(this.#statePath, this.#state);
74
+ });
75
+ return this.#snapshot(now);
76
+ }
77
+ async getPersistedStateForTesting() {
78
+ return this.current(Date.now());
79
+ }
80
+ async #advanceIfNeeded(now) {
81
+ await this.#enqueue(async () => {
82
+ const elapsed = Math.max(0, now - this.#state.quoteStartedAt);
83
+ const steps = Math.floor(elapsed / QUOTE_ROTATION_MS);
84
+ if (steps <= 0) {
85
+ return;
86
+ }
87
+ this.#advanceState(now, steps);
88
+ await writeJsonAtomic(this.#statePath, this.#state);
89
+ });
90
+ }
91
+ #advanceState(now, steps) {
92
+ let remaining = steps;
93
+ while (remaining > 0) {
94
+ const nextIndex = this.#state.index + 1;
95
+ if (nextIndex < this.#state.permutation.length) {
96
+ this.#state.index = nextIndex;
97
+ }
98
+ else {
99
+ this.#state.completedCycles += 1;
100
+ this.#state.permutation = shuffleIndicesForTesting(this.#quotes.length, this.#random);
101
+ this.#state.index = 0;
102
+ }
103
+ this.#state.quoteStartedAt += QUOTE_ROTATION_MS;
104
+ remaining -= 1;
105
+ }
106
+ if (now > this.#state.quoteStartedAt + QUOTE_ROTATION_MS) {
107
+ this.#state.quoteStartedAt = now - ((now - this.#state.quoteStartedAt) % QUOTE_ROTATION_MS);
108
+ }
109
+ this.#state.updatedAt = now;
110
+ }
111
+ static #advanceLoadedState(state, quoteCount, random, now) {
112
+ let remaining = Math.floor(Math.max(0, now - state.quoteStartedAt) / QUOTE_ROTATION_MS);
113
+ while (remaining > 0) {
114
+ const nextIndex = state.index + 1;
115
+ if (nextIndex < state.permutation.length) {
116
+ state.index = nextIndex;
117
+ }
118
+ else {
119
+ state.completedCycles += 1;
120
+ state.permutation = shuffleIndicesForTesting(quoteCount, random);
121
+ state.index = 0;
122
+ }
123
+ state.quoteStartedAt += QUOTE_ROTATION_MS;
124
+ remaining -= 1;
125
+ }
126
+ if (now > state.quoteStartedAt + QUOTE_ROTATION_MS) {
127
+ state.quoteStartedAt = now - ((now - state.quoteStartedAt) % QUOTE_ROTATION_MS);
128
+ }
129
+ state.updatedAt = now;
130
+ }
131
+ #snapshot(now) {
132
+ const quoteIndex = this.#state.permutation[this.#state.index] ?? 0;
133
+ return {
134
+ currentQuote: this.#quotes[quoteIndex] ?? this.#quotes[0],
135
+ completedCycles: this.#state.completedCycles,
136
+ index: this.#state.index,
137
+ permutation: [...this.#state.permutation],
138
+ displayPhase: now < this.#state.displayStartedAt + BANNER_DISPLAY_MS ? "banner" : "scroll",
139
+ displayStartedAt: this.#state.displayStartedAt,
140
+ quoteStartedAt: this.#state.quoteStartedAt,
141
+ };
142
+ }
143
+ async #enqueue(fn) {
144
+ const resultPromise = this.#queue.then(fn, fn);
145
+ this.#queue = resultPromise.then(() => undefined, () => undefined);
146
+ return resultPromise;
147
+ }
148
+ static async #loadState(statePath, quoteCount, datasetHash, random) {
149
+ const now = Date.now();
150
+ try {
151
+ const raw = await readFile(statePath, "utf8");
152
+ const parsed = JSON.parse(raw);
153
+ if (parsed.datasetHash === datasetHash
154
+ && Array.isArray(parsed.permutation)
155
+ && isPermutation(parsed.permutation, quoteCount)
156
+ && typeof parsed.index === "number"
157
+ && parsed.index >= 0
158
+ && parsed.index < parsed.permutation.length
159
+ && typeof parsed.quoteStartedAt === "number"
160
+ && typeof parsed.completedCycles === "number") {
161
+ const state = {
162
+ datasetHash,
163
+ permutation: parsed.permutation,
164
+ index: parsed.index,
165
+ displayStartedAt: typeof parsed.displayStartedAt === "number"
166
+ ? parsed.displayStartedAt
167
+ : parsed.quoteStartedAt - BANNER_DISPLAY_MS,
168
+ quoteStartedAt: parsed.quoteStartedAt,
169
+ completedCycles: parsed.completedCycles,
170
+ updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : now,
171
+ };
172
+ WritingQuoteRotator.#advanceLoadedState(state, quoteCount, random, now);
173
+ state.displayStartedAt = now;
174
+ state.quoteStartedAt = now + BANNER_DISPLAY_MS;
175
+ state.updatedAt = now;
176
+ await writeJsonAtomic(statePath, state);
177
+ return state;
178
+ }
179
+ }
180
+ catch {
181
+ // Fall back to a fresh randomized cycle.
182
+ }
183
+ const state = {
184
+ datasetHash,
185
+ permutation: shuffleIndicesForTesting(quoteCount, random),
186
+ index: 0,
187
+ displayStartedAt: now,
188
+ quoteStartedAt: now + BANNER_DISPLAY_MS,
189
+ completedCycles: 0,
190
+ updatedAt: now,
191
+ };
192
+ await writeJsonAtomic(statePath, state);
193
+ return state;
194
+ }
195
+ }