quoxcli 0.3.1 → 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 (306) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +2 -2
  3. package/bin/quox.js +19 -1
  4. package/bin/quoxon.js +6 -1
  5. package/lib/api/endpoints/actions.js +15 -0
  6. package/lib/api/endpoints/admin.js +27 -0
  7. package/lib/api/endpoints/agent-attach.js +7 -0
  8. package/lib/api/endpoints/blog.js +16 -0
  9. package/lib/api/endpoints/chat.js +7 -0
  10. package/lib/api/endpoints/compliance.js +25 -0
  11. package/lib/api/endpoints/contactsq.js +4 -0
  12. package/lib/api/endpoints/devServerRegistry.js +25 -0
  13. package/lib/api/endpoints/engagement.js +26 -0
  14. package/lib/api/endpoints/evidence.js +20 -0
  15. package/lib/api/endpoints/hat.js +14 -0
  16. package/lib/api/endpoints/inbox.js +6 -0
  17. package/lib/api/endpoints/inboxq.js +19 -0
  18. package/lib/api/endpoints/index.js +14 -0
  19. package/lib/api/endpoints/loops.js +26 -0
  20. package/lib/api/endpoints/mon.js +295 -0
  21. package/lib/api/endpoints/objectives.js +6 -0
  22. package/lib/api/endpoints/quoxseo.js +6 -0
  23. package/lib/api/endpoints/server.js +21 -0
  24. package/lib/api/endpoints/setup.js +6 -0
  25. package/lib/api/endpoints/tools.js +13 -0
  26. package/lib/api/endpoints/v1.js +47 -0
  27. package/lib/commands/actions.js +81 -11
  28. package/lib/commands/admin.js +35 -0
  29. package/lib/commands/agent.js +11 -4
  30. package/lib/commands/agentAttach.js +61 -7
  31. package/lib/commands/approvals.js +198 -3
  32. package/lib/commands/ask.js +4 -2
  33. package/lib/commands/auth.js +64 -11
  34. package/lib/commands/backup.js +11 -4
  35. package/lib/commands/blog.js +110 -0
  36. package/lib/commands/capabilities.js +3 -2
  37. package/lib/commands/chat.js +142 -28
  38. package/lib/commands/chatCompanion.js +27 -0
  39. package/lib/commands/chatState.js +45 -0
  40. package/lib/commands/claude.js +7 -33
  41. package/lib/commands/compliance.js +109 -0
  42. package/lib/commands/connect.js +69 -0
  43. package/lib/commands/contacts.js +85 -4
  44. package/lib/commands/contactsSendAt.js +21 -0
  45. package/lib/commands/dev.js +6 -8
  46. package/lib/commands/doctor.js +22 -3
  47. package/lib/commands/engagement.js +90 -0
  48. package/lib/commands/evidence.js +155 -0
  49. package/lib/commands/ext.js +23 -0
  50. package/lib/commands/fleet.js +25 -0
  51. package/lib/commands/gripe.js +24 -13
  52. package/lib/commands/hat.js +76 -0
  53. package/lib/commands/image.js +68 -0
  54. package/lib/commands/inbox.js +37 -3
  55. package/lib/commands/inboxq.js +147 -0
  56. package/lib/commands/kuma.js +1123 -0
  57. package/lib/commands/kumaSla.js +174 -0
  58. package/lib/commands/licence.js +39 -1
  59. package/lib/commands/licenceLinkView.js +311 -0
  60. package/lib/commands/linkedin.js +19 -0
  61. package/lib/commands/loop.js +318 -0
  62. package/lib/commands/lucid.js +1 -0
  63. package/lib/commands/mail.js +161 -20
  64. package/lib/commands/memory.js +68 -4
  65. package/lib/commands/memorySync.js +158 -0
  66. package/lib/commands/mon.js +55 -144
  67. package/lib/commands/notes.js +29 -7
  68. package/lib/commands/notesSeal.js +259 -0
  69. package/lib/commands/objective.js +53 -3
  70. package/lib/commands/offload.js +2 -3
  71. package/lib/commands/org.js +21 -4
  72. package/lib/commands/plan.js +17 -0
  73. package/lib/commands/provider.js +87 -8
  74. package/lib/commands/qlarity.js +32 -1
  75. package/lib/commands/quoxseo.js +31 -0
  76. package/lib/commands/research.js +79 -0
  77. package/lib/commands/rows.js +9 -0
  78. package/lib/commands/run.js +20 -2
  79. package/lib/commands/scope.js +2 -2
  80. package/lib/commands/server.js +150 -0
  81. package/lib/commands/setup.js +127 -75
  82. package/lib/commands/shortcut.js +41 -0
  83. package/lib/commands/skills.js +24 -5
  84. package/lib/commands/slot.js +49 -9
  85. package/lib/commands/status.js +92 -44
  86. package/lib/commands/tool.js +30 -0
  87. package/lib/commands/vault.js +6 -3
  88. package/lib/commands/vm.js +2 -2
  89. package/lib/commands/walkthrough.js +14 -2
  90. package/lib/commands/watch.js +11 -2
  91. package/lib/commands/wazuh.js +824 -0
  92. package/lib/commands/wazuhIntake.js +317 -0
  93. package/lib/commands/wazuhReport.js +192 -0
  94. package/lib/commands/workflow.js +47 -4
  95. package/lib/commands/x.js +50 -0
  96. package/lib/commands/youtube.js +172 -0
  97. package/lib/core/actionItems.js +115 -0
  98. package/lib/core/agentAlias.js +2 -0
  99. package/lib/core/agentFamilies.js +7 -0
  100. package/lib/core/announce.js +86 -0
  101. package/lib/core/auth.js +20 -2
  102. package/lib/core/callMe.js +118 -0
  103. package/lib/core/client.js +37 -3
  104. package/lib/core/commandRegistrars.js +17 -0
  105. package/lib/core/config.js +29 -3
  106. package/lib/core/confirm.js +68 -7
  107. package/lib/core/crossOrgApprovals.js +69 -0
  108. package/lib/core/errors.js +52 -1
  109. package/lib/core/findingReconcile.js +90 -0
  110. package/lib/core/global-flags.js +1 -1
  111. package/lib/core/governedSession.js +136 -8
  112. package/lib/core/inboxResolution.js +3 -2
  113. package/lib/core/liveSessionsReport.js +1 -1
  114. package/lib/core/loginGuard.js +73 -0
  115. package/lib/core/loopGlyph.js +97 -0
  116. package/lib/core/offload.js +73 -7
  117. package/lib/core/org-override.js +5 -1
  118. package/lib/core/output.js +8 -8
  119. package/lib/core/profileTrust.js +182 -0
  120. package/lib/core/quoxglyph.js +22 -1
  121. package/lib/core/router.js +14 -0
  122. package/lib/core/sessionHeartbeat.js +12 -8
  123. package/lib/core/shortcut.js +83 -0
  124. package/lib/core/stepUpPrompt.js +99 -0
  125. package/lib/core/version.js +1 -1
  126. package/lib/core/vm.js +5 -0
  127. package/lib/core/walkthrough.js +9 -5
  128. package/lib/dragon/companionView.js +34 -0
  129. package/lib/dragon/frames.js +118 -0
  130. package/lib/dragon/jobs.js +123 -0
  131. package/lib/dragon/renderWorker.js +15 -0
  132. package/lib/dragon/select.js +72 -0
  133. package/lib/evidence/bundleExport.js +47 -0
  134. package/lib/evidence/bundleVerify.js +77 -0
  135. package/lib/quoxon/EXTENSIONS.md +146 -0
  136. package/lib/quoxon/api.js +364 -27
  137. package/lib/quoxon/app.js +1629 -183
  138. package/lib/quoxon/approvalBatch.js +117 -0
  139. package/lib/quoxon/approvalFacts.js +64 -8
  140. package/lib/quoxon/approvalSentence.js +48 -0
  141. package/lib/quoxon/approvalsView.js +124 -7
  142. package/lib/quoxon/autoOpen.js +94 -0
  143. package/lib/quoxon/backgroundWork.js +59 -0
  144. package/lib/quoxon/bang.js +1 -1
  145. package/lib/quoxon/capabilities.js +7 -2
  146. package/lib/quoxon/commandShort.js +22 -3
  147. package/lib/quoxon/commands.js +110 -15
  148. package/lib/quoxon/connect.js +5 -3
  149. package/lib/quoxon/countdownLine.js +10 -1
  150. package/lib/quoxon/deck.js +30 -0
  151. package/lib/quoxon/deckLazy.js +58 -0
  152. package/lib/quoxon/demo.autoplay.js +1 -1
  153. package/lib/quoxon/demo.js +3 -1
  154. package/lib/quoxon/destructive.js +35 -0
  155. package/lib/quoxon/dividerAocl.js +4 -3
  156. package/lib/quoxon/dividerRule.js +3 -3
  157. package/lib/quoxon/dnd.js +71 -0
  158. package/lib/quoxon/dragonCompanion.js +285 -0
  159. package/lib/quoxon/dragonModes.js +1 -0
  160. package/lib/quoxon/dragonMoments.js +160 -0
  161. package/lib/quoxon/dragonReads.js +68 -0
  162. package/lib/quoxon/dragonView.js +2 -2
  163. package/lib/quoxon/echo.js +1 -0
  164. package/lib/quoxon/events.js +67 -10
  165. package/lib/quoxon/extensions.js +636 -0
  166. package/lib/quoxon/gripeQueue.js +135 -0
  167. package/lib/quoxon/inboxView.js +182 -0
  168. package/lib/quoxon/installCli.js +79 -0
  169. package/lib/quoxon/keys.js +4 -4
  170. package/lib/quoxon/loopCard.js +460 -0
  171. package/lib/quoxon/loopCards.js +189 -0
  172. package/lib/quoxon/loopStream.js +98 -0
  173. package/lib/quoxon/mailView.js +71 -0
  174. package/lib/quoxon/main.js +29 -5
  175. package/lib/quoxon/markdown.js +1 -1
  176. package/lib/quoxon/model.js +15 -0
  177. package/lib/quoxon/mouse.js +12 -1
  178. package/lib/quoxon/neverAnswered.js +20 -0
  179. package/lib/quoxon/objectiveOffer.js +82 -0
  180. package/lib/quoxon/objects/away.js +43 -5
  181. package/lib/quoxon/objects/index.js +22 -4
  182. package/lib/quoxon/objects/model.js +33 -0
  183. package/lib/quoxon/objects/proof.js +12 -4
  184. package/lib/quoxon/onboarding/connectServer.js +162 -0
  185. package/lib/quoxon/onboarding/copy.js +6 -0
  186. package/lib/quoxon/onboarding/index.js +5 -43
  187. package/lib/quoxon/onboarding/probe.js +20 -1
  188. package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
  189. package/lib/quoxon/onboarding/view.js +16 -3
  190. package/lib/quoxon/orgSwitch.js +49 -1
  191. package/lib/quoxon/planSteps.js +51 -0
  192. package/lib/quoxon/print.js +8 -3
  193. package/lib/quoxon/profileSwitch.js +7 -0
  194. package/lib/quoxon/promptHistory.js +3 -1
  195. package/lib/quoxon/qdragonFly.js +7 -6
  196. package/lib/quoxon/qmotion.js +17 -0
  197. package/lib/quoxon/render.js +187 -41
  198. package/lib/quoxon/roster.js +36 -17
  199. package/lib/quoxon/rosterSessions.js +44 -0
  200. package/lib/quoxon/rows.js +244 -1
  201. package/lib/quoxon/screenContext.js +1 -1
  202. package/lib/quoxon/secretShape.js +86 -0
  203. package/lib/quoxon/serverChip.js +42 -0
  204. package/lib/quoxon/session.js +10 -0
  205. package/lib/quoxon/slash/agents.js +2 -1
  206. package/lib/quoxon/slash/argSources.js +9 -2
  207. package/lib/quoxon/slash/args.js +4 -2
  208. package/lib/quoxon/slash/broadcast.js +18 -5
  209. package/lib/quoxon/slash/broadcastRun.js +13 -2
  210. package/lib/quoxon/slash/cliCommand.js +42 -1
  211. package/lib/quoxon/slash/cliLeaves.json +139 -2
  212. package/lib/quoxon/slash/evidenceCmd.js +34 -0
  213. package/lib/quoxon/slash/hatWalk.js +1 -1
  214. package/lib/quoxon/slash/palette.js +23 -2
  215. package/lib/quoxon/slash/paletteController.js +10 -4
  216. package/lib/quoxon/slash/qpeCmd.js +35 -0
  217. package/lib/quoxon/slash/restart.js +31 -4
  218. package/lib/quoxon/slash/resume.js +18 -1
  219. package/lib/quoxon/slash/stepUp.js +5 -5
  220. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  221. package/lib/quoxon/slash/todoCmd.js +122 -0
  222. package/lib/quoxon/slashSpinner.js +35 -0
  223. package/lib/quoxon/slotAttach.js +8 -0
  224. package/lib/quoxon/slotChat.js +17 -0
  225. package/lib/quoxon/slotFormat.js +1 -1
  226. package/lib/quoxon/slotOutbox.js +113 -0
  227. package/lib/quoxon/suggestCommand.js +34 -3
  228. package/lib/quoxon/term.js +25 -1
  229. package/lib/quoxon/toolCards.js +30 -0
  230. package/lib/quoxon/transcript.js +2 -1
  231. package/lib/quoxon/welcome.js +1 -1
  232. package/lib/quoxon/whatsnew.js +1 -1
  233. package/lib/quoxon/whatsnew.jsonl +81 -0
  234. package/lib/quoxon/windowOrg.js +11 -1
  235. package/lib/server/devServers.js +137 -0
  236. package/lib/server/installFlow.js +168 -0
  237. package/lib/server/status.js +15 -1
  238. package/lib/toolOutputShape.js +55 -0
  239. package/package.json +4 -2
  240. package/vendor/dragonlab/SOURCE +4 -0
  241. package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
  242. package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
  243. package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
  244. package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
  245. package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
  246. package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
  247. package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
  248. package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
  249. package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
  250. package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
  251. package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
  252. package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
  253. package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
  254. package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
  255. package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
  256. package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
  257. package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
  258. package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
  259. package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
  260. package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
  261. package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
  262. package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
  263. package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
  264. package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
  265. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  266. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  267. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  268. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  269. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  270. package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
  271. package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
  272. package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
  273. package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
  274. package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
  275. package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
  276. package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
  277. package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
  278. package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
  279. package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
  280. package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
  281. package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
  282. package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
  283. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  284. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  285. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  286. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  287. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  288. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  289. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  290. package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
  291. package/vendor/dragonlab/integration/companions-v1/characters.mjs +108 -0
  292. package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
  293. package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
  294. package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
  295. package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
  296. package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
  297. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  298. package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
  299. package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
  300. package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
  301. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
  302. package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
  303. package/vendor/dragonlab/lib/power-effects.js +294 -0
  304. package/vendor/dragonlab/lib/power-registry.js +74 -0
  305. package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
  306. package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Sentinel-specific "Warden V" face rig.
3
+ *
4
+ * Resting eyes continue to use the EXACT canonical Q eyeCells().
5
+ * During action, dot whites turn into rounded triangular shields whose TOP
6
+ * EDGES descend toward the bridge. No floating shocked / raised eyebrows.
7
+ *
8
+ * Deliberately preserves qeyes' most important rule: an eye owns WHOLE
9
+ * terminal cells, including its dark pupil and empty eyelid cut-outs.
10
+ * Sentinel additions remain pure functions of time / geometry; no globals.
11
+ */
12
+ import {eyeCells,EYE_WHITE} from '../../vendor/quox-dragon/qeyes.js';
13
+
14
+ const BR=[[1,8],[2,16],[4,32],[64,128]];
15
+ const clamp=(n,a=0,b=1)=>Math.max(a,Math.min(b,n));
16
+
17
+ export function renderWardenEyes(geo,variant,{
18
+ intensity=0, leftIntensity=null,rightIntensity=null,
19
+ gx=0,gy=.25,open=1, mono=false
20
+ }={}){
21
+ const {cols,rows,Hq}=geo;
22
+ const valid=Number.isFinite(Hq)&&Hq>0&&
23
+ Number.isSafeInteger(cols)&&Number.isSafeInteger(rows)&&cols>0&&rows>0;
24
+ if(!valid)throw new RangeError('Invalid Sentinel eye geometry.');
25
+ const severity=clamp(Number.isFinite(intensity)?intensity:0);
26
+ if(severity===0&&leftIntensity==null&&rightIntensity==null){
27
+ // The familiar resting Sentinel look is real Quox art, unchanged.
28
+ const original=eyeCells(geo,{open:clamp(open),gx,gy},variant);
29
+ return original.map(row=>row.map(cell=>cell?
30
+ {ch:cell.ch,rgb:mono?null:cell.rgb,bg:null}:null));
31
+ }
32
+ const W=cols*2,H=rows*4;
33
+ const axis=2*Math.round((geo.ox+.41*Hq)/2);
34
+ const sep=2*Math.ceil(variant.sep*Hq/2);
35
+ const R=variant.r*Hq,cy=(geo.oy??0)+variant.y*Hq;
36
+ const lit=new Uint8Array(W*H),foot=new Uint8Array(cols*rows);
37
+ let visible=0;
38
+ const eyes=[
39
+ {side:-1,cx:axis-sep,level:clamp(leftIntensity??severity)},
40
+ {side:1,cx:axis+sep,level:clamp(rightIntensity??severity)}
41
+ ];
42
+ for(const {side,cx,level} of eyes){
43
+ const rx=R*variant.ox,ry=R*variant.oy;
44
+ const lean=level*0.72; // angled down *toward the bridge*
45
+ const lo=Math.floor(cx-rx-2),hi=Math.ceil(cx+rx+2);
46
+ const ty=Math.max(0,Math.floor(cy-ry-2)),by=Math.min(H-1,Math.ceil(cy+ry+2));
47
+ const pupilRadius=Math.max(1.1,R*variant.pupil*(1-level*.24));
48
+ const pupilX=cx+clamp(gx,-1,1)*Math.max(0,rx-pupilRadius-1)*.48;
49
+ const pupilY=cy+clamp(gy,-1,1)*Math.max(0,ry-pupilRadius-1)*.48+level*ry*.18;
50
+ for(let y=ty;y<=by;y++)for(let x=Math.max(0,lo);x<=Math.min(W-1,hi);x++){
51
+ const u=(x+.5-cx)/rx;
52
+ if(Math.abs(u)>1)continue;
53
+ const v=Math.sqrt(Math.max(0,1-u*u));
54
+ const inner=-side*u; // LEFT inner edge is right, RIGHT inner edge is left
55
+ const top=cy-ry*v+lean*ry*(.18+.42*clamp((inner+1)/2));
56
+ const bottom=cy+ry*v*(1-level*.14);
57
+ const yy=y+.5;
58
+ const inRoundEye=yy>=top-1.2&&yy<=bottom+1.1;
59
+ if(inRoundEye)foot[Math.floor(y/4)*cols+Math.floor(x/2)]=1;
60
+ if(yy<top||yy>bottom||clamp(open)<.12)continue;
61
+ // Keep an upper bright ridge, even for the severest glare.
62
+ const ridge=yy-top<1.15;
63
+ const dist=Math.hypot((x+.5-pupilX), (yy-pupilY));
64
+ const pupil=!ridge&&dist<pupilRadius;
65
+ const shine=pupil&&
66
+ Math.abs(x+.5-(pupilX-pupilRadius*.30))<.9&&
67
+ Math.abs(yy-(pupilY-pupilRadius*.40))<.9&&
68
+ pupilRadius>2.7;
69
+ if(!pupil||shine){
70
+ lit[y*W+x]=1;visible++;
71
+ }
72
+ }
73
+ // Cell-level margin: no solid blue body allowed to flood an eye's edge.
74
+ // The body is allowed around the two eyes but never inside either one.
75
+ for(let r=Math.max(0,Math.floor((cy-ry-2)/4));r<=Math.min(rows-1,Math.ceil((cy+ry+2)/4));r++)
76
+ for(let c=Math.max(0,Math.floor((cx-rx-2)/2));c<=Math.min(cols-1,Math.ceil((cx+rx+2)/2));c++){
77
+ const x=c*2+1,y=r*4+2;
78
+ if(((x-cx)/(rx+1.0))**2+((y-cy)/(ry+1.0))**2<=1)
79
+ foot[r*cols+c]=1;
80
+ }
81
+ }
82
+ const output=[];
83
+ for(let row=0;row<rows;row++){
84
+ const cells=[];
85
+ for(let col=0;col<cols;col++){
86
+ let bits=0;
87
+ for(let dy=0;dy<4;dy++)for(let dx=0;dx<2;dx++)
88
+ if(lit[(row*4+dy)*W+col*2+dx])bits|=BR[dy][dx];
89
+ cells.push(bits?{ch:String.fromCodePoint(0x2800+bits),rgb:mono?null:EYE_WHITE,bg:null}:
90
+ foot[row*cols+col]?{ch:' ',rgb:null,bg:null}:null);
91
+ }
92
+ output.push(cells);
93
+ }
94
+ return output;
95
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Tiny, deterministic absolute SVG path parser and 2x2-coverage shape helper.
3
+ * Only M/L/H/V/C/Q/Z are accepted; unsupported commands fail closed.
4
+ * No DOM, browser APIs, canvas dependency, third-party SVG parser or I/O.
5
+ *
6
+ * Unlike naive point-stamping, all fills are tested at sub-dot offsets identical
7
+ * to Quox's 2x2 vector->Braille rasterisation in qart.js and qdragon.js.
8
+ */
9
+ const TOKEN=/[MLHVCQZ]|[-+]?(?:\d*\.\d+|\d+\.?)(?:e[-+]?\d+)?/g;
10
+ const CMD=/^[MLHVCQZ]$/;
11
+ const PTS=[0.25,0.75];
12
+
13
+ export function flattenPath(source){
14
+ if(typeof source!=='string'||source.length>24000)
15
+ throw new TypeError('A path must be a short SVG path string.');
16
+ const tokens=source.match(TOKEN)||[];
17
+ // Reject SVG commands we have not deliberately implemented, including
18
+ // arc and relative-path syntax. Silently skipping them corrupts the art.
19
+ if(source.replace(TOKEN,'').replace(/[\s,]/g,'')!=='')
20
+ throw new RangeError('Unsupported SVG path command or syntax.');
21
+ if(!tokens.length||tokens[0]!=='M')throw new RangeError('Path must start with absolute M.');
22
+ const polygons=[];let poly=[],point=[0,0],start=[0,0],i=0,cmd=null,loops=0;
23
+ const take=()=>{
24
+ const v=tokens[i++];
25
+ if(v===undefined||CMD.test(v))throw new RangeError('Invalid SVG numeric argument.');
26
+ const n=Number(v);if(!Number.isFinite(n))throw new RangeError('Invalid SVG numeric coordinate.');
27
+ return n;
28
+ };
29
+ const push=(p)=>{point=p;poly.push(p);};
30
+ while(i<tokens.length){
31
+ if(++loops>20000)throw new RangeError('SVG path command limit reached.');
32
+ if(CMD.test(tokens[i]))cmd=tokens[i++];
33
+ if(!cmd)throw new RangeError('Unexpected coordinate before command.');
34
+ if(cmd==='M'){
35
+ if(poly.length>2)polygons.push(poly);
36
+ poly=[];start=[take(),take()];push(start);cmd='L';continue;
37
+ }
38
+ if(cmd==='L'){push([take(),take()]);continue;}
39
+ if(cmd==='H'){push([take(),point[1]]);continue;}
40
+ if(cmd==='V'){push([point[0],take()]);continue;}
41
+ if(cmd==='Q'){
42
+ const a=point,b=[take(),take()],c=[take(),take()];
43
+ const n=24;
44
+ for(let j=1;j<=n;j++){
45
+ const u=j/n,v=1-u;
46
+ push([v*v*a[0]+2*v*u*b[0]+u*u*c[0],
47
+ v*v*a[1]+2*v*u*b[1]+u*u*c[1]]);
48
+ }
49
+ continue;
50
+ }
51
+ if(cmd==='C'){
52
+ const a=point,b=[take(),take()],c=[take(),take()],d=[take(),take()];
53
+ const n=36;
54
+ for(let j=1;j<=n;j++){
55
+ const u=j/n,v=1-u;
56
+ push([v*v*v*a[0]+3*v*v*u*b[0]+3*v*u*u*c[0]+u*u*u*d[0],
57
+ v*v*v*a[1]+3*v*v*u*b[1]+3*v*u*u*c[1]+u*u*u*d[1]]);
58
+ }
59
+ continue;
60
+ }
61
+ if(cmd==='Z'){
62
+ if(poly.length>=3)polygons.push(poly);
63
+ poly=[];point=start;cmd=null;continue;
64
+ }
65
+ throw new RangeError('SVG command not supported: '+cmd);
66
+ }
67
+ if(poly.length>=2)polygons.push(poly);
68
+ if(!polygons.length)throw new RangeError('SVG path has no drawable coordinates.');
69
+ return polygons;
70
+ }
71
+
72
+ export function inside(polygons,x,y){
73
+ let yes=false;
74
+ for(const poly of polygons){
75
+ for(let i=0,j=poly.length-1;i<poly.length;j=i++){
76
+ const [ax,ay]=poly[i],[bx,by]=poly[j];
77
+ if((ay>y)!==(by>y)&&x<(bx-ax)*(y-ay)/(by-ay)+ax)yes=!yes;
78
+ }
79
+ }
80
+ return yes;
81
+ }
82
+
83
+ function segmentDistSq(px,py,ax,ay,bx,by){
84
+ const dx=bx-ax,dy=by-ay,dd=dx*dx+dy*dy;
85
+ const t=dd?Math.max(0,Math.min(1,((px-ax)*dx+(py-ay)*dy)/dd)):0;
86
+ const a=px-(ax+dx*t),b=py-(ay+dy*t);
87
+ return a*a+b*b;
88
+ }
89
+ export function nearStroke(polygons,x,y,width){
90
+ const radius=width*.5,limit=radius*radius;
91
+ for(const poly of polygons)
92
+ for(let i=1;i<poly.length;i++)
93
+ if(segmentDistSq(x,y,...poly[i-1],...poly[i])<=limit)return true;
94
+ return false;
95
+ }
96
+
97
+ /**
98
+ * Callback receives target dot x/y only if >=2 of the same four supersamples
99
+ * that the production Q uses lie within the source-space predicate.
100
+ */
101
+ export function sampleShape(polygons,{W,H,scale,ox,oy,stroke=0},put){
102
+ const points=polygons.flat();
103
+ const xx=points.map(p=>p[0]),yy=points.map(p=>p[1]);
104
+ const guard=stroke?stroke*.5+1:1;
105
+ const x0=Math.max(1,Math.floor(ox+(Math.min(...xx)-guard)*scale)-1);
106
+ const x1=Math.min(W-2,Math.ceil(ox+(Math.max(...xx)+guard)*scale)+1);
107
+ const y0=Math.max(1,Math.floor(oy+(Math.min(...yy)-guard)*scale)-1);
108
+ const y1=Math.min(H-2,Math.ceil(oy+(Math.max(...yy)+guard)*scale)+1);
109
+ if(stroke){
110
+ // Short brows and mouth outlines still use the straightforward
111
+ // segment-distance test, which is exact for these narrow contours.
112
+ for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++){
113
+ let n=0;
114
+ for(const sy of PTS)for(const sx of PTS)
115
+ if(nearStroke(polygons,(x+sx-ox)/scale,(y+sy-oy)/scale,stroke))n++;
116
+ if(n>=2)put(x,y);
117
+ }
118
+ return;
119
+ }
120
+ // Scanline fill: EXACTLY the same even/odd polygon rule and four sample
121
+ // locations, but calculate intersections once per sub-dot row instead of
122
+ // walking hundreds of SVG edges for every single dot. This dramatically
123
+ // reduces cold-frame latency while preserving the original Q 2x2 coverage.
124
+ const width=x1-x0+1,counts=new Uint8Array(width*(y1-y0+1));
125
+ const edges=[];
126
+ for(const poly of polygons)for(let i=0,j=poly.length-1;i<poly.length;j=i++){
127
+ const [ax,ay]=poly[j],[bx,by]=poly[i];
128
+ if(ay===by)continue;
129
+ edges.push([ax,ay,(bx-ax)/(by-ay),Math.min(ay,by),Math.max(ay,by)]);
130
+ }
131
+ for(let y=y0;y<=y1;y++)for(const sy of PTS){
132
+ const ys=(y+sy-oy)/scale;
133
+ const crosses=[];
134
+ for(const [ax,ay,slope,lo,hi] of edges)
135
+ if(ys>=lo&&ys<hi)crosses.push(ox+(ax+(ys-ay)*slope)*scale);
136
+ crosses.sort((a,b)=>a-b);
137
+ for(let i=0;i+1<crosses.length;i+=2){
138
+ const a=crosses[i],b=crosses[i+1];
139
+ for(const sx of PTS){
140
+ const start=Math.max(x0,Math.ceil(a-sx));
141
+ const end=Math.min(x1,Math.ceil(b-sx)-1);
142
+ for(let x=start;x<=end;x++)counts[(y-y0)*width+x-x0]++;
143
+ }
144
+ }
145
+ }
146
+ for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++)
147
+ if(counts[(y-y0)*width+x-x0]>=2)put(x,y);
148
+ }
@@ -0,0 +1,34 @@
1
+ import {renderSvgRig} from '../../../factory/svg-rig.mjs';
2
+ export const VULCAN_CLIPS=Object.freeze([{id:'idle',title:'Banked Furnace',duration:6000},{id:'fire',title:'Fire and Brimstone',duration:6000}]);
3
+ export const STORY_DURATION=12000;
4
+ export function poseRig(rig,scene,ms){
5
+ const t=ms%6000,heat=(1-Math.cos(t/6000*Math.PI*2))/2;
6
+ const paths=rig.paths.map(p=>({...p,rgb:p.id.includes('lava')||p.id.includes('heat')||p.id.includes('crack')||p.id==='hot-billet'||p.id==='hammer-seam'?[255,Math.round(100+95*heat),Math.round(25+30*heat)]:p.rgb}));
7
+ // Fixed camera includes the complete mouth-to-forge flame envelope.
8
+ if(scene==='fire'){
9
+ const power=Math.sin(Math.PI*t/6000)**2;
10
+ if(power>.02){
11
+ const flame=(id,rgb,points)=>({id,group:'fire',rgb,stroke:0,erase:false,polygons:[points]});
12
+ // Authored muzzle already faces right/down toward the workpiece.
13
+ const tip=[468+153*power,194+113*power],cx=468+87*power,cy=194+57*power;
14
+ paths.push(flame('fire-outer',[255,85,20],[[458,187],[cx,cy-23*power],[tip[0]-9*power,tip[1]-34*power],tip,[tip[0]-45*power,tip[1]-12*power],[cx-13*power,cy+19*power],[458,201]]));
15
+ paths.push(flame('fire-heart',[255,220,92],[[458,192],[cx,cy-5*power],[tip[0]-8*power,tip[1]-6*power],[cx-5*power,cy+7*power],[458,198]]));
16
+ for(let i=0;i<4;i++){
17
+ const phase=(t/1100+i/4)%1,x=595+(i-1.5)*18+phase*12,y=304-phase*64;
18
+ const s=2+2*power;paths.push(flame('brimstone-'+i,[255,211,67],[[x,y-s],[x+s,y],[x,y+s],[x-s,y]]));
19
+ }
20
+ }
21
+ }
22
+ return {...rig,paths};
23
+ }
24
+ export function renderVulcan(rig,scene='idle',ms=0,options={}){
25
+ if(!VULCAN_CLIPS.some(c=>c.id===scene))return {ok:false,code:'UNKNOWN_CLIP'};
26
+ if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
27
+ const r=renderSvgRig(poseRig(rig,scene,options.still?3000:ms),options);
28
+ return {...r,scene,clip:scene,caption:scene==='fire'?'Fire and brimstone.':'The furnace waits.'};
29
+ }
30
+ export function renderStory(rig,ms,options={}){
31
+ if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
32
+ const t=options.still?9000:ms%STORY_DURATION;
33
+ return renderVulcan(rig,t<6000?'idle':'fire',t%6000,options);
34
+ }
@@ -0,0 +1,57 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480">
2
+ <title>Vulcan — basalt dragon at the brimstone forge</title>
3
+ <g id="tail">
4
+ <path id="tail-rock" fill="#706a71" d="M 297 331 C 212 319 191 391 110 380 L 57 345 L 75 398 Q 167 450 290 398 Z"/>
5
+ <path id="tail-lava" fill="none" stroke="#ff801f" stroke-width="9" d="M 283 365 L 228 363 L 191 399 L 120 398 L 79 375"/>
6
+ <path id="tail-spines" fill="#c1a68c" d="M 80 362 L 53 317 L 112 376 L 121 347 L 152 382 L 176 355 L 190 381 L 214 333 L 234 356 Z"/>
7
+ </g>
8
+ <g id="wings">
9
+ <path id="far-wing" fill="#504953" d="M 366 287 L 349 126 L 293 65 L 300 162 L 241 129 L 278 219 L 252 239 Z"/>
10
+ <path id="near-wing" fill="#7e7273" d="M 302 307 L 231 186 L 146 130 L 159 213 L 112 192 L 154 265 L 123 284 L 229 316 Z"/>
11
+ <path id="wing-lava" fill="none" stroke="#ff8b27" stroke-width="9" d="M 302 307 L 231 186 L 146 130 M 231 186 L 190 255 L 123 284 M 366 287 L 349 126 L 293 65"/>
12
+ </g>
13
+ <g id="body">
14
+ <path id="body-rock" fill="#8b7d78" d="M 290 205 L 367 188 L 433 250 L 452 345 L 416 408 L 291 409 L 255 359 L 263 278 Z"/>
15
+ <path id="shoulder-plate" fill="#b4a08a" d="M 291 214 L 332 190 L 361 222 L 346 259 L 301 266 L 276 240 Z"/>
16
+ <path id="belly-core" fill="#ff6820" d="M 352 255 L 403 268 L 420 329 L 394 381 L 346 366 L 321 312 Z"/>
17
+ <path id="belly-plates" fill="none" stroke="#554953" stroke-width="15" d="M 336 280 L 405 295 M 329 310 L 418 326 M 339 341 L 408 352"/>
18
+ <path id="rock-feet" fill="#a28b7a" d="M 292 345 L 329 365 L 321 410 L 341 430 L 266 430 L 252 410 L 261 370 Z M 397 365 L 434 356 L 453 407 L 476 428 L 390 430 L 378 411 Z"/>
19
+ <path id="claws" fill="#f3dfb0" d="M 268 413 L 255 433 L 282 430 L 290 410 L 287 431 L 309 430 L 315 414 L 321 432 L 343 429 M 398 412 L 386 433 L 409 430 L 420 411 L 419 432 L 442 431 L 447 414 L 454 432 L 479 430"/>
20
+ <path id="body-cracks" fill="none" stroke="#ffbc37" stroke-width="7" d="M 281 269 L 298 300 L 279 327 L 297 345 M 421 271 L 435 305 L 420 329 M 282 379 L 296 397 L 286 414"/>
21
+ </g>
22
+ <g id="neck">
23
+ <path id="neck-rock" fill="#99857a" d="M 338 163 L 397 162 L 430 257 L 375 280 L 327 232 Z"/>
24
+ <path id="neck-lava" fill="none" stroke="#ff9c2f" stroke-width="10" d="M 371 187 L 362 222 L 388 246"/>
25
+ </g>
26
+ <g id="head">
27
+ <path id="horn-far" fill="#d3ba91" d="M 330 131 L 311 89 L 326 33 L 341 92 L 357 115 Z"/>
28
+ <path id="horn-near" fill="#e4c798" d="M 387 119 L 401 65 L 439 34 L 423 90 L 420 138 Z"/>
29
+ <path id="head-rock" fill="#a99786" d="M 325 110 L 370 93 L 414 113 L 443 148 L 429 201 L 385 224 L 339 203 L 312 157 Z"/>
30
+ <path id="crown-plates" fill="#6d6063" d="M 328 112 L 347 83 L 365 110 L 382 77 L 400 112 L 418 94 L 422 134 L 382 123 Z"/>
31
+ <path id="face-cracks" fill="none" stroke="#ffbc37" stroke-width="7" d="M 371 109 L 367 131 L 380 143 M 335 172 L 348 192 L 372 202"/>
32
+ <path id="muzzle" fill="#bf9c7f" d="M 406 156 L 448 158 L 469 177 L 461 198 L 423 211 L 394 193 Z"/>
33
+ <path id="furnace-mouth" fill="#080d19" d="M 419 183 L 464 183 L 458 199 L 423 206 L 410 197 Z"/>
34
+ <path id="mouth-heat" fill="#ff8c22" d="M 423 191 L 459 189 L 452 196 L 427 201 Z"/>
35
+ <path id="nose" fill="#080d19" d="M 448 166 L 458 174 L 446 176 Z"/>
36
+ <path id="brow" fill="none" stroke="#e3c49b" stroke-width="8" d="M 330 140 L 354 135 M 374 137 L 406 143"/>
37
+ </g>
38
+ <g id="tools">
39
+ <path id="hammer-handle" fill="none" stroke="#d5aa77" stroke-width="17" d="M 461 296 L 533 140"/>
40
+ <path id="hammer-head" fill="#9b9290" d="M 493 86 L 565 110 L 580 151 L 559 176 L 485 150 L 474 112 Z"/>
41
+ <path id="hammer-face" fill="#dbb885" d="M 565 110 L 580 151 L 559 176 L 549 135 Z"/>
42
+ <path id="hammer-seam" fill="none" stroke="#ffaf2b" stroke-width="8" d="M 495 102 L 518 121 L 509 142 L 545 157"/>
43
+ <path id="arm" fill="#b59b80" d="M 410 247 L 438 241 L 455 267 L 478 253 L 495 264 L 493 288 L 455 309 L 431 293 Z"/>
44
+ <path id="grip" fill="none" stroke="#554953" stroke-width="5" d="M 471 261 L 488 271 M 466 273 L 485 282"/>
45
+ <path id="anvil" fill="#9a98a4" d="M 519 321 L 690 321 L 737 339 L 682 356 L 654 356 L 638 385 L 649 404 L 680 418 L 680 433 L 549 433 L 549 419 L 580 405 L 591 385 L 575 358 L 544 355 Z"/>
46
+ <path id="anvil-top" fill="none" stroke="#f0d8b2" stroke-width="9" d="M 521 322 L 688 322 L 733 338"/>
47
+ <path id="hot-billet" fill="#ffac2d" d="M 567 307 L 638 307 L 648 319 L 563 319 Z"/>
48
+ </g>
49
+ <g id="eyes">
50
+ <path id="far-eye-socket" fill="#080d19" d="M 325 146 Q 342 133 358 148 L 360 175 Q 342 188 326 173 Z"/>
51
+ <path id="far-eye-white" fill="#ffffff" d="M 331 149 Q 343 141 353 151 L 353 169 Q 342 179 332 169 Z"/>
52
+ <path id="far-pupil" fill="#080d19" d="M 343 160 C 343 152 351 152 351 160 C 351 168 343 168 343 160 Z"/>
53
+ <path id="near-eye-socket" fill="#080d19" d="M 369 145 Q 391 132 412 151 L 413 178 Q 389 193 370 177 Z"/>
54
+ <path id="near-eye-white" fill="#ffffff" d="M 376 149 Q 392 141 406 154 L 406 172 Q 391 183 377 172 Z"/>
55
+ <path id="near-pupil" fill="#080d19" d="M 391 162 C 391 152 401 152 401 162 C 401 172 391 172 391 162 Z"/>
56
+ </g>
57
+ </svg>
@@ -0,0 +1,39 @@
1
+ import {renderSvgRig} from '../../../factory/svg-rig.mjs';
2
+ import {renderVulcan as previous,poseRig as heatRig,VULCAN_CLIPS as previousClips} from '../v1/character.mjs';
3
+ export const VULCAN_CLIPS=Object.freeze([...previousClips,{id:'forge',title:'The Brimstone Forge',duration:9000}]);
4
+ export const STORY_DURATION=9000;
5
+ const ease=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x);};
6
+ export function hammerAngle(t){
7
+ if(t<3000)return 0;
8
+ if(t<3800)return -.15*ease((t-3000)/800);
9
+ if(t<4200)return -.15+1.23*((t-3800)/400)**2;
10
+ if(t<4480)return 1.08-.22*ease((t-4200)/280);
11
+ if(t<4900)return .86+.09*ease((t-4480)/420);
12
+ if(t<7000)return .95*(1-ease((t-4900)/2100));
13
+ return 0;
14
+ }
15
+ export function poseRig(rig,ms){
16
+ const t=ms%9000;
17
+ const posed=heatRig(rig,t<3000?'fire':'idle',t<3000?t*2:0);
18
+ const a=hammerAngle(t),c=Math.cos(a),s=Math.sin(a);
19
+ const turn=([x,y])=>[478+(x-478)*c-(y-275)*s,275+(x-478)*s+(y-275)*c];
20
+ const moving=new Set(['hammer-handle','hammer-head','hammer-face','hammer-seam','grip']);
21
+ const paths=posed.paths.map(p=>moving.has(p.id)?{...p,polygons:p.polygons.map(poly=>poly.map(turn))}:p);
22
+ const delta=t-4200;
23
+ if(delta>=0&&delta<1000){
24
+ const u=delta/1000;
25
+ for(let i=0;i<9;i++){
26
+ const vx=(i-4)*25,vy=-85-12*(i%3),x=625+vx*u,y=307+vy*u+72*u*u,r=3.5*(1-u)+.5;
27
+ paths.push({id:'strike-spark-'+i,group:'sparks',stroke:0,erase:false,rgb:i%2?[255,233,143]:[255,154,34],polygons:[[[x-r,y],[x,y-r*1.8],[x+r,y],[x,y+r*1.8]]]});
28
+ }
29
+ }
30
+ return {...posed,paths};
31
+ }
32
+ export function renderVulcan(rig,scene='idle',ms=0,options={}){
33
+ if(scene!=='forge')return previous(rig,scene,ms,options);
34
+ if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
35
+ const t=options.still?4350:ms%9000;
36
+ const caption=t<3000?'Heat the iron.':t<4200?'Raise the hammer.':t<4900?'Fire. Iron. Impact.':t<7000?'Let the sparks settle.':'Ready for the next forging.';
37
+ return {...renderSvgRig(poseRig(rig,t),options),clip:scene,scene,caption};
38
+ }
39
+ export const renderStory=(rig,ms,options={})=>renderVulcan(rig,'forge',ms,options);
@@ -0,0 +1,41 @@
1
+ import {renderSvgRig} from '../../../factory/svg-rig.mjs';
2
+ import {poseRig as thermalPose} from '../v1/character.mjs';
3
+ import {poseRig as forgePose,renderVulcan as previous,VULCAN_CLIPS as oldClips} from '../v2/character.mjs';
4
+ export const VULCAN_CLIPS=Object.freeze([{id:'attach',title:'The Smith Arrives',duration:3600},...oldClips.map(c=>c.id==='idle'?{...c,duration:12000}:c)]);
5
+ export const STORY_DURATION=15600;
6
+ const pulse=(t,start,end)=>t<=start||t>=end?0:Math.sin(Math.PI*(t-start)/(end-start))**2;
7
+ export function poseRig(rig,scene,ms){
8
+ const t=scene==='forge'?ms%9000:scene==='idle'?ms%12000:ms;
9
+ let base=scene==='forge'?forgePose(rig,t):thermalPose(rig,scene==='fire'?'fire':'idle',scene==='attach'?0:t);
10
+ // Keep mouth and target locked while emitting fire. Move only after it dies down.
11
+ const nod=scene==='attach'?pulse(t,300,1900):scene==='forge'?pulse(t,3800,4900):scene==='idle'?.22*pulse(t,2200,5200):0;
12
+ const follow=scene==='attach'?pulse(t,900,3200):scene==='forge'?pulse(t,4200,6800):scene==='idle'?.35*pulse(t,3100,7800):0;
13
+ const salute=scene==='attach'?-.13*pulse(t,1100,2900):0,c=Math.cos(salute),s=Math.sin(salute);
14
+ const tools=new Set(['hammer-handle','hammer-head','hammer-face','hammer-seam','grip']);
15
+ const paths=base.paths.map(p=>({...p,polygons:p.polygons.map(poly=>poly.map(([x,y])=>{
16
+ if(p.group==='head'||p.group==='eyes')return [x+4*nod,y+10*nod];
17
+ if(p.group==='neck'){const w=Math.max(0,Math.min(1,(270-y)/65));return [x+4*nod*w,y+10*nod*w];}
18
+ if(p.group==='wings'){const w=Math.max(0,Math.min(1,(310-y)/170));return [x+7*follow*w,y+5*follow*w];}
19
+ if(p.group==='tail'){const w=Math.max(0,Math.min(1,(270-x)/190));return [x,y-9*follow*w*w];}
20
+ if(tools.has(p.id)&&salute)return [478+(x-478)*c-(y-275)*s,275+(x-478)*s+(y-275)*c];
21
+ return [x,y];
22
+ }))}));
23
+ const byId=new Map(paths.map(p=>[p.id,p]));
24
+ const eyes=rig.eyes.map(e=>({...e,socket:byId.get(e.socket.id),white:byId.get(e.white.id),pupil:byId.get(e.pupil.id)}));
25
+ return {...base,paths,eyes};
26
+ }
27
+ export function renderVulcan(rig,scene='idle',ms=0,options={}){
28
+ if(!VULCAN_CLIPS.some(c=>c.id===scene))return {ok:false,code:'UNKNOWN_CLIP'};
29
+ if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
30
+ if(scene==='attach'&&ms>=3600&&!options.still)return renderVulcan(rig,'idle',ms-3600,options);
31
+ const t=options.still?({attach:1400,idle:4000,forge:4350,fire:3000}[scene]):ms;
32
+ const caption=scene==='attach'?'A nod from the forge.':scene==='idle'?'The furnace waits.':previous(rig,scene,t,options).caption;
33
+ return {...renderSvgRig(poseRig(rig,scene,t),options),clip:scene,scene,caption};
34
+ }
35
+ export function renderStory(rig,ms,options={}){
36
+ if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
37
+ if(options.still)return renderVulcan(rig,'forge',4350,options);
38
+ if(ms<3600)return renderVulcan(rig,'attach',ms,options);
39
+ if(ms<12600)return renderVulcan(rig,'forge',ms-3600,options);
40
+ return renderVulcan(rig,'idle',ms-12600,options);
41
+ }
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs/promises';
3
+ import {parseSvgRig} from '../../../factory/svg-rig.mjs';
4
+ import {renderVulcan,VULCAN_CLIPS} from './character.mjs';
5
+ import {artSlot,composeMessage} from '../../lucid/recovery/layout.mjs';
6
+ import {cellsToAnsi} from '../../../vendor/quox-dragon/qart.js';
7
+ const args=process.argv.slice(2),values=new Set(['--cols','--rows','--terminal-cols','--terminal-rows','--message','--scene','--at']);
8
+ for(let i=0;i<args.length;i++){
9
+ if(values.has(args[i])){if(!args[i+1]||args[i+1].startsWith('--'))throw Error('Missing option value');i++;}
10
+ else if(!['--large','--mono','--help','--watch','--attach','--intro','--still','--list'].includes(args[i]))throw Error('Unknown option: '+args[i]);
11
+ }
12
+ const value=(name,fallback)=>args.includes(name)?args[args.indexOf(name)+1]:fallback;
13
+ if(args.includes('--help')){console.log('Vulcan V3 forge studio. --watch --attach --intro --still --scene ID --at MS --list --large --mono --message TEXT --cols N --rows N --terminal-cols N --terminal-rows N');process.exit(0);}
14
+ if(args.includes('--list')){console.log(VULCAN_CLIPS.map(c=>c.id+' · '+c.title).join('\n'));process.exit(0);}
15
+ const w=Number(value('--terminal-cols',process.stdout.columns||80)),h=Number(value('--terminal-rows',process.stdout.rows||24));
16
+ if(!Number.isSafeInteger(w)||!Number.isSafeInteger(h)||w<1||h<1)throw Error('Invalid terminal size');
17
+ const slot=artSlot(w,h),large=args.includes('--large')||args.includes('--intro'),explicit=large||args.includes('--cols')||args.includes('--rows');
18
+ const cols=Number(value('--cols',large?Math.min(120,w-4):Math.min(39,slot.cols))),rows=Number(value('--rows',large?Math.min(36,h-4):Math.min(11,slot.rows)));
19
+ const message=value('--message','VULCAN\nThe furnace waits.');
20
+ if(!slot.showArt&&!explicit){console.log(composeMessage(null,message,{terminalCols:w}).join('\n'));}
21
+ else{
22
+ if(cols>w-4||rows>h-2)throw Error('Requested art exceeds terminal margins');
23
+ const rig=parseSvgRig(await fs.readFile(new URL('../v1/vulcan-master.svg',import.meta.url),'utf8'));
24
+ const opts={cols,rows,mono:args.includes('--mono'),still:args.includes('--still'),deck:large};
25
+ const scene=value('--scene',args.includes('--attach')?'attach':'idle');
26
+ const r=renderVulcan(rig,scene,Number(value('--at',0)),opts);
27
+ if(!r.ok)throw Error(r.code);
28
+ if(args.includes('--watch')||args.includes('--attach')||args.includes('--intro')){
29
+ const {watch}=await import('./watch.mjs');
30
+ await watch(rig,opts,{terminalCols:w,terminalRows:h,large,scene:args.includes('--scene')||args.includes('--attach')?scene:null,arrival:args.includes('--attach'),message:args.includes('--message')?message:null});
31
+ }else{
32
+ const shownMessage=args.includes('--message')?message:'VULCAN\n'+r.caption;
33
+ console.log((large?[...cellsToAnsi(r.frame),...composeMessage(null,shownMessage,{terminalCols:w})]:composeMessage(r.frame,shownMessage,{terminalCols:w})).join('\n'));
34
+ }
35
+ }
@@ -0,0 +1,56 @@
1
+ import {performance} from 'node:perf_hooks';
2
+ import {VULCAN_CLIPS,renderVulcan,renderStory,STORY_DURATION} from './character.mjs';
3
+ import {composeMessage} from '../../lucid/recovery/layout.mjs';
4
+ import {cellsToAnsi} from '../../../vendor/quox-dragon/qart.js';
5
+
6
+ /** One bounded performance. No alternate screen or full-screen clearing. */
7
+ export async function watch(paths,options,config){
8
+ if(!process.stdout.isTTY||!process.stdin.isTTY)throw new Error('--watch/--intro needs an interactive terminal. Use --scene and --at for snapshots.');
9
+ const {terminalCols,terminalRows,scene,large,message}=config;
10
+ const clip=scene?VULCAN_CLIPS.find(c=>c.id===scene):null;
11
+ const duration=(clip?.duration??STORY_DURATION)+(config.arrival?6000:0);
12
+ const realCols=process.stdout.columns,realRows=process.stdout.rows;
13
+ const start=performance.now();
14
+ const render=t=>{
15
+ const r=scene?renderVulcan(paths,scene,t,options):renderStory(paths,t,options);
16
+ if(!r?.ok)throw new Error(r?.code??'Invalid story frame');
17
+ const caption=message??'VULCAN · '+r.clip+'\n'+r.caption;
18
+ return large?[...cellsToAnsi(r.frame),...caption.split('\n')]:composeMessage(r.frame,caption,{terminalCols});
19
+ };
20
+ // Reserve enough lines for captions too; keep within the real terminal.
21
+ const height=options.rows+3;
22
+ if(height>=terminalRows)throw new Error('Animation needs room for its caption; reduce --rows.');
23
+ let ended=false,drawn=false,timer,resolveDone,rejectDone;
24
+ const wasRaw=Boolean(process.stdin.isRaw);
25
+ const done=new Promise((resolve,reject)=>{resolveDone=resolve;rejectDone=reject;});
26
+ const cleanup=(error)=>{
27
+ if(ended)return;ended=true;clearInterval(timer);
28
+ process.stdin.off('data',key);process.stdout.off('resize',resized);
29
+ process.off('SIGINT',sigint);process.off('SIGTERM',sigterm);
30
+ process.stdin.setRawMode(wasRaw);process.stdin.pause();
31
+ if(drawn)process.stdout.write(`\x1b[${height}B\r`);
32
+ process.stdout.write('\x1b[0m\x1b[?25h');
33
+ error?rejectDone(error):resolveDone();
34
+ };
35
+ const key=data=>{if(data.includes(3)||data.toString().includes('q'))cleanup();};
36
+ const sigint=()=>cleanup(),sigterm=()=>cleanup();
37
+ const resized=()=>cleanup(new Error('Terminal resized; rerun for the new art slot.'));
38
+ try{
39
+ process.stdin.setRawMode(true);process.stdin.resume();process.stdin.on('data',key);
40
+ process.stdout.on('resize',resized);process.on('SIGINT',sigint);process.on('SIGTERM',sigterm);
41
+ process.stdout.write('\x1b[?25l');
42
+ // Allocate the block once; each frame replaces only these lines.
43
+ process.stdout.write('\n'.repeat(height)+`\x1b[${height}A`);drawn=true;
44
+ const tick=()=>{
45
+ try{
46
+ if(process.stdout.columns!==realCols||process.stdout.rows!==realRows)return resized();
47
+ const t=Math.min(performance.now()-start,duration-1),lines=render(t);
48
+ if(lines.length>height)throw new Error('Message exceeds the animation block; use a shorter message or a static preview.');
49
+ process.stdout.write(Array.from({length:height},(_,i)=>'\r\x1b[2K'+(lines[i]??'')+'\n').join('')+`\x1b[${height}A`);
50
+ if(performance.now()-start>=duration)cleanup();
51
+ }catch(e){cleanup(e);}
52
+ };
53
+ tick();if(!ended)timer=setInterval(tick,1000/6);
54
+ await done;
55
+ }catch(e){cleanup(e);await done;}
56
+ }
@@ -0,0 +1,64 @@
1
+ /** Shared authored SVG → original Q cells, supporting independently owned eyes. */
2
+ import {flattenPath,sampleShape} from '../design/stage3/svg-coverage.js';
3
+ import {makeField,fieldToCells} from '../vendor/quox-dragon/qart.js';
4
+ const attr=(s,k)=>s.match(new RegExp(`(?:^|\\s)${k}="([^"]*)"`))?.[1];
5
+ export function parseSvgRig(svg){
6
+ if(!svg.includes('viewBox="0 0 800 480"'))throw Error('Expected authored 800x480 master');
7
+ const ids=new Set(),groups=new Map(),stack=[];
8
+ for(const [tag]of svg.matchAll(/<g\s[^>]*>|<\/g>|<path\s[^>]*\/>/g)){
9
+ if(tag.startsWith('<g '))stack.push(attr(tag,'id'));else if(tag==='</g>')stack.pop();else groups.set(attr(tag,'id'),stack.at(-1));
10
+ }
11
+ const paths=[...svg.matchAll(/<path\s[^>]*\/>/g)].map(([tag])=>{
12
+ const id=attr(tag,'id'),d=attr(tag,'d'),stroke=attr(tag,'stroke'),fill=attr(tag,'fill');
13
+ if(!id||ids.has(id)||!d)throw Error('Every path requires unique id and geometry');ids.add(id);
14
+ const colour=stroke||fill;if(!/^#[0-9a-f]{6}$/i.test(colour))throw Error('Explicit hex colour required');
15
+ const width=stroke?Number(attr(tag,'stroke-width')):0;
16
+ if(!Number.isFinite(width)||width<0||width>30)throw Error('Invalid stroke width');
17
+ return {id,group:groups.get(id),polygons:flattenPath(d),rgb:[1,3,5].map(i=>parseInt(colour.slice(i,i+2),16)),stroke:width,erase:colour==='#080d19'};
18
+ });
19
+ const eyes=paths.filter(p=>p.id.endsWith('-eye-socket')).map(p=>{
20
+ const name=p.id.slice(0,-11),white=paths.find(p=>p.id===name+'-eye-white'),pupil=paths.find(p=>p.id===name+'-pupil');
21
+ if(!white||!pupil)throw Error('Every eye needs white and pupil');return {name,socket:p,white,pupil};
22
+ });
23
+ if(!paths.length||!eyes.length)throw Error('Missing anatomy or eyes');
24
+ const points=paths.flatMap(p=>p.polygons.flat());
25
+ const x=points.map(p=>p[0]),y=points.map(p=>p[1]);
26
+ return {paths,eyes,bounds:{left:Math.min(...x),top:Math.min(...y),right:Math.max(...x),bottom:Math.max(...y)}};
27
+ }
28
+ export function renderSvgRig(rig,{cols=39,rows=11,mono=false}={}){
29
+ if(!Number.isSafeInteger(cols)||!Number.isSafeInteger(rows)||cols<28||rows<10||cols>200||rows>72)throw RangeError('Art needs 28–200 columns and 10–72 rows');
30
+ if(typeof mono!=='boolean')throw TypeError('mono must be boolean');
31
+ const W=cols*2,H=rows*4,{left,top,right,bottom}=rig.bounds;
32
+ const scale=Math.min((W-4)/(right-left),(H-4)/(bottom-top));
33
+ const geometry={W,H,scale,ox:(W-(right-left)*scale)/2-left*scale,oy:(H-(bottom-top)*scale)/2-top*scale};
34
+ const field=makeField(W,H),labels=new Array(W*H).fill(''),colour=new Array(W*H).fill(null);
35
+ const coverage=p=>{const indices=[];sampleShape(p.polygons,{...geometry,stroke:p.stroke},(x,y)=>indices.push(y*W+x));return indices;};
36
+ const eyeIds=new Set(rig.eyes.flatMap(e=>[e.socket.id,e.white.id,e.pupil.id]));
37
+ function paint(p){for(const i of coverage(p)){field.v[i]=p.erase?0:1;labels[i]=p.id;colour[i]=p.rgb;}}
38
+ for(const p of rig.paths)if(!eyeIds.has(p.id))paint(p);
39
+ // Track eye cells for diagnostics; carve only the curved socket coverage.
40
+ const owned=new Set();
41
+ for(const e of rig.eyes)for(const i of coverage(e.socket))owned.add(Math.floor(i%W/2)+Math.floor(Math.floor(i/W)/4)*cols);
42
+ // Preserve facial dots outside each authored socket. Cell colour still prioritizes eye white.
43
+ for(const e of rig.eyes)paint(e.socket);
44
+ const pupilDots={};
45
+ for(const e of rig.eyes){
46
+ paint(e.white);let dots=coverage(e.pupil);
47
+ if(!dots.length){
48
+ // Optical correction is local to THIS eye; no Lucid-specific coordinates.
49
+ const points=e.pupil.polygons.flat(),cx=points.reduce((n,p)=>n+p[0],0)/points.length,cy=points.reduce((n,p)=>n+p[1],0)/points.length;
50
+ let best=-1,d=Infinity;
51
+ for(let i=0;i<labels.length;i++)if(labels[i]===e.white.id&&field.v[i]){const q=(i%W+.5-(geometry.ox+cx*scale))**2+(Math.floor(i/W)+.5-(geometry.oy+cy*scale))**2;if(q<d){best=i;d=q;}}
52
+ if(best>=0)dots=[best];
53
+ }
54
+ for(const i of dots){field.v[i]=0;labels[i]=e.pupil.id;colour[i]=null;}
55
+ pupilDots[e.name]=dots.length;
56
+ }
57
+ const frame=fieldToCells(field,{color:false,quant:false});let ink=0;
58
+ for(let y=0;y<rows;y++)for(let x=0;x<cols;x++){
59
+ const c=frame[y][x];c.bg=null;c.rgb=null;let chosen=-1,rank=-1;
60
+ for(let dy=0;dy<4;dy++)for(let dx=0;dx<2;dx++){const i=(y*4+dy)*W+x*2+dx;if(!field.v[i])continue;const r=labels[i].endsWith('-eye-white')?2:1;if(r>rank){rank=r;chosen=i;}}
61
+ if(chosen>=0){ink++;if(!mono)c.rgb=[...colour[chosen]];}
62
+ }
63
+ return {ok:true,frame,geometry,metrics:{cols,rows,ink,pupilDots,eyeOwnedCells:owned.size},visualOnly:true,demo:true};
64
+ }
@@ -0,0 +1,63 @@
1
+ /** Shared authored SVG → original Q cells, supporting independently owned eyes. */
2
+ import {flattenPath,sampleShape} from '../design/stage3/svg-coverage.js';
3
+ import {makeField,fieldToCells} from '../vendor/quox-dragon/qart.js';
4
+ const attr=(s,k)=>s.match(new RegExp(`(?:^|\\s)${k}="([^"]*)"`))?.[1];
5
+ export function parseSvgRig(svg){
6
+ if(!svg.includes('viewBox="0 0 800 480"'))throw Error('Expected authored 800x480 master');
7
+ const ids=new Set(),groups=new Map(),stack=[];
8
+ for(const [tag]of svg.matchAll(/<g\s[^>]*>|<\/g>|<path\s[^>]*\/>/g)){
9
+ if(tag.startsWith('<g '))stack.push(attr(tag,'id'));else if(tag==='</g>')stack.pop();else groups.set(attr(tag,'id'),stack.at(-1));
10
+ }
11
+ const paths=[...svg.matchAll(/<path\s[^>]*\/>/g)].map(([tag])=>{
12
+ const id=attr(tag,'id'),d=attr(tag,'d'),stroke=attr(tag,'stroke'),fill=attr(tag,'fill');
13
+ if(!id||ids.has(id)||!d)throw Error('Every path requires unique id and geometry');ids.add(id);
14
+ const colour=stroke||fill;if(!/^#[0-9a-f]{6}$/i.test(colour))throw Error('Explicit hex colour required');
15
+ const width=stroke?Number(attr(tag,'stroke-width')):0;
16
+ if(!Number.isFinite(width)||width<0||width>30)throw Error('Invalid stroke width');
17
+ return {id,group:groups.get(id),polygons:flattenPath(d),rgb:[1,3,5].map(i=>parseInt(colour.slice(i,i+2),16)),stroke:width,erase:colour==='#080d19'};
18
+ });
19
+ const eyes=paths.filter(p=>p.id.endsWith('-eye-socket')).map(p=>{
20
+ const name=p.id.slice(0,-11),white=paths.find(p=>p.id===name+'-eye-white'),pupil=paths.find(p=>p.id===name+'-pupil');
21
+ if(!white||!pupil)throw Error('Every eye needs white and pupil');return {name,socket:p,white,pupil};
22
+ });
23
+ if(!paths.length||!eyes.length)throw Error('Missing anatomy or eyes');
24
+ const points=paths.flatMap(p=>p.polygons.flat());
25
+ const x=points.map(p=>p[0]),y=points.map(p=>p[1]);
26
+ return {paths,eyes,bounds:{left:Math.min(...x),top:Math.min(...y),right:Math.max(...x),bottom:Math.max(...y)}};
27
+ }
28
+ export function renderSvgRig(rig,{cols=39,rows=11,mono=false}={}){
29
+ if(!Number.isSafeInteger(cols)||!Number.isSafeInteger(rows)||cols<28||rows<10||cols>200||rows>72)throw RangeError('Art needs 28–200 columns and 10–72 rows');
30
+ if(typeof mono!=='boolean')throw TypeError('mono must be boolean');
31
+ const W=cols*2,H=rows*4,{left,top,right,bottom}=rig.bounds;
32
+ const scale=Math.min((W-4)/(right-left),(H-4)/(bottom-top));
33
+ const geometry={W,H,scale,ox:(W-(right-left)*scale)/2-left*scale,oy:(H-(bottom-top)*scale)/2-top*scale};
34
+ const field=makeField(W,H),labels=new Array(W*H).fill(''),colour=new Array(W*H).fill(null);
35
+ const coverage=p=>{const indices=[];sampleShape(p.polygons,{...geometry,stroke:p.stroke},(x,y)=>indices.push(y*W+x));return indices;};
36
+ const eyeIds=new Set(rig.eyes.flatMap(e=>[e.socket.id,e.white.id,e.pupil.id]));
37
+ function paint(p){for(const i of coverage(p)){field.v[i]=p.erase?0:1;labels[i]=p.id;colour[i]=p.rgb;}}
38
+ for(const p of rig.paths)if(!eyeIds.has(p.id))paint(p);
39
+ // Clear all eye-owned cells FIRST, so the far eye cannot erase the near eye.
40
+ const owned=new Set();
41
+ for(const e of rig.eyes)for(const i of coverage(e.socket))owned.add(Math.floor(i%W/2)+Math.floor(Math.floor(i/W)/4)*cols);
42
+ for(const cell of owned){const x=cell%cols*2,y=Math.floor(cell/cols)*4;for(let dy=0;dy<4;dy++)for(let dx=0;dx<2;dx++){const i=(y+dy)*W+x+dx;field.v[i]=0;labels[i]='';colour[i]=null;}}
43
+ const pupilDots={};
44
+ for(const e of rig.eyes){
45
+ paint(e.white);let dots=coverage(e.pupil);
46
+ if(!dots.length){
47
+ // Optical correction is local to THIS eye; no Lucid-specific coordinates.
48
+ const points=e.pupil.polygons.flat(),cx=points.reduce((n,p)=>n+p[0],0)/points.length,cy=points.reduce((n,p)=>n+p[1],0)/points.length;
49
+ let best=-1,d=Infinity;
50
+ for(let i=0;i<labels.length;i++)if(labels[i]===e.white.id&&field.v[i]){const q=(i%W+.5-(geometry.ox+cx*scale))**2+(Math.floor(i/W)+.5-(geometry.oy+cy*scale))**2;if(q<d){best=i;d=q;}}
51
+ if(best>=0)dots=[best];
52
+ }
53
+ for(const i of dots){field.v[i]=0;labels[i]=e.pupil.id;colour[i]=null;}
54
+ pupilDots[e.name]=dots.length;
55
+ }
56
+ const frame=fieldToCells(field,{color:false,quant:false});let ink=0;
57
+ for(let y=0;y<rows;y++)for(let x=0;x<cols;x++){
58
+ const c=frame[y][x];c.bg=null;c.rgb=null;let chosen=-1,rank=-1;
59
+ for(let dy=0;dy<4;dy++)for(let dx=0;dx<2;dx++){const i=(y*4+dy)*W+x*2+dx;if(!field.v[i])continue;const r=labels[i].endsWith('-eye-white')?2:1;if(r>rank){rank=r;chosen=i;}}
60
+ if(chosen>=0){ink++;if(!mono)c.rgb=[...colour[chosen]];}
61
+ }
62
+ return {ok:true,frame,geometry,metrics:{cols,rows,ink,pupilDots,eyeOwnedCells:owned.size},visualOnly:true,demo:true};
63
+ }