cyberia 3.0.3 → 3.2.5

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 (296) hide show
  1. package/{.env.production → .env.example} +20 -4
  2. package/.github/workflows/engine-cyberia.cd.yml +43 -10
  3. package/.github/workflows/engine-cyberia.ci.yml +48 -26
  4. package/.github/workflows/ghpkg.ci.yml +5 -5
  5. package/.github/workflows/gitlab.ci.yml +1 -1
  6. package/.github/workflows/hardhat.ci.yml +82 -0
  7. package/.github/workflows/npmpkg.ci.yml +60 -14
  8. package/.github/workflows/publish.ci.yml +26 -7
  9. package/.github/workflows/publish.cyberia.ci.yml +5 -5
  10. package/.github/workflows/pwa-microservices-template-page.cd.yml +6 -7
  11. package/.github/workflows/pwa-microservices-template-test.ci.yml +4 -4
  12. package/.github/workflows/release.cd.yml +14 -8
  13. package/.vscode/extensions.json +9 -8
  14. package/.vscode/settings.json +3 -2
  15. package/CHANGELOG.md +643 -1
  16. package/CLI-HELP.md +132 -57
  17. package/Dockerfile +4 -2
  18. package/README.md +347 -22
  19. package/WHITE-PAPER.md +1540 -0
  20. package/bin/build.js +21 -12
  21. package/bin/cyberia.js +2640 -106
  22. package/bin/deploy.js +258 -372
  23. package/bin/file.js +5 -1
  24. package/bin/index.js +2640 -106
  25. package/bin/vs.js +3 -3
  26. package/conf.js +169 -105
  27. package/deployment.yaml +236 -20
  28. package/hardhat/.env.example +31 -0
  29. package/hardhat/README.md +531 -0
  30. package/hardhat/WHITE-PAPER.md +1540 -0
  31. package/hardhat/contracts/ObjectLayerToken.sol +391 -0
  32. package/hardhat/deployments/.gitkeep +0 -0
  33. package/hardhat/deployments/hardhat-ObjectLayerToken.json +11 -0
  34. package/hardhat/hardhat.config.js +136 -0
  35. package/hardhat/ignition/modules/ObjectLayerToken.js +21 -0
  36. package/hardhat/networks/besu-object-layer.network.json +138 -0
  37. package/hardhat/package-lock.json +4323 -0
  38. package/hardhat/package.json +36 -0
  39. package/hardhat/scripts/deployObjectLayerToken.js +98 -0
  40. package/hardhat/test/ObjectLayerToken.js +592 -0
  41. package/hardhat/types/ethers-contracts/ObjectLayerToken.ts +690 -0
  42. package/hardhat/types/ethers-contracts/common.ts +92 -0
  43. package/hardhat/types/ethers-contracts/factories/ObjectLayerToken__factory.ts +1055 -0
  44. package/hardhat/types/ethers-contracts/factories/index.ts +4 -0
  45. package/hardhat/types/ethers-contracts/hardhat.d.ts +47 -0
  46. package/hardhat/types/ethers-contracts/index.ts +6 -0
  47. package/jsdoc.dd-cyberia.json +68 -0
  48. package/jsdoc.json +65 -49
  49. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -4
  50. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +5 -4
  51. package/manifests/deployment/dd-cyberia-development/deployment.yaml +562 -0
  52. package/manifests/deployment/dd-cyberia-development/proxy.yaml +297 -0
  53. package/manifests/deployment/dd-cyberia-development/pv-pvc.yaml +132 -0
  54. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  55. package/manifests/deployment/dd-test-development/deployment.yaml +88 -74
  56. package/manifests/deployment/dd-test-development/proxy.yaml +13 -4
  57. package/manifests/deployment/playwright/deployment.yaml +1 -1
  58. package/manifests/pv-pvc-dd.yaml +1 -1
  59. package/nodemon.json +1 -1
  60. package/package.json +60 -48
  61. package/proxy.yaml +118 -10
  62. package/pv-pvc.yaml +132 -0
  63. package/scripts/k3s-node-setup.sh +1 -1
  64. package/scripts/ports-ls.sh +2 -0
  65. package/scripts/rhel-grpc-setup.sh +56 -0
  66. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +47 -1
  67. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.model.js +17 -2
  68. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +5 -0
  69. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +80 -7
  70. package/src/api/cyberia-dialogue/cyberia-dialogue.controller.js +93 -0
  71. package/src/api/cyberia-dialogue/cyberia-dialogue.model.js +36 -0
  72. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +29 -0
  73. package/src/api/cyberia-dialogue/cyberia-dialogue.service.js +51 -0
  74. package/src/api/cyberia-entity/cyberia-entity.controller.js +74 -0
  75. package/src/api/cyberia-entity/cyberia-entity.model.js +24 -0
  76. package/src/api/cyberia-entity/cyberia-entity.router.js +27 -0
  77. package/src/api/cyberia-entity/cyberia-entity.service.js +42 -0
  78. package/src/api/cyberia-instance/cyberia-fallback-world.js +368 -0
  79. package/src/api/cyberia-instance/cyberia-instance.controller.js +92 -0
  80. package/src/api/cyberia-instance/cyberia-instance.model.js +84 -0
  81. package/src/api/cyberia-instance/cyberia-instance.router.js +63 -0
  82. package/src/api/cyberia-instance/cyberia-instance.service.js +191 -0
  83. package/src/api/cyberia-instance/cyberia-portal-connector.js +486 -0
  84. package/src/api/cyberia-instance-conf/cyberia-instance-conf.controller.js +74 -0
  85. package/src/api/cyberia-instance-conf/cyberia-instance-conf.defaults.js +413 -0
  86. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +228 -0
  87. package/src/api/cyberia-instance-conf/cyberia-instance-conf.router.js +27 -0
  88. package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +42 -0
  89. package/src/api/cyberia-map/cyberia-map.controller.js +79 -0
  90. package/src/api/cyberia-map/cyberia-map.model.js +30 -0
  91. package/src/api/cyberia-map/cyberia-map.router.js +40 -0
  92. package/src/api/cyberia-map/cyberia-map.service.js +74 -0
  93. package/src/api/document/document.service.js +1 -1
  94. package/src/api/file/file.controller.js +3 -1
  95. package/src/api/file/file.ref.json +18 -0
  96. package/src/api/file/file.service.js +28 -5
  97. package/src/api/ipfs/ipfs.controller.js +4 -25
  98. package/src/api/ipfs/ipfs.model.js +43 -34
  99. package/src/api/ipfs/ipfs.router.js +8 -13
  100. package/src/api/ipfs/ipfs.service.js +56 -104
  101. package/src/api/object-layer/README.md +347 -22
  102. package/src/api/object-layer/object-layer.controller.js +6 -2
  103. package/src/api/object-layer/object-layer.model.js +12 -8
  104. package/src/api/object-layer/object-layer.router.js +698 -42
  105. package/src/api/object-layer/object-layer.service.js +119 -37
  106. package/src/api/object-layer-render-frames/object-layer-render-frames.model.js +1 -2
  107. package/src/api/user/user.router.js +10 -5
  108. package/src/api/user/user.service.js +15 -14
  109. package/src/cli/baremetal.js +6 -10
  110. package/src/cli/cloud-init.js +0 -3
  111. package/src/cli/cluster.js +7 -7
  112. package/src/cli/db.js +723 -857
  113. package/src/cli/deploy.js +215 -105
  114. package/src/cli/env.js +34 -5
  115. package/src/cli/fs.js +5 -4
  116. package/src/cli/image.js +0 -3
  117. package/src/cli/index.js +83 -15
  118. package/src/cli/kubectl.js +211 -0
  119. package/src/cli/monitor.js +5 -6
  120. package/src/cli/release.js +284 -0
  121. package/src/cli/repository.js +708 -62
  122. package/src/cli/run.js +371 -151
  123. package/src/cli/secrets.js +73 -2
  124. package/src/cli/ssh.js +1 -1
  125. package/src/cli/test.js +3 -3
  126. package/src/client/Cryptokoyn.index.js +3 -4
  127. package/src/client/CyberiaPortal.index.js +3 -4
  128. package/src/client/Default.index.js +3 -4
  129. package/src/client/Itemledger.index.js +4 -963
  130. package/src/client/Underpost.index.js +3 -4
  131. package/src/client/components/core/AgGrid.js +20 -5
  132. package/src/client/components/core/Alert.js +2 -2
  133. package/src/client/components/core/AppStore.js +69 -0
  134. package/src/client/components/core/CalendarCore.js +2 -2
  135. package/src/client/components/core/Content.js +22 -3
  136. package/src/client/components/core/Docs.js +30 -6
  137. package/src/client/components/core/DropDown.js +137 -17
  138. package/src/client/components/core/FileExplorer.js +71 -4
  139. package/src/client/components/core/Input.js +1 -1
  140. package/src/client/components/core/Keyboard.js +2 -2
  141. package/src/client/components/core/LogIn.js +2 -2
  142. package/src/client/components/core/LogOut.js +2 -2
  143. package/src/client/components/core/Modal.js +20 -7
  144. package/src/client/components/core/Panel.js +0 -1
  145. package/src/client/components/core/PanelForm.js +19 -19
  146. package/src/client/components/core/RichText.js +1 -2
  147. package/src/client/components/core/SocketIo.js +82 -29
  148. package/src/client/components/core/SocketIoHandler.js +75 -0
  149. package/src/client/components/core/Stream.js +143 -95
  150. package/src/client/components/core/Webhook.js +40 -7
  151. package/src/client/components/cryptokoyn/AppStoreCryptokoyn.js +5 -0
  152. package/src/client/components/cryptokoyn/LogInCryptokoyn.js +3 -3
  153. package/src/client/components/cryptokoyn/LogOutCryptokoyn.js +2 -2
  154. package/src/client/components/cryptokoyn/MenuCryptokoyn.js +3 -3
  155. package/src/client/components/cryptokoyn/SocketIoCryptokoyn.js +3 -51
  156. package/src/client/components/cyberia/InstanceEngineCyberia.js +700 -0
  157. package/src/client/components/cyberia/MapEngineCyberia.js +1359 -2
  158. package/src/client/components/cyberia/ObjectLayerEngineModal.js +17 -6
  159. package/src/client/components/cyberia/ObjectLayerEngineViewer.js +92 -54
  160. package/src/client/components/cyberia-portal/AppStoreCyberiaPortal.js +5 -0
  161. package/src/client/components/cyberia-portal/CommonCyberiaPortal.js +217 -30
  162. package/src/client/components/cyberia-portal/CssCyberiaPortal.js +44 -2
  163. package/src/client/components/cyberia-portal/LogInCyberiaPortal.js +3 -4
  164. package/src/client/components/cyberia-portal/LogOutCyberiaPortal.js +2 -2
  165. package/src/client/components/cyberia-portal/MenuCyberiaPortal.js +104 -9
  166. package/src/client/components/cyberia-portal/RoutesCyberiaPortal.js +5 -0
  167. package/src/client/components/cyberia-portal/SocketIoCyberiaPortal.js +3 -49
  168. package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +4 -0
  169. package/src/client/components/default/AppStoreDefault.js +5 -0
  170. package/src/client/components/default/LogInDefault.js +3 -3
  171. package/src/client/components/default/LogOutDefault.js +2 -2
  172. package/src/client/components/default/MenuDefault.js +5 -5
  173. package/src/client/components/default/SocketIoDefault.js +3 -51
  174. package/src/client/components/itemledger/AppStoreItemledger.js +5 -0
  175. package/src/client/components/itemledger/LogInItemledger.js +3 -3
  176. package/src/client/components/itemledger/LogOutItemledger.js +2 -2
  177. package/src/client/components/itemledger/MenuItemledger.js +3 -3
  178. package/src/client/components/itemledger/SocketIoItemledger.js +3 -51
  179. package/src/client/components/underpost/AppStoreUnderpost.js +5 -0
  180. package/src/client/components/underpost/CssUnderpost.js +59 -0
  181. package/src/client/components/underpost/LogInUnderpost.js +6 -3
  182. package/src/client/components/underpost/LogOutUnderpost.js +4 -2
  183. package/src/client/components/underpost/MenuUnderpost.js +104 -18
  184. package/src/client/components/underpost/RoutesUnderpost.js +2 -0
  185. package/src/client/components/underpost/SocketIoUnderpost.js +3 -51
  186. package/src/client/public/cryptokoyn/assets/logo/base-icon.png +0 -0
  187. package/src/client/public/cryptokoyn/browserconfig.xml +12 -0
  188. package/src/client/public/cryptokoyn/microdata.json +85 -0
  189. package/src/client/public/cryptokoyn/site.webmanifest +57 -0
  190. package/src/client/public/cryptokoyn/sitemap +3 -3
  191. package/src/client/public/default/sitemap +3 -3
  192. package/src/client/public/itemledger/browserconfig.xml +2 -2
  193. package/src/client/public/itemledger/manifest.webmanifest +4 -4
  194. package/src/client/public/itemledger/microdata.json +71 -0
  195. package/src/client/public/itemledger/sitemap +3 -3
  196. package/src/client/public/itemledger/yandex-browser-manifest.json +2 -2
  197. package/src/client/public/test/sitemap +3 -3
  198. package/src/client/services/core/core.service.js +20 -8
  199. package/src/client/services/cyberia-dialogue/cyberia-dialogue.service.js +105 -0
  200. package/src/client/services/cyberia-entity/cyberia-entity.management.js +57 -0
  201. package/src/client/services/cyberia-entity/cyberia-entity.service.js +105 -0
  202. package/src/client/services/cyberia-instance/cyberia-instance.management.js +194 -0
  203. package/src/client/services/cyberia-instance/cyberia-instance.service.js +122 -0
  204. package/src/client/services/cyberia-instance-conf/cyberia-instance-conf.service.js +105 -0
  205. package/src/client/services/cyberia-map/cyberia-map.management.js +193 -0
  206. package/src/client/services/cyberia-map/cyberia-map.service.js +126 -0
  207. package/src/client/services/instance/instance.management.js +2 -2
  208. package/src/client/services/ipfs/ipfs.service.js +3 -23
  209. package/src/client/services/object-layer/object-layer.management.js +3 -3
  210. package/src/client/services/object-layer/object-layer.service.js +21 -0
  211. package/src/client/services/user/user.management.js +2 -2
  212. package/src/client/ssr/body/404.js +15 -11
  213. package/src/client/ssr/body/500.js +15 -11
  214. package/src/client/ssr/body/SwaggerDarkMode.js +285 -0
  215. package/src/client/ssr/head/PwaItemledger.js +60 -0
  216. package/src/client/ssr/offline/NoNetworkConnection.js +11 -10
  217. package/src/client/ssr/pages/CyberiaServerMetrics.js +1 -1
  218. package/src/client/ssr/pages/Test.js +11 -10
  219. package/src/client.build.js +0 -3
  220. package/src/client.dev.js +0 -3
  221. package/src/db/DataBaseProvider.js +17 -2
  222. package/src/db/mariadb/MariaDB.js +14 -9
  223. package/src/db/mongo/MongooseDB.js +17 -1
  224. package/src/grpc/cyberia/OFF_CHAIN_ECONOMY.md +305 -0
  225. package/src/grpc/cyberia/README.md +326 -0
  226. package/src/grpc/cyberia/grpc-server.js +530 -0
  227. package/src/index.js +24 -1
  228. package/src/proxy.js +0 -3
  229. package/src/runtime/express/Dockerfile +4 -0
  230. package/src/runtime/express/Express.js +33 -10
  231. package/src/runtime/lampp/Dockerfile +13 -2
  232. package/src/runtime/lampp/Lampp.js +33 -17
  233. package/src/runtime/wp/Dockerfile +68 -0
  234. package/src/runtime/wp/Wp.js +639 -0
  235. package/src/server/auth.js +36 -15
  236. package/src/server/backup.js +39 -12
  237. package/src/server/besu-genesis-generator.js +1630 -0
  238. package/src/server/client-build-docs.js +133 -17
  239. package/src/server/client-build-live.js +9 -18
  240. package/src/server/client-build.js +229 -101
  241. package/src/server/client-dev-server.js +14 -13
  242. package/src/server/client-formatted.js +109 -57
  243. package/src/server/conf.js +391 -164
  244. package/src/server/cron.js +27 -24
  245. package/src/server/dns.js +29 -12
  246. package/src/server/downloader.js +0 -2
  247. package/src/server/ipfs-client.js +24 -1
  248. package/src/server/logger.js +27 -9
  249. package/src/server/object-layer.js +217 -103
  250. package/src/server/peer.js +8 -2
  251. package/src/server/process.js +1 -50
  252. package/src/server/proxy.js +4 -8
  253. package/src/server/runtime.js +30 -9
  254. package/src/server/semantic-layer-generator-floor.js +359 -0
  255. package/src/server/semantic-layer-generator-skin.js +1294 -0
  256. package/src/server/semantic-layer-generator.js +116 -555
  257. package/src/server/ssr.js +0 -3
  258. package/src/server/start.js +19 -12
  259. package/src/server/tls.js +0 -2
  260. package/src/server.js +0 -4
  261. package/src/ws/IoInterface.js +1 -10
  262. package/src/ws/IoServer.js +14 -33
  263. package/src/ws/core/channels/core.ws.chat.js +65 -20
  264. package/src/ws/core/channels/core.ws.mailer.js +113 -32
  265. package/src/ws/core/channels/core.ws.stream.js +90 -31
  266. package/src/ws/core/core.ws.connection.js +12 -33
  267. package/src/ws/core/core.ws.emit.js +10 -26
  268. package/src/ws/core/core.ws.server.js +25 -58
  269. package/src/ws/default/channels/default.ws.main.js +53 -12
  270. package/src/ws/default/default.ws.connection.js +26 -13
  271. package/src/ws/default/default.ws.server.js +30 -12
  272. package/.env.development +0 -43
  273. package/.env.test +0 -43
  274. package/hardhat/contracts/CryptoKoyn.sol +0 -59
  275. package/hardhat/contracts/ItemLedger.sol +0 -73
  276. package/hardhat/contracts/Lock.sol +0 -34
  277. package/hardhat/hardhat.config.cjs +0 -45
  278. package/hardhat/ignition/modules/Lock.js +0 -18
  279. package/hardhat/networks/cryptokoyn-itemledger.network.json +0 -29
  280. package/hardhat/scripts/deployCryptokoyn.cjs +0 -25
  281. package/hardhat/scripts/deployItemledger.cjs +0 -25
  282. package/hardhat/test/Lock.js +0 -126
  283. package/hardhat/white-paper.md +0 -581
  284. package/src/client/components/cryptokoyn/CommonCryptokoyn.js +0 -29
  285. package/src/client/components/cryptokoyn/ElementsCryptokoyn.js +0 -38
  286. package/src/client/components/cyberia-portal/ElementsCyberiaPortal.js +0 -38
  287. package/src/client/components/default/ElementsDefault.js +0 -38
  288. package/src/client/components/itemledger/CommonItemledger.js +0 -29
  289. package/src/client/components/itemledger/ElementsItemledger.js +0 -38
  290. package/src/client/components/underpost/CommonUnderpost.js +0 -29
  291. package/src/client/components/underpost/ElementsUnderpost.js +0 -38
  292. package/src/ws/core/management/core.ws.chat.js +0 -8
  293. package/src/ws/core/management/core.ws.mailer.js +0 -16
  294. package/src/ws/core/management/core.ws.stream.js +0 -8
  295. package/src/ws/default/management/default.ws.main.js +0 -8
  296. package/white-paper.md +0 -581
@@ -0,0 +1,1294 @@
1
+ /**
2
+ * Skin semantic descriptor and template-based procedural generator.
3
+ *
4
+ * Instead of the shape/noise-field approach used by floor tiles, character
5
+ * skins are produced by painting pixel zones read from the canonical
6
+ * artist-authored templates in:
7
+ *
8
+ * src/client/public/cyberia/assets/templates/
9
+ *
10
+ * Templates define which pixels belong to each body part (hair, face/hands,
11
+ * shirt/breastplate, pants, shoes). A procedural per-seed color palette is
12
+ * applied so every generated skin has a distinct look while remaining
13
+ * technically valid for all four cardinal directions.
14
+ *
15
+ * Zone painting order (later zones override earlier ones):
16
+ * 1. skin – full body silhouette in skin-tone color
17
+ * 2. shirt – breastplate/shirt overpaints the torso
18
+ * 3. pants – legs overpaints the legs area
19
+ * 4. shoes – bottom rows of legs in shoe color
20
+ * 5. hair – hair overpaints the head crown
21
+ *
22
+ * UP direction derives from DOWN but colours the head area with hair instead
23
+ * of skin (the back of the head is visible).
24
+ * RIGHT direction mirrors LEFT (direction 06) template horizontally.
25
+ *
26
+ * @module src/server/semantic-layer-generator-skin.js
27
+ * @namespace SemanticLayerGeneratorSkin
28
+ */
29
+
30
+ import { readFileSync } from 'fs';
31
+ import path from 'path';
32
+ import { fileURLToPath } from 'url';
33
+ import crypto from 'crypto';
34
+
35
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
+ const TEMPLATES_DIR = path.resolve(__dirname, '../client/public/cyberia/assets/templates');
37
+
38
+ /* ─── Grid dimension (must match GRID_DIM in main generator) ────────────── */
39
+ const SKIN_GRID_DIM = 24;
40
+
41
+ /* ─── Y threshold: pixels at or above this row are considered "head" ─────── */
42
+ const HEAD_Y_MAX = 12;
43
+
44
+ /* ─── Y threshold: pixels at or above (>=) this row are "shoe" zone ──────── */
45
+ const SHOE_Y_MIN = 23;
46
+
47
+ /* ═══════════════════════════════════════════════════════════════════════════
48
+ * TEMPLATE LOADING
49
+ * Templates are loaded once at module initialisation (synchronous I/O).
50
+ * ═══════════════════════════════════════════════════════════════════════════ */
51
+
52
+ /**
53
+ * Loads a JSON template file and returns the parsed value.
54
+ * Returns [] on any error so the generator degrades gracefully.
55
+ * @param {string} filename
56
+ * @returns {Array}
57
+ */
58
+ function loadTemplate(filename) {
59
+ try {
60
+ return JSON.parse(readFileSync(path.join(TEMPLATES_DIR, filename), 'utf8'));
61
+ } catch {
62
+ return [];
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Reads a full skin template (item-skin-08.json / item-skin-06.json) and
68
+ * extracts the black (#000000) outline pixels from its `color` grid.
69
+ * Pixels outside the 24 × 24 play area (x or y >= SKIN_GRID_DIM) are ignored.
70
+ * @param {string} filename Template filename (full template, not style overlay).
71
+ * @returns {number[][]} Array of [x, y] border pixel coordinates.
72
+ */
73
+ function loadBorderFromTemplate(filename) {
74
+ try {
75
+ const template = JSON.parse(readFileSync(path.join(TEMPLATES_DIR, filename), 'utf8'));
76
+ const colorGrid = template.color;
77
+ if (!Array.isArray(colorGrid)) return [];
78
+ const border = [];
79
+ for (let y = 0; y < colorGrid.length; y++) {
80
+ const row = colorGrid[y];
81
+ if (!Array.isArray(row)) continue;
82
+ for (let x = 0; x < row.length; x++) {
83
+ if (row[x] === '#000000' && x < SKIN_GRID_DIM && y < SKIN_GRID_DIM) {
84
+ border.push([x, y]);
85
+ }
86
+ }
87
+ }
88
+ return border;
89
+ } catch {
90
+ return [];
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Raw template pixel lists keyed by direction and zone name.
96
+ * Coordinates are [x, y] pairs where (0,0) is the top-left cell of a
97
+ * SKIN_GRID_DIM × SKIN_GRID_DIM (24 × 24) grid.
98
+ *
99
+ * @type {{ down: Object, left: Object }}
100
+ */
101
+ const RAW = {
102
+ down: {
103
+ /** Full body silhouette – face, torso, arms, legs */
104
+ skin: loadTemplate('item-skin-style-skin-08.json'),
105
+ /** Shirt / breastplate zone */
106
+ shirt: loadTemplate('item-skin-style-breastplate-08.json'),
107
+ /** Legs / pants zone (includes shoe row) */
108
+ legs: loadTemplate('item-skin-style-legs-08.json'),
109
+ /** Hair – direction-agnostic crown */
110
+ hair: loadTemplate('item-skin-style-hair.json'),
111
+ /** Black outline border from full template color grid */
112
+ border: loadBorderFromTemplate('item-skin-08.json'),
113
+ },
114
+ left: {
115
+ skin: loadTemplate('item-skin-style-skin-06.json'),
116
+ shirt: loadTemplate('item-skin-style-breastplate-06.json'),
117
+ legs: loadTemplate('item-skin-style-legs-06.json'),
118
+ hair: loadTemplate('item-skin-style-hair.json'),
119
+ border: loadBorderFromTemplate('item-skin-06.json'),
120
+ },
121
+ };
122
+
123
+ /**
124
+ * Splits the legs template into pants (y < SHOE_Y_MIN) and shoes (y >= SHOE_Y_MIN).
125
+ * @param {number[][]} legsCoords
126
+ * @returns {{ pants: number[][], shoes: number[][] }}
127
+ */
128
+ function splitLegs(legsCoords) {
129
+ const pants = [];
130
+ const shoes = [];
131
+ for (const [x, y] of legsCoords) {
132
+ if (y >= SHOE_Y_MIN) shoes.push([x, y]);
133
+ else pants.push([x, y]);
134
+ }
135
+ return { pants, shoes };
136
+ }
137
+
138
+ /**
139
+ * Mirrors a pixel list horizontally so a left-facing sprite becomes right-facing.
140
+ * Uses the reference width 26 (template grid): x_new = 25 − x.
141
+ * All resulting coordinates remain within 0..23.
142
+ * @param {number[][]} coords
143
+ * @returns {number[][]}
144
+ */
145
+ function mirrorH(coords) {
146
+ return coords
147
+ .map(([x, y]) => [25 - x, y])
148
+ .filter(([x, y]) => x >= 0 && x < SKIN_GRID_DIM && y >= 0 && y < SKIN_GRID_DIM);
149
+ }
150
+
151
+ /**
152
+ * Per-direction pixel zone definitions.
153
+ * Shoes are separated from pants; hair and skin are provided per direction.
154
+ *
155
+ * @typedef {{ skin: number[][], shirt: number[][], pants: number[][], shoes: number[][], hair: number[][] }} DirectionZones
156
+ * @type {{ down: DirectionZones, left: DirectionZones, right: DirectionZones, up: Object }}
157
+ */
158
+ const ZONES = (() => {
159
+ const down = (() => {
160
+ const { pants, shoes } = splitLegs(RAW.down.legs);
161
+ return { skin: RAW.down.skin, shirt: RAW.down.shirt, pants, shoes, hair: RAW.down.hair, border: RAW.down.border };
162
+ })();
163
+
164
+ const left = (() => {
165
+ const { pants, shoes } = splitLegs(RAW.left.legs);
166
+ return { skin: RAW.left.skin, shirt: RAW.left.shirt, pants, shoes, hair: RAW.left.hair, border: RAW.left.border };
167
+ })();
168
+
169
+ const right = {
170
+ skin: mirrorH(left.skin),
171
+ shirt: mirrorH(left.shirt),
172
+ pants: mirrorH(left.pants),
173
+ shoes: mirrorH(left.shoes),
174
+ hair: mirrorH(left.hair),
175
+ border: mirrorH(left.border),
176
+ };
177
+
178
+ // UP: same body layout as DOWN but head pixels (y <= HEAD_Y_MAX) are painted
179
+ // with hair color (back of character's head), body/arms remain skin colored.
180
+ // Reuse down zones; we flag this dynamically in buildDirectionMatrix.
181
+ const up = { ...down, isUpDirection: true };
182
+
183
+ return { down, left, right, up };
184
+ })();
185
+
186
+ /* ═══════════════════════════════════════════════════════════════════════════
187
+ * COLOUR UTILITIES
188
+ * ═══════════════════════════════════════════════════════════════════════════ */
189
+
190
+ /**
191
+ * Mini LCG-based RNG (32-bit seed). Avoids importing from the parent module.
192
+ * @param {number} seed
193
+ * @returns {function(): number} RNG returning floats in [0, 1).
194
+ */
195
+ function lcgRng(seed) {
196
+ let s = seed >>> 0;
197
+ return () => {
198
+ s = (Math.imul(1664525, s) + 1013904223) >>> 0;
199
+ return s / 4294967296;
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Simple string hash → 32-bit unsigned integer.
205
+ * @param {string} str
206
+ * @returns {number}
207
+ */
208
+ function hashStr(str) {
209
+ let h = 0;
210
+ for (let i = 0; i < str.length; i++) h = (Math.imul(31, h) + str.charCodeAt(i)) | 0;
211
+ return h >>> 0;
212
+ }
213
+
214
+ /**
215
+ * HSL colour to [r, g, b] (each 0–255).
216
+ * @param {number} h Hue in [0, 1).
217
+ * @param {number} s Saturation in [0, 1].
218
+ * @param {number} l Lightness in [0, 1].
219
+ * @returns {number[]}
220
+ */
221
+ function hslToRgb(h, s, l) {
222
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
223
+ const p = 2 * l - q;
224
+ const hue2rgb = (x) => {
225
+ const t = ((x % 1) + 1) % 1;
226
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
227
+ if (t < 1 / 2) return q;
228
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
229
+ return p;
230
+ };
231
+ return [Math.round(hue2rgb(h + 1 / 3) * 255), Math.round(hue2rgb(h) * 255), Math.round(hue2rgb(h - 1 / 3) * 255)];
232
+ }
233
+
234
+ /** Clamp v to [lo, hi]. */
235
+ function clamp(v, lo, hi) {
236
+ return v < lo ? lo : v > hi ? hi : v;
237
+ }
238
+
239
+ /* ═══════════════════════════════════════════════════════════════════════════
240
+ * PALETTE DERIVATION
241
+ * All colours are deterministic from (seed, itemId).
242
+ * ═══════════════════════════════════════════════════════════════════════════ */
243
+
244
+ /**
245
+ * @typedef {Object} SkinPalette
246
+ * @property {number[]} skinColor RGBA – face / hands / neck
247
+ * @property {number[]} hairColor RGBA – hair
248
+ * @property {number[]} shirtColor RGBA – shirt / breastplate
249
+ * @property {number[]} pantsColor RGBA – pants / legs
250
+ * @property {number[]} shoeColor RGBA – shoes / boots
251
+ * @property {number} hairDepth Max Y row (inclusive) covered by hair; controls hair length.
252
+ */
253
+
254
+ /**
255
+ * Derives a fully deterministic 5-colour palette for a skin variant.
256
+ *
257
+ * @param {string} seed
258
+ * @param {string} itemId
259
+ * @param {'random'|'dark'|'light'|'vivid'|'natural'|'shaved'} [subtype='random']
260
+ * @returns {SkinPalette}
261
+ */
262
+ function deriveSkinPalette(seed, itemId, subtype = 'random') {
263
+ const rng = lcgRng(hashStr(`${seed}:${itemId}:skin-palette`));
264
+
265
+ /* ── Skin tones: dark brown → light peach ───────────────────────────── */
266
+ const allSkinTones = [
267
+ [38, 22, 14], // near-black
268
+ [72, 44, 28], // very dark
269
+ [110, 68, 44], // dark
270
+ [152, 100, 66], // medium
271
+ [186, 134, 94], // warm mid
272
+ [214, 170, 130], // light
273
+ [232, 196, 164], // very light
274
+ [248, 220, 196], // pale
275
+ ];
276
+ // Constrain tone pool by subtype
277
+ const skinPool =
278
+ subtype === 'dark' ? allSkinTones.slice(0, 3) : subtype === 'light' ? allSkinTones.slice(5) : allSkinTones;
279
+ const skinRgb = skinPool[Math.floor(rng() * skinPool.length)];
280
+
281
+ /* ── Hair: natural shades + vivid options ───────────────────────────── */
282
+ const naturalHair = [
283
+ [18, 12, 8], // near-black
284
+ [55, 32, 14], // very dark brown
285
+ [95, 56, 22], // dark brown
286
+ [158, 108, 42], // medium brown
287
+ [194, 156, 60], // blond
288
+ [210, 90, 36], // auburn / ginger
289
+ [36, 36, 36], // dark grey
290
+ [140, 140, 140], // grey
291
+ [230, 230, 230], // white
292
+ ];
293
+ const vividHair = [
294
+ [168, 22, 22], // vivid red
295
+ [22, 72, 210], // vivid blue
296
+ [20, 180, 76], // vivid green
297
+ [160, 22, 178], // vivid purple
298
+ [210, 168, 22], // golden
299
+ [22, 200, 200], // vivid cyan
300
+ [220, 60, 160], // hot pink
301
+ ];
302
+ const allHairPresets = [...naturalHair, ...vividHair];
303
+ const hairPool = subtype === 'vivid' ? vividHair : subtype === 'natural' ? naturalHair : allHairPresets;
304
+ const hairRgb = hairPool[Math.floor(rng() * hairPool.length)];
305
+
306
+ /* ── Clothing: random hue, reasonable saturation/lightness ─────────── */
307
+ const shirtRgb = hslToRgb(rng(), 0.6 + rng() * 0.35, 0.32 + rng() * 0.28);
308
+ const pantsRgb = hslToRgb(rng(), 0.55 + rng() * 0.4, 0.2 + rng() * 0.3);
309
+
310
+ /* Shoes tend to be darker (lower lightness) */
311
+ const shoeH = rng();
312
+ const shoeRgb = hslToRgb(shoeH, 0.35 + rng() * 0.4, 0.12 + rng() * 0.2);
313
+
314
+ /* ── Hair depth: controls how far down the hair goes on the head ───── *
315
+ * 0 → shaved (no hair at all — bald silhouette) *
316
+ * 5 – 11 → short crop to long flowing hair */
317
+ const hairDepthOptions = subtype === 'shaved' ? [0] : [5, 6, 7, 8, 9, 10, 11];
318
+ const hairDepth = hairDepthOptions[Math.floor(rng() * hairDepthOptions.length)];
319
+
320
+ return {
321
+ skinColor: [...skinRgb, 255],
322
+ hairColor: [...hairRgb, 255],
323
+ shirtColor: [...shirtRgb, 255],
324
+ pantsColor: [...pantsRgb, 255],
325
+ shoeColor: [...shoeRgb, 255],
326
+ hairDepth,
327
+ };
328
+ }
329
+
330
+ /* ═══════════════════════════════════════════════════════════════════════════
331
+ * FRAME MATRIX BUILDER
332
+ * ═══════════════════════════════════════════════════════════════════════════ */
333
+
334
+ /**
335
+ * Returns the index of `rgba` in `globalColors`, adding it if absent.
336
+ * @param {number[][]} globalColors
337
+ * @param {number[]} rgba
338
+ * @returns {number}
339
+ */
340
+ function getOrAddColor(globalColors, rgba) {
341
+ const idx = globalColors.findIndex(
342
+ (c) => c[0] === rgba[0] && c[1] === rgba[1] && c[2] === rgba[2] && c[3] === rgba[3],
343
+ );
344
+ if (idx >= 0) return idx;
345
+ globalColors.push([...rgba]);
346
+ return globalColors.length - 1;
347
+ }
348
+
349
+ /**
350
+ * Paints a list of [x,y] coordinates onto a frame matrix with a color index.
351
+ * Skips coordinates outside SKIN_GRID_DIM.
352
+ * @param {number[][]} matrix
353
+ * @param {number[][]} coords
354
+ * @param {number} colorIdx
355
+ */
356
+ function paint(matrix, coords, colorIdx) {
357
+ for (const [x, y] of coords) {
358
+ if (x >= 0 && x < SKIN_GRID_DIM && y >= 0 && y < SKIN_GRID_DIM) {
359
+ matrix[y][x] = colorIdx;
360
+ }
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Builds a 24 × 24 frame matrix for DOWN, LEFT, or RIGHT directions.
366
+ *
367
+ * Hair is derived from interior skin pixels at y <= palette.hairDepth.
368
+ * hairDepth === 0 means shaved head — all hair steps are skipped.
369
+ *
370
+ * Non-shaved heads get:
371
+ * 2b. Black hairline at y = hairDepth+1 with 2–4 bang wisps punching through.
372
+ * 2c. Side hair extension: narrow strips hang from the crown's left/right
373
+ * edges for hairExtend = hairDepth − 3 rows (same formula as UP's
374
+ * back-extension), ensuring visual hair-length consistency across all 4
375
+ * directions.
376
+ *
377
+ * @param {DirectionZones} zones
378
+ * @param {SkinPalette} palette
379
+ * @param {number[][]} globalColors Mutated in place to accumulate unique colours.
380
+ * @param {string} seed
381
+ * @param {string} itemId
382
+ * @param {string} dirLabel 'down' | 'left' | 'right' — differentiates RNG streams.
383
+ * @returns {number[][]}
384
+ */
385
+ function buildDirectionMatrix(zones, palette, globalColors, seed, itemId, dirLabel) {
386
+ const matrix = Array.from({ length: SKIN_GRID_DIM }, () => Array(SKIN_GRID_DIM).fill(0));
387
+
388
+ const skinIdx = getOrAddColor(globalColors, palette.skinColor);
389
+ const hairIdx = getOrAddColor(globalColors, palette.hairColor);
390
+ const shirtIdx = getOrAddColor(globalColors, palette.shirtColor);
391
+ const pantsIdx = getOrAddColor(globalColors, palette.pantsColor);
392
+ const shoeIdx = getOrAddColor(globalColors, palette.shoeColor);
393
+
394
+ // Sets for fast per-pixel lookup
395
+ const borderSet = new Set(zones.border.map(([x, y]) => `${x},${y}`));
396
+ const skinSet = new Set(zones.skin.map(([x, y]) => `${x},${y}`));
397
+
398
+ // 1. Full body silhouette → skin tone
399
+ paint(matrix, zones.skin, skinIdx);
400
+
401
+ // Compute hair pixels and per-row bounding boxes once — shared by steps 2b and 2c.
402
+ // hairDepth === 0 → shaved head, all hair steps are skipped.
403
+ const hairPixels =
404
+ palette.hairDepth > 0 ? zones.skin.filter(([x, y]) => y <= palette.hairDepth && !borderSet.has(`${x},${y}`)) : [];
405
+
406
+ const hairRowBounds = new Map();
407
+ for (const [x, y] of hairPixels) {
408
+ const b = hairRowBounds.get(y) || { min: x, max: x };
409
+ b.min = Math.min(b.min, x);
410
+ b.max = Math.max(b.max, x);
411
+ hairRowBounds.set(y, b);
412
+ }
413
+ const hairRows = [...hairRowBounds.keys()].sort((a, b) => a - b);
414
+
415
+ if (palette.hairDepth > 0) {
416
+ // 2. Hair fill
417
+ paint(matrix, hairPixels, hairIdx);
418
+
419
+ const blackIdx = getOrAddColor(globalColors, [0, 0, 0, 255]);
420
+
421
+ // 2b. Black hairline + bang wisps at the fringe (bottom of hair zone).
422
+ if (hairRows.length > 0) {
423
+ const rngFringe = lcgRng(hashStr(`${seed}:${itemId}:fringe-${dirLabel}`));
424
+ const bottomY = hairRows[hairRows.length - 1];
425
+ const { min: fMin, max: fMax } = hairRowBounds.get(bottomY);
426
+
427
+ // Build 2–4 random bang-wisp columns
428
+ const fringeCols = [];
429
+ for (let x = fMin; x <= fMax; x++) fringeCols.push(x);
430
+ const numWisps = 2 + Math.floor(rngFringe() * 3);
431
+ const wispCols = new Set();
432
+ for (let i = 0; i < numWisps; i++) {
433
+ const wx = fringeCols[Math.floor(rngFringe() * fringeCols.length)];
434
+ wispCols.add(wx);
435
+ const wLen = 1 + Math.floor(rngFringe() * 2);
436
+ for (let dy = 1; dy <= wLen; dy++) {
437
+ const wy = bottomY + dy;
438
+ if (wy < SKIN_GRID_DIM && wy <= HEAD_Y_MAX && skinSet.has(`${wx},${wy}`) && !borderSet.has(`${wx},${wy}`)) {
439
+ matrix[wy][wx] = hairIdx;
440
+ }
441
+ }
442
+ const tipY = bottomY + wLen + 1;
443
+ if (
444
+ tipY < SKIN_GRID_DIM &&
445
+ tipY <= HEAD_Y_MAX &&
446
+ skinSet.has(`${wx},${tipY}`) &&
447
+ !borderSet.has(`${wx},${tipY}`)
448
+ ) {
449
+ matrix[tipY][wx] = blackIdx;
450
+ }
451
+ }
452
+
453
+ // Black hairline for non-wisp fringe columns
454
+ const fringeY = bottomY + 1;
455
+ if (fringeY < SKIN_GRID_DIM) {
456
+ for (let x = fMin; x <= fMax; x++) {
457
+ if (!wispCols.has(x) && skinSet.has(`${x},${fringeY}`) && !borderSet.has(`${x},${fringeY}`)) {
458
+ matrix[fringeY][x] = blackIdx;
459
+ }
460
+ }
461
+ }
462
+ }
463
+
464
+ // 2c. SIDE HAIR STRANDS — 1-px strands at each temple, anchored at the
465
+ // outermost border columns of the head at HEAD_Y_MAX (ear level).
466
+ // Fixed anchor means position is independent of hairDepth, giving a
467
+ // consistent result across all seeds and templates.
468
+ //
469
+ // Length: 1–3 rows (hairDepth 5→1, 6→2, 7+→3).
470
+ // Width: 1 px by default; 30 % chance of 2 px per row for subtle
471
+ // seed-based variation. No inward drift.
472
+ {
473
+ const rngWisp = lcgRng(hashStr(`${seed}:${itemId}:outer-wisp-${dirLabel}`));
474
+ const strandRows = palette.hairDepth >= 5 ? Math.min(palette.hairDepth - 4, 3) : 0;
475
+
476
+ if (strandRows > 0) {
477
+ const borderAtHead = zones.border.filter(([, y]) => y === HEAD_Y_MAX);
478
+ if (borderAtHead.length >= 2) {
479
+ const hbL = Math.min(...borderAtHead.map(([x]) => x));
480
+ const hbR = Math.max(...borderAtHead.map(([x]) => x));
481
+
482
+ let lastLx = null,
483
+ lastRx = null;
484
+
485
+ for (let row = 0; row < strandRows; row++) {
486
+ const y = HEAD_Y_MAX + 1 + row; // just below ear level
487
+ if (y >= SKIN_GRID_DIM) break;
488
+ const ext = rngWisp() < 0.3 ? 2 : 1;
489
+
490
+ const lMin = Math.max(0, hbL - ext);
491
+ const rMax = Math.min(SKIN_GRID_DIM - 1, hbR + ext);
492
+
493
+ // Left strand: pixels outside the body to the left of hbL
494
+ for (let x = lMin; x < hbL; x++) matrix[y][x] = hairIdx;
495
+ // Right strand: pixels outside the body to the right of hbR
496
+ for (let x = hbR + 1; x <= rMax; x++) matrix[y][x] = hairIdx;
497
+
498
+ if (lMin > 0 && matrix[y][lMin - 1] !== hairIdx) matrix[y][lMin - 1] = blackIdx;
499
+ if (rMax < SKIN_GRID_DIM - 1 && matrix[y][rMax + 1] !== hairIdx) matrix[y][rMax + 1] = blackIdx;
500
+
501
+ lastLx = lMin;
502
+ lastRx = rMax;
503
+ }
504
+
505
+ const tipY = HEAD_Y_MAX + 1 + strandRows;
506
+ if (tipY < SKIN_GRID_DIM) {
507
+ if (lastLx !== null) matrix[tipY][Math.max(0, lastLx)] = blackIdx;
508
+ if (lastRx !== null) matrix[tipY][Math.min(SKIN_GRID_DIM - 1, lastRx)] = blackIdx;
509
+ }
510
+ }
511
+ }
512
+ }
513
+
514
+ // 2d. UPPER SIDE HAIR ARCH — thick arch on each temple anchored at y=10.
515
+ // The arch is widest at its crown (y=10, 4-5 px outward from the border)
516
+ // and tapers toward the ear base, creating a visible sideburn arc.
517
+ // 3 rows max (hairDepth 5→1 row, 6→2 rows, 7+→3 rows).
518
+ // Together with the lower 2c strands the pair forms a broken arc shape.
519
+ {
520
+ const UPPER_ANCHOR_Y = HEAD_Y_MAX - 2; // y=10
521
+ const rngWisp2 = lcgRng(hashStr(`${seed}:${itemId}:outer-wisp2-${dirLabel}`));
522
+ const strandRows2 = palette.hairDepth >= 5 ? Math.min(palette.hairDepth - 4, 3) : 0;
523
+
524
+ if (strandRows2 > 0) {
525
+ const borderAtUpper = zones.border.filter(([, y]) => y === UPPER_ANCHOR_Y);
526
+ if (borderAtUpper.length >= 2) {
527
+ const hbL2 = Math.min(...borderAtUpper.map(([x]) => x));
528
+ const hbR2 = Math.max(...borderAtUpper.map(([x]) => x));
529
+
530
+ // Arch widths per row: narrow at crown (y=10), widest at ear base (y=12).
531
+ // Follows the head silhouette arc — wider where the head is wider.
532
+ const extBase = [2, 3, 4];
533
+ let lastLx2 = null,
534
+ lastRx2 = null;
535
+
536
+ for (let row = 0; row < strandRows2; row++) {
537
+ const y = UPPER_ANCHOR_Y + row; // y=10, y=11, y=12
538
+ if (y >= SKIN_GRID_DIM) break;
539
+ const ext2 = extBase[row] + (rngWisp2() < 0.4 ? 1 : 0); // 40 % +1 bonus
540
+
541
+ const innerL2 = hbL2 + 2; // 2 px toward center
542
+ const innerR2 = hbR2 - 2;
543
+ const lMin2 = Math.max(0, innerL2 - ext2);
544
+ const rMax2 = Math.min(SKIN_GRID_DIM - 1, innerR2 + ext2);
545
+
546
+ for (let x = lMin2; x < innerL2; x++) matrix[y][x] = hairIdx;
547
+ for (let x = innerR2 + 1; x <= rMax2; x++) matrix[y][x] = hairIdx;
548
+
549
+ if (lMin2 > 0 && matrix[y][lMin2 - 1] !== hairIdx) matrix[y][lMin2 - 1] = blackIdx;
550
+ if (rMax2 < SKIN_GRID_DIM - 1 && matrix[y][rMax2 + 1] !== hairIdx) matrix[y][rMax2 + 1] = blackIdx;
551
+
552
+ lastLx2 = lMin2;
553
+ lastRx2 = rMax2;
554
+ }
555
+
556
+ const tipY2 = UPPER_ANCHOR_Y + strandRows2; // first row after arch
557
+ if (tipY2 < SKIN_GRID_DIM) {
558
+ if (lastLx2 !== null && matrix[tipY2][Math.max(0, lastLx2)] !== hairIdx)
559
+ matrix[tipY2][Math.max(0, lastLx2)] = blackIdx;
560
+ if (lastRx2 !== null && matrix[tipY2][Math.min(SKIN_GRID_DIM - 1, lastRx2)] !== hairIdx)
561
+ matrix[tipY2][Math.min(SKIN_GRID_DIM - 1, lastRx2)] = blackIdx;
562
+ }
563
+ }
564
+ }
565
+ }
566
+
567
+ // 2e. HAIR EDGE DISTORTIONS — 5–9 stray hair pixels scattered along the
568
+ // outer border of the hair zone for an organic, hand-drawn look.
569
+ // Each pixel sits one step outside the body silhouette and is closed
570
+ // with a black tip pixel for pixel-art definition.
571
+ {
572
+ const rngDist = lcgRng(hashStr(`${seed}:${itemId}:hair-distort-${dirLabel}`));
573
+ // Collect the outermost border column per hair row.
574
+ const borderInHair = zones.border.filter(([, y]) => y <= palette.hairDepth);
575
+ const bhrMap = new Map();
576
+ for (const [x, y] of borderInHair) {
577
+ const b = bhrMap.get(y) || { min: x, max: x };
578
+ b.min = Math.min(b.min, x);
579
+ b.max = Math.max(b.max, x);
580
+ bhrMap.set(y, b);
581
+ }
582
+ const bhrRows = [...bhrMap.keys()];
583
+ const numDistort = 5 + Math.floor(rngDist() * 5); // 5–9
584
+ for (let i = 0; i < numDistort; i++) {
585
+ const y = bhrRows[Math.floor(rngDist() * bhrRows.length)];
586
+ const bnd = bhrMap.get(y);
587
+ if (!bnd) continue;
588
+ const side = rngDist() < 0.5 ? -1 : 1;
589
+ const px = side < 0 ? bnd.min - 1 : bnd.max + 1;
590
+ if (px >= 0 && px < SKIN_GRID_DIM && matrix[y][px] !== hairIdx) {
591
+ matrix[y][px] = hairIdx;
592
+ const bx = px + side;
593
+ if (bx >= 0 && bx < SKIN_GRID_DIM && matrix[y][bx] !== hairIdx) matrix[y][bx] = blackIdx;
594
+ }
595
+ }
596
+ }
597
+
598
+ // 2f. MID-CROWN SIDE HAIR ARC — 7-row arc of hair strands following the
599
+ // head edge, centered at y=7 (crown shoulders). Right strand anchors
600
+ // near x=8 (character right), left mirrors it at x≈15. Width peaks
601
+ // at the center row (3 px outward) and tapers to 1 px at the extremes.
602
+ // Per-row 40 % width bonus + 30 % extra distortion pixel per side.
603
+ {
604
+ const CROWN_Y = 7;
605
+ const crownW = [3, 2, 2, 1]; // base width at |dy| = 0, 1, 2, 3
606
+ const rngCrown = lcgRng(hashStr(`${seed}:${itemId}:crown-side-${dirLabel}`));
607
+
608
+ for (let dy = -3; dy <= 3; dy++) {
609
+ const y = CROWN_Y + dy;
610
+ if (y < 0 || y >= SKIN_GRID_DIM) continue;
611
+
612
+ const borderAtY = zones.border.filter(([, by]) => by === y);
613
+ if (borderAtY.length < 2) continue;
614
+ const hbL = Math.min(...borderAtY.map(([x]) => x));
615
+ const hbR = Math.max(...borderAtY.map(([x]) => x));
616
+
617
+ const ext = crownW[Math.abs(dy)] + (rngCrown() < 0.4 ? 1 : 0);
618
+
619
+ // Left strand (character right, viewer left)
620
+ const lMin = Math.max(0, hbL - ext);
621
+ for (let x = lMin; x < hbL; x++) matrix[y][x] = hairIdx;
622
+ if (lMin > 0 && matrix[y][lMin - 1] !== hairIdx) matrix[y][lMin - 1] = blackIdx;
623
+
624
+ // Right strand (character left, viewer right)
625
+ const rMax = Math.min(SKIN_GRID_DIM - 1, hbR + ext);
626
+ for (let x = hbR + 1; x <= rMax; x++) matrix[y][x] = hairIdx;
627
+ if (rMax < SKIN_GRID_DIM - 1 && matrix[y][rMax + 1] !== hairIdx) matrix[y][rMax + 1] = blackIdx;
628
+
629
+ // Distortion: 30 % chance of one extra pixel per side
630
+ if (rngCrown() < 0.3) {
631
+ const px = lMin - 1;
632
+ if (px >= 0 && matrix[y][px] !== hairIdx) {
633
+ matrix[y][px] = hairIdx;
634
+ if (px > 0 && matrix[y][px - 1] !== hairIdx) matrix[y][px - 1] = blackIdx;
635
+ }
636
+ }
637
+ if (rngCrown() < 0.3) {
638
+ const px = rMax + 1;
639
+ if (px < SKIN_GRID_DIM && matrix[y][px] !== hairIdx) {
640
+ matrix[y][px] = hairIdx;
641
+ if (px < SKIN_GRID_DIM - 1 && matrix[y][px + 1] !== hairIdx) matrix[y][px + 1] = blackIdx;
642
+ }
643
+ }
644
+ }
645
+ }
646
+ }
647
+
648
+ // 3. Clothes
649
+ paint(matrix, zones.shirt, shirtIdx);
650
+ paint(matrix, zones.pants, pantsIdx);
651
+ paint(matrix, zones.shoes, shoeIdx);
652
+
653
+ // 4. Black border outline — always last
654
+ const borderIdx = getOrAddColor(globalColors, [0, 0, 0, 255]);
655
+ paint(matrix, zones.border, borderIdx);
656
+
657
+ // 4b. CROWN BORDER SOFTENING — for non-shaved skins, replace most outer
658
+ // silhouette border pixels within the hair zone with hair colour so the
659
+ // crown blends into the background rather than having a hard black outline.
660
+ // Interior features (eyes, pupils) are never touched — only outer-silhouette
661
+ // pixels (those with at least one empty 8-neighbour) are candidates.
662
+ // ~25 % of candidates are kept black for pixel-art definition.
663
+ if (palette.hairDepth > 0) {
664
+ const rngBorder = lcgRng(hashStr(`${seed}:${itemId}:soften-border-${dirLabel}`));
665
+ for (const [bx, by] of zones.border) {
666
+ if (by > palette.hairDepth) continue;
667
+ let isOuter = false;
668
+ for (let dx = -1; dx <= 1 && !isOuter; dx++) {
669
+ for (let dy = -1; dy <= 1 && !isOuter; dy++) {
670
+ if (dx === 0 && dy === 0) continue;
671
+ const nx = bx + dx, ny = by + dy;
672
+ if (nx < 0 || nx >= SKIN_GRID_DIM || ny < 0 || ny >= SKIN_GRID_DIM ||
673
+ (!skinSet.has(`${nx},${ny}`) && !borderSet.has(`${nx},${ny}`)))
674
+ isOuter = true;
675
+ }
676
+ }
677
+ if (isOuter && rngBorder() < 0.75) matrix[by][bx] = hairIdx;
678
+ }
679
+ }
680
+
681
+ return matrix;
682
+ }
683
+
684
+ /**
685
+ * Builds a 24 × 24 frame matrix for the UP (back-facing) direction.
686
+ *
687
+ * The entire head is covered with hair, eliminating face features (eyes/mouth).
688
+ * Only the outer silhouette border is kept black; inner face-feature pixels
689
+ * (pupils, mouth line) are overwritten with hair.
690
+ *
691
+ * Hair flows below the head onto the upper back with a tapering strip.
692
+ * Black borders frame the sides and bottom of the flowing hair.
693
+ * Random wisps extend 1–2 px beyond the body silhouette for dynamism.
694
+ *
695
+ * @param {DirectionZones} zones Reuses ZONES.down pixel data.
696
+ * @param {SkinPalette} palette
697
+ * @param {number[][]} globalColors Mutated in place.
698
+ * @param {string} seed
699
+ * @param {string} itemId
700
+ * @returns {number[][]}
701
+ */
702
+ function buildUpDirectionMatrix(zones, palette, globalColors, seed, itemId) {
703
+ const matrix = Array.from({ length: SKIN_GRID_DIM }, () => Array(SKIN_GRID_DIM).fill(0));
704
+
705
+ const skinIdx = getOrAddColor(globalColors, palette.skinColor);
706
+ const hairIdx = getOrAddColor(globalColors, palette.hairColor);
707
+ const shirtIdx = getOrAddColor(globalColors, palette.shirtColor);
708
+ const pantsIdx = getOrAddColor(globalColors, palette.pantsColor);
709
+ const shoeIdx = getOrAddColor(globalColors, palette.shoeColor);
710
+ const blackIdx = getOrAddColor(globalColors, [0, 0, 0, 255]);
711
+
712
+ const rng = lcgRng(hashStr(`${seed}:${itemId}:up-hair`));
713
+
714
+ // All body pixels (skin + border) used for outer-silhouette detection
715
+ const allBodyCoords = [...zones.skin, ...zones.border];
716
+ const bodySet = new Set(allBodyCoords.map(([x, y]) => `${x},${y}`));
717
+
718
+ // 1. Paint full body → skin tone, then clothes, then border
719
+ paint(matrix, zones.skin, skinIdx);
720
+ paint(matrix, zones.shirt, shirtIdx);
721
+ paint(matrix, zones.pants, pantsIdx);
722
+ paint(matrix, zones.shoes, shoeIdx);
723
+ paint(matrix, zones.border, blackIdx);
724
+
725
+ // 1b. FACE FEATURE WIPE — the zones come from the DOWN (front-facing) template
726
+ // which embeds eyes, pupils and mouth as black border pixels. In the UP
727
+ // (back-of-head) view those pixels must not appear.
728
+ // Walk every border pixel in the face+neck band (y ≤ FACE_FEATURE_Y_MAX).
729
+ // If a pixel has ALL 8 neighbours inside bodySet it is an *interior* feature
730
+ // (eye outline, pupil, mouth corners) — paint it with skinIdx so it is
731
+ // invisible. Outer-silhouette pixels keep their black value.
732
+ //
733
+ // The mouth is at y=15 in the 26×26 template, which is ABOVE HEAD_Y_MAX=12
734
+ // so the subsequent scanline fill does NOT reach it — this wipe is the only
735
+ // place that erases it.
736
+ const FACE_FEATURE_Y_MAX = 17; // below collar; safe upper bound for face features
737
+ for (const [bx, by] of zones.border) {
738
+ if (by > FACE_FEATURE_Y_MAX) continue;
739
+ let outer = false;
740
+ for (let dx = -1; dx <= 1 && !outer; dx++) {
741
+ for (let dy = -1; dy <= 1 && !outer; dy++) {
742
+ if (dx === 0 && dy === 0) continue;
743
+ const nx = bx + dx,
744
+ ny = by + dy;
745
+ if (nx < 0 || nx >= SKIN_GRID_DIM || ny < 0 || ny >= SKIN_GRID_DIM || !bodySet.has(`${nx},${ny}`)) outer = true;
746
+ }
747
+ }
748
+ if (!outer) matrix[by][bx] = skinIdx;
749
+ }
750
+
751
+ // 2. HEAD HAIR — two paths: shaved (hairDepth=0) or normal.
752
+ //
753
+ // SHAVED: scanline-fill the head with *skin tone* (covers inner face-feature
754
+ // border pixels so no eyes/mouth bleed through on the bald back-head).
755
+ // Repaint only the outer silhouette black. No extensions or wisps.
756
+ //
757
+ // NORMAL: scanline-fill with hair colour, keep inner pixels as hair, repaint
758
+ // outer silhouette black, then extend hair strip + add wisps.
759
+ const headBounds = new Map();
760
+ for (const [x, y] of allBodyCoords) {
761
+ if (y > HEAD_Y_MAX) continue;
762
+ const b = headBounds.get(y) || { min: x, max: x };
763
+ b.min = Math.min(b.min, x);
764
+ b.max = Math.max(b.max, x);
765
+ headBounds.set(y, b);
766
+ }
767
+
768
+ // Shared helper: repaint outer head silhouette black; inner pixels stay as-is.
769
+ // A border pixel is "outer" if any 8-connected neighbour is outside bodySet.
770
+ const repaintOuterHeadBorder = () => {
771
+ for (const [bx, by] of zones.border) {
772
+ if (by > HEAD_Y_MAX) continue;
773
+ let outer = false;
774
+ for (let dx = -1; dx <= 1 && !outer; dx++) {
775
+ for (let dy = -1; dy <= 1 && !outer; dy++) {
776
+ if (dx === 0 && dy === 0) continue;
777
+ const nx = bx + dx,
778
+ ny = by + dy;
779
+ if (nx < 0 || nx >= SKIN_GRID_DIM || ny < 0 || ny >= SKIN_GRID_DIM || !bodySet.has(`${nx},${ny}`))
780
+ outer = true;
781
+ }
782
+ }
783
+ if (outer) matrix[by][bx] = blackIdx;
784
+ }
785
+ };
786
+
787
+ if (palette.hairDepth === 0) {
788
+ // ── SHAVED HEAD ─────────────────────────────────────────────────────────
789
+ // Fill head scanlines with skin tone (eliminates any face-feature artefacts)
790
+ for (const [y, { min, max }] of headBounds) {
791
+ for (let x = min; x <= max; x++) matrix[y][x] = skinIdx;
792
+ }
793
+ repaintOuterHeadBorder();
794
+ // No extended hair, no wisps.
795
+ } else {
796
+ // ── NORMAL HAIR HEAD ────────────────────────────────────────────────────
797
+ // Scanline-fill y <= HEAD_Y_MAX with hair colour
798
+ for (const [y, { min, max }] of headBounds) {
799
+ for (let x = min; x <= max; x++) matrix[y][x] = hairIdx;
800
+ }
801
+
802
+ // Repaint outer silhouette black; inner face-feature pixels stay as hair
803
+ repaintOuterHeadBorder();
804
+
805
+ // Crown border softening (UP) — same intent as 4b in buildDirectionMatrix.
806
+ // repaintOuterHeadBorder() just set outer head border pixels to black;
807
+ // randomly convert 75 % of them back to hair colour for a softer crown edge.
808
+ {
809
+ const rngBorder = lcgRng(hashStr(`${seed}:${itemId}:soften-border-up`));
810
+ for (const [bx, by] of zones.border) {
811
+ if (by > HEAD_Y_MAX) continue;
812
+ if (matrix[by][bx] !== blackIdx) continue; // already hair / not yet set
813
+ let isOuter = false;
814
+ for (let dx = -1; dx <= 1 && !isOuter; dx++) {
815
+ for (let dy = -1; dy <= 1 && !isOuter; dy++) {
816
+ if (dx === 0 && dy === 0) continue;
817
+ const nx = bx + dx, ny = by + dy;
818
+ if (nx < 0 || nx >= SKIN_GRID_DIM || ny < 0 || ny >= SKIN_GRID_DIM ||
819
+ !bodySet.has(`${nx},${ny}`)) isOuter = true;
820
+ }
821
+ }
822
+ if (isOuter && rngBorder() < 0.75) matrix[by][bx] = hairIdx;
823
+ }
824
+ }
825
+
826
+ // 2b side. SIDE HAIR STRANDS (UP) — identical geometry to the other three
827
+ // directions: 1-px strands just below HEAD_Y_MAX, anchored at the
828
+ // head border bounds at that row.
829
+ {
830
+ const headAtMax = headBounds.get(HEAD_Y_MAX);
831
+ if (headAtMax && palette.hairDepth >= 5) {
832
+ const rngWisp = lcgRng(hashStr(`${seed}:${itemId}:outer-wisp-up`));
833
+ const strandRows = Math.min(palette.hairDepth - 4, 3);
834
+ const hbL = headAtMax.min;
835
+ const hbR = headAtMax.max;
836
+
837
+ let lastLx = null,
838
+ lastRx = null;
839
+
840
+ for (let row = 0; row < strandRows; row++) {
841
+ const y = HEAD_Y_MAX + 1 + row;
842
+ if (y >= SKIN_GRID_DIM) break;
843
+ const ext = rngWisp() < 0.3 ? 2 : 1;
844
+
845
+ const lMin = Math.max(0, hbL - ext);
846
+ const rMax = Math.min(SKIN_GRID_DIM - 1, hbR + ext);
847
+
848
+ for (let x = lMin; x < hbL; x++) matrix[y][x] = hairIdx;
849
+ for (let x = hbR + 1; x <= rMax; x++) matrix[y][x] = hairIdx;
850
+
851
+ if (lMin > 0 && matrix[y][lMin - 1] !== hairIdx) matrix[y][lMin - 1] = blackIdx;
852
+ if (rMax < SKIN_GRID_DIM - 1 && matrix[y][rMax + 1] !== hairIdx) matrix[y][rMax + 1] = blackIdx;
853
+
854
+ lastLx = lMin;
855
+ lastRx = rMax;
856
+ }
857
+
858
+ const tipY = HEAD_Y_MAX + 1 + strandRows;
859
+ if (tipY < SKIN_GRID_DIM) {
860
+ if (lastLx !== null) matrix[tipY][Math.max(0, lastLx)] = blackIdx;
861
+ if (lastRx !== null) matrix[tipY][Math.min(SKIN_GRID_DIM - 1, lastRx)] = blackIdx;
862
+ }
863
+ }
864
+ }
865
+
866
+ // 2c side. UPPER SIDE HAIR ARCH (UP) — mirrors 2d in buildDirectionMatrix;
867
+ // thick arch starting at y=10, widest at the crown.
868
+ {
869
+ const UPPER_ANCHOR_Y = HEAD_Y_MAX - 2; // y=10
870
+ const headAtUpper = headBounds.get(UPPER_ANCHOR_Y);
871
+ if (headAtUpper && palette.hairDepth >= 5) {
872
+ const rngWisp2 = lcgRng(hashStr(`${seed}:${itemId}:outer-wisp2-up`));
873
+ const strandRows2 = Math.min(palette.hairDepth - 4, 3);
874
+ const hbL2 = headAtUpper.min;
875
+ const hbR2 = headAtUpper.max;
876
+
877
+ const extBase = [2, 3, 4];
878
+ let lastLx2 = null,
879
+ lastRx2 = null;
880
+
881
+ for (let row = 0; row < strandRows2; row++) {
882
+ const y = UPPER_ANCHOR_Y + row; // y=10, y=11, y=12
883
+ if (y >= SKIN_GRID_DIM) break;
884
+ const ext2 = extBase[row] + (rngWisp2() < 0.4 ? 1 : 0);
885
+
886
+ const innerL2 = hbL2 + 2; // 2 px toward center
887
+ const innerR2 = hbR2 - 2;
888
+ const lMin2 = Math.max(0, innerL2 - ext2);
889
+ const rMax2 = Math.min(SKIN_GRID_DIM - 1, innerR2 + ext2);
890
+
891
+ for (let x = lMin2; x < innerL2; x++) matrix[y][x] = hairIdx;
892
+ for (let x = innerR2 + 1; x <= rMax2; x++) matrix[y][x] = hairIdx;
893
+
894
+ if (lMin2 > 0 && matrix[y][lMin2 - 1] !== hairIdx) matrix[y][lMin2 - 1] = blackIdx;
895
+ if (rMax2 < SKIN_GRID_DIM - 1 && matrix[y][rMax2 + 1] !== hairIdx) matrix[y][rMax2 + 1] = blackIdx;
896
+
897
+ lastLx2 = lMin2;
898
+ lastRx2 = rMax2;
899
+ }
900
+
901
+ const tipY2 = UPPER_ANCHOR_Y + strandRows2;
902
+ if (tipY2 < SKIN_GRID_DIM) {
903
+ if (lastLx2 !== null && matrix[tipY2][Math.max(0, lastLx2)] !== hairIdx)
904
+ matrix[tipY2][Math.max(0, lastLx2)] = blackIdx;
905
+ if (lastRx2 !== null && matrix[tipY2][Math.min(SKIN_GRID_DIM - 1, lastRx2)] !== hairIdx)
906
+ matrix[tipY2][Math.min(SKIN_GRID_DIM - 1, lastRx2)] = blackIdx;
907
+ }
908
+ }
909
+ }
910
+
911
+ // 2d. HEAD HAIR EDGE DISTORTIONS (UP) — 5–9 stray pixels along the outer
912
+ // head silhouette for organic texture. Identical approach to 2e in
913
+ // buildDirectionMatrix, using headBounds for column references.
914
+ {
915
+ const rngDist = lcgRng(hashStr(`${seed}:${itemId}:hair-distort-up`));
916
+ const hbKeys = [...headBounds.keys()];
917
+ const numDistort = 5 + Math.floor(rngDist() * 5);
918
+ for (let i = 0; i < numDistort; i++) {
919
+ const y = hbKeys[Math.floor(rngDist() * hbKeys.length)];
920
+ const bnd = headBounds.get(y);
921
+ if (!bnd) continue;
922
+ const side = rngDist() < 0.5 ? -1 : 1;
923
+ const px = side < 0 ? bnd.min - 1 : bnd.max + 1;
924
+ if (px >= 0 && px < SKIN_GRID_DIM && matrix[y][px] !== hairIdx) {
925
+ matrix[y][px] = hairIdx;
926
+ const bx = px + side;
927
+ if (bx >= 0 && bx < SKIN_GRID_DIM && matrix[y][bx] !== hairIdx) matrix[y][bx] = blackIdx;
928
+ }
929
+ }
930
+ }
931
+
932
+ // 2e. MID-CROWN SIDE HAIR ARC (UP) — mirrors 2f in buildDirectionMatrix;
933
+ // same 7-row arc centered at y=7, using headBounds for the silhouette
934
+ // edge reference instead of zones.border.
935
+ {
936
+ const CROWN_Y = 7;
937
+ const crownW = [3, 2, 2, 1];
938
+ const rngCrown = lcgRng(hashStr(`${seed}:${itemId}:crown-side-up`));
939
+
940
+ for (let dy = -3; dy <= 3; dy++) {
941
+ const y = CROWN_Y + dy;
942
+ if (y < 0 || y >= SKIN_GRID_DIM) continue;
943
+ const bnd = headBounds.get(y);
944
+ if (!bnd) continue;
945
+ const hbL = bnd.min;
946
+ const hbR = bnd.max;
947
+
948
+ const ext = crownW[Math.abs(dy)] + (rngCrown() < 0.4 ? 1 : 0);
949
+
950
+ const lMin = Math.max(0, hbL - ext);
951
+ for (let x = lMin; x < hbL; x++) matrix[y][x] = hairIdx;
952
+ if (lMin > 0 && matrix[y][lMin - 1] !== hairIdx) matrix[y][lMin - 1] = blackIdx;
953
+
954
+ const rMax = Math.min(SKIN_GRID_DIM - 1, hbR + ext);
955
+ for (let x = hbR + 1; x <= rMax; x++) matrix[y][x] = hairIdx;
956
+ if (rMax < SKIN_GRID_DIM - 1 && matrix[y][rMax + 1] !== hairIdx) matrix[y][rMax + 1] = blackIdx;
957
+
958
+ if (rngCrown() < 0.3) {
959
+ const px = lMin - 1;
960
+ if (px >= 0 && matrix[y][px] !== hairIdx) {
961
+ matrix[y][px] = hairIdx;
962
+ if (px > 0 && matrix[y][px - 1] !== hairIdx) matrix[y][px - 1] = blackIdx;
963
+ }
964
+ }
965
+ if (rngCrown() < 0.3) {
966
+ const px = rMax + 1;
967
+ if (px < SKIN_GRID_DIM && matrix[y][px] !== hairIdx) {
968
+ matrix[y][px] = hairIdx;
969
+ if (px < SKIN_GRID_DIM - 1 && matrix[y][px + 1] !== hairIdx) matrix[y][px + 1] = blackIdx;
970
+ }
971
+ }
972
+ }
973
+ }
974
+
975
+ // 4. EXTENDED HAIR — flows below the head onto the upper back.
976
+ // hairDepth (5–11) maps to hairExtend (2–8 rows below HEAD_Y_MAX).
977
+ const hairExtend = Math.min(palette.hairDepth - 3, 6); // 5→2 … 9→6, capped at 6
978
+ const extMaxY = Math.min(HEAD_Y_MAX + hairExtend, SKIN_GRID_DIM - 2);
979
+
980
+ // Compute body-bounds for each extension row (centering reference)
981
+ const extBounds = new Map();
982
+ for (const [x, y] of allBodyCoords) {
983
+ if (y <= HEAD_Y_MAX || y > extMaxY) continue;
984
+ const b = extBounds.get(y) || { min: x, max: x };
985
+ b.min = Math.min(b.min, x);
986
+ b.max = Math.max(b.max, x);
987
+ extBounds.set(y, b);
988
+ }
989
+ const extYs = [...extBounds.keys()].sort((a, b) => a - b);
990
+
991
+ // Track actual hair strip bounds per row (for border painting)
992
+ const hairStripBounds = new Map();
993
+ for (const y of extYs) {
994
+ const { min, max } = extBounds.get(y);
995
+ const cx = (min + max) / 2;
996
+ const bodyHalfW = (max - min) / 2;
997
+ const yDist = y - HEAD_Y_MAX;
998
+ // Taper: narrower further from the head
999
+ const taper = Math.max(0.35, 1 - yDist * 0.08);
1000
+ const halfW = Math.max(2, Math.round(bodyHalfW * taper));
1001
+ // Per-row random outside extension (0–2 px each side = wisps)
1002
+ const extL = Math.floor(rng() * 2);
1003
+ const extR = Math.floor(rng() * 2);
1004
+ const hMin = Math.max(0, Math.floor(cx) - halfW - extL);
1005
+ const hMax = Math.min(SKIN_GRID_DIM - 1, Math.ceil(cx) + halfW + extR);
1006
+ for (let x = hMin; x <= hMax; x++) matrix[y][x] = hairIdx;
1007
+ // Store core bounds (without wisp extension) for border
1008
+ hairStripBounds.set(y, {
1009
+ min: Math.max(0, Math.floor(cx) - halfW),
1010
+ max: Math.min(SKIN_GRID_DIM - 1, Math.ceil(cx) + halfW),
1011
+ });
1012
+ }
1013
+
1014
+ // 5. BLACK BORDERS on sides and bottom of the hair extension.
1015
+ for (const y of extYs) {
1016
+ const { min, max } = hairStripBounds.get(y);
1017
+ if (min > 0 && matrix[y][min - 1] !== hairIdx) matrix[y][min - 1] = blackIdx;
1018
+ if (max < SKIN_GRID_DIM - 1 && matrix[y][max + 1] !== hairIdx) matrix[y][max + 1] = blackIdx;
1019
+ }
1020
+ if (extYs.length > 0) {
1021
+ const bottomY = extYs[extYs.length - 1];
1022
+ const { min, max } = hairStripBounds.get(bottomY);
1023
+ const nextY = bottomY + 1;
1024
+ if (nextY < SKIN_GRID_DIM) {
1025
+ for (let x = min - 1; x <= max + 1; x++) {
1026
+ if (x >= 0 && x < SKIN_GRID_DIM && matrix[nextY][x] !== hairIdx) matrix[nextY][x] = blackIdx;
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+ // 6. RANDOM WISPS — 2–4 stray hair pixels at edges for visual dynamism.
1032
+ // Each wisp extends 1–2 px beyond the current hair boundary,
1033
+ // with a black tip pixel closing it off.
1034
+ const allHairRows = [...headBounds.keys(), ...extYs];
1035
+ const numWisps = 2 + Math.floor(rng() * 3);
1036
+ for (let i = 0; i < numWisps; i++) {
1037
+ const wy = allHairRows[Math.floor(rng() * allHairRows.length)];
1038
+ const hb = hairStripBounds.get(wy) || headBounds.get(wy);
1039
+ if (!hb) continue;
1040
+ const side = rng() < 0.5 ? -1 : 1;
1041
+ const baseX = side < 0 ? hb.min - 1 : hb.max + 1;
1042
+ if (baseX < 0 || baseX >= SKIN_GRID_DIM) continue;
1043
+ matrix[wy][baseX] = hairIdx;
1044
+ // Optional second wisp pixel
1045
+ if (rng() < 0.45) {
1046
+ const px2 = baseX + side;
1047
+ if (px2 >= 0 && px2 < SKIN_GRID_DIM) matrix[wy][px2] = hairIdx;
1048
+ }
1049
+ // Black closing tip
1050
+ const tipX =
1051
+ side < 0
1052
+ ? Math.max(0, baseX - (rng() < 0.45 ? 2 : 1))
1053
+ : Math.min(SKIN_GRID_DIM - 1, baseX + (rng() < 0.45 ? 2 : 1));
1054
+ if (matrix[wy][tipX] !== hairIdx) matrix[wy][tipX] = blackIdx;
1055
+ }
1056
+ } // end else (normal hair)
1057
+
1058
+ return matrix;
1059
+ }
1060
+
1061
+ /* ═══════════════════════════════════════════════════════════════════════════
1062
+ * WALK FRAME BUILDER
1063
+ * Two-frame walk cycle: frame 0 = idle pose, frame 1 = body shifted up 1px.
1064
+ * The vertical bob creates a natural walking bounce effect.
1065
+ * ═══════════════════════════════════════════════════════════════════════════ */
1066
+
1067
+ /**
1068
+ * Derives a two-frame walk cycle from a pre-built idle matrix.
1069
+ * Frame 0: deep copy of idleMatrix.
1070
+ * Frame 1: all rows shifted up by 1 pixel — the walk-bounce step.
1071
+ * The bottom row becomes fully transparent (index 0).
1072
+ *
1073
+ * @param {number[][]} idleMatrix Pre-built idle frame (not mutated).
1074
+ * @returns {number[][][]} [frame0, frame1]
1075
+ */
1076
+ function buildWalkFrames(idleMatrix) {
1077
+ const FOOT_TOP = SHOE_Y_MIN - 1; // y=22 — bottom of leg zone, just above shoes
1078
+ const midX = Math.floor(SKIN_GRID_DIM / 2); // x=12 — column boundary between left/right foot
1079
+
1080
+ // Build one walk frame: copy idle, then raise shoe row up 1 px for the chosen side,
1081
+ // leaving y=SHOE_Y_MIN transparent for that foot.
1082
+ const makeFrame = (liftLeft) => {
1083
+ const frame = idleMatrix.map((row) => [...row]);
1084
+ const xFrom = liftLeft ? 0 : midX;
1085
+ const xTo = liftLeft ? midX : SKIN_GRID_DIM;
1086
+ for (let x = xFrom; x < xTo; x++) {
1087
+ frame[FOOT_TOP][x] = idleMatrix[SHOE_Y_MIN][x]; // raise shoe to y=22
1088
+ frame[SHOE_Y_MIN][x] = 0; // clear y=23 → transparent gap
1089
+ }
1090
+ return frame;
1091
+ };
1092
+
1093
+ // frame0: right foot raised; frame1: left foot raised → alternating step cycle
1094
+ return [makeFrame(false), makeFrame(true)];
1095
+ }
1096
+
1097
+ /* ═══════════════════════════════════════════════════════════════════════════
1098
+ * UUID HELPER (self-contained, no import from parent)
1099
+ * ═══════════════════════════════════════════════════════════════════════════ */
1100
+
1101
+ /**
1102
+ * Derives a deterministic UUID v4 from an arbitrary seed string.
1103
+ * @param {string} seed
1104
+ * @returns {string}
1105
+ */
1106
+ function localSeedToUUIDv4(seed) {
1107
+ const hash = crypto.createHash('sha256').update(seed).digest();
1108
+ hash[6] = (hash[6] & 0x0f) | 0x40;
1109
+ hash[8] = (hash[8] & 0x3f) | 0x80;
1110
+ const hex = hash.toString('hex');
1111
+ return [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20, 32)].join('-');
1112
+ }
1113
+
1114
+ /* ═══════════════════════════════════════════════════════════════════════════
1115
+ * CUSTOM MULTI-FRAME GENERATOR
1116
+ * Called by generateMultiFrame() when descriptor.customMultiFrameGenerator exists.
1117
+ * ═══════════════════════════════════════════════════════════════════════════ */
1118
+
1119
+ /**
1120
+ * Generates a complete MultiFrameResult for a skin item using template-based
1121
+ * pixel painting. Each of the four cardinal directions gets a properly oriented
1122
+ * frame matrix; all share the same deterministic colour palette.
1123
+ *
1124
+ * @param {import('./semantic-layer-generator.js').GenerateLayerOptions & { frameCount?: number, startFrame?: number, frameDuration?: number }} options
1125
+ * @param {import('./semantic-layer-generator.js').SemanticDescriptor} _descriptor
1126
+ * @returns {import('./semantic-layer-generator.js').MultiFrameResult}
1127
+ */
1128
+ function generateSkinMultiFrame(options, _descriptor) {
1129
+ const { itemId, seed, frameCount = 1, startFrame = 0, frameDuration = 250 } = options;
1130
+
1131
+ // Shared mutable palette; starts with index 0 = transparent
1132
+ const globalColors = [[0, 0, 0, 0]];
1133
+
1134
+ // Skin subtype is injected via the descriptor (set during registration).
1135
+ // Falls back to 'random' for the legacy skin- prefix or any unknown descriptor.
1136
+ const subtype = _descriptor?.skinSubtype ?? 'random';
1137
+ const palette = deriveSkinPalette(seed, itemId, subtype);
1138
+
1139
+ // Build idle direction matrices
1140
+ const matrices = {
1141
+ down: buildDirectionMatrix(ZONES.down, palette, globalColors, seed, itemId, 'down'),
1142
+ up: buildUpDirectionMatrix(ZONES.up, palette, globalColors, seed, itemId),
1143
+ left: buildDirectionMatrix(ZONES.left, palette, globalColors, seed, itemId, 'left'),
1144
+ right: buildDirectionMatrix(ZONES.right, palette, globalColors, seed, itemId, 'right'),
1145
+ };
1146
+
1147
+ // Build 2-frame walk cycles from idle matrices (shared color palette, no extra allocations)
1148
+ const walkFrames = {
1149
+ down: buildWalkFrames(matrices.down),
1150
+ up: buildWalkFrames(matrices.up),
1151
+ left: buildWalkFrames(matrices.left),
1152
+ right: buildWalkFrames(matrices.right),
1153
+ };
1154
+
1155
+ // Idle: frameCount identical copies of the direction matrix.
1156
+ const makeIdleArray = (matrix) => Array.from({ length: frameCount }, () => matrix.map((row) => [...row]));
1157
+
1158
+ // Walking: always exactly 2 frames (walk cycle is independent of frameCount).
1159
+ const makeWalkArray = (frames2) => frames2.map((m) => m.map((row) => [...row]));
1160
+
1161
+ const objectLayerRenderFramesData = {
1162
+ frame_duration: frameDuration,
1163
+ is_stateless: false,
1164
+ frames: {
1165
+ // DOWN (08) idle
1166
+ down_idle: makeIdleArray(matrices.down),
1167
+ none_idle: makeIdleArray(matrices.down),
1168
+ default_idle: makeIdleArray(matrices.down),
1169
+ // UP (02) idle
1170
+ up_idle: makeIdleArray(matrices.up),
1171
+ // LEFT (06) idle
1172
+ left_idle: makeIdleArray(matrices.left),
1173
+ up_left_idle: makeIdleArray(matrices.left),
1174
+ down_left_idle: makeIdleArray(matrices.left),
1175
+ // RIGHT (04) idle
1176
+ right_idle: makeIdleArray(matrices.right),
1177
+ up_right_idle: makeIdleArray(matrices.right),
1178
+ down_right_idle: makeIdleArray(matrices.right),
1179
+ // Walking animations – 2-frame bounce cycle
1180
+ down_walking: makeWalkArray(walkFrames.down),
1181
+ up_walking: makeWalkArray(walkFrames.up),
1182
+ left_walking: makeWalkArray(walkFrames.left),
1183
+ right_walking: makeWalkArray(walkFrames.right),
1184
+ },
1185
+ colors: globalColors,
1186
+ };
1187
+
1188
+ const objectLayerData = {
1189
+ data: {
1190
+ item: {
1191
+ id: itemId,
1192
+ type: 'skin',
1193
+ description: `Procedurally generated character skin (seed: ${seed})`,
1194
+ activable: true,
1195
+ },
1196
+ stats: {
1197
+ effect: 0,
1198
+ resistance: 0,
1199
+ agility: 0,
1200
+ range: 0,
1201
+ intelligence: 0,
1202
+ utility: 0,
1203
+ },
1204
+ ledger: { type: 'OFF_CHAIN' },
1205
+ seed: localSeedToUUIDv4(`${seed}:${itemId}`),
1206
+ },
1207
+ };
1208
+
1209
+ // Build synthetic frames array for layer-summary logging in cyberia.js
1210
+ const layerSummary = [
1211
+ { layerKey: 'skin', layerId: `${itemId}-skin`, keys: ZONES.down.skin.map(() => ({ type: 'template' })) },
1212
+ { layerKey: 'hair', layerId: `${itemId}-hair`, keys: ZONES.down.hair.map(() => ({ type: 'template' })) },
1213
+ { layerKey: 'shirt', layerId: `${itemId}-shirt`, keys: ZONES.down.shirt.map(() => ({ type: 'template' })) },
1214
+ { layerKey: 'pants', layerId: `${itemId}-pants`, keys: ZONES.down.pants.map(() => ({ type: 'template' })) },
1215
+ { layerKey: 'shoes', layerId: `${itemId}-shoes`, keys: ZONES.down.shoes.map(() => ({ type: 'template' })) },
1216
+ { layerKey: 'border', layerId: `${itemId}-border`, keys: ZONES.down.border.map(() => ({ type: 'template' })) },
1217
+ ];
1218
+
1219
+ const frames = Array.from({ length: frameCount }, (_, fi) => ({
1220
+ itemId,
1221
+ seed,
1222
+ frameIndex: startFrame + fi,
1223
+ layers: layerSummary,
1224
+ compositeFrameMatrix: matrices.down,
1225
+ compositeColors: globalColors,
1226
+ }));
1227
+
1228
+ return {
1229
+ itemId,
1230
+ seed,
1231
+ frameCount,
1232
+ frames,
1233
+ objectLayerRenderFramesData,
1234
+ objectLayerData,
1235
+ };
1236
+ }
1237
+
1238
+ /* ═══════════════════════════════════════════════════════════════════════════
1239
+ * REGISTRATION
1240
+ * ═══════════════════════════════════════════════════════════════════════════ */
1241
+
1242
+ /**
1243
+ * Registers all skin semantic descriptors.
1244
+ * Uses dependency injection — no import from the parent module.
1245
+ *
1246
+ * @param {function(string, import('./semantic-layer-generator.js').SemanticDescriptor): void} registerFn
1247
+ * @memberof SemanticLayerGeneratorSkin
1248
+ */
1249
+ export function registerSkinSemantics(registerFn) {
1250
+ /**
1251
+ * Skin subtypes — each maps a prefix to palette constraints.
1252
+ *
1253
+ * | Prefix | Skin tone | Hair pool | hairDepth |
1254
+ * |-----------------|---------------|--------------------|----------|
1255
+ * | skin-random | any | any | 5–11 |
1256
+ * | skin-dark | dark (1–3) | any | 5–11 |
1257
+ * | skin-light | light (6–8) | any | 5–11 |
1258
+ * | skin-vivid | any | vivid only | 5–11 |
1259
+ * | skin-natural | any | natural only | 5–11 |
1260
+ * | skin-shaved | any | any (unused) | 0 only |
1261
+ */
1262
+ const SUBTYPES = [
1263
+ { prefix: 'skin-random', subtype: 'random', desc: 'Fully random skin tone and hair' },
1264
+ { prefix: 'skin-dark', subtype: 'dark', desc: 'Dark skin tones' },
1265
+ { prefix: 'skin-light', subtype: 'light', desc: 'Light / pale skin tones' },
1266
+ { prefix: 'skin-vivid', subtype: 'vivid', desc: 'Vivid / exotic hair colours (blue, red, green…)' },
1267
+ { prefix: 'skin-natural', subtype: 'natural', desc: 'Natural hair colours (brown, blond, grey…)' },
1268
+ { prefix: 'skin-shaved', subtype: 'shaved', desc: 'Shaved / bald head — no hair' },
1269
+ ];
1270
+
1271
+ const sharedLayers = {
1272
+ skin: { generator: 'template-zone' },
1273
+ hair: { generator: 'template-zone' },
1274
+ shirt: { generator: 'template-zone' },
1275
+ pants: { generator: 'template-zone' },
1276
+ shoes: { generator: 'template-zone' },
1277
+ border: { generator: 'template-zone' },
1278
+ };
1279
+
1280
+ for (const { prefix, subtype, desc } of SUBTYPES) {
1281
+ registerFn(prefix, {
1282
+ semanticTags: ['character', 'body', 'humanoid'],
1283
+ paletteHints: [],
1284
+ preferredShapes: {},
1285
+ itemType: 'skin',
1286
+ skinSubtype: subtype,
1287
+ description: desc,
1288
+ layers: sharedLayers,
1289
+ // Custom generator bypasses the default shape/noise pipeline;
1290
+ // receives this descriptor so it can read skinSubtype.
1291
+ customMultiFrameGenerator: generateSkinMultiFrame,
1292
+ });
1293
+ }
1294
+ }