Compare commits
No commits in common. "1d0f2f7c3e5e44b005e3860354363396f2fcf5ba" and "d56e8ca23dcb199d821acaa3e2175dfa5147f4bf" have entirely different histories.
1d0f2f7c3e
...
d56e8ca23d
|
|
@ -13,7 +13,6 @@
|
||||||
"@solidjs/router": "^0.15.0",
|
"@solidjs/router": "^0.15.0",
|
||||||
"@thisbeyond/solid-dnd": "^0.7.5",
|
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||||
"@types/three": "^0.183.1",
|
"@types/three": "^0.183.1",
|
||||||
"aedes": "^1.1.1",
|
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
|
|
@ -24,14 +23,11 @@
|
||||||
"marked-directive": "^1.0.7",
|
"marked-directive": "^1.0.7",
|
||||||
"marked-gfm-heading-id": "^4.1.3",
|
"marked-gfm-heading-id": "^4.1.3",
|
||||||
"mermaid": "^11.0.0",
|
"mermaid": "^11.0.0",
|
||||||
"mqtt": "^5.15.1",
|
|
||||||
"solid-element": "^1.9.1",
|
"solid-element": "^1.9.1",
|
||||||
"solid-js": "^1.9.3",
|
"solid-js": "^1.9.3",
|
||||||
"three": "^0.183.2",
|
"three": "^0.183.2",
|
||||||
"three-3mf-exporter": "^45.1.0",
|
"three-3mf-exporter": "^45.1.0",
|
||||||
"ws": "^8.21.0",
|
"yarn-spinner-loader": "^0.1.0"
|
||||||
"yarn-spinner-loader": "^0.1.0",
|
|
||||||
"zod": "^4.4.3"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"ttrpg": "dist/cli/index.js"
|
"ttrpg": "dist/cli/index.js"
|
||||||
|
|
@ -47,7 +43,6 @@
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^22.19.13",
|
"@types/node": "^22.19.13",
|
||||||
"@types/ws": "^8.18.1",
|
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
|
|
@ -726,15 +721,6 @@
|
||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/runtime": {
|
|
||||||
"version": "7.29.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
|
||||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||||
|
|
@ -1885,15 +1871,6 @@
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/zod": {
|
|
||||||
"version": "3.25.76",
|
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@module-federation/error-codes": {
|
"node_modules/@module-federation/error-codes": {
|
||||||
"version": "0.22.0",
|
"version": "0.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz",
|
||||||
|
|
@ -3333,20 +3310,12 @@
|
||||||
"version": "22.19.17",
|
"version": "22.19.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
||||||
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/readable-stream": {
|
|
||||||
"version": "4.0.24",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz",
|
|
||||||
"integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/stack-utils": {
|
"node_modules/@types/stack-utils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
||||||
|
|
@ -3395,15 +3364,6 @@
|
||||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/ws": {
|
|
||||||
"version": "8.18.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
|
||||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.35",
|
"version": "17.0.35",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||||
|
|
@ -3445,18 +3405,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/abort-controller": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"event-target-shim": "^5.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
|
|
@ -3493,138 +3441,6 @@
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/aedes": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/aedes/-/aedes-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-kmKFRgQrjGyv9HuVqpNGM/aPD2K4bf20aF1UbrxEvh3j8BJ1OD0rHi3E3u3xfrgc3ydj2+9UiPdW0sHiP6d+Sg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"aedes-packet": "^3.0.0",
|
|
||||||
"aedes-persistence": "^10.2.2",
|
|
||||||
"mqemitter": "^7.1.0",
|
|
||||||
"mqtt-packet": "^9.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/aedes"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"aedes-persistence-level": "^9.1.2",
|
|
||||||
"aedes-persistence-mongodb": "^9.3.1",
|
|
||||||
"aedes-persistence-redis": "^11.2.1"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"aedes-persistence-level": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"aedes-persistence-mongodb": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"aedes-persistence-redis": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/aedes-packet": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/aedes-packet/-/aedes-packet-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-swASey0BxGs4/npZGWoiVDmnEyPvVFIRY6l2LVKL4rbiW8IhcIGDLfnb20Qo8U20itXlitAKPQ3MVTEbOGG5ZA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mqtt-packet": "^7.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/aedes-packet/node_modules/bl": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"buffer": "^5.5.0",
|
|
||||||
"inherits": "^2.0.4",
|
|
||||||
"readable-stream": "^3.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/aedes-packet/node_modules/buffer": {
|
|
||||||
"version": "5.7.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
|
||||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"base64-js": "^1.3.1",
|
|
||||||
"ieee754": "^1.1.13"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/aedes-packet/node_modules/mqtt-packet": {
|
|
||||||
"version": "7.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-7.1.2.tgz",
|
|
||||||
"integrity": "sha512-FFZbcZ2omsf4c5TxEQfcX9hI+JzDpDKPT46OmeIBpVA7+t32ey25UNqlqNXTmeZOr5BLsSIERpQQLsFWJS94SQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bl": "^4.0.2",
|
|
||||||
"debug": "^4.1.1",
|
|
||||||
"process-nextick-args": "^2.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/aedes-packet/node_modules/readable-stream": {
|
|
||||||
"version": "3.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
|
||||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"inherits": "^2.0.3",
|
|
||||||
"string_decoder": "^1.1.1",
|
|
||||||
"util-deprecate": "^1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/aedes-persistence": {
|
|
||||||
"version": "10.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/aedes-persistence/-/aedes-persistence-10.3.1.tgz",
|
|
||||||
"integrity": "sha512-C/z0zLEXY9PkUH+7pz31H5gGzlQZxqhFONK1lt5VON0hiHXQq6tr4cSIAtFUVQ1kyUdd7SsfkeKLAECp3gjjpw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"aedes-packet": "^4.0.1",
|
|
||||||
"qlobber": "^8.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/aedes-persistence/node_modules/aedes-packet": {
|
|
||||||
"version": "4.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/aedes-packet/-/aedes-packet-4.0.1.tgz",
|
|
||||||
"integrity": "sha512-PLjKJElXEYB66RzHhJCw0SrRLfSmGRbbg2mtO5t/6FAbFp8vC0cG5ErhvPvfa9iKBrKZwoluWLgn8doBU8sRAw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mqtt-packet": "^9.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
|
@ -3911,26 +3727,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/base64-js": {
|
|
||||||
"version": "1.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
|
||||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.15",
|
"version": "2.10.15",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.15.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.15.tgz",
|
||||||
|
|
@ -3944,63 +3740,6 @@
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bl": {
|
|
||||||
"version": "6.1.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
|
|
||||||
"integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/readable-stream": "^4.0.0",
|
|
||||||
"buffer": "^6.0.3",
|
|
||||||
"inherits": "^2.0.4",
|
|
||||||
"readable-stream": "^4.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bl/node_modules/readable-stream": {
|
|
||||||
"version": "4.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
|
||||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"abort-controller": "^3.0.0",
|
|
||||||
"buffer": "^6.0.3",
|
|
||||||
"events": "^3.3.0",
|
|
||||||
"process": "^0.11.10",
|
|
||||||
"string_decoder": "^1.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bl/node_modules/safe-buffer": {
|
|
||||||
"version": "5.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
|
||||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/bl/node_modules/string_decoder": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "~5.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||||
|
|
@ -4024,18 +3763,6 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/broker-factory": {
|
|
||||||
"version": "3.1.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.15.tgz",
|
|
||||||
"integrity": "sha512-ko+aWvgNuP49meGrdjUu7rC+Y+Wai3cCPxP3xWwHsHfehFjOh5ZQM2yC4gEB2UddeZ/YXhm0K1eG/L6fxym2Og==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.29.7",
|
|
||||||
"fast-unique-numbers": "^9.0.27",
|
|
||||||
"tslib": "^2.8.1",
|
|
||||||
"worker-factory": "^7.0.50"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.2",
|
"version": "4.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||||
|
|
@ -4093,34 +3820,11 @@
|
||||||
"node-int64": "^0.4.0"
|
"node-int64": "^0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/buffer": {
|
|
||||||
"version": "6.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
|
||||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"base64-js": "^1.3.1",
|
|
||||||
"ieee754": "^1.2.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
|
|
@ -4359,12 +4063,6 @@
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/commist": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/component-register": {
|
"node_modules/component-register": {
|
||||||
"version": "0.8.8",
|
"version": "0.8.8",
|
||||||
"resolved": "https://registry.npmjs.org/component-register/-/component-register-0.8.8.tgz",
|
"resolved": "https://registry.npmjs.org/component-register/-/component-register-0.8.8.tgz",
|
||||||
|
|
@ -4378,35 +4076,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/concat-stream": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
|
||||||
"engines": [
|
|
||||||
"node >= 6.0"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"buffer-from": "^1.0.0",
|
|
||||||
"inherits": "^2.0.3",
|
|
||||||
"readable-stream": "^3.0.2",
|
|
||||||
"typedarray": "^0.0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/concat-stream/node_modules/readable-stream": {
|
|
||||||
"version": "3.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
|
||||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"inherits": "^2.0.3",
|
|
||||||
"string_decoder": "^1.1.1",
|
|
||||||
"util-deprecate": "^1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/confbox": {
|
"node_modules/confbox": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
|
||||||
|
|
@ -5109,6 +4778,7 @@
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
|
|
@ -5449,24 +5119,6 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/event-target-shim": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/events": {
|
|
||||||
"version": "3.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
|
||||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/execa": {
|
"node_modules/execa": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||||
|
|
@ -5547,19 +5199,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-unique-numbers": {
|
|
||||||
"version": "9.0.27",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz",
|
|
||||||
"integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.29.2",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||||
|
|
@ -5611,16 +5250,6 @@
|
||||||
"fxparser": "src/cli/cli.js"
|
"fxparser": "src/cli/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fastparallel": {
|
|
||||||
"version": "2.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
|
|
||||||
"integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"reusify": "^1.0.4",
|
|
||||||
"xtend": "^4.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||||
|
|
@ -5942,12 +5571,6 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/help-me": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/html-encoding-sniffer": {
|
"node_modules/html-encoding-sniffer": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
|
||||||
|
|
@ -6062,26 +5685,6 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ieee754": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/immediate": {
|
"node_modules/immediate": {
|
||||||
"version": "3.0.6",
|
"version": "3.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
|
@ -6145,15 +5748,6 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
|
||||||
"version": "10.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
|
||||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-arrayish": {
|
"node_modules/is-arrayish": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||||
|
|
@ -7836,16 +7430,6 @@
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/js-sdsl": {
|
|
||||||
"version": "4.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
|
|
||||||
"integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/js-sdsl"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|
@ -8583,6 +8167,7 @@
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
|
@ -8606,117 +8191,11 @@
|
||||||
"integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==",
|
"integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/mqemitter": {
|
|
||||||
"version": "7.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mqemitter/-/mqemitter-7.1.0.tgz",
|
|
||||||
"integrity": "sha512-GnBDNz3lxmllW201ne0mrmdy5tPOTnc79jjVcsfUa2LG2pUGeyGWVeiae6ZysfC/64XrYOqCKRAQYrB7pGyBVQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"fastparallel": "^2.4.1",
|
|
||||||
"qlobber": "^8.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mqtt": {
|
|
||||||
"version": "5.15.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.1.tgz",
|
|
||||||
"integrity": "sha512-V1WnkGuJh3ec9QXzy5Iylw8OOBK+Xu1WhxcQ9mMpLThG+/JZIMV1PgLNRgIiqXhZnvnVLsuyxHl5A/3bHHbcAA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/readable-stream": "^4.0.21",
|
|
||||||
"@types/ws": "^8.18.1",
|
|
||||||
"commist": "^3.2.0",
|
|
||||||
"concat-stream": "^2.0.0",
|
|
||||||
"debug": "^4.4.1",
|
|
||||||
"help-me": "^5.0.0",
|
|
||||||
"lru-cache": "^10.4.3",
|
|
||||||
"minimist": "^1.2.8",
|
|
||||||
"mqtt-packet": "^9.0.2",
|
|
||||||
"number-allocator": "^1.0.14",
|
|
||||||
"readable-stream": "^4.7.0",
|
|
||||||
"rfdc": "^1.4.1",
|
|
||||||
"socks": "^2.8.6",
|
|
||||||
"split2": "^4.2.0",
|
|
||||||
"worker-timers": "^8.0.23",
|
|
||||||
"ws": "^8.18.3"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"mqtt": "build/bin/mqtt.js",
|
|
||||||
"mqtt_pub": "build/bin/pub.js",
|
|
||||||
"mqtt_sub": "build/bin/sub.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mqtt-packet": {
|
|
||||||
"version": "9.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz",
|
|
||||||
"integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bl": "^6.0.8",
|
|
||||||
"debug": "^4.3.4",
|
|
||||||
"process-nextick-args": "^2.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mqtt/node_modules/lru-cache": {
|
|
||||||
"version": "10.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
|
||||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/mqtt/node_modules/readable-stream": {
|
|
||||||
"version": "4.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
|
||||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"abort-controller": "^3.0.0",
|
|
||||||
"buffer": "^6.0.3",
|
|
||||||
"events": "^3.3.0",
|
|
||||||
"process": "^0.11.10",
|
|
||||||
"string_decoder": "^1.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mqtt/node_modules/safe-buffer": {
|
|
||||||
"version": "5.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
|
||||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/mqtt/node_modules/string_decoder": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "~5.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
|
|
@ -8789,16 +8268,6 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/number-allocator": {
|
|
||||||
"version": "1.0.14",
|
|
||||||
"resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz",
|
|
||||||
"integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "^4.3.1",
|
|
||||||
"js-sdsl": "4.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nwsapi": {
|
"node_modules/nwsapi": {
|
||||||
"version": "2.2.23",
|
"version": "2.2.23",
|
||||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
|
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
|
||||||
|
|
@ -9155,15 +8624,6 @@
|
||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/process": {
|
|
||||||
"version": "0.11.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
|
||||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/process-nextick-args": {
|
"node_modules/process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
|
@ -9224,15 +8684,6 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/qlobber": {
|
|
||||||
"version": "8.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/qlobber/-/qlobber-8.0.1.tgz",
|
|
||||||
"integrity": "sha512-O+Wd1chXj5YE1DwmD+ae0bXiSLehmnS3czlC1R9FL/Nt/3q8uMS1bIHmg2lJfCoiimCxClWM8AAuJrF0EvNiog==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/querystringify": {
|
"node_modules/querystringify": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||||
|
|
@ -9423,12 +8874,6 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rfdc": {
|
|
||||||
"version": "1.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
|
||||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/robust-predicates": {
|
"node_modules/robust-predicates": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||||
|
|
@ -9626,30 +9071,6 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/smart-buffer": {
|
|
||||||
"version": "4.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
|
||||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6.0.0",
|
|
||||||
"npm": ">= 3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socks": {
|
|
||||||
"version": "2.8.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
|
|
||||||
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ip-address": "^10.1.1",
|
|
||||||
"smart-buffer": "^4.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0",
|
|
||||||
"npm": ">= 3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/solid-element": {
|
"node_modules/solid-element": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/solid-element/-/solid-element-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/solid-element/-/solid-element-1.9.1.tgz",
|
||||||
|
|
@ -9718,15 +9139,6 @@
|
||||||
"source-map": "^0.6.0"
|
"source-map": "^0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/split2": {
|
|
||||||
"version": "4.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
|
||||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sprintf-js": {
|
"node_modules/sprintf-js": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||||
|
|
@ -10199,6 +9611,7 @@
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/type-detect": {
|
"node_modules/type-detect": {
|
||||||
|
|
@ -10224,12 +9637,6 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typedarray": {
|
|
||||||
"version": "0.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
|
||||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
|
@ -10268,6 +9675,7 @@
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/universalify": {
|
"node_modules/universalify": {
|
||||||
|
|
@ -10608,53 +10016,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/worker-factory": {
|
|
||||||
"version": "7.0.50",
|
|
||||||
"resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.50.tgz",
|
|
||||||
"integrity": "sha512-hhwc0G+sFwM4qBuhJIUBn2p1Jf8v/FwmLUANBf/Q+Lt2uI8mfIZQhXaZQACodQD4R7Zp6cn/6702bIvNn2puJQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.29.7",
|
|
||||||
"fast-unique-numbers": "^9.0.27",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/worker-timers": {
|
|
||||||
"version": "8.0.33",
|
|
||||||
"resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.33.tgz",
|
|
||||||
"integrity": "sha512-RQVlKkek80v8M6SHvdMKiywaXFmX3XTpYTXTw0r62PdXB06t74XNxcTuG8wsQwnjorAQymNQyyQ0rzv7PykEMQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.29.7",
|
|
||||||
"tslib": "^2.8.1",
|
|
||||||
"worker-timers-broker": "^8.0.18",
|
|
||||||
"worker-timers-worker": "^9.0.15"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/worker-timers-broker": {
|
|
||||||
"version": "8.0.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.18.tgz",
|
|
||||||
"integrity": "sha512-FrjzDVX1wKfZN0gRbCFqv8VHuTncG4sbI/WGEg4tSSQeIsnwqg4YBYWMAHYLJtUDEYYmiK65UKFrVMVk2irDSg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.29.7",
|
|
||||||
"broker-factory": "^3.1.15",
|
|
||||||
"fast-unique-numbers": "^9.0.27",
|
|
||||||
"tslib": "^2.8.1",
|
|
||||||
"worker-timers-worker": "^9.0.15"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/worker-timers-worker": {
|
|
||||||
"version": "9.0.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.15.tgz",
|
|
||||||
"integrity": "sha512-KKUe7lZ/Aignr51H6hOUik8LwTnIgojH/1lwhli8A8qIEIyewogZTpNpMW5B6BF7nmwBOkUoTYgf1H3QShcjSA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.29.7",
|
|
||||||
"tslib": "^2.8.1",
|
|
||||||
"worker-factory": "^7.0.50"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/wrap-ansi": {
|
"node_modules/wrap-ansi": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
|
@ -10695,9 +10056,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.21.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
|
@ -10732,15 +10094,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/xtend": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|
@ -10841,9 +10194,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.4.3",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@
|
||||||
"@solidjs/router": "^0.15.0",
|
"@solidjs/router": "^0.15.0",
|
||||||
"@thisbeyond/solid-dnd": "^0.7.5",
|
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||||
"@types/three": "^0.183.1",
|
"@types/three": "^0.183.1",
|
||||||
"aedes": "^1.1.1",
|
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
|
|
@ -47,14 +46,11 @@
|
||||||
"marked-directive": "^1.0.7",
|
"marked-directive": "^1.0.7",
|
||||||
"marked-gfm-heading-id": "^4.1.3",
|
"marked-gfm-heading-id": "^4.1.3",
|
||||||
"mermaid": "^11.0.0",
|
"mermaid": "^11.0.0",
|
||||||
"mqtt": "^5.15.1",
|
|
||||||
"solid-element": "^1.9.1",
|
"solid-element": "^1.9.1",
|
||||||
"solid-js": "^1.9.3",
|
"solid-js": "^1.9.3",
|
||||||
"three": "^0.183.2",
|
"three": "^0.183.2",
|
||||||
"three-3mf-exporter": "^45.1.0",
|
"three-3mf-exporter": "^45.1.0",
|
||||||
"ws": "^8.21.0",
|
"yarn-spinner-loader": "^0.1.0"
|
||||||
"yarn-spinner-loader": "^0.1.0",
|
|
||||||
"zod": "^4.4.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@image-tracer-ts/core": "^1.0.2",
|
"@image-tracer-ts/core": "^1.0.2",
|
||||||
|
|
@ -67,7 +63,6 @@
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^22.19.13",
|
"@types/node": "^22.19.13",
|
||||||
"@types/ws": "^8.18.1",
|
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
|
|
|
||||||
44
src/App.tsx
44
src/App.tsx
|
|
@ -1,6 +1,5 @@
|
||||||
import { Component, createEffect, createMemo, createSignal } from "solid-js";
|
import { Component, createEffect, createMemo, createSignal } from "solid-js";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
import { useJournalStream } from "./components/stores/journalStream";
|
|
||||||
|
|
||||||
// 导入组件以注册自定义元素
|
// 导入组件以注册自定义元素
|
||||||
import "./components";
|
import "./components";
|
||||||
|
|
@ -12,15 +11,12 @@ import {
|
||||||
DataSourceDialog,
|
DataSourceDialog,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
||||||
import { JournalPanel } from "./components/journal";
|
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const stream = useJournalStream();
|
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
||||||
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
||||||
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
|
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
|
||||||
const [isJournalOpen, setIsJournalOpen] = createSignal(false);
|
|
||||||
|
|
||||||
// Sidebar and TOC data — reload when source changes
|
// Sidebar and TOC data — reload when source changes
|
||||||
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
||||||
|
|
@ -59,18 +55,13 @@ const App: Component = () => {
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen bg-gray-50">
|
<div class="min-h-screen bg-gray-50">
|
||||||
{/* 桌面端固定侧边栏 */}
|
{/* 桌面端固定侧边栏 */}
|
||||||
<DesktopSidebar
|
<DesktopSidebar fileTree={fileTree()} pathHeadings={pathHeadings()} />
|
||||||
fileTree={fileTree()}
|
|
||||||
pathHeadings={pathHeadings()}
|
|
||||||
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
|
||||||
/>
|
|
||||||
{/* 移动端抽屉式侧边栏 */}
|
{/* 移动端抽屉式侧边栏 */}
|
||||||
<MobileSidebar
|
<MobileSidebar
|
||||||
isOpen={isSidebarOpen()}
|
isOpen={isSidebarOpen()}
|
||||||
onClose={() => setIsSidebarOpen(false)}
|
onClose={() => setIsSidebarOpen(false)}
|
||||||
fileTree={fileTree()}
|
fileTree={fileTree()}
|
||||||
pathHeadings={pathHeadings()}
|
pathHeadings={pathHeadings()}
|
||||||
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
|
||||||
/>
|
/>
|
||||||
<header class="fixed top-0 left-0 right-0 bg-white shadow z-30">
|
<header class="fixed top-0 left-0 right-0 bg-white shadow z-30">
|
||||||
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
|
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||||
|
|
@ -85,25 +76,11 @@ const App: Component = () => {
|
||||||
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
|
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsJournalOpen((v) => !v)}
|
onClick={() => setIsDataSourceOpen(true)}
|
||||||
class={`relative w-8 h-8 rounded flex items-center justify-center text-sm ${
|
class="text-sm text-gray-500 hover:text-gray-700 px-2 py-1 rounded hover:bg-gray-100"
|
||||||
isJournalOpen()
|
title="Content Source"
|
||||||
? "bg-blue-100 text-blue-600"
|
|
||||||
: "text-gray-500 hover:bg-gray-100"
|
|
||||||
}`}
|
|
||||||
title={`Journal (${stream.connectionStatus})`}
|
|
||||||
>
|
>
|
||||||
<span
|
📂
|
||||||
class="absolute top-0.5 right-0.5 w-2 h-2 rounded-full border border-white"
|
|
||||||
classList={{
|
|
||||||
"bg-gray-400": stream.connectionStatus === "disconnected",
|
|
||||||
"bg-yellow-400 animate-pulse":
|
|
||||||
stream.connectionStatus === "connecting",
|
|
||||||
"bg-green-500": stream.connectionStatus === "connected",
|
|
||||||
"bg-red-500": stream.connectionStatus === "error",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
📋
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsDocOpen(true)}
|
onClick={() => setIsDocOpen(true)}
|
||||||
|
|
@ -114,11 +91,8 @@ const App: Component = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{/* fill the rest of the space */}
|
{/* fill th rest of the space*/}
|
||||||
<div
|
<div class="fixed top-16 left-0 right-0 bottom-0 overflow-auto">
|
||||||
class="fixed top-16 left-0 right-0 bottom-0 overflow-auto transition-all duration-300"
|
|
||||||
classList={{ "md:pr-[420px]": isJournalOpen() }}
|
|
||||||
>
|
|
||||||
<main class="max-w-4xl mx-auto px-4 py-8 pt-4 md:ml-64 2xl:ml-auto flex justify-center items-center">
|
<main class="max-w-4xl mx-auto px-4 py-8 pt-4 md:ml-64 2xl:ml-auto flex justify-center items-center">
|
||||||
<Article
|
<Article
|
||||||
class="prose text-black prose-sm max-w-full flex-1"
|
class="prose text-black prose-sm max-w-full flex-1"
|
||||||
|
|
@ -132,10 +106,6 @@ const App: Component = () => {
|
||||||
onClose={() => setIsDataSourceOpen(false)}
|
onClose={() => setIsDataSourceOpen(false)}
|
||||||
onSourceChanged={handleSourceChanged}
|
onSourceChanged={handleSourceChanged}
|
||||||
/>
|
/>
|
||||||
<JournalPanel
|
|
||||||
open={isJournalOpen()}
|
|
||||||
onClose={() => setIsJournalOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,6 @@ import { createReadStream } from "fs";
|
||||||
import { join, resolve, extname, sep, relative, dirname } from "path";
|
import { join, resolve, extname, sep, relative, dirname } from "path";
|
||||||
import { watch } from "chokidar";
|
import { watch } from "chokidar";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { createJournalServer } from "../journal.js";
|
|
||||||
import {
|
|
||||||
scanCompletions,
|
|
||||||
type CompletionsPayload,
|
|
||||||
} from "../completions/index.js";
|
|
||||||
|
|
||||||
interface ContentIndex {
|
interface ContentIndex {
|
||||||
[path: string]: string;
|
[path: string]: string;
|
||||||
|
|
@ -68,11 +63,7 @@ export function scanDirectory(dir: string): ContentIndex {
|
||||||
const stats = statSync(fullPath);
|
const stats = statSync(fullPath);
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
scan(fullPath, relPath);
|
scan(fullPath, relPath);
|
||||||
} else if (
|
} else if (entry.endsWith(".md") || entry.endsWith(".csv") || entry.endsWith(".yarn")) {
|
||||||
entry.endsWith(".md") ||
|
|
||||||
entry.endsWith(".csv") ||
|
|
||||||
entry.endsWith(".yarn")
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(fullPath, "utf-8");
|
const content = readFileSync(fullPath, "utf-8");
|
||||||
index[normalizedRelPath] = content;
|
index[normalizedRelPath] = content;
|
||||||
|
|
@ -148,7 +139,6 @@ function createRequestHandler(
|
||||||
contentDir: string,
|
contentDir: string,
|
||||||
distDir: string,
|
distDir: string,
|
||||||
getIndex: () => ContentIndex,
|
getIndex: () => ContentIndex,
|
||||||
getCompletions: () => CompletionsPayload,
|
|
||||||
) {
|
) {
|
||||||
return (req: IncomingMessage, res: ServerResponse) => {
|
return (req: IncomingMessage, res: ServerResponse) => {
|
||||||
const url = req.url || "/";
|
const url = req.url || "/";
|
||||||
|
|
@ -160,12 +150,6 @@ function createRequestHandler(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1b. 处理 /__COMPLETIONS.json
|
|
||||||
if (filePath === "/__COMPLETIONS.json") {
|
|
||||||
sendJson(res, getCompletions());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 处理 /static/ 目录(从 dist/web)
|
// 2. 处理 /static/ 目录(从 dist/web)
|
||||||
if (filePath.startsWith("/static/")) {
|
if (filePath.startsWith("/static/")) {
|
||||||
if (tryServeStatic(res, filePath, distDir)) {
|
if (tryServeStatic(res, filePath, distDir)) {
|
||||||
|
|
@ -207,10 +191,6 @@ export interface ContentServer {
|
||||||
* 内容索引
|
* 内容索引
|
||||||
*/
|
*/
|
||||||
index: ContentIndex;
|
index: ContentIndex;
|
||||||
/**
|
|
||||||
* 补全数据
|
|
||||||
*/
|
|
||||||
completions: CompletionsPayload;
|
|
||||||
/**
|
/**
|
||||||
* 关闭服务器
|
* 关闭服务器
|
||||||
*/
|
*/
|
||||||
|
|
@ -227,112 +207,79 @@ export function createContentServer(
|
||||||
host: string = "0.0.0.0",
|
host: string = "0.0.0.0",
|
||||||
): ContentServer {
|
): ContentServer {
|
||||||
let contentIndex: ContentIndex = {};
|
let contentIndex: ContentIndex = {};
|
||||||
let completionsIndex: CompletionsPayload = { dice: [], links: [] };
|
|
||||||
|
|
||||||
/** Re-scan completions from current content index (cached) */
|
|
||||||
function recomputeCompletions(): void {
|
|
||||||
completionsIndex = scanCompletions(contentIndex);
|
|
||||||
console.log(
|
|
||||||
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 扫描内容目录生成索引
|
// 扫描内容目录生成索引
|
||||||
console.log("扫描内容目录...");
|
console.log("扫描内容目录...");
|
||||||
contentIndex = scanDirectory(contentDir);
|
contentIndex = scanDirectory(contentDir);
|
||||||
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
|
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
|
||||||
recomputeCompletions();
|
|
||||||
|
|
||||||
// 监听文件变化
|
// 监听文件变化
|
||||||
console.log("监听文件变化...");
|
console.log("监听文件变化...");
|
||||||
const watcher = watch(contentDir, {
|
const watcher = watch(contentDir, {
|
||||||
ignored: /(^|[\/\\])\./,
|
ignored: /(^|[\/\\])\../,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
ignoreInitial: true,
|
ignoreInitial: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
watcher
|
watcher
|
||||||
.on("add", (path) => {
|
.on("add", (path) => {
|
||||||
if (
|
if (path.endsWith(".md") || path.endsWith(".csv") || path.endsWith(".yarn")) {
|
||||||
path.endsWith(".md") ||
|
|
||||||
path.endsWith(".csv") ||
|
|
||||||
path.endsWith(".yarn")
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(path, "utf-8");
|
const content = readFileSync(path, "utf-8");
|
||||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||||
contentIndex[relPath] = content;
|
contentIndex[relPath] = content;
|
||||||
console.log(`[新增] ${relPath}`);
|
console.log(`[新增] ${relPath}`);
|
||||||
if (relPath.endsWith(".md")) recomputeCompletions();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`读取新增文件失败:${path}`, e);
|
console.error(`读取新增文件失败:${path}`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on("change", (path) => {
|
.on("change", (path) => {
|
||||||
if (
|
if (path.endsWith(".md") || path.endsWith(".csv") || path.endsWith(".yarn")) {
|
||||||
path.endsWith(".md") ||
|
|
||||||
path.endsWith(".csv") ||
|
|
||||||
path.endsWith(".yarn")
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(path, "utf-8");
|
const content = readFileSync(path, "utf-8");
|
||||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||||
contentIndex[relPath] = content;
|
contentIndex[relPath] = content;
|
||||||
console.log(`[更新] ${relPath}`);
|
console.log(`[更新] ${relPath}`);
|
||||||
if (relPath.endsWith(".md")) recomputeCompletions();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`读取更新文件失败:${path}`, e);
|
console.error(`读取更新文件失败:${path}`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on("unlink", (path) => {
|
.on("unlink", (path) => {
|
||||||
if (
|
if (path.endsWith(".md") || path.endsWith(".csv") || path.endsWith(".yarn")) {
|
||||||
path.endsWith(".md") ||
|
|
||||||
path.endsWith(".csv") ||
|
|
||||||
path.endsWith(".yarn")
|
|
||||||
) {
|
|
||||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||||
delete contentIndex[relPath];
|
delete contentIndex[relPath];
|
||||||
console.log(`[删除] ${relPath}`);
|
console.log(`[删除] ${relPath}`);
|
||||||
if (relPath.endsWith(".md")) recomputeCompletions();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Journal / MQTT broker ----
|
// 创建请求处理器
|
||||||
let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null;
|
|
||||||
|
|
||||||
// 创建 HTTP 服务器 BEFORE journal so we can pass it in
|
|
||||||
const handleRequest = createRequestHandler(
|
const handleRequest = createRequestHandler(
|
||||||
contentDir,
|
contentDir,
|
||||||
distPath,
|
distPath,
|
||||||
() => contentIndex,
|
() => contentIndex,
|
||||||
() => completionsIndex,
|
|
||||||
);
|
);
|
||||||
const server = createServer(handleRequest);
|
|
||||||
|
|
||||||
createJournalServer(contentDir, server).then((j) => {
|
// 创建 HTTP 服务器
|
||||||
journal = j;
|
const server = createServer(handleRequest);
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(port, host, () => {
|
server.listen(port, host, () => {
|
||||||
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
||||||
console.log(`\n开发服务器已启动:http://${displayHost}:${port}`);
|
console.log(`\n开发服务器已启动:http://${displayHost}:${port}`);
|
||||||
console.log(`内容目录:${contentDir}`);
|
console.log(`内容目录:${contentDir}`);
|
||||||
console.log(`静态资源目录:${distPath}`);
|
console.log(`静态资源目录:${distPath}`);
|
||||||
console.log(`Journal 连接:ws://${displayHost}:${port}\n`);
|
console.log(`索引文件:http://${displayHost}:${port}/__CONTENT_INDEX.json\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server,
|
server,
|
||||||
watcher,
|
watcher,
|
||||||
index: contentIndex,
|
index: contentIndex,
|
||||||
completions: completionsIndex,
|
|
||||||
close() {
|
close() {
|
||||||
console.log("关闭内容服务器...");
|
console.log("关闭内容服务器...");
|
||||||
server.close();
|
server.close();
|
||||||
watcher.close();
|
watcher.close();
|
||||||
if (journal) journal.close();
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
/**
|
|
||||||
* Completion index — orchestrates all registered completion sources.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { diceSource } from "./sources/dice.js";
|
|
||||||
import { linksSource } from "./sources/links.js";
|
|
||||||
import type { CompletionSource, CompletionsPayload } from "./types.js";
|
|
||||||
|
|
||||||
export type {
|
|
||||||
CompletionsPayload,
|
|
||||||
DiceCompletion,
|
|
||||||
LinkCompletion,
|
|
||||||
} from "./types.js";
|
|
||||||
|
|
||||||
/** Registered sources — open for extension */
|
|
||||||
const sources: CompletionSource[] = [diceSource, linksSource];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan the full content index and return structured completion data.
|
|
||||||
* Called at server startup and on any file change.
|
|
||||||
*/
|
|
||||||
export function scanCompletions(
|
|
||||||
index: Record<string, string>,
|
|
||||||
): CompletionsPayload {
|
|
||||||
const payload: Record<string, unknown[]> = {};
|
|
||||||
|
|
||||||
for (const source of sources) {
|
|
||||||
payload[source.key] = source.scan(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload as unknown as CompletionsPayload;
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
/**
|
|
||||||
* Dice completion source — extracts `<md-dice>` text content from markdown files.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { CompletionSource, DiceCompletion } from "../types.js";
|
|
||||||
|
|
||||||
function looksLikeDice(raw: string): boolean {
|
|
||||||
if (raw.length > 80) return false;
|
|
||||||
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const diceSource: CompletionSource = {
|
|
||||||
key: "dice",
|
|
||||||
|
|
||||||
scan(index) {
|
|
||||||
const items: DiceCompletion[] = [];
|
|
||||||
const tagRegex = /:md-dice\[([^[]+)\]/gi;
|
|
||||||
|
|
||||||
for (const [path, content] of Object.entries(index)) {
|
|
||||||
if (!path.endsWith(".md")) continue;
|
|
||||||
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
tagRegex.lastIndex = 0;
|
|
||||||
while ((match = tagRegex.exec(content)) !== null) {
|
|
||||||
const raw = match[1].trim();
|
|
||||||
if (!raw || !looksLikeDice(raw)) continue;
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
label: raw,
|
|
||||||
notation: raw,
|
|
||||||
source: path,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
/**
|
|
||||||
* Link completion source — extracts markdown headings from all .md files.
|
|
||||||
*
|
|
||||||
* Produces two entries per heading section, plus one for the file itself.
|
|
||||||
* Uses github-slugger to match marked-gfm-heading-id's generated IDs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Slugger from "github-slugger";
|
|
||||||
import type { CompletionSource, LinkCompletion } from "../types.js";
|
|
||||||
|
|
||||||
export const linksSource: CompletionSource = {
|
|
||||||
key: "links",
|
|
||||||
|
|
||||||
scan(index) {
|
|
||||||
const items: LinkCompletion[] = [];
|
|
||||||
|
|
||||||
for (const [filePath, content] of Object.entries(index)) {
|
|
||||||
if (!filePath.endsWith(".md")) continue;
|
|
||||||
|
|
||||||
// Strip .md extension for the router-friendly path
|
|
||||||
const basePath = filePath.replace(/\.md$/, "");
|
|
||||||
const fileName = fileNameFromPath(basePath);
|
|
||||||
const slugger = new Slugger();
|
|
||||||
|
|
||||||
// Add the file itself as a link (whole article)
|
|
||||||
items.push({
|
|
||||||
path: basePath,
|
|
||||||
label: fileName,
|
|
||||||
section: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse headings for section-scoped links
|
|
||||||
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
|
|
||||||
while ((match = headingRegex.exec(content)) !== null) {
|
|
||||||
const title = match[2].trim();
|
|
||||||
const id = slugger.slug(title.toLowerCase());
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
path: basePath,
|
|
||||||
label: `${fileName} § ${title}`,
|
|
||||||
section: id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function fileNameFromPath(path: string): string {
|
|
||||||
const parts = path.split("/").filter(Boolean);
|
|
||||||
return parts[parts.length - 1] || path;
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
/**
|
|
||||||
* Completion source types — shared between CLI scanner and frontend consumer.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** A single dice expression found in a markdown file */
|
|
||||||
export interface DiceCompletion {
|
|
||||||
/** Display text shown in the dropdown */
|
|
||||||
label: string;
|
|
||||||
/** Dice notation, e.g. "3d6kh1" or "1d20+5" */
|
|
||||||
notation: string;
|
|
||||||
/** Source file for attribution */
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A single link target (article path, optionally scoped to a heading) */
|
|
||||||
export interface LinkCompletion {
|
|
||||||
/** Route-relative path without .md extension, e.g. "/rules/combat" */
|
|
||||||
path: string;
|
|
||||||
/** Human-readable label */
|
|
||||||
label: string;
|
|
||||||
/** Heading slug anchor (without #), or null for the whole file */
|
|
||||||
section: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Top-level payload served at /__COMPLETIONS.json */
|
|
||||||
export interface CompletionsPayload {
|
|
||||||
dice: DiceCompletion[];
|
|
||||||
links: LinkCompletion[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A completion source plugin. Add new sources by implementing this
|
|
||||||
* interface and registering in the barrel.
|
|
||||||
*/
|
|
||||||
export interface CompletionSource {
|
|
||||||
/** Unique key — becomes the top-level key in CompletionsPayload */
|
|
||||||
key: string;
|
|
||||||
/** Scan the content index and return structured completion items */
|
|
||||||
scan(index: Record<string, string>): unknown[];
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +1,33 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { Command } from "commander";
|
import { Command } from 'commander';
|
||||||
import { serveCommand } from "./commands/serve.js";
|
import { serveCommand } from './commands/serve.js';
|
||||||
import { compileCommand } from "./commands/compile.js";
|
import { compileCommand } from './commands/compile.js';
|
||||||
import { mcpCommand } from "./commands/mcp.js";
|
import { mcpCommand } from './commands/mcp.js';
|
||||||
import type { ServeOptions, CompileOptions } from "./types.js";
|
import type { ServeOptions, CompileOptions } from './types.js';
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
program
|
program
|
||||||
.name("ttrpg")
|
.name('ttrpg')
|
||||||
.description("TTRPG 工具箱 - 用于编译和预览 TTRPG 文档")
|
.description('TTRPG 工具箱 - 用于编译和预览 TTRPG 文档')
|
||||||
.version("0.0.1");
|
.version('0.0.1');
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("serve")
|
.command('serve')
|
||||||
.description("运行一个 web 服务器预览目录中的内容,并实时监听更新")
|
.description('运行一个 web 服务器预览目录中的内容,并实时监听更新')
|
||||||
.argument("[dir]", "要预览的目录", ".")
|
.argument('[dir]', '要预览的目录', '.')
|
||||||
.option("-p, --port <port>", "HTTP 端口号", "3000")
|
.option('-p, --port <port>', '端口号', '3000')
|
||||||
.option("-h, --host <host>", "主机地址", "0.0.0.0")
|
.option('-h, --host <host>', '主机地址', '0.0.0.0')
|
||||||
.action(serveCommand);
|
.action(serveCommand);
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("compile")
|
.command('compile')
|
||||||
.description("将目录中的内容输出为带 hash 路由、单个 html 入口的 web 应用")
|
.description('将目录中的内容输出为带 hash 路由、单个 html 入口的 web 应用')
|
||||||
.argument("[dir]", "要编译的目录", ".")
|
.argument('[dir]', '要编译的目录', '.')
|
||||||
.option("-o, --output <dir>", "输出目录", "./dist/output")
|
.option('-o, --output <dir>', '输出目录', './dist/output')
|
||||||
.action(compileCommand);
|
.action(compileCommand);
|
||||||
|
|
||||||
program.addCommand(mcpCommand);
|
program
|
||||||
|
.addCommand(mcpCommand);
|
||||||
|
|
||||||
program.parse();
|
program.parse();
|
||||||
|
|
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
/**
|
|
||||||
* Journal stream persistence — MQTT broker + JSONL append + session manifest
|
|
||||||
*
|
|
||||||
* Runs an aedes MQTT broker over WebSocket, attached to the existing
|
|
||||||
* HTTP server. Uses ws v8's built-in createWebSocketStream.
|
|
||||||
*
|
|
||||||
* Pattern from aedes docs:
|
|
||||||
* https://github.com/moscajs/aedes#mqtt-server-over-websocket
|
|
||||||
*
|
|
||||||
* Persistence uses aedes's internal subscribe/publish API, so the
|
|
||||||
* server process doesn't need to connect to itself as an MQTT client.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { join, resolve } from "path";
|
|
||||||
import {
|
|
||||||
appendFileSync,
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
readFileSync,
|
|
||||||
rmdirSync,
|
|
||||||
unlinkSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "fs";
|
|
||||||
import type { Server as HttpServer } from "http";
|
|
||||||
import type { AedesPublishPacket } from "aedes";
|
|
||||||
import { WebSocketServer, createWebSocketStream } from "ws";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface SessionMeta {
|
|
||||||
name: string;
|
|
||||||
created: number;
|
|
||||||
players: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionManifest {
|
|
||||||
sessions: Record<string, SessionMeta>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JournalServer {
|
|
||||||
broker: import("aedes").Aedes;
|
|
||||||
wsServer: import("ws").WebSocketServer;
|
|
||||||
close(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Path helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function dataDir(contentDir: string): string {
|
|
||||||
const d = resolve(contentDir, ".ttrpg");
|
|
||||||
mkdirSync(d, { recursive: true });
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
function manifestPath(dataRoot: string): string {
|
|
||||||
return join(dataRoot, "manifest.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
function sessionDir(dataRoot: string, id: string): string {
|
|
||||||
return join(dataRoot, "sessions", id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function streamPath(dataRoot: string, id: string): string {
|
|
||||||
return join(sessionDir(dataRoot, id), "stream.jsonl");
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadManifest(dataRoot: string): SessionManifest {
|
|
||||||
const p = manifestPath(dataRoot);
|
|
||||||
if (!existsSync(p)) return { sessions: {} };
|
|
||||||
try {
|
|
||||||
return JSON.parse(readFileSync(p, "utf-8"));
|
|
||||||
} catch {
|
|
||||||
return { sessions: {} };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveManifest(dataRoot: string, m: SessionManifest): void {
|
|
||||||
writeFileSync(manifestPath(dataRoot), JSON.stringify(m, null, 2), "utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendStream(dataRoot: string, id: string, line: string): void {
|
|
||||||
const sd = sessionDir(dataRoot, id);
|
|
||||||
mkdirSync(sd, { recursive: true });
|
|
||||||
appendFileSync(streamPath(dataRoot, id), line + "\n", "utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Server factory
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const $SESSIONS = "ttrpg/$SESSIONS";
|
|
||||||
|
|
||||||
export async function createJournalServer(
|
|
||||||
contentDir: string,
|
|
||||||
httpServer: HttpServer,
|
|
||||||
): Promise<JournalServer> {
|
|
||||||
const root = dataDir(contentDir);
|
|
||||||
console.log(`[journal] data dir: ${root}`);
|
|
||||||
|
|
||||||
// ---- MQTT Broker ----
|
|
||||||
const { Aedes: AedesFactory } = await import("aedes");
|
|
||||||
// aedes requires listen() to initialize persistence before handling connections.
|
|
||||||
const broker = await AedesFactory.createBroker();
|
|
||||||
|
|
||||||
// ---- WebSocket server (attached to the existing HTTP server) ----
|
|
||||||
// Pattern from aedes docs: pass { server } to WSS, use 'connection' event
|
|
||||||
const wss = new WebSocketServer({ server: httpServer });
|
|
||||||
|
|
||||||
wss.on("connection", (ws, req) => {
|
|
||||||
const stream = createWebSocketStream(ws);
|
|
||||||
broker.handle(stream, req);
|
|
||||||
});
|
|
||||||
|
|
||||||
broker.on("client", (client: import("aedes").Client) => {
|
|
||||||
console.log(`[journal] client ready: ${client.id}`);
|
|
||||||
});
|
|
||||||
broker.on("clientDisconnect", (client: import("aedes").Client) => {
|
|
||||||
console.log(`[journal] client disconnected: ${client.id}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- Persistence (internal subscribe, no separate MQTT client) ----
|
|
||||||
broker.subscribe(
|
|
||||||
"ttrpg/+/stream",
|
|
||||||
(packet, cb) => {
|
|
||||||
const sessionId = extractSessionId(packet.topic);
|
|
||||||
if (sessionId) {
|
|
||||||
try {
|
|
||||||
appendStream(root, sessionId, packet.payload.toString());
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[journal] stream append err for ${sessionId}:`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cb();
|
|
||||||
},
|
|
||||||
() => {},
|
|
||||||
);
|
|
||||||
|
|
||||||
broker.subscribe(
|
|
||||||
"ttrpg/+/meta",
|
|
||||||
(packet, cb) => {
|
|
||||||
const sessionId = extractSessionId(packet.topic);
|
|
||||||
if (sessionId) {
|
|
||||||
handleMetaChange(root, sessionId, broker, packet.payload.toString());
|
|
||||||
}
|
|
||||||
cb();
|
|
||||||
},
|
|
||||||
() => {},
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("[journal] persistence listener active");
|
|
||||||
console.log("[journal] broker available at ws://<host>:<port>");
|
|
||||||
|
|
||||||
return {
|
|
||||||
broker,
|
|
||||||
wsServer: wss,
|
|
||||||
async close() {
|
|
||||||
console.log("[journal] shutting down...");
|
|
||||||
await new Promise<void>((r) => wss.close(() => r()));
|
|
||||||
await new Promise<void>((r) => broker.close(() => r()));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function extractSessionId(topic: string): string | null {
|
|
||||||
const parts = topic.split("/");
|
|
||||||
return parts.length >= 3 ? parts[1] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Session lifecycle
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function handleMetaChange(
|
|
||||||
root: string,
|
|
||||||
sessionId: string,
|
|
||||||
broker: import("aedes").Aedes,
|
|
||||||
rawPayload: string,
|
|
||||||
): void {
|
|
||||||
const manifest = loadManifest(root);
|
|
||||||
try {
|
|
||||||
const meta = JSON.parse(rawPayload) as SessionMeta | null;
|
|
||||||
|
|
||||||
if (!meta || !meta.name) {
|
|
||||||
if (manifest.sessions[sessionId]) {
|
|
||||||
delete manifest.sessions[sessionId];
|
|
||||||
const sp = streamPath(root, sessionId);
|
|
||||||
if (existsSync(sp)) unlinkSync(sp);
|
|
||||||
const sd = sessionDir(root, sessionId);
|
|
||||||
try {
|
|
||||||
rmdirSync(sd);
|
|
||||||
} catch {
|
|
||||||
/* */
|
|
||||||
}
|
|
||||||
console.log(`[journal] session deleted: ${sessionId}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
manifest.sessions[sessionId] = {
|
|
||||||
name: meta.name,
|
|
||||||
created: meta.created || Date.now(),
|
|
||||||
players: meta.players || [],
|
|
||||||
};
|
|
||||||
console.log(`[journal] session updated: ${sessionId} (${meta.name})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveManifest(root, manifest);
|
|
||||||
broker.publish(
|
|
||||||
{
|
|
||||||
topic: $SESSIONS,
|
|
||||||
payload: Buffer.from(JSON.stringify(manifest)),
|
|
||||||
qos: 1,
|
|
||||||
retain: true,
|
|
||||||
cmd: "publish" as const,
|
|
||||||
dup: false,
|
|
||||||
},
|
|
||||||
() => {},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[journal] meta parse err for ${sessionId}:`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,14 +7,8 @@ export interface CompileOptions {
|
||||||
output: string;
|
output: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServeCommandHandler = (
|
export type ServeCommandHandler = (dir: string, options: ServeOptions) => Promise<void>;
|
||||||
dir: string,
|
export type CompileCommandHandler = (dir: string, options: CompileOptions) => Promise<void>;
|
||||||
options: ServeOptions,
|
|
||||||
) => Promise<void>;
|
|
||||||
export type CompileCommandHandler = (
|
|
||||||
dir: string,
|
|
||||||
options: CompileOptions,
|
|
||||||
) => Promise<void>;
|
|
||||||
|
|
||||||
export interface MarkdownFile {
|
export interface MarkdownFile {
|
||||||
path: string;
|
path: string;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ export interface SidebarProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
fileTree?: FileNode[];
|
fileTree?: FileNode[];
|
||||||
pathHeadings?: Record<string, TocNode[]>;
|
pathHeadings?: Record<string, TocNode[]>;
|
||||||
onDataSourceOpen?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarContentProps {
|
interface SidebarContentProps {
|
||||||
|
|
@ -17,7 +16,6 @@ interface SidebarContentProps {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
isDesktop?: boolean;
|
isDesktop?: boolean;
|
||||||
onDataSourceOpen?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -39,14 +37,6 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
||||||
<div class="p-4 border-b border-b-gray-200">
|
<div class="p-4 border-b border-b-gray-200">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-bold text-gray-900">目录</h2>
|
<h2 class="text-lg font-bold text-gray-900">目录</h2>
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={props.onDataSourceOpen}
|
|
||||||
class="text-gray-400 hover:text-gray-600 p-1 rounded hover:bg-gray-100"
|
|
||||||
title="Content Source"
|
|
||||||
>
|
|
||||||
📂
|
|
||||||
</button>
|
|
||||||
<Show when={!props.isDesktop}>
|
<Show when={!props.isDesktop}>
|
||||||
<button
|
<button
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
|
|
@ -58,7 +48,6 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 文件树滚动区域 */}
|
{/* 文件树滚动区域 */}
|
||||||
<div class="flex-1 overflow-y-auto p-4 min-h-0">
|
<div class="flex-1 overflow-y-auto p-4 min-h-0">
|
||||||
|
|
@ -132,7 +121,6 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
||||||
pathHeadings={pathHeadings()}
|
pathHeadings={pathHeadings()}
|
||||||
currentPath={location.pathname}
|
currentPath={location.pathname}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
onDataSourceOpen={props.onDataSourceOpen}
|
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
|
|
@ -145,7 +133,6 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
||||||
export const DesktopSidebar: Component<{
|
export const DesktopSidebar: Component<{
|
||||||
fileTree?: FileNode[];
|
fileTree?: FileNode[];
|
||||||
pathHeadings?: Record<string, TocNode[]>;
|
pathHeadings?: Record<string, TocNode[]>;
|
||||||
onDataSourceOpen?: () => void;
|
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
|
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
|
||||||
|
|
@ -171,7 +158,6 @@ export const DesktopSidebar: Component<{
|
||||||
currentPath={location.pathname}
|
currentPath={location.pathname}
|
||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
isDesktop
|
isDesktop
|
||||||
onDataSourceOpen={props.onDataSourceOpen}
|
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
/**
|
|
||||||
* ComposePanel — type picker + dynamic form + send button
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component, createSignal, For, Show, createMemo } from "solid-js";
|
|
||||||
import { registeredTypes, canEmit } from "./registry";
|
|
||||||
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
|
||||||
import type { MessageTypeDef } from "./registry";
|
|
||||||
import { DynamicForm } from "./DynamicForm";
|
|
||||||
|
|
||||||
export const ComposePanel: Component = () => {
|
|
||||||
const stream = useJournalStream();
|
|
||||||
const [selectedType, setSelectedType] = createSignal<string>("chat");
|
|
||||||
const [formData, setFormData] = createSignal<Record<string, unknown>>({});
|
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
|
||||||
const [sending, setSending] = createSignal(false);
|
|
||||||
|
|
||||||
const role = () => stream.myRole;
|
|
||||||
|
|
||||||
// Available types filtered by role
|
|
||||||
const availableTypes = createMemo(() => {
|
|
||||||
const types: string[] = [];
|
|
||||||
for (const type of registeredTypes()) {
|
|
||||||
if (canEmit(type, role())) {
|
|
||||||
types.push(type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Default to chat if available, otherwise first
|
|
||||||
if (types.includes("chat")) {
|
|
||||||
return types;
|
|
||||||
}
|
|
||||||
return types;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSend = () => {
|
|
||||||
setError(null);
|
|
||||||
setSending(true);
|
|
||||||
|
|
||||||
const result = sendMessage(selectedType(), formData());
|
|
||||||
if (!result.success) {
|
|
||||||
setError(result.error);
|
|
||||||
} else {
|
|
||||||
setFormData({}); // Reset on success
|
|
||||||
}
|
|
||||||
|
|
||||||
setSending(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="border-t border-gray-200 p-2 space-y-2 bg-white">
|
|
||||||
{/* Type picker */}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
value={selectedType()}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSelectedType(e.currentTarget.value);
|
|
||||||
setFormData({});
|
|
||||||
}}
|
|
||||||
class="text-xs border border-gray-300 rounded px-2 py-1 bg-white"
|
|
||||||
>
|
|
||||||
<For each={availableTypes()}>
|
|
||||||
{(type) => <option value={type}>{type}</option>}
|
|
||||||
</For>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={sending()}
|
|
||||||
class="ml-auto bg-blue-600 text-white rounded px-3 py-1 text-xs hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{sending() ? "..." : "Send"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dynamic form */}
|
|
||||||
<DynamicForm
|
|
||||||
type={selectedType()}
|
|
||||||
value={formData()}
|
|
||||||
onChange={setFormData}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Show when={error()}>
|
|
||||||
<p class="text-red-500 text-xs">{error()}</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
/**
|
|
||||||
* ConnectionBar — session picker, player info (connected state only)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component, createSignal, Show, For } from "solid-js";
|
|
||||||
import {
|
|
||||||
hydrateFromServer,
|
|
||||||
useJournalStream,
|
|
||||||
sessions,
|
|
||||||
createSession,
|
|
||||||
deleteSession,
|
|
||||||
disconnectStream,
|
|
||||||
setSessionId,
|
|
||||||
} from "../stores/journalStream";
|
|
||||||
import { journalSetState } from "../stores/journalStream";
|
|
||||||
|
|
||||||
export const ConnectionBar: Component = () => {
|
|
||||||
const stream = useJournalStream();
|
|
||||||
const [newSessionName, setNewSessionName] = createSignal("");
|
|
||||||
const [showNewSession, setShowNewSession] = createSignal(false);
|
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
|
||||||
|
|
||||||
const manifest = sessions;
|
|
||||||
|
|
||||||
const handleCreateSession = () => {
|
|
||||||
const name = newSessionName().trim();
|
|
||||||
if (!name) return;
|
|
||||||
createSession(name);
|
|
||||||
setNewSessionName("");
|
|
||||||
setShowNewSession(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteSession = (id: string) => {
|
|
||||||
if (confirm(`Delete session "${id}"? This cannot be undone.`)) {
|
|
||||||
deleteSession(id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSessionSelect = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await hydrateFromServer(id);
|
|
||||||
setSessionId(id);
|
|
||||||
} catch (e) {
|
|
||||||
setError("Failed to load session");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
|
||||||
disconnectStream();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="border-b border-gray-200 px-3 py-2 space-y-2 text-sm">
|
|
||||||
{/* Status row */}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="w-2 h-2 rounded-full bg-green-500" />
|
|
||||||
<span class="text-xs text-gray-500">connected</span>
|
|
||||||
<Show when={stream.sessionId}>
|
|
||||||
<span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded">
|
|
||||||
{stream.sessionName || stream.sessionId}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<div class="flex-1" />
|
|
||||||
<Show when={stream.myName !== "gm"}>
|
|
||||||
<span class="text-xs text-gray-400">Playing as {stream.myName}</span>
|
|
||||||
</Show>
|
|
||||||
<button
|
|
||||||
onClick={handleDisconnect}
|
|
||||||
class="text-xs text-gray-400 hover:text-red-500"
|
|
||||||
title="Disconnect"
|
|
||||||
>
|
|
||||||
⏻
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* GM session management */}
|
|
||||||
<Show when={stream.myName === "gm"}>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<span class="text-xs text-gray-500">Sessions:</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowNewSession((v) => !v)}
|
|
||||||
class="text-xs text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
+ new
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={showNewSession()}>
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newSessionName()}
|
|
||||||
onInput={(e) => setNewSessionName(e.currentTarget.value)}
|
|
||||||
placeholder="Session name"
|
|
||||||
class="flex-1 border border-gray-300 rounded px-2 py-0.5 text-xs"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleCreateSession()}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleCreateSession}
|
|
||||||
class="bg-green-600 text-white rounded px-2 py-0.5 text-xs hover:bg-green-700"
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<For each={Object.entries(manifest().sessions)}>
|
|
||||||
{([id, meta]) => (
|
|
||||||
<div
|
|
||||||
class={`flex items-center gap-1 px-1 py-0.5 rounded cursor-pointer text-xs ${
|
|
||||||
id === stream.sessionId
|
|
||||||
? "bg-blue-100 text-blue-800"
|
|
||||||
: "hover:bg-gray-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex-1 truncate"
|
|
||||||
onClick={() => handleSessionSelect(id)}
|
|
||||||
>
|
|
||||||
{meta.name || id}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDeleteSession(id);
|
|
||||||
}}
|
|
||||||
class="text-gray-400 hover:text-red-500 text-xs"
|
|
||||||
title="Delete session"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={error()}>
|
|
||||||
<p class="text-red-500 text-xs">{error()}</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,299 +0,0 @@
|
||||||
/**
|
|
||||||
* DynamicForm — renders form inputs from a Zod schema
|
|
||||||
*
|
|
||||||
* Walks the schema's shape and renders appropriate inputs for each field.
|
|
||||||
* Supports: string, number, boolean, enum, array, and nested objects.
|
|
||||||
* Optional fields get a checkbox to toggle inclusion.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component, For, Show, createMemo } from "solid-js";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { getMessageType } from "./registry";
|
|
||||||
import type { MessageTypeDef } from "./registry";
|
|
||||||
|
|
||||||
type FormData = Record<string, unknown>;
|
|
||||||
|
|
||||||
interface DynamicFormProps {
|
|
||||||
type: string;
|
|
||||||
value: FormData;
|
|
||||||
onChange: (data: FormData) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DynamicForm: Component<DynamicFormProps> = (props) => {
|
|
||||||
const def = createMemo(() => getMessageType(props.type));
|
|
||||||
|
|
||||||
// Generate default on type change
|
|
||||||
const schema = createMemo(() => def()?.schema);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Show when={def()}>
|
|
||||||
{(d) => (
|
|
||||||
<SchemaFields
|
|
||||||
schema={d().schema}
|
|
||||||
value={props.value}
|
|
||||||
onChange={props.onChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// SchemaFields — recursive
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const SchemaFields: Component<{
|
|
||||||
schema: z.ZodTypeAny;
|
|
||||||
value: FormData;
|
|
||||||
onChange: (data: FormData) => void;
|
|
||||||
prefix?: string;
|
|
||||||
}> = (props) => {
|
|
||||||
const shape = unwrapShape(props.schema);
|
|
||||||
|
|
||||||
if (!shape) {
|
|
||||||
return <p class="text-xs text-gray-400">No fields</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<For each={Object.entries(shape)}>
|
|
||||||
{([key, fieldSchema]) => {
|
|
||||||
const def = (fieldSchema as z.ZodDefault<any>)._def;
|
|
||||||
const isOptional = fieldSchema.isOptional?.() ?? false;
|
|
||||||
const fieldType = getFieldType(fieldSchema);
|
|
||||||
|
|
||||||
// Nested object
|
|
||||||
if (fieldType === "object" && fieldSchema instanceof z.ZodObject) {
|
|
||||||
const nestedValue = (props.value[key] as FormData) ?? {};
|
|
||||||
return (
|
|
||||||
<fieldset class="border border-gray-200 rounded px-2 py-1">
|
|
||||||
<legend class="text-xs text-gray-500 px-1">{key}</legend>
|
|
||||||
<SchemaFields
|
|
||||||
schema={fieldSchema}
|
|
||||||
value={nestedValue}
|
|
||||||
onChange={(v) => updateField(props, key, v)}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional toggle
|
|
||||||
const isSet = () => props.value[key] !== undefined;
|
|
||||||
const toggleOptional = () => {
|
|
||||||
if (isSet()) {
|
|
||||||
const next = { ...props.value };
|
|
||||||
delete next[key];
|
|
||||||
props.onChange(next);
|
|
||||||
} else {
|
|
||||||
props.onChange({ ...props.value, [key]: getDefault(fieldSchema) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex items-start gap-1.5">
|
|
||||||
<Show when={isOptional}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isSet()}
|
|
||||||
onChange={toggleOptional}
|
|
||||||
class="mt-1.5"
|
|
||||||
title={`Include ${key}`}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<div class={`flex-1 ${isOptional && !isSet() ? "opacity-40" : ""}`}>
|
|
||||||
<FieldInput
|
|
||||||
label={key}
|
|
||||||
schema={fieldSchema}
|
|
||||||
value={isSet() ? props.value[key] : getDefault(fieldSchema)}
|
|
||||||
onChange={(v) => updateField(props, key, v)}
|
|
||||||
disabled={isOptional && !isSet()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// FieldInput
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const FieldInput: Component<{
|
|
||||||
label: string;
|
|
||||||
schema: z.ZodTypeAny;
|
|
||||||
value: unknown;
|
|
||||||
onChange: (v: unknown) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}> = (props) => {
|
|
||||||
const fieldType = createMemo(() => getFieldType(props.schema));
|
|
||||||
|
|
||||||
const inputClass =
|
|
||||||
"w-full border border-gray-300 rounded px-2 py-1 text-xs disabled:bg-gray-100 disabled:text-gray-400";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<label
|
|
||||||
class="text-xs text-gray-500 w-20 flex-shrink-0 truncate"
|
|
||||||
title={props.label}
|
|
||||||
>
|
|
||||||
{props.label}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<Show when={fieldType() === "string"}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={(props.value as string) ?? ""}
|
|
||||||
onInput={(e) => props.onChange(e.currentTarget.value)}
|
|
||||||
disabled={props.disabled}
|
|
||||||
class={inputClass}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={fieldType() === "number"}>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={(props.value as number) ?? ""}
|
|
||||||
onInput={(e) =>
|
|
||||||
props.onChange(parseFloat(e.currentTarget.value) || 0)
|
|
||||||
}
|
|
||||||
disabled={props.disabled}
|
|
||||||
class={inputClass}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={fieldType() === "boolean"}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={(props.value as boolean) ?? false}
|
|
||||||
onChange={(e) => props.onChange(e.currentTarget.checked)}
|
|
||||||
disabled={props.disabled}
|
|
||||||
class="mt-1"
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={fieldType() === "enum"}>
|
|
||||||
<select
|
|
||||||
value={(props.value as string) ?? ""}
|
|
||||||
onChange={(e) => props.onChange(e.currentTarget.value)}
|
|
||||||
disabled={props.disabled}
|
|
||||||
class={inputClass}
|
|
||||||
>
|
|
||||||
<For each={getEnumValues(props.schema)}>
|
|
||||||
{(v) => <option value={v}>{v}</option>}
|
|
||||||
</For>
|
|
||||||
</select>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={fieldType() === "array"}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={
|
|
||||||
Array.isArray(props.value) ? (props.value as any[]).join(", ") : ""
|
|
||||||
}
|
|
||||||
onInput={(e) => {
|
|
||||||
const raw = e.currentTarget.value;
|
|
||||||
const parsed = raw
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((s) => (isNaN(Number(s)) ? s : Number(s)));
|
|
||||||
props.onChange(parsed);
|
|
||||||
}}
|
|
||||||
disabled={props.disabled}
|
|
||||||
class={inputClass}
|
|
||||||
placeholder="comma-separated"
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function unwrapShape(
|
|
||||||
schema: z.ZodTypeAny,
|
|
||||||
): Record<string, z.ZodTypeAny> | null {
|
|
||||||
try {
|
|
||||||
if (schema instanceof z.ZodObject) {
|
|
||||||
return schema.shape as Record<string, z.ZodTypeAny>;
|
|
||||||
}
|
|
||||||
// Unwrap optional/branded/etc
|
|
||||||
const inner = (schema as any)?._def?.innerType;
|
|
||||||
if (inner) return unwrapShape(inner);
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type FieldType =
|
|
||||||
"string" | "number" | "boolean" | "enum" | "array" | "object" | "unknown";
|
|
||||||
|
|
||||||
function getFieldType(schema: z.ZodTypeAny): FieldType {
|
|
||||||
if (schema instanceof z.ZodString) return "string";
|
|
||||||
if (schema instanceof z.ZodNumber) return "number";
|
|
||||||
if (schema instanceof z.ZodBoolean) return "boolean";
|
|
||||||
if (schema instanceof z.ZodEnum) return "enum";
|
|
||||||
// Zod v4: nativeEnum returns ZodEnum, catch with duck-type check
|
|
||||||
if (isZodEnum(schema)) return "enum";
|
|
||||||
if (schema instanceof z.ZodArray) return "array";
|
|
||||||
if (schema instanceof z.ZodObject) return "object";
|
|
||||||
// Unwrap optional
|
|
||||||
const inner = (schema as any)?._def?.innerType;
|
|
||||||
if (inner) return getFieldType(inner);
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Duck-type check: Zod v4's nativeEnum returns a ZodEnum instance */
|
|
||||||
function isZodEnum(schema: z.ZodTypeAny): boolean {
|
|
||||||
return (schema as any)?._def?.typeName === "ZodEnum";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEnumValues(schema: z.ZodTypeAny): string[] {
|
|
||||||
try {
|
|
||||||
if (schema instanceof z.ZodEnum) return schema.options as string[];
|
|
||||||
if (isZodEnum(schema) && (schema as any).enum) {
|
|
||||||
return Object.values((schema as any).enum).filter(
|
|
||||||
(v: unknown) => typeof v === "string",
|
|
||||||
) as string[];
|
|
||||||
}
|
|
||||||
const inner = (schema as any)?._def?.innerType;
|
|
||||||
if (inner) return getEnumValues(inner);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefault(schema: z.ZodTypeAny): unknown {
|
|
||||||
const ft = getFieldType(schema);
|
|
||||||
switch (ft) {
|
|
||||||
case "string":
|
|
||||||
return "";
|
|
||||||
case "number":
|
|
||||||
return 0;
|
|
||||||
case "boolean":
|
|
||||||
return false;
|
|
||||||
case "array":
|
|
||||||
return [];
|
|
||||||
case "object":
|
|
||||||
return {};
|
|
||||||
case "enum":
|
|
||||||
return getEnumValues(schema)[0] ?? "";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateField(
|
|
||||||
parent: { value: FormData; onChange: (data: FormData) => void },
|
|
||||||
key: string,
|
|
||||||
value: unknown,
|
|
||||||
): void {
|
|
||||||
parent.onChange({ ...parent.value, [key]: value });
|
|
||||||
}
|
|
||||||
|
|
@ -1,461 +0,0 @@
|
||||||
/**
|
|
||||||
* JournalInput — chat-style textarea with command dispatch and autocomplete.
|
|
||||||
*
|
|
||||||
* - Enter sends, Shift+Enter inserts newline
|
|
||||||
* - Plain text → "chat" type
|
|
||||||
* - `/roll 3d6kh1` → "roll" type (result resolved client-side by GM)
|
|
||||||
* - `/link path#section` → "link" type
|
|
||||||
* - `/` alone opens completions dropdown (populated from /__COMPLETIONS.json
|
|
||||||
* in CLI mode, or client-side scan of the in-memory file index in dev mode)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
createSignal,
|
|
||||||
createEffect,
|
|
||||||
onMount,
|
|
||||||
onCleanup,
|
|
||||||
Show,
|
|
||||||
For,
|
|
||||||
Switch,
|
|
||||||
Match,
|
|
||||||
} from "solid-js";
|
|
||||||
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
|
||||||
import { useJournalCompletions, ensureCompletions } from "./completions";
|
|
||||||
import { resolveRollPayload } from "./types/roll";
|
|
||||||
|
|
||||||
// ---- Helpers ----
|
|
||||||
|
|
||||||
interface CompletionItem {
|
|
||||||
label: string;
|
|
||||||
kind: "command" | "value" | "no-results";
|
|
||||||
insertText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParsedInput {
|
|
||||||
type: "chat" | "roll" | "link";
|
|
||||||
payload: Record<string, unknown>;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseInput(raw: string): ParsedInput {
|
|
||||||
if (raw.startsWith("/roll ")) {
|
|
||||||
const notation = raw.slice("/roll ".length).trim();
|
|
||||||
if (!notation)
|
|
||||||
return { type: "roll", payload: {}, error: "Dice notation required" };
|
|
||||||
return { type: "roll", payload: { notation, label: notation } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (raw.startsWith("/link ")) {
|
|
||||||
const arg = raw.slice("/link ".length).trim();
|
|
||||||
if (!arg) return { type: "link", payload: {}, error: "Path required" };
|
|
||||||
const hashIdx = arg.indexOf("#");
|
|
||||||
const path = hashIdx === -1 ? arg : arg.slice(0, hashIdx);
|
|
||||||
const section =
|
|
||||||
hashIdx === -1 ? undefined : arg.slice(hashIdx + 1) || undefined;
|
|
||||||
return { type: "link", payload: { path, section } };
|
|
||||||
}
|
|
||||||
|
|
||||||
// /roll or /link with no space — need to complete, don't send
|
|
||||||
if (raw === "/roll" || raw === "/link") {
|
|
||||||
return { type: "chat", payload: {}, error: "Complete the command" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { type: "chat", payload: { text: raw } };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Component ----
|
|
||||||
|
|
||||||
export const JournalInput: Component = () => {
|
|
||||||
const stream = useJournalStream();
|
|
||||||
const comp = useJournalCompletions();
|
|
||||||
|
|
||||||
const isObserver = () => stream.myRole === "observer";
|
|
||||||
const isPlayer = () => stream.myRole === "player";
|
|
||||||
const isGm = () => stream.myRole === "gm";
|
|
||||||
|
|
||||||
const [text, setText] = createSignal("");
|
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
|
||||||
const [sending, setSending] = createSignal(false);
|
|
||||||
const [showCompletions, setShowCompletions] = createSignal(false);
|
|
||||||
const [selectedIdx, setSelectedIdx] = createSignal(0);
|
|
||||||
|
|
||||||
let textareaRef!: HTMLTextAreaElement;
|
|
||||||
let completionsRef!: HTMLDivElement;
|
|
||||||
|
|
||||||
// Ensure completions are loading on mount
|
|
||||||
onMount(() => {
|
|
||||||
void ensureCompletions();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- Send ----
|
|
||||||
|
|
||||||
function handleSend() {
|
|
||||||
const raw = text().trim();
|
|
||||||
if (!raw) return;
|
|
||||||
|
|
||||||
// Players / observers: everything is plain chat
|
|
||||||
if (isPlayer() || isObserver()) {
|
|
||||||
const result = sendMessage("chat", { text: raw });
|
|
||||||
if (!result.success) {
|
|
||||||
setError(result.error);
|
|
||||||
} else {
|
|
||||||
setText("");
|
|
||||||
}
|
|
||||||
textareaRef?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseInput(raw);
|
|
||||||
if (parsed.error) {
|
|
||||||
setError(parsed.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(null);
|
|
||||||
setSending(true);
|
|
||||||
|
|
||||||
// GM roll: resolve the dice result locally
|
|
||||||
if (parsed.type === "roll") {
|
|
||||||
const p = resolveRollPayload(
|
|
||||||
parsed.payload as { notation: string; label?: string },
|
|
||||||
);
|
|
||||||
const result = sendMessage("roll", p);
|
|
||||||
if (!result.success) {
|
|
||||||
setError(result.error);
|
|
||||||
} else {
|
|
||||||
setText("");
|
|
||||||
}
|
|
||||||
setSending(false);
|
|
||||||
textareaRef?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = sendMessage(parsed.type, parsed.payload);
|
|
||||||
if (!result.success) {
|
|
||||||
setError(result.error);
|
|
||||||
} else {
|
|
||||||
setText("");
|
|
||||||
}
|
|
||||||
|
|
||||||
setSending(false);
|
|
||||||
textareaRef?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Completions ----
|
|
||||||
// ---- Scroll selected completion into view ----
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const idx = selectedIdx();
|
|
||||||
if (!showCompletions() || !completionsRef) return;
|
|
||||||
const el = completionsRef.querySelector(`[data-comp-idx="${idx}"]`);
|
|
||||||
if (el) el.scrollIntoView({ block: "nearest" });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- Completions ----
|
|
||||||
|
|
||||||
function buildCompletions(): CompletionItem[] {
|
|
||||||
const raw = text().trim();
|
|
||||||
// Only GM gets completions
|
|
||||||
if (!isGm()) return [];
|
|
||||||
const data = comp.data;
|
|
||||||
const commands = [
|
|
||||||
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
|
|
||||||
{ label: "/link", kind: "command" as const, insertText: "/link " },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Show commands when user types / or starts typing a command name
|
|
||||||
if (raw.startsWith("/") && !raw.includes(" ")) {
|
|
||||||
const prefix = raw.toLowerCase();
|
|
||||||
const matches = commands.filter((c) =>
|
|
||||||
c.label.toLowerCase().startsWith(prefix),
|
|
||||||
);
|
|
||||||
return matches.length > 0
|
|
||||||
? matches
|
|
||||||
: [{ label: "Unknown command", kind: "no-results", insertText: "" }];
|
|
||||||
}
|
|
||||||
|
|
||||||
// After /roll — show dice suggestions
|
|
||||||
if (raw.startsWith("/roll ")) {
|
|
||||||
const prefix = raw.slice("/roll ".length).toLowerCase();
|
|
||||||
const matches = data.dice
|
|
||||||
.filter(
|
|
||||||
(d) =>
|
|
||||||
d.notation.toLowerCase().includes(prefix) ||
|
|
||||||
d.label.toLowerCase().includes(prefix),
|
|
||||||
)
|
|
||||||
.slice(0, 8);
|
|
||||||
if (matches.length === 0) {
|
|
||||||
return [{ label: "No dice found", kind: "no-results", insertText: "" }];
|
|
||||||
}
|
|
||||||
return matches.map((d) => ({
|
|
||||||
label: d.notation,
|
|
||||||
kind: "value" as const,
|
|
||||||
insertText: "/roll " + d.notation,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// After /link — show article and heading suggestions
|
|
||||||
if (raw.startsWith("/link ")) {
|
|
||||||
const prefix = raw.slice("/link ".length).toLowerCase();
|
|
||||||
const matches = data.links
|
|
||||||
.filter(
|
|
||||||
(l) =>
|
|
||||||
l.path.toLowerCase().includes(prefix) ||
|
|
||||||
l.label.toLowerCase().includes(prefix),
|
|
||||||
)
|
|
||||||
.slice(0, 8);
|
|
||||||
if (matches.length === 0) {
|
|
||||||
return [
|
|
||||||
{ label: "No links found", kind: "no-results", insertText: "" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return matches.map((l) => {
|
|
||||||
const insert = l.section
|
|
||||||
? `/link ${l.path}#${l.section}`
|
|
||||||
: `/link ${l.path}`;
|
|
||||||
return {
|
|
||||||
label: l.label,
|
|
||||||
kind: "value" as const,
|
|
||||||
insertText: insert,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentCompletions(): CompletionItem[] {
|
|
||||||
return buildCompletions();
|
|
||||||
}
|
|
||||||
|
|
||||||
function acceptCompletion(item: CompletionItem) {
|
|
||||||
if (item.kind === "no-results") return;
|
|
||||||
setText(item.insertText);
|
|
||||||
setShowCompletions(false);
|
|
||||||
textareaRef?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectCompletion(dir: "up" | "down") {
|
|
||||||
const comps = currentCompletions();
|
|
||||||
if (comps.length === 0) return;
|
|
||||||
setSelectedIdx((prev) => {
|
|
||||||
if (dir === "down") return (prev + 1) % comps.length;
|
|
||||||
return (prev - 1 + comps.length) % comps.length;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Keyboard ----
|
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
const comps = currentCompletions();
|
|
||||||
const open = showCompletions();
|
|
||||||
|
|
||||||
// When opening completions with Tab, if the text matches a command prefix
|
|
||||||
// and there are options, accept the first. Otherwise just show.
|
|
||||||
if (e.key === "Tab") {
|
|
||||||
if (open) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (comps.length > 0 && comps[selectedIdx()].kind !== "no-results") {
|
|
||||||
acceptCompletion(comps[selectedIdx()]);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// If not open and starts with /, open completions (GM only)
|
|
||||||
const raw = text();
|
|
||||||
if (isGm() && raw.startsWith("/")) {
|
|
||||||
e.preventDefault();
|
|
||||||
setShowCompletions(true);
|
|
||||||
setSelectedIdx(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (open && comps.length > 0) {
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
selectCompletion("down");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
selectCompletion("up");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Enter" && comps[selectedIdx()].kind !== "no-results") {
|
|
||||||
e.preventDefault();
|
|
||||||
acceptCompletion(comps[selectedIdx()]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setShowCompletions(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSend();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInput(e: InputEvent) {
|
|
||||||
const input = e.currentTarget as HTMLTextAreaElement;
|
|
||||||
const raw = input.value;
|
|
||||||
setText(raw);
|
|
||||||
|
|
||||||
// Completions visibility — keep open while user types any / (GM only)
|
|
||||||
if (isGm() && raw.startsWith("/")) {
|
|
||||||
setShowCompletions(true);
|
|
||||||
setSelectedIdx(0);
|
|
||||||
} else {
|
|
||||||
setShowCompletions(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-resize
|
|
||||||
textareaRef.style.height = "auto";
|
|
||||||
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Click outside ----
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const handler = (e: MouseEvent) => {
|
|
||||||
if (completionsRef && !completionsRef.contains(e.target as Node)) {
|
|
||||||
setShowCompletions(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", handler);
|
|
||||||
onCleanup(() => document.removeEventListener("mousedown", handler));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Completions placeholder — shown in the dropdown area when no matches
|
|
||||||
function renderCompletionsDropdown() {
|
|
||||||
const comps = currentCompletions();
|
|
||||||
if (comps.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={completionsRef}
|
|
||||||
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 max-h-40 overflow-y-auto z-50"
|
|
||||||
>
|
|
||||||
<For each={comps}>
|
|
||||||
{(item, idx) => (
|
|
||||||
<div
|
|
||||||
data-comp-idx={idx()}
|
|
||||||
class={`flex items-center gap-2 px-3 py-1.5 cursor-pointer text-sm ${
|
|
||||||
item.kind === "no-results"
|
|
||||||
? "text-gray-400 italic cursor-default"
|
|
||||||
: idx() === selectedIdx()
|
|
||||||
? "bg-blue-50 text-blue-700"
|
|
||||||
: "hover:bg-gray-100 text-gray-700"
|
|
||||||
}`}
|
|
||||||
onClick={() => acceptCompletion(item)}
|
|
||||||
onMouseEnter={() =>
|
|
||||||
item.kind !== "no-results" && setSelectedIdx(idx())
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={item.kind !== "no-results"}
|
|
||||||
fallback={<span class="text-xs text-gray-300 shrink-0">—</span>}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class={`text-xs px-1 rounded shrink-0 ${
|
|
||||||
item.kind === "command"
|
|
||||||
? "bg-purple-100 text-purple-700"
|
|
||||||
: "bg-gray-100 text-gray-500"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.kind === "command" ? "cmd" : "val"}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<span class="font-mono truncate flex-1">{item.label}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="border-t border-gray-200 bg-white relative">
|
|
||||||
{/* Completions loading / error / empty teaser */}
|
|
||||||
<Show when={showCompletions()}>
|
|
||||||
<Switch>
|
|
||||||
<Match when={comp.state.status === "loading"}>
|
|
||||||
<div
|
|
||||||
ref={completionsRef}
|
|
||||||
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-gray-400 italic"
|
|
||||||
>
|
|
||||||
Loading completions…
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={comp.state.status === "error"}>
|
|
||||||
<div
|
|
||||||
ref={completionsRef}
|
|
||||||
class="absolute bottom-full left-0 right-0 bg-white border border-red-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-red-500"
|
|
||||||
>
|
|
||||||
{(comp.state as any).message || "Failed to load completions"}
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match
|
|
||||||
when={
|
|
||||||
comp.state.status === "empty" || comp.state.status === "loaded"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{renderCompletionsDropdown()}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Textarea + actions */}
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<Show
|
|
||||||
when={!isObserver()}
|
|
||||||
fallback={
|
|
||||||
<div class="px-3 py-4 text-xs text-gray-400 italic text-center">
|
|
||||||
You are observing. Open this panel on another device to join as GM
|
|
||||||
or player.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={text()}
|
|
||||||
onInput={handleInput}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder={
|
|
||||||
isGm()
|
|
||||||
? "Type a message, or /roll or /link..."
|
|
||||||
: "Type a message..."
|
|
||||||
}
|
|
||||||
rows={2}
|
|
||||||
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
|
|
||||||
text-gray-800 placeholder-gray-400 focus:outline-none
|
|
||||||
bg-transparent min-h-[60px]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Bottom row: error + buttons */}
|
|
||||||
<div class="flex items-center justify-between px-2 pb-2">
|
|
||||||
<Show when={error()}>
|
|
||||||
<p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p>
|
|
||||||
</Show>
|
|
||||||
<div class="flex items-center gap-1 ml-auto">
|
|
||||||
<button
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={sending() || !text().trim()}
|
|
||||||
class="bg-blue-600 text-white rounded px-3 py-1 text-xs
|
|
||||||
hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed
|
|
||||||
transition-colors"
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,347 +0,0 @@
|
||||||
/**
|
|
||||||
* JournalPanel — right panel container for the journal stream
|
|
||||||
*
|
|
||||||
* Fixed overlay. Shows stream when connected, connect dialog when not.
|
|
||||||
* Auto-connects to ws://{current host}:{current port} — no URL input needed.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component, Show, createSignal, onMount, For } from "solid-js";
|
|
||||||
import {
|
|
||||||
connectStream,
|
|
||||||
hydrateFromServer,
|
|
||||||
useJournalStream,
|
|
||||||
setMyName,
|
|
||||||
setMyRole,
|
|
||||||
setSessionId,
|
|
||||||
sessions,
|
|
||||||
createSession,
|
|
||||||
disconnectStream,
|
|
||||||
} from "../stores/journalStream";
|
|
||||||
import { StreamView } from "./StreamView";
|
|
||||||
import { JournalInput } from "./JournalInput";
|
|
||||||
|
|
||||||
export interface JournalPanelProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|
||||||
const stream = useJournalStream();
|
|
||||||
|
|
||||||
// Player list (exclude gm and observer)
|
|
||||||
const playerEntries = () =>
|
|
||||||
Object.entries(stream.players).filter(
|
|
||||||
([_, p]) => p.role !== "gm" && p.role !== "observer",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Backdrop — only on mobile, starts below header */}
|
|
||||||
<div
|
|
||||||
class="fixed top-16 inset-x-0 bottom-0 bg-black/30 z-40 md:hidden"
|
|
||||||
classList={{ hidden: !props.open }}
|
|
||||||
onClick={props.onClose}
|
|
||||||
/>
|
|
||||||
{/* Panel — always mounted for exit animation, visibility toggled */}
|
|
||||||
<aside
|
|
||||||
class={`fixed top-16 right-0 bottom-0 z-50 bg-white border-l border-gray-200
|
|
||||||
flex flex-col w-full max-w-md md:w-[420px] shadow-lg
|
|
||||||
transition-transform duration-300 ease-in-out ${
|
|
||||||
props.open ? "translate-x-0" : "translate-x-full"
|
|
||||||
}`}
|
|
||||||
aria-hidden={!props.open}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<JournalHeader onClose={props.onClose} />
|
|
||||||
|
|
||||||
{/* Player list tabs */}
|
|
||||||
<Show when={stream.connected && playerEntries().length > 0}>
|
|
||||||
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-gray-100 bg-gray-50 overflow-x-auto shrink-0">
|
|
||||||
<For each={playerEntries()}>
|
|
||||||
{([name]) => (
|
|
||||||
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full whitespace-nowrap">
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show
|
|
||||||
when={stream.connected}
|
|
||||||
fallback={
|
|
||||||
<div class="flex-1 flex items-center justify-center p-4 overflow-y-auto">
|
|
||||||
<ConnectDialog />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="flex-1 min-h-0">
|
|
||||||
<StreamView />
|
|
||||||
</div>
|
|
||||||
<JournalInput />
|
|
||||||
</Show>
|
|
||||||
</aside>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Journal Header — status dot, player name, session dropdown, disconnect & close
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface JournalHeaderProps {
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
|
||||||
const stream = useJournalStream();
|
|
||||||
const manifest = sessions;
|
|
||||||
const [dropdownOpen, setDropdownOpen] = createSignal(false);
|
|
||||||
const [newSessionName, setNewSessionName] = createSignal("");
|
|
||||||
const [showNewInput, setShowNewInput] = createSignal(false);
|
|
||||||
let dropdownRef!: HTMLDivElement;
|
|
||||||
|
|
||||||
const handleSessionSelect = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await hydrateFromServer(id);
|
|
||||||
setSessionId(id);
|
|
||||||
} catch {
|
|
||||||
/* */
|
|
||||||
}
|
|
||||||
setDropdownOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreate = () => {
|
|
||||||
const name = newSessionName().trim();
|
|
||||||
if (!name) return;
|
|
||||||
createSession(name);
|
|
||||||
setNewSessionName("");
|
|
||||||
setShowNewInput(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisconnect = () => disconnectStream();
|
|
||||||
|
|
||||||
// Close dropdown on outside click
|
|
||||||
if (typeof document !== "undefined") {
|
|
||||||
document.addEventListener("click", (e) => {
|
|
||||||
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
|
|
||||||
setDropdownOpen(false);
|
|
||||||
setShowNewInput(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0">
|
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
|
||||||
<span
|
|
||||||
class="w-2 h-2 rounded-full shrink-0"
|
|
||||||
classList={{
|
|
||||||
"bg-gray-400": stream.connectionStatus === "disconnected",
|
|
||||||
"bg-yellow-400 animate-pulse":
|
|
||||||
stream.connectionStatus === "connecting",
|
|
||||||
"bg-green-500": stream.connectionStatus === "connected",
|
|
||||||
"bg-red-500": stream.connectionStatus === "error",
|
|
||||||
}}
|
|
||||||
title={stream.connectionStatus}
|
|
||||||
/>
|
|
||||||
<Show
|
|
||||||
when={stream.connected && stream.sessionId}
|
|
||||||
fallback={
|
|
||||||
<h2 class="text-sm font-semibold text-gray-700 truncate">
|
|
||||||
Disconnected
|
|
||||||
</h2>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="text-sm font-semibold text-gray-700 truncate leading-tight flex items-center gap-1">
|
|
||||||
{stream.myName}
|
|
||||||
<Show when={stream.myRole !== "gm"}>
|
|
||||||
<span
|
|
||||||
class={`text-[10px] px-1 rounded ${
|
|
||||||
stream.myRole === "observer"
|
|
||||||
? "bg-gray-200 text-gray-500"
|
|
||||||
: "bg-blue-100 text-blue-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{stream.myRole}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</p>
|
|
||||||
{/* Clickable subtitle — session dropdown (GM only) */}
|
|
||||||
<Show when={stream.myRole === "gm"}>
|
|
||||||
<div ref={dropdownRef} class="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setDropdownOpen((v) => !v)}
|
|
||||||
class="text-[10px] text-gray-400 hover:text-gray-600 truncate leading-tight cursor-pointer text-left"
|
|
||||||
>
|
|
||||||
{stream.sessionName || stream.sessionId}{" "}
|
|
||||||
<span class="text-gray-300">▾</span>
|
|
||||||
</button>
|
|
||||||
<Show when={dropdownOpen()}>
|
|
||||||
<div class="absolute top-full left-0 mt-1 w-48 bg-white border border-gray-200 rounded shadow-lg z-50 py-1 max-h-48 overflow-y-auto">
|
|
||||||
<For each={Object.entries(manifest().sessions)}>
|
|
||||||
{([id, meta]) => (
|
|
||||||
<button
|
|
||||||
onClick={() => handleSessionSelect(id)}
|
|
||||||
class={`w-full text-left px-3 py-1 text-xs hover:bg-gray-100 flex items-center gap-1 ${
|
|
||||||
id === stream.sessionId
|
|
||||||
? "bg-blue-50 text-blue-700"
|
|
||||||
: "text-gray-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span class="truncate flex-1">{meta.name || id}</span>
|
|
||||||
<Show when={id === stream.sessionId}>
|
|
||||||
<span class="text-blue-500 shrink-0">✓</span>
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<div class="border-t border-gray-100 my-0.5" />
|
|
||||||
<Show
|
|
||||||
when={!showNewInput()}
|
|
||||||
fallback={
|
|
||||||
<div class="px-3 py-1 flex gap-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newSessionName()}
|
|
||||||
onInput={(e) =>
|
|
||||||
setNewSessionName(e.currentTarget.value)
|
|
||||||
}
|
|
||||||
placeholder="Session name"
|
|
||||||
class="flex-1 border border-gray-300 rounded px-1.5 py-0.5 text-xs"
|
|
||||||
autofocus
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") handleCreate();
|
|
||||||
if (e.key === "Escape") setShowNewInput(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleCreate}
|
|
||||||
class="bg-blue-600 text-white rounded px-2 py-0.5 text-xs hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowNewInput(true)}
|
|
||||||
class="w-full text-left px-3 py-1 text-xs text-blue-600 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
+ New session
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
|
||||||
<Show when={stream.connected}>
|
|
||||||
<button
|
|
||||||
onClick={handleDisconnect}
|
|
||||||
class="text-xs text-gray-400 hover:text-red-500 px-1"
|
|
||||||
title="Disconnect"
|
|
||||||
>
|
|
||||||
⏻
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
<button
|
|
||||||
onClick={props.onClose}
|
|
||||||
class="text-gray-400 hover:text-gray-600 text-sm px-1"
|
|
||||||
title="Hide panel"
|
|
||||||
>
|
|
||||||
▸
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Connect dialog — one input: player name. Broker URL is always current host.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const ConnectDialog: Component = () => {
|
|
||||||
const stream = useJournalStream();
|
|
||||||
const [playerName, setPlayerName] = createSignal(stream.myName);
|
|
||||||
const [role, setRole] = createSignal<"gm" | "player" | "observer">(
|
|
||||||
stream.myRole,
|
|
||||||
);
|
|
||||||
const [connecting, setConnecting] = createSignal(false);
|
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
|
||||||
|
|
||||||
const handleConnect = async () => {
|
|
||||||
const name = playerName().trim() || stream.myName;
|
|
||||||
if (!name) return;
|
|
||||||
|
|
||||||
const brokerUrl = `ws://${window.location.host}`;
|
|
||||||
|
|
||||||
setConnecting(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
setMyName(name);
|
|
||||||
setMyRole(role());
|
|
||||||
const sessionId = stream.sessionId || "default";
|
|
||||||
setSessionId(sessionId);
|
|
||||||
await hydrateFromServer(sessionId);
|
|
||||||
await connectStream(sessionId, brokerUrl);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : "Connection failed";
|
|
||||||
setError(msg);
|
|
||||||
} finally {
|
|
||||||
setConnecting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="w-full max-w-sm space-y-3">
|
|
||||||
<p class="text-sm text-gray-600 text-center">
|
|
||||||
Enter your name and role to join the session.
|
|
||||||
<br />
|
|
||||||
<span class="text-xs text-gray-400">
|
|
||||||
Connecting to {window.location.host}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={playerName()}
|
|
||||||
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
|
|
||||||
placeholder="Your name"
|
|
||||||
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
|
|
||||||
autofocus
|
|
||||||
/>
|
|
||||||
{/* Role selector */}
|
|
||||||
<div class="flex gap-1">
|
|
||||||
{(["gm", "player", "observer"] as const).map((r) => (
|
|
||||||
<button
|
|
||||||
onClick={() => setRole(r)}
|
|
||||||
class={`flex-1 rounded px-2 py-1.5 text-xs font-medium border transition-colors ${
|
|
||||||
role() === r
|
|
||||||
? "bg-blue-600 text-white border-blue-600"
|
|
||||||
: "bg-white text-gray-600 border-gray-300 hover:border-gray-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{r === "gm" ? "GM" : r === "player" ? "Player" : "Observer"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleConnect}
|
|
||||||
disabled={connecting() || !playerName().trim()}
|
|
||||||
class="w-full bg-blue-600 text-white rounded px-3 py-2 text-sm font-medium
|
|
||||||
hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{connecting() ? "Connecting..." : "Join"}
|
|
||||||
</button>
|
|
||||||
<Show when={error()}>
|
|
||||||
<p class="text-red-500 text-sm text-center">{error()}</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
/**
|
|
||||||
* StreamMessage — single message card in the stream
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component, Show, createMemo } from "solid-js";
|
|
||||||
import type { StreamMessage as StreamMessageType } from "./registry";
|
|
||||||
import { getMessageType } from "./registry";
|
|
||||||
import { revertLatest, useJournalStream } from "../stores/journalStream";
|
|
||||||
|
|
||||||
export const StreamMessageCard: Component<{
|
|
||||||
message: StreamMessageType;
|
|
||||||
}> = (props) => {
|
|
||||||
const stream = useJournalStream();
|
|
||||||
const def = createMemo(() => getMessageType(props.message.type));
|
|
||||||
const rendered = createMemo(() =>
|
|
||||||
def()?.render?.(props.message.payload, props.message as any),
|
|
||||||
);
|
|
||||||
|
|
||||||
const time = createMemo(() => {
|
|
||||||
const d = new Date(props.message.timestamp);
|
|
||||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const isMine = () => props.message.sender === stream.myName;
|
|
||||||
const latestSeq = () => stream.senderSeq[stream.myName] ?? 0;
|
|
||||||
const canRevertThis = () =>
|
|
||||||
isMine() && props.message.seq === latestSeq() && !props.message.reverted;
|
|
||||||
|
|
||||||
const senderClass = () =>
|
|
||||||
props.message.sender === "gm"
|
|
||||||
? "text-purple-600 font-semibold"
|
|
||||||
: "text-blue-600 font-semibold";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={!props.message.reverted}
|
|
||||||
fallback={<RevealedCard msg={props.message} />}
|
|
||||||
>
|
|
||||||
<div class="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
|
|
||||||
{/* Header */}
|
|
||||||
<div class="flex items-center gap-1.5 px-2.5 py-1 bg-gray-50 border-b border-gray-100">
|
|
||||||
<span class={`text-xs ${senderClass()}`}>{props.message.sender}</span>
|
|
||||||
<span class="text-xs text-gray-400">{time()}</span>
|
|
||||||
<Show when={canRevertThis()}>
|
|
||||||
<button
|
|
||||||
onClick={() => revertLatest()}
|
|
||||||
class="text-xs text-gray-300 hover:text-red-500 ml-auto px-0.5 leading-none"
|
|
||||||
title="Revert this message"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
<span
|
|
||||||
class="text-xs text-gray-300 bg-gray-100 px-1 rounded"
|
|
||||||
classList={{ "ml-auto": !canRevertThis() }}
|
|
||||||
>
|
|
||||||
{props.message.type}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div class="px-2.5 py-1.5 text-sm text-gray-800">
|
|
||||||
<Show
|
|
||||||
when={rendered()}
|
|
||||||
fallback={<JsonBody payload={props.message.payload} />}
|
|
||||||
>
|
|
||||||
{rendered()}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Fallback: render payload as formatted JSON */
|
|
||||||
const JsonBody: Component<{ payload: unknown }> = (props) => {
|
|
||||||
const text = () => JSON.stringify(props.payload, null, 2);
|
|
||||||
return (
|
|
||||||
<pre class="text-xs text-gray-600 whitespace-pre-wrap font-mono">
|
|
||||||
{text()}
|
|
||||||
</pre>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Reverted card: struck-through, grey */
|
|
||||||
const RevealedCard: Component<{ msg: StreamMessageType }> = (props) => {
|
|
||||||
return (
|
|
||||||
<div class="bg-gray-100 rounded-lg border border-gray-200 opacity-50 px-2.5 py-1.5">
|
|
||||||
<span class="text-xs text-gray-400 line-through">
|
|
||||||
{props.msg.sender} — reverted
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
/**
|
|
||||||
* StreamView — scrollable message log
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component, For, createEffect, onMount } from "solid-js";
|
|
||||||
import { visibleMessages, useJournalStream } from "../stores/journalStream";
|
|
||||||
import { StreamMessageCard } from "./StreamMessage";
|
|
||||||
|
|
||||||
export const StreamView: Component = () => {
|
|
||||||
const stream = useJournalStream();
|
|
||||||
let containerRef: HTMLDivElement | undefined;
|
|
||||||
|
|
||||||
// Auto-scroll to bottom on new messages
|
|
||||||
const messages = () => visibleMessages();
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
void messages().length; // track
|
|
||||||
if (!containerRef) return;
|
|
||||||
containerRef.scrollTop = containerRef.scrollHeight;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
class="h-full overflow-y-auto px-3 py-2 space-y-2 bg-gray-50"
|
|
||||||
>
|
|
||||||
<For each={messages()}>
|
|
||||||
{(msg) => <StreamMessageCard message={msg} />}
|
|
||||||
</For>
|
|
||||||
<Show when={messages().length === 0}>
|
|
||||||
<p class="text-center text-gray-400 text-xs py-8">
|
|
||||||
{stream.connected
|
|
||||||
? "No messages yet. Start by posting something."
|
|
||||||
: "Connect to a session to see messages."}
|
|
||||||
</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
import { Show } from "solid-js";
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
/**
|
|
||||||
* Journal completions — client-side loader for /__COMPLETIONS.json
|
|
||||||
*
|
|
||||||
* In CLI mode, fetches the pre-computed index. In dev/browser mode, falls
|
|
||||||
* back to scanning the in-memory file index for dice expressions and headings.
|
|
||||||
*
|
|
||||||
* The fetch runs eagerly on module import. Call `useJournalCompletions()`
|
|
||||||
* from any Solid component to reactively read the state.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
|
||||||
import { extractHeadings } from "../../data-loader/toc";
|
|
||||||
import {
|
|
||||||
getPathsByExtension,
|
|
||||||
getIndexedData,
|
|
||||||
} from "../../data-loader/file-index";
|
|
||||||
|
|
||||||
// ------------------- Types (mirrors CLI) -------------------
|
|
||||||
|
|
||||||
export interface DiceCompletion {
|
|
||||||
label: string;
|
|
||||||
notation: string;
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LinkCompletion {
|
|
||||||
path: string;
|
|
||||||
label: string;
|
|
||||||
section: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JournalCompletions {
|
|
||||||
dice: DiceCompletion[];
|
|
||||||
links: LinkCompletion[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CompletionsState =
|
|
||||||
| { status: "loading" }
|
|
||||||
| { status: "loaded"; data: JournalCompletions }
|
|
||||||
| { status: "empty" }
|
|
||||||
| { status: "error"; message: string };
|
|
||||||
|
|
||||||
// ------------------- Reactive state (module-level signal) -------------------
|
|
||||||
|
|
||||||
const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
|
|
||||||
status: "loading",
|
|
||||||
});
|
|
||||||
|
|
||||||
// ------------------- Fetch (CLI mode) -------------------
|
|
||||||
|
|
||||||
async function tryServer(): Promise<JournalCompletions | null> {
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/__COMPLETIONS.json");
|
|
||||||
if (!resp.ok) return null;
|
|
||||||
const data = await resp.json();
|
|
||||||
return {
|
|
||||||
dice: Array.isArray(data.dice) ? data.dice : [],
|
|
||||||
links: Array.isArray(data.links) ? data.links : [],
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------- Client-side fallback scan -------------------
|
|
||||||
|
|
||||||
async function scanClientSide(): Promise<JournalCompletions> {
|
|
||||||
const paths = await getPathsByExtension("md");
|
|
||||||
const dice: DiceCompletion[] = [];
|
|
||||||
const links: LinkCompletion[] = [];
|
|
||||||
const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi;
|
|
||||||
|
|
||||||
for (const filePath of paths) {
|
|
||||||
const content = await getIndexedData(filePath);
|
|
||||||
if (!content) continue;
|
|
||||||
|
|
||||||
// Dice scan
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
tagRegex.lastIndex = 0;
|
|
||||||
while ((match = tagRegex.exec(content)) !== null) {
|
|
||||||
const raw = match[1].trim();
|
|
||||||
if (!raw || raw.length > 80) continue;
|
|
||||||
if (!/^\d*d\d+/i.test(raw) && !/^[+-]/.test(raw)) continue;
|
|
||||||
dice.push({ label: raw, notation: raw, source: filePath });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Link scan (headings)
|
|
||||||
const basePath = filePath.replace(/\.md$/, "");
|
|
||||||
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
|
||||||
links.push({ path: basePath, label: fileName, section: null });
|
|
||||||
for (const heading of extractHeadings(content)) {
|
|
||||||
links.push({
|
|
||||||
path: basePath,
|
|
||||||
label: `${fileName} § ${heading.title}`,
|
|
||||||
section: heading.id ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { dice, links };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------- Init (runs eagerly at import time) -------------------
|
|
||||||
|
|
||||||
// Using a top-level IIFE so the promise starts immediately
|
|
||||||
const _initPromise: Promise<void> = (async () => {
|
|
||||||
// 1. Try server first (CLI mode)
|
|
||||||
const serverData = await tryServer();
|
|
||||||
if (serverData) {
|
|
||||||
setCompletionsState({ status: "loaded", data: serverData });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fall back to client-side scan (dev/browser mode)
|
|
||||||
try {
|
|
||||||
const data = await scanClientSide();
|
|
||||||
if (data.dice.length > 0 || data.links.length > 0) {
|
|
||||||
setCompletionsState({ status: "loaded", data });
|
|
||||||
} else {
|
|
||||||
setCompletionsState({ status: "empty" });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setCompletionsState({
|
|
||||||
status: "error",
|
|
||||||
message: e instanceof Error ? e.message : "Failed to scan",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ------------------- Public API -------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a Promise that resolves once completions are loaded (or failed).
|
|
||||||
* Useful for waiting before showing the completions dropdown.
|
|
||||||
*/
|
|
||||||
export function ensureCompletions(): Promise<void> {
|
|
||||||
return _initPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Force a re-fetch on the next page load. For runtime, call before invalidate triggers. */
|
|
||||||
let _invalidated = false;
|
|
||||||
export function invalidateCompletions(): void {
|
|
||||||
_invalidated = true;
|
|
||||||
// On next import (page reload), the module will re-init.
|
|
||||||
// For a runtime invalidation, you could call init again.
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reactive hooks for the journal input.
|
|
||||||
* Returns the current completions state + a convenience `data` extractor.
|
|
||||||
*/
|
|
||||||
export function useJournalCompletions(): {
|
|
||||||
state: CompletionsState;
|
|
||||||
data: JournalCompletions;
|
|
||||||
} {
|
|
||||||
const s = completionsState();
|
|
||||||
if (s.status === "loaded") {
|
|
||||||
return { state: s, data: s.data };
|
|
||||||
}
|
|
||||||
return { state: s, data: { dice: [], links: [] } };
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
/**
|
|
||||||
* Journal module — barrel export
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Trigger message type registration
|
|
||||||
import "./types";
|
|
||||||
|
|
||||||
export {
|
|
||||||
registerMessageType,
|
|
||||||
getMessageType,
|
|
||||||
registeredTypes,
|
|
||||||
validatePayload,
|
|
||||||
canEmit,
|
|
||||||
} from "./registry";
|
|
||||||
export type { MessageTypeDef, StreamMessage } from "./registry";
|
|
||||||
export {
|
|
||||||
connectStream,
|
|
||||||
disconnectStream,
|
|
||||||
hydrateFromServer,
|
|
||||||
sendMessage,
|
|
||||||
revertLatest,
|
|
||||||
canRevert,
|
|
||||||
visibleMessages,
|
|
||||||
useJournalStream,
|
|
||||||
journalStreamState,
|
|
||||||
createSession,
|
|
||||||
deleteSession,
|
|
||||||
sessions,
|
|
||||||
setMyName,
|
|
||||||
} from "../stores/journalStream";
|
|
||||||
export type {
|
|
||||||
JournalStreamState,
|
|
||||||
SessionMeta,
|
|
||||||
SessionManifest,
|
|
||||||
} from "../stores/journalStream";
|
|
||||||
|
|
||||||
// UI components
|
|
||||||
export { JournalPanel } from "./JournalPanel";
|
|
||||||
export type { JournalPanelProps } from "./JournalPanel";
|
|
||||||
export { ConnectionBar } from "./ConnectionBar";
|
|
||||||
export { StreamView } from "./StreamView";
|
|
||||||
export { StreamMessageCard } from "./StreamMessage";
|
|
||||||
export { ComposePanel } from "./ComposePanel";
|
|
||||||
export { JournalInput } from "./JournalInput";
|
|
||||||
export { DynamicForm } from "./DynamicForm";
|
|
||||||
export { useJournalCompletions, invalidateCompletions } from "./completions";
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
/**
|
|
||||||
* Journal Stream — Message Type Registry
|
|
||||||
*
|
|
||||||
* An open, add-only registry of message types. Game-specific widgets
|
|
||||||
* call `registerMessageType()` at module import time. The stream core
|
|
||||||
* uses this to validate, render, and reduce messages.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ZodType } from "zod";
|
|
||||||
import type { JSX } from "solid-js";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// StreamMessage
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The typed envelope carried on the stream topic.
|
|
||||||
* `P` is narrowed by the `type` key at the receiving side.
|
|
||||||
*/
|
|
||||||
export interface StreamMessage<P = unknown> {
|
|
||||||
/** Deterministic id: `{sender}-{seq}` */
|
|
||||||
id: string;
|
|
||||||
/** Sender identifier — "gm" or a player name */
|
|
||||||
sender: string;
|
|
||||||
/** Monotonic sequence number, per sender */
|
|
||||||
seq: number;
|
|
||||||
/** Registered type key — determines which payload schema & widget are used */
|
|
||||||
type: string;
|
|
||||||
/** Type-specific data, validated against the registry's Zod schema */
|
|
||||||
payload: P;
|
|
||||||
/** Epoch millis, set by the sender at publish time */
|
|
||||||
timestamp: number;
|
|
||||||
/**
|
|
||||||
* Soft-delete. Only the sender's latest (highest seq) message can be
|
|
||||||
* reverted. Sending a new message locks the previous one permanently.
|
|
||||||
*/
|
|
||||||
reverted: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// MessageTypeDef
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface MessageTypeDef<P = unknown> {
|
|
||||||
/** Unique type key, e.g. "roll", "link" */
|
|
||||||
type: string;
|
|
||||||
/** Human-readable label shown in the compose panel dropdown */
|
|
||||||
label: string;
|
|
||||||
/** Which roles can emit this type. Empty array = all roles (including observer) allowed. */
|
|
||||||
emitters?: ("gm" | "player" | "observer")[];
|
|
||||||
/** Zod schema used to validate payload before publish */
|
|
||||||
schema: ZodType<P>;
|
|
||||||
/** Returns a default payload for compose panel initialization */
|
|
||||||
defaultPayload?: () => P;
|
|
||||||
/**
|
|
||||||
* Render the message body in the stream.
|
|
||||||
* Receives the full message for context (sender, timestamp, reverted).
|
|
||||||
* Return undefined to suppress inline rendering (e.g. pure state mutations).
|
|
||||||
*/
|
|
||||||
render?: (payload: P, msg: StreamMessage<P>) => JSX.Element | undefined;
|
|
||||||
/**
|
|
||||||
* Apply side-effects to local state when this message is received.
|
|
||||||
* Runs once per message, at initial hydration and on live receipt.
|
|
||||||
*/
|
|
||||||
reducer?: (payload: P, msg: StreamMessage<P>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Registry
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const registry = new Map<string, MessageTypeDef<any>>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a message type. Calling module should import and call this at
|
|
||||||
* top-level so the registry is populated before any stream operations.
|
|
||||||
*/
|
|
||||||
export function registerMessageType<P>(def: MessageTypeDef<P>): void {
|
|
||||||
if (registry.has(def.type)) {
|
|
||||||
throw new Error(`Message type "${def.type}" is already registered.`);
|
|
||||||
}
|
|
||||||
registry.set(def.type, def);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Retrieve a registered type definition. */
|
|
||||||
export function getMessageType(type: string): MessageTypeDef | undefined {
|
|
||||||
return registry.get(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Iterate over all registered type keys. */
|
|
||||||
export function registeredTypes(): IterableIterator<string> {
|
|
||||||
return registry.keys();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a payload against the registered schema for `type`.
|
|
||||||
* Returns the parsed (narrowed) payload on success, or a ZodError.
|
|
||||||
*/
|
|
||||||
export function validatePayload<P>(
|
|
||||||
type: string,
|
|
||||||
payload: P,
|
|
||||||
): { success: true; data: P } | { success: false; error: string } {
|
|
||||||
const def = registry.get(type);
|
|
||||||
if (!def) {
|
|
||||||
return { success: false, error: `Unknown message type: "${type}"` };
|
|
||||||
}
|
|
||||||
const result = def.schema.safeParse(payload);
|
|
||||||
if (result.success) {
|
|
||||||
return { success: true, data: result.data as P };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: result.error.issues
|
|
||||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
||||||
.join("; "),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether a role (sender) is allowed to emit a given message type.
|
|
||||||
*/
|
|
||||||
export function canEmit(type: string, role: string): boolean {
|
|
||||||
const def = registry.get(type);
|
|
||||||
if (!def) return false;
|
|
||||||
const emitters = def.emitters;
|
|
||||||
if (!emitters || emitters.length === 0) return true;
|
|
||||||
return emitters.includes(role as "gm" | "player" | "observer");
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
/**
|
|
||||||
* Built-in message type: chat
|
|
||||||
*
|
|
||||||
* Emitters: gm, player
|
|
||||||
* Command: plain text (or press Enter)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from "zod";
|
|
||||||
import { registerMessageType } from "../registry";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
text: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Message cannot be empty")
|
|
||||||
.max(2000, "Message too long"),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ChatPayload = z.infer<typeof schema>;
|
|
||||||
|
|
||||||
registerMessageType<ChatPayload>({
|
|
||||||
type: "chat",
|
|
||||||
label: "Chat",
|
|
||||||
emitters: ["gm", "player"],
|
|
||||||
schema,
|
|
||||||
defaultPayload: () => ({ text: "" }),
|
|
||||||
render: (p) => <p class="text-sm whitespace-pre-wrap">{p.text}</p>,
|
|
||||||
});
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
/**
|
|
||||||
* Journal message types — barrel export
|
|
||||||
*
|
|
||||||
* Importing this module registers all built-in message types.
|
|
||||||
* Game-specific widgets should create their own barrel and import it.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import "./chat";
|
|
||||||
import "./roll";
|
|
||||||
import "./link";
|
|
||||||
import "./intent";
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
/**
|
|
||||||
* Built-in message types: intent / resolution
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from "zod";
|
|
||||||
import { registerMessageType } from "../registry";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// intent
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const intentSchema = z.object({
|
|
||||||
description: z.string().min(1),
|
|
||||||
context: z.record(z.string(), z.unknown()).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type IntentPayload = z.infer<typeof intentSchema>;
|
|
||||||
|
|
||||||
registerMessageType<IntentPayload>({
|
|
||||||
type: "intent",
|
|
||||||
label: "Declare Intent",
|
|
||||||
emitters: ["player"],
|
|
||||||
schema: intentSchema,
|
|
||||||
defaultPayload: () => ({ description: "" }),
|
|
||||||
render: (p) => (
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-lg">🙋</span>
|
|
||||||
<span class="text-sm font-medium text-yellow-700">Intent</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm">{p.description}</p>
|
|
||||||
{p.context && Object.keys(p.context).length > 0 && (
|
|
||||||
<pre class="text-xs bg-gray-100 rounded px-2 py-1 text-gray-600">
|
|
||||||
{JSON.stringify(p.context, null, 2)}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// resolution
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const resolutionSchema = z.object({
|
|
||||||
intentId: z.string().min(1),
|
|
||||||
outcome: z.string().min(1),
|
|
||||||
statChanges: z.record(z.string(), z.unknown()).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ResolutionPayload = z.infer<typeof resolutionSchema>;
|
|
||||||
|
|
||||||
registerMessageType<ResolutionPayload>({
|
|
||||||
type: "resolution",
|
|
||||||
label: "Resolve Intent",
|
|
||||||
emitters: ["gm"],
|
|
||||||
schema: resolutionSchema,
|
|
||||||
render: (p) => (
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-lg">✅</span>
|
|
||||||
<span class="text-sm font-medium text-green-700">Resolution</span>
|
|
||||||
<span class="text-xs text-gray-400">re: {p.intentId}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm">{p.outcome}</p>
|
|
||||||
{p.statChanges && Object.keys(p.statChanges).length > 0 && (
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
{Object.entries(p.statChanges).map(([k, v]) => (
|
|
||||||
<span class="text-xs bg-green-50 text-green-800 px-1.5 py-0.5 rounded border border-green-200">
|
|
||||||
{k}: {String(v)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
/**
|
|
||||||
* Built-in message type: link (article reveal)
|
|
||||||
*
|
|
||||||
* Emitters: gm
|
|
||||||
* Command: /link path#section
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component } from "solid-js";
|
|
||||||
import { useNavigate } from "@solidjs/router";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { registerMessageType } from "../registry";
|
|
||||||
import { journalSetState } from "../../stores/journalStream";
|
|
||||||
import { produce } from "solid-js/store";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
path: z.string().min(1),
|
|
||||||
label: z.string().optional(),
|
|
||||||
section: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type LinkPayload = z.infer<typeof schema>;
|
|
||||||
|
|
||||||
function fileNameFromPath(path: string): string {
|
|
||||||
const parts = path.split("/").filter(Boolean);
|
|
||||||
return parts[parts.length - 1] || path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Section slug → human-readable title */
|
|
||||||
function slugToTitle(slug: string): string {
|
|
||||||
return slug
|
|
||||||
.split("-")
|
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
const RevealLink: Component<LinkPayload> = (p) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const target = () => {
|
|
||||||
let t = p.path;
|
|
||||||
if (p.section) t += `#${p.section}`;
|
|
||||||
return t;
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayLabel = () => {
|
|
||||||
if (p.label) return p.label;
|
|
||||||
return fileNameFromPath(p.path);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClick = (e: MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
navigate(target());
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-lg">📄</span>
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
href={target()}
|
|
||||||
onClick={handleClick}
|
|
||||||
class="text-blue-600 underline text-sm"
|
|
||||||
>
|
|
||||||
{displayLabel()}
|
|
||||||
</a>
|
|
||||||
{p.section && (
|
|
||||||
<span class="text-xs text-gray-400 ml-1">
|
|
||||||
§ {slugToTitle(p.section)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
registerMessageType<LinkPayload>({
|
|
||||||
type: "link",
|
|
||||||
label: "Link Article",
|
|
||||||
emitters: ["gm"],
|
|
||||||
schema,
|
|
||||||
defaultPayload: () => ({ path: "/" }),
|
|
||||||
render: (p) => <RevealLink {...p} />,
|
|
||||||
reducer: (p) => {
|
|
||||||
journalSetState(
|
|
||||||
produce((s) => {
|
|
||||||
s.revealedPaths.add(p.path);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
/**
|
|
||||||
* Built-in message type: roll
|
|
||||||
*
|
|
||||||
* Emitters: gm
|
|
||||||
* Command: /roll formula
|
|
||||||
* The GM's browser rolls the formula and publishes the resolved result.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from "zod";
|
|
||||||
import { registerMessageType } from "../registry";
|
|
||||||
import { rollFormula } from "../../md-commander/hooks";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
notation: z.string().min(1),
|
|
||||||
label: z.string().optional(),
|
|
||||||
result: z.object({
|
|
||||||
total: z.number(),
|
|
||||||
detail: z.string(),
|
|
||||||
plainDetail: z.string(),
|
|
||||||
pools: z.array(
|
|
||||||
z.object({
|
|
||||||
rolls: z.array(z.number()),
|
|
||||||
subtotal: z.number(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type RollPayload = z.infer<typeof schema>;
|
|
||||||
|
|
||||||
/** Compute the dice result on the GM's machine before send */
|
|
||||||
export function resolveRollPayload(raw: {
|
|
||||||
notation: string;
|
|
||||||
label?: string;
|
|
||||||
}): RollPayload {
|
|
||||||
const roll = rollFormula(raw.notation);
|
|
||||||
return {
|
|
||||||
notation: raw.notation,
|
|
||||||
label: raw.label,
|
|
||||||
result: roll.result,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
registerMessageType<RollPayload>({
|
|
||||||
type: "roll",
|
|
||||||
label: "Roll Dice",
|
|
||||||
emitters: ["gm"],
|
|
||||||
schema,
|
|
||||||
defaultPayload: () => ({
|
|
||||||
notation: "1d20",
|
|
||||||
result: { total: 0, detail: "", plainDetail: "", pools: [] },
|
|
||||||
}),
|
|
||||||
render: (p) => {
|
|
||||||
const { result } = p;
|
|
||||||
return (
|
|
||||||
<div class="space-y-0.5">
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<span class="text-lg">🎲</span>
|
|
||||||
<span class="font-mono text-sm">
|
|
||||||
{p.notation} →{" "}
|
|
||||||
<span class="font-bold text-base">{result.total}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{result.detail && (
|
|
||||||
<p class="text-xs text-gray-400" innerHTML={result.detail} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -38,10 +38,7 @@ export function rollDicePool(pool: DicePool): DicePoolResult {
|
||||||
const { keptRolls, droppedRolls } = applyModifier(rolls, pool.modifier);
|
const { keptRolls, droppedRolls } = applyModifier(rolls, pool.modifier);
|
||||||
|
|
||||||
// 固定数字总是保留
|
// 固定数字总是保留
|
||||||
const finalKeptRolls = [
|
const finalKeptRolls = [...keptRolls, ...allRolls.filter((r) => r.sides === 0)];
|
||||||
...keptRolls,
|
|
||||||
...allRolls.filter((r) => r.sides === 0),
|
|
||||||
];
|
|
||||||
|
|
||||||
// 计算小计
|
// 计算小计
|
||||||
const subtotal = finalKeptRolls.reduce((sum, roll) => {
|
const subtotal = finalKeptRolls.reduce((sum, roll) => {
|
||||||
|
|
@ -62,7 +59,7 @@ export function rollDicePool(pool: DicePool): DicePoolResult {
|
||||||
*/
|
*/
|
||||||
export function applyModifier(
|
export function applyModifier(
|
||||||
rolls: DieResult[],
|
rolls: DieResult[],
|
||||||
modifier: DicePool["modifier"],
|
modifier: DicePool["modifier"]
|
||||||
): { keptRolls: DieResult[]; droppedRolls: DieResult[] } {
|
): { keptRolls: DieResult[]; droppedRolls: DieResult[] } {
|
||||||
if (!modifier) {
|
if (!modifier) {
|
||||||
return { keptRolls: rolls, droppedRolls: [] };
|
return { keptRolls: rolls, droppedRolls: [] };
|
||||||
|
|
@ -73,8 +70,7 @@ export function applyModifier(
|
||||||
|
|
||||||
switch (modifier.type) {
|
switch (modifier.type) {
|
||||||
case "kh": // Keep Highest - 保留最大的 N 个
|
case "kh": // Keep Highest - 保留最大的 N 个
|
||||||
case "dh": {
|
case "dh": { // Drop Highest - 丢弃最大的 N 个
|
||||||
// Drop Highest - 丢弃最大的 N 个
|
|
||||||
const count = modifier.count;
|
const count = modifier.count;
|
||||||
const higher = sorted.slice(-count);
|
const higher = sorted.slice(-count);
|
||||||
const rest = sorted.slice(0, -count);
|
const rest = sorted.slice(0, -count);
|
||||||
|
|
@ -85,8 +81,7 @@ export function applyModifier(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "kl": // Keep Lowest - 保留最小的 N 个
|
case "kl": // Keep Lowest - 保留最小的 N 个
|
||||||
case "dl": {
|
case "dl": { // Drop Lowest - 丢弃最小的 N 个
|
||||||
// Drop Lowest - 丢弃最小的 N 个
|
|
||||||
const count = modifier.count;
|
const count = modifier.count;
|
||||||
const lower = sorted.slice(0, count);
|
const lower = sorted.slice(0, count);
|
||||||
const rest = sorted.slice(count);
|
const rest = sorted.slice(count);
|
||||||
|
|
@ -110,35 +105,29 @@ export function roll(formula: string): RollResult {
|
||||||
|
|
||||||
const total = poolResults.reduce((sum, result) => sum + result.subtotal, 0);
|
const total = poolResults.reduce((sum, result) => sum + result.subtotal, 0);
|
||||||
|
|
||||||
// Generate detail expressions (both HTML and plain text)
|
// 生成详细表达式 - 使用 HTML 格式
|
||||||
const htmlSpans: string[] = [];
|
const rollSpans: string[] = [];
|
||||||
const textSpans: string[] = [];
|
|
||||||
for (const result of poolResults) {
|
for (const result of poolResults) {
|
||||||
|
// 构建骰子结果 HTML
|
||||||
for (const roll of result.rolls) {
|
for (const roll of result.rolls) {
|
||||||
const isKept = result.keptRolls.some((kept) => kept === roll);
|
const isKept = result.keptRolls.some((kept) => kept === roll);
|
||||||
if (roll.sides === 0) {
|
if (roll.sides === 0) {
|
||||||
const sign = roll.isNegative ? "-" : "";
|
// 固定数字,不显示括号
|
||||||
htmlSpans.push(`<strong>${sign}${roll.value}</strong>`);
|
rollSpans.push(`<strong>${roll.isNegative ? '-' : ''}${roll.value}</strong>`);
|
||||||
textSpans.push(`${sign}${roll.value}`);
|
|
||||||
} else if (isKept) {
|
} else if (isKept) {
|
||||||
htmlSpans.push(`<strong>[${roll.value}]</strong>`);
|
rollSpans.push(`<strong>[${roll.value}]</strong>`);
|
||||||
textSpans.push(`[${roll.value}]`);
|
|
||||||
} else {
|
} else {
|
||||||
htmlSpans.push(`<span class="text-gray-400">[${roll.value}]</span>`);
|
rollSpans.push(`<span class="text-gray-400">[${roll.value}]</span>`);
|
||||||
textSpans.push(`[${roll.value}]`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rollHtml = htmlSpans.join(" + ");
|
const rollsStr = rollSpans.join(" + ");
|
||||||
const rollText = textSpans.join(" + ");
|
const detail = `<strong>${total}</strong> = ${rollsStr}`;
|
||||||
const detail = `<strong>${total}</strong> = ${rollHtml}`;
|
|
||||||
const plainDetail = `${total} = ${rollText}`;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pools: poolResults,
|
pools: poolResults,
|
||||||
total,
|
total,
|
||||||
detail,
|
detail,
|
||||||
plainDetail,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export interface DieResult {
|
||||||
export interface DicePool {
|
export interface DicePool {
|
||||||
dice: DieResult[]; // 骰子列表(未掷骰前为模板)
|
dice: DieResult[]; // 骰子列表(未掷骰前为模板)
|
||||||
modifier?: {
|
modifier?: {
|
||||||
type: "kh" | "kl" | "dh" | "dl";
|
type: 'kh' | 'kl' | 'dh' | 'dl';
|
||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
isNegative: boolean; // 整个骰池是否为负
|
isNegative: boolean; // 整个骰池是否为负
|
||||||
|
|
@ -37,8 +37,7 @@ export interface DicePool {
|
||||||
export interface RollResult {
|
export interface RollResult {
|
||||||
pools: DicePoolResult[]; // 各骰池结果
|
pools: DicePoolResult[]; // 各骰池结果
|
||||||
total: number; // 总和
|
total: number; // 总和
|
||||||
detail: string; // 详细表达式 (HTML)
|
detail: string; // 详细表达式
|
||||||
plainDetail: string; // 详细表达式 (plain text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -56,11 +55,11 @@ export interface DicePoolResult {
|
||||||
* 解析后的令牌
|
* 解析后的令牌
|
||||||
*/
|
*/
|
||||||
export type Token =
|
export type Token =
|
||||||
| { type: "number"; value: number }
|
| { type: 'number'; value: number }
|
||||||
| { type: "dice"; count: number; sides: number }
|
| { type: 'dice'; count: number; sides: number }
|
||||||
| { type: "pool_start" }
|
| { type: 'pool_start' }
|
||||||
| { type: "pool_end" }
|
| { type: 'pool_end' }
|
||||||
| { type: "comma" }
|
| { type: 'comma' }
|
||||||
| { type: "plus" }
|
| { type: 'plus' }
|
||||||
| { type: "minus" }
|
| { type: 'minus' }
|
||||||
| { type: "modifier"; modType: "kh" | "kl" | "dh" | "dl"; count: number };
|
| { type: 'modifier'; modType: 'kh' | 'kl' | 'dh' | 'dl'; count: number };
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ export interface DiceRollerResult {
|
||||||
result: {
|
result: {
|
||||||
total: number;
|
total: number;
|
||||||
detail: string;
|
detail: string;
|
||||||
plainDetail: string;
|
|
||||||
pools: Array<{
|
pools: Array<{
|
||||||
rolls: number[];
|
rolls: number[];
|
||||||
keptRolls: number[];
|
keptRolls: number[];
|
||||||
|
|
@ -39,7 +38,6 @@ export function rollFormula(formula: string): DiceRollerResult {
|
||||||
result: {
|
result: {
|
||||||
total: result.total,
|
total: result.total,
|
||||||
detail: result.detail,
|
detail: result.detail,
|
||||||
plainDetail: result.plainDetail,
|
|
||||||
pools: result.pools.map((pool) => ({
|
pools: result.pools.map((pool) => ({
|
||||||
rolls: pool.rolls.map((r) => r.value),
|
rolls: pool.rolls.map((r) => r.value),
|
||||||
keptRolls: pool.keptRolls.map((r) => r.value),
|
keptRolls: pool.keptRolls.map((r) => r.value),
|
||||||
|
|
@ -54,7 +52,6 @@ export function rollFormula(formula: string): DiceRollerResult {
|
||||||
result: {
|
result: {
|
||||||
total: 0,
|
total: 0,
|
||||||
detail: "",
|
detail: "",
|
||||||
plainDetail: "",
|
|
||||||
pools: [],
|
pools: [],
|
||||||
},
|
},
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -67,10 +64,7 @@ export function rollFormula(formula: string): DiceRollerResult {
|
||||||
* 简化版掷骰,返回 HTML 格式结果
|
* 简化版掷骰,返回 HTML 格式结果
|
||||||
* 格式:[1] [2] [3] = <strong>6</strong>
|
* 格式:[1] [2] [3] = <strong>6</strong>
|
||||||
*/
|
*/
|
||||||
export function rollSimple(formula: string): {
|
export function rollSimple(formula: string): { text: string; isHtml?: boolean } {
|
||||||
text: string;
|
|
||||||
isHtml?: boolean;
|
|
||||||
} {
|
|
||||||
try {
|
try {
|
||||||
const result = rollDice(formula);
|
const result = rollDice(formula);
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,9 @@ customElement("md-dice", { key: "" }, (props, { element }) => {
|
||||||
const handleRoll = (e: MouseEvent) => {
|
const handleRoll = (e: MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// 点击骰子图标:总是重 roll
|
// 点击骰子图标:总是重 roll
|
||||||
const rollResult = rollFormula(formula);
|
const rollResult = rollFormula(formula).result;
|
||||||
setResult(rollResult.result.total);
|
setResult(rollResult.total);
|
||||||
setRollDetail(rollResult.result.plainDetail);
|
setRollDetail(rollResult.detail);
|
||||||
setIsRolled(true);
|
setIsRolled(true);
|
||||||
if (effectiveKey()) {
|
if (effectiveKey()) {
|
||||||
setDiceResultToUrl(effectiveKey(), rollResult.total);
|
setDiceResultToUrl(effectiveKey(), rollResult.total);
|
||||||
|
|
|
||||||
|
|
@ -1,608 +0,0 @@
|
||||||
/**
|
|
||||||
* Journal Stream — Client Store
|
|
||||||
*
|
|
||||||
* Reactive state for a single session's message stream. Manages MQTT
|
|
||||||
* connection, local message log, per-sender sequence tracking, and the
|
|
||||||
* revealed-paths set (populated by the link reducer).
|
|
||||||
*
|
|
||||||
* Session lifecycle (create/list/delete) is handled via MQTT retained
|
|
||||||
* topics — ttrpg/$SESSIONS for the manifest and ttrpg/{id}/meta per session.
|
|
||||||
*
|
|
||||||
* No persistence here — that's the CLI server's job via JSONL append.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createStore, produce } from "solid-js/store";
|
|
||||||
import { createSignal } from "solid-js";
|
|
||||||
import type { StreamMessage } from "../journal/registry";
|
|
||||||
import { getMessageType, validatePayload } from "../journal/registry";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface JournalStreamState {
|
|
||||||
sessionId: string | null;
|
|
||||||
/** Human-readable name for the current session (from manifest) */
|
|
||||||
sessionName: string | null;
|
|
||||||
/** Full message log, oldest-first */
|
|
||||||
messages: StreamMessage[];
|
|
||||||
/** Last sequence number per sender */
|
|
||||||
senderSeq: Record<string, number>;
|
|
||||||
/**
|
|
||||||
* Paths revealed by link messages.
|
|
||||||
* Populated during hydration and live receipt via the type's reducer.
|
|
||||||
*/
|
|
||||||
revealedPaths: Set<string>;
|
|
||||||
/** MQTT connection status */
|
|
||||||
connected: boolean;
|
|
||||||
/** Granular connection state for UI indicators */
|
|
||||||
connectionStatus: "disconnected" | "connecting" | "connected" | "error";
|
|
||||||
/** Last connection error message, if any */
|
|
||||||
connectionError: string | null;
|
|
||||||
/** This client's identity */
|
|
||||||
myName: string;
|
|
||||||
/** Role: gm | player | observer. Immutable while connected. */
|
|
||||||
myRole: "gm" | "player" | "observer";
|
|
||||||
/** Broker URL, set after connect */
|
|
||||||
brokerUrl: string | null;
|
|
||||||
/** Active player list (keyed by player name) */
|
|
||||||
players: Record<string, { role: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionMeta {
|
|
||||||
name: string;
|
|
||||||
created: number;
|
|
||||||
players: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionManifest {
|
|
||||||
sessions: Record<string, SessionMeta>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// localStorage keys
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const LS_PLAYER_NAME = "ttrpg.playerName";
|
|
||||||
const LS_BROKER_URL = "ttrpg.brokerUrl";
|
|
||||||
const LS_LAST_SESSION = "ttrpg.lastSessionId";
|
|
||||||
const LS_PLAYER_ROLE = "ttrpg.playerRole";
|
|
||||||
|
|
||||||
function loadPersisted(): {
|
|
||||||
myName: string;
|
|
||||||
brokerUrl: string | null;
|
|
||||||
lastSessionId: string | null;
|
|
||||||
myRole: string;
|
|
||||||
} {
|
|
||||||
if (typeof localStorage === "undefined") {
|
|
||||||
return { myName: "gm", brokerUrl: null, lastSessionId: null, myRole: "gm" };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
myName: localStorage.getItem(LS_PLAYER_NAME) || "gm",
|
|
||||||
brokerUrl: localStorage.getItem(LS_BROKER_URL),
|
|
||||||
lastSessionId: localStorage.getItem(LS_LAST_SESSION),
|
|
||||||
myRole: localStorage.getItem(LS_PLAYER_ROLE) || "gm",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Store
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const persisted = loadPersisted();
|
|
||||||
const urlParams = readUrlParams();
|
|
||||||
|
|
||||||
// URL params override localStorage if present
|
|
||||||
const initialName = urlParams.playerName ?? persisted.myName;
|
|
||||||
const initialSession = urlParams.sessionId ?? persisted.lastSessionId;
|
|
||||||
|
|
||||||
const [state, setState] = createStore<JournalStreamState>({
|
|
||||||
sessionId: initialSession,
|
|
||||||
sessionName: null,
|
|
||||||
messages: [],
|
|
||||||
senderSeq: {},
|
|
||||||
revealedPaths: new Set(),
|
|
||||||
connected: false,
|
|
||||||
connectionStatus: "disconnected",
|
|
||||||
connectionError: null,
|
|
||||||
myName: initialName,
|
|
||||||
myRole: (persisted.myRole as "gm" | "player" | "observer") || "gm",
|
|
||||||
brokerUrl: persisted.brokerUrl,
|
|
||||||
players: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync initial URL params if they came from localStorage (not URL)
|
|
||||||
if (initialName && !urlParams.playerName) syncUrlParam("player", initialName);
|
|
||||||
if (initialSession && !urlParams.sessionId)
|
|
||||||
syncUrlParam("session", initialSession);
|
|
||||||
|
|
||||||
export { setState as journalSetState };
|
|
||||||
|
|
||||||
const [sessionList, setSessionList] = createSignal<SessionManifest>({
|
|
||||||
sessions: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export { sessionList as sessions };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change the current player's name. Persisted to localStorage so it
|
|
||||||
* survives page reloads. Also syncs to URL search param.
|
|
||||||
*/
|
|
||||||
export function setMyName(name: string): void {
|
|
||||||
setState("myName", name);
|
|
||||||
if (typeof localStorage !== "undefined") {
|
|
||||||
localStorage.setItem(LS_PLAYER_NAME, name);
|
|
||||||
}
|
|
||||||
syncUrlParam("player", name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change the current player's role. Persisted to localStorage.
|
|
||||||
* Only callable when disconnected.
|
|
||||||
*/
|
|
||||||
export function setMyRole(role: "gm" | "player" | "observer"): void {
|
|
||||||
setState("myRole", role);
|
|
||||||
if (typeof localStorage !== "undefined") {
|
|
||||||
localStorage.setItem(LS_PLAYER_ROLE, role);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the active session ID and sync to URL. Also resolves the human-readable
|
|
||||||
* session name from the cached manifest.
|
|
||||||
*/
|
|
||||||
export function setSessionId(id: string | null): void {
|
|
||||||
setState("sessionId", id);
|
|
||||||
if (typeof localStorage !== "undefined" && id) {
|
|
||||||
localStorage.setItem(LS_LAST_SESSION, id);
|
|
||||||
}
|
|
||||||
if (id) {
|
|
||||||
syncUrlParam("session", id);
|
|
||||||
// Resolve session name from current manifest
|
|
||||||
const manifest = sessionList();
|
|
||||||
const name = manifest.sessions[id]?.name ?? null;
|
|
||||||
setState("sessionName", name);
|
|
||||||
} else {
|
|
||||||
removeUrlParam("session");
|
|
||||||
setState("sessionName", null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// URL param sync (session & player for bookmarkable links)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function syncUrlParam(key: string, value: string): void {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.set(key, value);
|
|
||||||
window.history.replaceState(null, "", url.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeUrlParam(key: string): void {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.delete(key);
|
|
||||||
window.history.replaceState(null, "", url.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read initial session/player from URL search params on store init.
|
|
||||||
* Called once at module load time.
|
|
||||||
*/
|
|
||||||
function readUrlParams(): {
|
|
||||||
sessionId: string | null;
|
|
||||||
playerName: string | null;
|
|
||||||
} {
|
|
||||||
if (typeof window === "undefined")
|
|
||||||
return { sessionId: null, playerName: null };
|
|
||||||
const params = new URL(window.location.href).searchParams;
|
|
||||||
return {
|
|
||||||
sessionId: params.get("session"),
|
|
||||||
playerName: params.get("player"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Will hold the MQTT client instance after connect()
|
|
||||||
let _mqttClient: import("mqtt").MqttClient | null = null;
|
|
||||||
let _mqttConnected = false;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function makeMessageId(sender: string, seq: number): string {
|
|
||||||
return `${sender}-${seq}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runReducer(msg: StreamMessage): void {
|
|
||||||
const def = getMessageType(msg.type);
|
|
||||||
if (def?.reducer) {
|
|
||||||
def.reducer(msg.payload, msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const $SESSIONS = "ttrpg/$SESSIONS";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Hydration (initial load from server)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the full message history from the static server's JSONL file.
|
|
||||||
* The file is served from the same HTTP origin as the web app.
|
|
||||||
* Runs all reducers in order.
|
|
||||||
*/
|
|
||||||
export async function hydrateFromServer(sessionId: string): Promise<void> {
|
|
||||||
const response = await fetch(
|
|
||||||
`/.ttrpg/sessions/${encodeURIComponent(sessionId)}/stream.jsonl`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 404) {
|
|
||||||
return; // fresh session, no file yet
|
|
||||||
}
|
|
||||||
throw new Error(`Failed to load session: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await response.text();
|
|
||||||
const lines = text.split("\n").filter((l) => l.trim());
|
|
||||||
|
|
||||||
const messages: StreamMessage[] = [];
|
|
||||||
const senderSeq: Record<string, number> = {};
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
try {
|
|
||||||
const msg: StreamMessage = JSON.parse(line);
|
|
||||||
messages.push(msg);
|
|
||||||
senderSeq[msg.sender] = Math.max(senderSeq[msg.sender] ?? 0, msg.seq);
|
|
||||||
runReducer(msg);
|
|
||||||
} catch {
|
|
||||||
/* skip corrupt */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(
|
|
||||||
produce((s) => {
|
|
||||||
s.messages = messages;
|
|
||||||
s.senderSeq = senderSeq;
|
|
||||||
s.sessionId = sessionId;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// MQTT Connect
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to the MQTT broker, subscribe to the session stream, session
|
|
||||||
* list, and session meta. Must be called after `hydrateFromServer`.
|
|
||||||
*/
|
|
||||||
export async function connectStream(
|
|
||||||
sessionId: string,
|
|
||||||
brokerUrl: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const { default: mqtt } = await import("mqtt");
|
|
||||||
|
|
||||||
setState("connectionStatus", "connecting");
|
|
||||||
setState("connectionError", null);
|
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
const client = mqtt.connect(brokerUrl, {
|
|
||||||
clientId: `${state.myName}-${state.myRole}-${Date.now()}`,
|
|
||||||
protocol: brokerUrl.startsWith("wss") ? "wss" : "ws",
|
|
||||||
reconnectPeriod: 2000,
|
|
||||||
});
|
|
||||||
|
|
||||||
_mqttClient = client;
|
|
||||||
|
|
||||||
client.on("connect", () => {
|
|
||||||
_mqttConnected = true;
|
|
||||||
setState("connected", true);
|
|
||||||
setState("connectionStatus", "connected");
|
|
||||||
setState("connectionError", null);
|
|
||||||
setState("brokerUrl", brokerUrl);
|
|
||||||
|
|
||||||
// Persist connection info for next time
|
|
||||||
if (typeof localStorage !== "undefined") {
|
|
||||||
localStorage.setItem(LS_BROKER_URL, brokerUrl);
|
|
||||||
localStorage.setItem(LS_LAST_SESSION, sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
|
|
||||||
if (err) console.error("[stream] stream sub err:", err);
|
|
||||||
});
|
|
||||||
client.subscribe($SESSIONS, { qos: 1 }, (err) => {
|
|
||||||
if (err) console.error("[stream] sessions sub err:", err);
|
|
||||||
});
|
|
||||||
client.subscribe(`ttrpg/${sessionId}/meta`, { qos: 1 });
|
|
||||||
// Presence tracking
|
|
||||||
client.subscribe(`ttrpg/${sessionId}/presence/+`, { qos: 1 });
|
|
||||||
|
|
||||||
// Publish own presence (retained)
|
|
||||||
const presenceData = JSON.stringify({
|
|
||||||
name: state.myName,
|
|
||||||
role: state.myRole,
|
|
||||||
});
|
|
||||||
client.publish(
|
|
||||||
`ttrpg/${sessionId}/presence/${state.myName}`,
|
|
||||||
presenceData,
|
|
||||||
{ qos: 1, retain: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on("error", (err) => {
|
|
||||||
console.error("[stream] mqtt error:", err);
|
|
||||||
setState("connectionStatus", "error");
|
|
||||||
setState("connectionError", err.message);
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on("close", () => {
|
|
||||||
_mqttConnected = false;
|
|
||||||
setState("connected", false);
|
|
||||||
setState("connectionStatus", "disconnected");
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on("message", (topic, payload) => {
|
|
||||||
const raw = payload.toString();
|
|
||||||
const parts = topic.split("/");
|
|
||||||
|
|
||||||
if (topic === $SESSIONS) {
|
|
||||||
// Session manifest update
|
|
||||||
try {
|
|
||||||
const manifest: SessionManifest = JSON.parse(raw);
|
|
||||||
setSessionList(manifest);
|
|
||||||
// Refresh the sessionName if we're in a session now
|
|
||||||
const currentId = state.sessionId;
|
|
||||||
if (currentId && manifest.sessions[currentId]) {
|
|
||||||
setState("sessionName", manifest.sessions[currentId].name);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[stream] manifest parse err:", e);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "meta") {
|
|
||||||
// Session meta update — manifest will be republished by server,
|
|
||||||
// handled via $SESSIONS subscription above.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Presence: ttrpg/{sessionId}/presence/{playerName}
|
|
||||||
if (
|
|
||||||
parts.length >= 4 &&
|
|
||||||
parts[0] === "ttrpg" &&
|
|
||||||
parts[2] === "presence"
|
|
||||||
) {
|
|
||||||
const playerName = parts[3];
|
|
||||||
if (raw) {
|
|
||||||
try {
|
|
||||||
const presence = JSON.parse(raw);
|
|
||||||
setState("players", playerName, {
|
|
||||||
role: presence.role || "player",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
setState("players", playerName, { role: "player" });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Tombstone — player disconnected
|
|
||||||
setState(
|
|
||||||
produce((s) => {
|
|
||||||
delete s.players[playerName];
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "stream") {
|
|
||||||
try {
|
|
||||||
const msg: StreamMessage = JSON.parse(raw);
|
|
||||||
receiveMessage(msg);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[stream] malformed message:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Session lifecycle
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new session by publishing retained metadata to its meta topic.
|
|
||||||
* The server picks it up and adds it to the $SESSIONS manifest.
|
|
||||||
*/
|
|
||||||
export function createSession(name: string, players: string[] = []): void {
|
|
||||||
if (!_mqttClient || !_mqttConnected) return;
|
|
||||||
|
|
||||||
const id =
|
|
||||||
name
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-|-$/g, "") || "session";
|
|
||||||
|
|
||||||
const meta: SessionMeta = { name, created: Date.now(), players };
|
|
||||||
|
|
||||||
_mqttClient.publish(`ttrpg/${id}/meta`, JSON.stringify(meta), {
|
|
||||||
qos: 1,
|
|
||||||
retain: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a session: publish tombstone (empty payload) to its meta topic.
|
|
||||||
*/
|
|
||||||
export function deleteSession(sessionId: string): void {
|
|
||||||
if (!_mqttClient || !_mqttConnected) return;
|
|
||||||
|
|
||||||
_mqttClient.publish(`ttrpg/${sessionId}/meta`, "", { qos: 1, retain: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Send
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish a message to the stream. Validates the payload against the
|
|
||||||
* registered Zod schema before sending. Auto-increments the sender's seq.
|
|
||||||
*/
|
|
||||||
export function sendMessage<T>(
|
|
||||||
type: string,
|
|
||||||
payload: T,
|
|
||||||
):
|
|
||||||
{ success: true; msg: StreamMessage<T> } | { success: false; error: string } {
|
|
||||||
if (!_mqttClient || !_mqttConnected) {
|
|
||||||
return { success: false, error: "Not connected to stream" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = state.sessionId;
|
|
||||||
if (!sessionId) {
|
|
||||||
return { success: false, error: "No active session" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const validation = validatePayload(type, payload);
|
|
||||||
if (!validation.success) {
|
|
||||||
return { success: false, error: validation.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
const sender = state.myName;
|
|
||||||
const seq = (state.senderSeq[sender] ?? 0) + 1;
|
|
||||||
const id = makeMessageId(sender, seq);
|
|
||||||
|
|
||||||
const msg: StreamMessage<T> = {
|
|
||||||
id,
|
|
||||||
sender,
|
|
||||||
seq,
|
|
||||||
type,
|
|
||||||
payload: validation.data,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
reverted: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const topic = `ttrpg/${sessionId}/stream`;
|
|
||||||
_mqttClient.publish(topic, JSON.stringify(msg), { qos: 1 }, (err) => {
|
|
||||||
if (err) console.error("[stream] publish error:", err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Optimistic local insert
|
|
||||||
receiveMessage(msg as StreamMessage);
|
|
||||||
|
|
||||||
return { success: true, msg };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Receive
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function receiveMessage(msg: StreamMessage): void {
|
|
||||||
const existing = state.messages.find((m) => m.id === msg.id);
|
|
||||||
if (existing) {
|
|
||||||
setState(
|
|
||||||
produce((s) => {
|
|
||||||
const idx = s.messages.findIndex((m) => m.id === msg.id);
|
|
||||||
if (idx !== -1) s.messages[idx] = msg;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(
|
|
||||||
produce((s) => {
|
|
||||||
s.messages.push(msg);
|
|
||||||
s.senderSeq[msg.sender] = Math.max(s.senderSeq[msg.sender] ?? 0, msg.seq);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
runReducer(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Revert
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revert the current sender's latest (highest seq) message.
|
|
||||||
* Only works if it's still the latest — a subsequent message locks it.
|
|
||||||
*/
|
|
||||||
export function revertLatest():
|
|
||||||
{ success: true } | { success: false; error: string } {
|
|
||||||
if (!_mqttClient || !_mqttConnected) {
|
|
||||||
return { success: false, error: "Not connected to stream" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = state.sessionId;
|
|
||||||
if (!sessionId) return { success: false, error: "No active session" };
|
|
||||||
|
|
||||||
const sender = state.myName;
|
|
||||||
const latestSeq = state.senderSeq[sender];
|
|
||||||
if (!latestSeq) return { success: false, error: "No messages to revert" };
|
|
||||||
|
|
||||||
const id = makeMessageId(sender, latestSeq);
|
|
||||||
const original = state.messages.find((m) => m.id === id);
|
|
||||||
if (!original) return { success: false, error: "Message not found" };
|
|
||||||
if (original.reverted) return { success: false, error: "Already reverted" };
|
|
||||||
|
|
||||||
const reverted: StreamMessage = { ...original, reverted: true };
|
|
||||||
const topic = `ttrpg/${sessionId}/stream`;
|
|
||||||
_mqttClient.publish(topic, JSON.stringify(reverted), { qos: 1 }, (err) => {
|
|
||||||
if (err) console.error("[stream] revert publish error:", err);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Disconnect
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function disconnectStream(): void {
|
|
||||||
if (_mqttClient) {
|
|
||||||
// Clear our presence before disconnecting (tombstone)
|
|
||||||
const sessionId = state.sessionId;
|
|
||||||
if (sessionId) {
|
|
||||||
_mqttClient.publish(`ttrpg/${sessionId}/presence/${state.myName}`, "", {
|
|
||||||
qos: 1,
|
|
||||||
retain: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Force disconnect without reconnect
|
|
||||||
_mqttClient.end(true, void 0, () => {
|
|
||||||
// noop
|
|
||||||
});
|
|
||||||
_mqttClient = null;
|
|
||||||
_mqttConnected = false;
|
|
||||||
}
|
|
||||||
setState("connected", false);
|
|
||||||
setState("connectionStatus", "disconnected");
|
|
||||||
setState("players", {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Derived / helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function canRevert(): boolean {
|
|
||||||
const sender = state.myName;
|
|
||||||
const seq = state.senderSeq[sender];
|
|
||||||
if (!seq) return false;
|
|
||||||
const msg = state.messages.find((m) => m.id === makeMessageId(sender, seq));
|
|
||||||
return msg !== undefined && !msg.reverted;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function visibleMessages(): StreamMessage[] {
|
|
||||||
return state.messages.filter((m) => !m.reverted);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useJournalStream() {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { state as journalStreamState };
|
|
||||||
|
|
@ -1,63 +1,51 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@source inline("bg-{slate,gray,zinc,neutral,stone,red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose}-{50,{100..900..100},950}");
|
|
||||||
|
|
||||||
/* icon */
|
/* icon */
|
||||||
icon,
|
icon, pull, pull-2{
|
||||||
pull,
|
|
||||||
pull-2 {
|
|
||||||
@apply inline-block;
|
@apply inline-block;
|
||||||
width: 1.5em;
|
width: 1.5em;
|
||||||
height: 1.28em;
|
height: 1.28em;
|
||||||
vertical-align: text-bottom;
|
vertical-align: text-bottom;
|
||||||
--icon-src: "";
|
--icon-src: '';
|
||||||
background-image: var(--icon-src);
|
background-image: var(--icon-src);
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
|
||||||
icon.masked {
|
|
||||||
mask-image: var(--icon-src);
|
mask-image: var(--icon-src);
|
||||||
mask-size: contain;
|
mask-size: contain;
|
||||||
mask-position: center;
|
mask-position: center;
|
||||||
mask-repeat: no-repeat;
|
mask-repeat: no-repeat;
|
||||||
background-image: unset;
|
|
||||||
}
|
}
|
||||||
icon.big{
|
icon.big{
|
||||||
width: 2em;
|
width: 2em;
|
||||||
height: 2em;
|
height: 2em;
|
||||||
}
|
}
|
||||||
pull{
|
pull{
|
||||||
margin-right: -0.5em;
|
margin-right: -.5em;
|
||||||
width: 0;
|
width: 0;
|
||||||
}
|
}
|
||||||
pull-2{
|
pull-2{
|
||||||
margin-right: -1em;
|
margin-right: -1em;
|
||||||
width: 0;
|
width: 0;
|
||||||
}
|
}
|
||||||
icon.big .icon-label,
|
icon.big .icon-label, icon.big .icon-label-stroke{
|
||||||
icon.big .icon-label-stroke {
|
|
||||||
line-height: 2;
|
line-height: 2;
|
||||||
}
|
}
|
||||||
.icon-label,
|
.icon-label, .icon-label-stroke{
|
||||||
.icon-label-stroke {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.28;
|
line-height: 1.28;
|
||||||
left: 0;
|
left: 0; right: 0; top: 0; bottom: 0;
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
}
|
||||||
.icon-label{
|
.icon-label{
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
.icon-label-stroke{
|
.icon-label-stroke{
|
||||||
color: black;
|
color: black;
|
||||||
-webkit-text-stroke: 0.14em black;
|
-webkit-text-stroke: .14em black;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* prose */
|
/* prose */
|
||||||
|
|
@ -67,8 +55,8 @@ icon.big .icon-label-stroke {
|
||||||
code{
|
code{
|
||||||
background: #0001;
|
background: #0001;
|
||||||
@apply rounded;
|
@apply rounded;
|
||||||
padding: 0.2em 0.4em;
|
padding: .2em .4em;
|
||||||
margin: 0 0.2em;
|
margin: 0 .2em;
|
||||||
&::before,
|
&::before,
|
||||||
&::after {
|
&::after {
|
||||||
content: none;
|
content: none;
|
||||||
|
|
@ -90,7 +78,8 @@ icon.big .icon-label-stroke {
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-alert-title + * {
|
.markdown-alert-title + *
|
||||||
|
{
|
||||||
@apply mt-0;
|
@apply mt-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,21 +109,9 @@ icon.big .icon-label-stroke {
|
||||||
|
|
||||||
/* cols */
|
/* cols */
|
||||||
|
|
||||||
.cols {
|
.cols{ @apply lg:flex lg:gap-4; }
|
||||||
@apply lg:flex lg:gap-4;
|
.col{ @apply lg:flex-1; }
|
||||||
}
|
.col-2{ @apply lg:flex-2; }
|
||||||
.col {
|
.col-3{ @apply lg:flex-3; }
|
||||||
@apply lg:flex-1;
|
.col-4{ @apply lg:flex-4; }
|
||||||
}
|
.col-5{ @apply lg:flex-5; }
|
||||||
.col-2 {
|
|
||||||
@apply lg:flex-2;
|
|
||||||
}
|
|
||||||
.col-3 {
|
|
||||||
@apply lg:flex-3;
|
|
||||||
}
|
|
||||||
.col-4 {
|
|
||||||
@apply lg:flex-4;
|
|
||||||
}
|
|
||||||
.col-5 {
|
|
||||||
@apply lg:flex-5;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue