Compare commits
No commits in common. "1d0f2f7c3e5e44b005e3860354363396f2fcf5ba" and "d56e8ca23dcb199d821acaa3e2175dfa5147f4bf" have entirely different histories.
1d0f2f7c3e
...
d56e8ca23d
|
|
@ -13,7 +13,6 @@
|
|||
"@solidjs/router": "^0.15.0",
|
||||
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||
"@types/three": "^0.183.1",
|
||||
"aedes": "^1.1.1",
|
||||
"chokidar": "^5.0.0",
|
||||
"commander": "^14.0.3",
|
||||
"csv-parse": "^6.1.0",
|
||||
|
|
@ -24,14 +23,11 @@
|
|||
"marked-directive": "^1.0.7",
|
||||
"marked-gfm-heading-id": "^4.1.3",
|
||||
"mermaid": "^11.0.0",
|
||||
"mqtt": "^5.15.1",
|
||||
"solid-element": "^1.9.1",
|
||||
"solid-js": "^1.9.3",
|
||||
"three": "^0.183.2",
|
||||
"three-3mf-exporter": "^45.1.0",
|
||||
"ws": "^8.21.0",
|
||||
"yarn-spinner-loader": "^0.1.0",
|
||||
"zod": "^4.4.3"
|
||||
"yarn-spinner-loader": "^0.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"ttrpg": "dist/cli/index.js"
|
||||
|
|
@ -47,7 +43,6 @@
|
|||
"@types/jest": "^30.0.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.19.13",
|
||||
"@types/ws": "^8.18.1",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
|
|
@ -726,15 +721,6 @@
|
|||
"@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": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
|
|
@ -1885,15 +1871,6 @@
|
|||
"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": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz",
|
||||
|
|
@ -3333,20 +3310,12 @@
|
|||
"version": "22.19.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
||||
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"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": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
||||
|
|
@ -3395,15 +3364,6 @@
|
|||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"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": {
|
||||
"version": "17.0.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||
|
|
@ -3445,18 +3405,6 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
|
|
@ -3493,138 +3441,6 @@
|
|||
"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": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
|
|
@ -3911,26 +3727,6 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.10.15",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.15.tgz",
|
||||
|
|
@ -3944,63 +3740,6 @@
|
|||
"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": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
|
|
@ -4024,18 +3763,6 @@
|
|||
"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": {
|
||||
"version": "4.28.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||
|
|
@ -4093,34 +3820,11 @@
|
|||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
|
|
@ -4359,12 +4063,6 @@
|
|||
"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": {
|
||||
"version": "0.8.8",
|
||||
"resolved": "https://registry.npmjs.org/component-register/-/component-register-0.8.8.tgz",
|
||||
|
|
@ -4378,35 +4076,6 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
|
||||
|
|
@ -5109,6 +4778,7 @@
|
|||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
|
|
@ -5449,24 +5119,6 @@
|
|||
"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": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||
|
|
@ -5547,19 +5199,6 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
|
|
@ -5611,16 +5250,6 @@
|
|||
"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": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
|
|
@ -5942,12 +5571,6 @@
|
|||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
|
||||
|
|
@ -6062,26 +5685,6 @@
|
|||
"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": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
|
|
@ -6145,15 +5748,6 @@
|
|||
"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": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
|
|
@ -7836,16 +7430,6 @@
|
|||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
|
@ -8583,6 +8167,7 @@
|
|||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
|
|
@ -8606,117 +8191,11 @@
|
|||
"integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==",
|
||||
"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": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
|
|
@ -8789,16 +8268,6 @@
|
|||
"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": {
|
||||
"version": "2.2.23",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
|
||||
|
|
@ -9155,15 +8624,6 @@
|
|||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
|
|
@ -9224,15 +8684,6 @@
|
|||
],
|
||||
"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": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
|
|
@ -9423,12 +8874,6 @@
|
|||
"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": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||
|
|
@ -9626,30 +9071,6 @@
|
|||
"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": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/solid-element/-/solid-element-1.9.1.tgz",
|
||||
|
|
@ -9718,15 +9139,6 @@
|
|||
"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": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
|
|
@ -10199,6 +9611,7 @@
|
|||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"devOptional": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-detect": {
|
||||
|
|
@ -10224,12 +9637,6 @@
|
|||
"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": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
|
@ -10268,6 +9675,7 @@
|
|||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
|
|
@ -10608,53 +10016,6 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
|
|
@ -10695,9 +10056,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
|
@ -10732,15 +10094,6 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
|
@ -10841,9 +10194,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"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"
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@
|
|||
"@solidjs/router": "^0.15.0",
|
||||
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||
"@types/three": "^0.183.1",
|
||||
"aedes": "^1.1.1",
|
||||
"chokidar": "^5.0.0",
|
||||
"commander": "^14.0.3",
|
||||
"csv-parse": "^6.1.0",
|
||||
|
|
@ -47,14 +46,11 @@
|
|||
"marked-directive": "^1.0.7",
|
||||
"marked-gfm-heading-id": "^4.1.3",
|
||||
"mermaid": "^11.0.0",
|
||||
"mqtt": "^5.15.1",
|
||||
"solid-element": "^1.9.1",
|
||||
"solid-js": "^1.9.3",
|
||||
"three": "^0.183.2",
|
||||
"three-3mf-exporter": "^45.1.0",
|
||||
"ws": "^8.21.0",
|
||||
"yarn-spinner-loader": "^0.1.0",
|
||||
"zod": "^4.4.3"
|
||||
"yarn-spinner-loader": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@image-tracer-ts/core": "^1.0.2",
|
||||
|
|
@ -67,7 +63,6 @@
|
|||
"@types/jest": "^30.0.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.19.13",
|
||||
"@types/ws": "^8.18.1",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^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 { useLocation } from "@solidjs/router";
|
||||
import { useJournalStream } from "./components/stores/journalStream";
|
||||
|
||||
// 导入组件以注册自定义元素
|
||||
import "./components";
|
||||
|
|
@ -12,15 +11,12 @@ import {
|
|||
DataSourceDialog,
|
||||
} from "./components";
|
||||
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
||||
import { JournalPanel } from "./components/journal";
|
||||
|
||||
const App: Component = () => {
|
||||
const location = useLocation();
|
||||
const stream = useJournalStream();
|
||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
||||
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
||||
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
|
||||
const [isJournalOpen, setIsJournalOpen] = createSignal(false);
|
||||
|
||||
// Sidebar and TOC data — reload when source changes
|
||||
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
||||
|
|
@ -59,18 +55,13 @@ const App: Component = () => {
|
|||
return (
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
{/* 桌面端固定侧边栏 */}
|
||||
<DesktopSidebar
|
||||
fileTree={fileTree()}
|
||||
pathHeadings={pathHeadings()}
|
||||
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
||||
/>
|
||||
<DesktopSidebar fileTree={fileTree()} pathHeadings={pathHeadings()} />
|
||||
{/* 移动端抽屉式侧边栏 */}
|
||||
<MobileSidebar
|
||||
isOpen={isSidebarOpen()}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
fileTree={fileTree()}
|
||||
pathHeadings={pathHeadings()}
|
||||
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
||||
/>
|
||||
<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">
|
||||
|
|
@ -85,25 +76,11 @@ const App: Component = () => {
|
|||
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
|
||||
<div class="flex-1" />
|
||||
<button
|
||||
onClick={() => setIsJournalOpen((v) => !v)}
|
||||
class={`relative w-8 h-8 rounded flex items-center justify-center text-sm ${
|
||||
isJournalOpen()
|
||||
? "bg-blue-100 text-blue-600"
|
||||
: "text-gray-500 hover:bg-gray-100"
|
||||
}`}
|
||||
title={`Journal (${stream.connectionStatus})`}
|
||||
onClick={() => setIsDataSourceOpen(true)}
|
||||
class="text-sm text-gray-500 hover:text-gray-700 px-2 py-1 rounded hover:bg-gray-100"
|
||||
title="Content Source"
|
||||
>
|
||||
<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
|
||||
onClick={() => setIsDocOpen(true)}
|
||||
|
|
@ -114,11 +91,8 @@ const App: Component = () => {
|
|||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{/* fill the rest of the space */}
|
||||
<div
|
||||
class="fixed top-16 left-0 right-0 bottom-0 overflow-auto transition-all duration-300"
|
||||
classList={{ "md:pr-[420px]": isJournalOpen() }}
|
||||
>
|
||||
{/* fill th rest of the space*/}
|
||||
<div class="fixed top-16 left-0 right-0 bottom-0 overflow-auto">
|
||||
<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
|
||||
class="prose text-black prose-sm max-w-full flex-1"
|
||||
|
|
@ -132,10 +106,6 @@ const App: Component = () => {
|
|||
onClose={() => setIsDataSourceOpen(false)}
|
||||
onSourceChanged={handleSourceChanged}
|
||||
/>
|
||||
<JournalPanel
|
||||
open={isJournalOpen()}
|
||||
onClose={() => setIsJournalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,11 +5,6 @@ import { createReadStream } from "fs";
|
|||
import { join, resolve, extname, sep, relative, dirname } from "path";
|
||||
import { watch } from "chokidar";
|
||||
import { fileURLToPath } from "url";
|
||||
import { createJournalServer } from "../journal.js";
|
||||
import {
|
||||
scanCompletions,
|
||||
type CompletionsPayload,
|
||||
} from "../completions/index.js";
|
||||
|
||||
interface ContentIndex {
|
||||
[path: string]: string;
|
||||
|
|
@ -68,11 +63,7 @@ export function scanDirectory(dir: string): ContentIndex {
|
|||
const stats = statSync(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
scan(fullPath, relPath);
|
||||
} else if (
|
||||
entry.endsWith(".md") ||
|
||||
entry.endsWith(".csv") ||
|
||||
entry.endsWith(".yarn")
|
||||
) {
|
||||
} else if (entry.endsWith(".md") || entry.endsWith(".csv") || entry.endsWith(".yarn")) {
|
||||
try {
|
||||
const content = readFileSync(fullPath, "utf-8");
|
||||
index[normalizedRelPath] = content;
|
||||
|
|
@ -148,7 +139,6 @@ function createRequestHandler(
|
|||
contentDir: string,
|
||||
distDir: string,
|
||||
getIndex: () => ContentIndex,
|
||||
getCompletions: () => CompletionsPayload,
|
||||
) {
|
||||
return (req: IncomingMessage, res: ServerResponse) => {
|
||||
const url = req.url || "/";
|
||||
|
|
@ -160,12 +150,6 @@ function createRequestHandler(
|
|||
return;
|
||||
}
|
||||
|
||||
// 1b. 处理 /__COMPLETIONS.json
|
||||
if (filePath === "/__COMPLETIONS.json") {
|
||||
sendJson(res, getCompletions());
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 处理 /static/ 目录(从 dist/web)
|
||||
if (filePath.startsWith("/static/")) {
|
||||
if (tryServeStatic(res, filePath, distDir)) {
|
||||
|
|
@ -207,10 +191,6 @@ export interface ContentServer {
|
|||
* 内容索引
|
||||
*/
|
||||
index: ContentIndex;
|
||||
/**
|
||||
* 补全数据
|
||||
*/
|
||||
completions: CompletionsPayload;
|
||||
/**
|
||||
* 关闭服务器
|
||||
*/
|
||||
|
|
@ -227,112 +207,79 @@ export function createContentServer(
|
|||
host: string = "0.0.0.0",
|
||||
): ContentServer {
|
||||
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("扫描内容目录...");
|
||||
contentIndex = scanDirectory(contentDir);
|
||||
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
|
||||
recomputeCompletions();
|
||||
|
||||
// 监听文件变化
|
||||
console.log("监听文件变化...");
|
||||
const watcher = watch(contentDir, {
|
||||
ignored: /(^|[\/\\])\./,
|
||||
ignored: /(^|[\/\\])\../,
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
});
|
||||
|
||||
watcher
|
||||
.on("add", (path) => {
|
||||
if (
|
||||
path.endsWith(".md") ||
|
||||
path.endsWith(".csv") ||
|
||||
path.endsWith(".yarn")
|
||||
) {
|
||||
if (path.endsWith(".md") || path.endsWith(".csv") || path.endsWith(".yarn")) {
|
||||
try {
|
||||
const content = readFileSync(path, "utf-8");
|
||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||
contentIndex[relPath] = content;
|
||||
console.log(`[新增] ${relPath}`);
|
||||
if (relPath.endsWith(".md")) recomputeCompletions();
|
||||
} catch (e) {
|
||||
console.error(`读取新增文件失败:${path}`, e);
|
||||
}
|
||||
}
|
||||
})
|
||||
.on("change", (path) => {
|
||||
if (
|
||||
path.endsWith(".md") ||
|
||||
path.endsWith(".csv") ||
|
||||
path.endsWith(".yarn")
|
||||
) {
|
||||
if (path.endsWith(".md") || path.endsWith(".csv") || path.endsWith(".yarn")) {
|
||||
try {
|
||||
const content = readFileSync(path, "utf-8");
|
||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||
contentIndex[relPath] = content;
|
||||
console.log(`[更新] ${relPath}`);
|
||||
if (relPath.endsWith(".md")) recomputeCompletions();
|
||||
} catch (e) {
|
||||
console.error(`读取更新文件失败:${path}`, e);
|
||||
}
|
||||
}
|
||||
})
|
||||
.on("unlink", (path) => {
|
||||
if (
|
||||
path.endsWith(".md") ||
|
||||
path.endsWith(".csv") ||
|
||||
path.endsWith(".yarn")
|
||||
) {
|
||||
if (path.endsWith(".md") || path.endsWith(".csv") || path.endsWith(".yarn")) {
|
||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||
delete contentIndex[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(
|
||||
contentDir,
|
||||
distPath,
|
||||
() => contentIndex,
|
||||
() => completionsIndex,
|
||||
);
|
||||
const server = createServer(handleRequest);
|
||||
|
||||
createJournalServer(contentDir, server).then((j) => {
|
||||
journal = j;
|
||||
});
|
||||
// 创建 HTTP 服务器
|
||||
const server = createServer(handleRequest);
|
||||
|
||||
server.listen(port, host, () => {
|
||||
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
||||
console.log(`\n开发服务器已启动:http://${displayHost}:${port}`);
|
||||
console.log(`内容目录:${contentDir}`);
|
||||
console.log(`静态资源目录:${distPath}`);
|
||||
console.log(`Journal 连接:ws://${displayHost}:${port}\n`);
|
||||
console.log(`索引文件:http://${displayHost}:${port}/__CONTENT_INDEX.json\n`);
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
watcher,
|
||||
index: contentIndex,
|
||||
completions: completionsIndex,
|
||||
close() {
|
||||
console.log("关闭内容服务器...");
|
||||
server.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
|
||||
import { Command } from "commander";
|
||||
import { serveCommand } from "./commands/serve.js";
|
||||
import { compileCommand } from "./commands/compile.js";
|
||||
import { mcpCommand } from "./commands/mcp.js";
|
||||
import type { ServeOptions, CompileOptions } from "./types.js";
|
||||
import { Command } from 'commander';
|
||||
import { serveCommand } from './commands/serve.js';
|
||||
import { compileCommand } from './commands/compile.js';
|
||||
import { mcpCommand } from './commands/mcp.js';
|
||||
import type { ServeOptions, CompileOptions } from './types.js';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("ttrpg")
|
||||
.description("TTRPG 工具箱 - 用于编译和预览 TTRPG 文档")
|
||||
.version("0.0.1");
|
||||
.name('ttrpg')
|
||||
.description('TTRPG 工具箱 - 用于编译和预览 TTRPG 文档')
|
||||
.version('0.0.1');
|
||||
|
||||
program
|
||||
.command("serve")
|
||||
.description("运行一个 web 服务器预览目录中的内容,并实时监听更新")
|
||||
.argument("[dir]", "要预览的目录", ".")
|
||||
.option("-p, --port <port>", "HTTP 端口号", "3000")
|
||||
.option("-h, --host <host>", "主机地址", "0.0.0.0")
|
||||
.command('serve')
|
||||
.description('运行一个 web 服务器预览目录中的内容,并实时监听更新')
|
||||
.argument('[dir]', '要预览的目录', '.')
|
||||
.option('-p, --port <port>', '端口号', '3000')
|
||||
.option('-h, --host <host>', '主机地址', '0.0.0.0')
|
||||
.action(serveCommand);
|
||||
|
||||
program
|
||||
.command("compile")
|
||||
.description("将目录中的内容输出为带 hash 路由、单个 html 入口的 web 应用")
|
||||
.argument("[dir]", "要编译的目录", ".")
|
||||
.option("-o, --output <dir>", "输出目录", "./dist/output")
|
||||
.command('compile')
|
||||
.description('将目录中的内容输出为带 hash 路由、单个 html 入口的 web 应用')
|
||||
.argument('[dir]', '要编译的目录', '.')
|
||||
.option('-o, --output <dir>', '输出目录', './dist/output')
|
||||
.action(compileCommand);
|
||||
|
||||
program.addCommand(mcpCommand);
|
||||
program
|
||||
.addCommand(mcpCommand);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export type ServeCommandHandler = (
|
||||
dir: string,
|
||||
options: ServeOptions,
|
||||
) => Promise<void>;
|
||||
export type CompileCommandHandler = (
|
||||
dir: string,
|
||||
options: CompileOptions,
|
||||
) => Promise<void>;
|
||||
export type ServeCommandHandler = (dir: string, options: ServeOptions) => Promise<void>;
|
||||
export type CompileCommandHandler = (dir: string, options: CompileOptions) => Promise<void>;
|
||||
|
||||
export interface MarkdownFile {
|
||||
path: string;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ export interface SidebarProps {
|
|||
onClose: () => void;
|
||||
fileTree?: FileNode[];
|
||||
pathHeadings?: Record<string, TocNode[]>;
|
||||
onDataSourceOpen?: () => void;
|
||||
}
|
||||
|
||||
interface SidebarContentProps {
|
||||
|
|
@ -17,7 +16,6 @@ interface SidebarContentProps {
|
|||
currentPath: string;
|
||||
onClose: () => void;
|
||||
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="flex items-center justify-between">
|
||||
<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}>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
|
|
@ -58,7 +48,6 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
|||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文件树滚动区域 */}
|
||||
<div class="flex-1 overflow-y-auto p-4 min-h-0">
|
||||
|
|
@ -132,7 +121,6 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
|||
pathHeadings={pathHeadings()}
|
||||
currentPath={location.pathname}
|
||||
onClose={props.onClose}
|
||||
onDataSourceOpen={props.onDataSourceOpen}
|
||||
/>
|
||||
</aside>
|
||||
</>
|
||||
|
|
@ -145,7 +133,6 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
|||
export const DesktopSidebar: Component<{
|
||||
fileTree?: FileNode[];
|
||||
pathHeadings?: Record<string, TocNode[]>;
|
||||
onDataSourceOpen?: () => void;
|
||||
}> = (props) => {
|
||||
const location = useLocation();
|
||||
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
|
||||
|
|
@ -171,7 +158,6 @@ export const DesktopSidebar: Component<{
|
|||
currentPath={location.pathname}
|
||||
onClose={() => {}}
|
||||
isDesktop
|
||||
onDataSourceOpen={props.onDataSourceOpen}
|
||||
/>
|
||||
</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 finalKeptRolls = [
|
||||
...keptRolls,
|
||||
...allRolls.filter((r) => r.sides === 0),
|
||||
];
|
||||
const finalKeptRolls = [...keptRolls, ...allRolls.filter((r) => r.sides === 0)];
|
||||
|
||||
// 计算小计
|
||||
const subtotal = finalKeptRolls.reduce((sum, roll) => {
|
||||
|
|
@ -62,7 +59,7 @@ export function rollDicePool(pool: DicePool): DicePoolResult {
|
|||
*/
|
||||
export function applyModifier(
|
||||
rolls: DieResult[],
|
||||
modifier: DicePool["modifier"],
|
||||
modifier: DicePool["modifier"]
|
||||
): { keptRolls: DieResult[]; droppedRolls: DieResult[] } {
|
||||
if (!modifier) {
|
||||
return { keptRolls: rolls, droppedRolls: [] };
|
||||
|
|
@ -73,8 +70,7 @@ export function applyModifier(
|
|||
|
||||
switch (modifier.type) {
|
||||
case "kh": // Keep Highest - 保留最大的 N 个
|
||||
case "dh": {
|
||||
// Drop Highest - 丢弃最大的 N 个
|
||||
case "dh": { // Drop Highest - 丢弃最大的 N 个
|
||||
const count = modifier.count;
|
||||
const higher = sorted.slice(-count);
|
||||
const rest = sorted.slice(0, -count);
|
||||
|
|
@ -85,8 +81,7 @@ export function applyModifier(
|
|||
}
|
||||
}
|
||||
case "kl": // Keep Lowest - 保留最小的 N 个
|
||||
case "dl": {
|
||||
// Drop Lowest - 丢弃最小的 N 个
|
||||
case "dl": { // Drop Lowest - 丢弃最小的 N 个
|
||||
const count = modifier.count;
|
||||
const lower = sorted.slice(0, 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);
|
||||
|
||||
// Generate detail expressions (both HTML and plain text)
|
||||
const htmlSpans: string[] = [];
|
||||
const textSpans: string[] = [];
|
||||
// 生成详细表达式 - 使用 HTML 格式
|
||||
const rollSpans: string[] = [];
|
||||
for (const result of poolResults) {
|
||||
// 构建骰子结果 HTML
|
||||
for (const roll of result.rolls) {
|
||||
const isKept = result.keptRolls.some((kept) => kept === roll);
|
||||
if (roll.sides === 0) {
|
||||
const sign = roll.isNegative ? "-" : "";
|
||||
htmlSpans.push(`<strong>${sign}${roll.value}</strong>`);
|
||||
textSpans.push(`${sign}${roll.value}`);
|
||||
// 固定数字,不显示括号
|
||||
rollSpans.push(`<strong>${roll.isNegative ? '-' : ''}${roll.value}</strong>`);
|
||||
} else if (isKept) {
|
||||
htmlSpans.push(`<strong>[${roll.value}]</strong>`);
|
||||
textSpans.push(`[${roll.value}]`);
|
||||
rollSpans.push(`<strong>[${roll.value}]</strong>`);
|
||||
} else {
|
||||
htmlSpans.push(`<span class="text-gray-400">[${roll.value}]</span>`);
|
||||
textSpans.push(`[${roll.value}]`);
|
||||
rollSpans.push(`<span class="text-gray-400">[${roll.value}]</span>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rollHtml = htmlSpans.join(" + ");
|
||||
const rollText = textSpans.join(" + ");
|
||||
const detail = `<strong>${total}</strong> = ${rollHtml}`;
|
||||
const plainDetail = `${total} = ${rollText}`;
|
||||
const rollsStr = rollSpans.join(" + ");
|
||||
const detail = `<strong>${total}</strong> = ${rollsStr}`;
|
||||
|
||||
return {
|
||||
pools: poolResults,
|
||||
total,
|
||||
detail,
|
||||
plainDetail,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export interface DieResult {
|
|||
export interface DicePool {
|
||||
dice: DieResult[]; // 骰子列表(未掷骰前为模板)
|
||||
modifier?: {
|
||||
type: "kh" | "kl" | "dh" | "dl";
|
||||
type: 'kh' | 'kl' | 'dh' | 'dl';
|
||||
count: number;
|
||||
};
|
||||
isNegative: boolean; // 整个骰池是否为负
|
||||
|
|
@ -37,8 +37,7 @@ export interface DicePool {
|
|||
export interface RollResult {
|
||||
pools: DicePoolResult[]; // 各骰池结果
|
||||
total: number; // 总和
|
||||
detail: string; // 详细表达式 (HTML)
|
||||
plainDetail: string; // 详细表达式 (plain text)
|
||||
detail: string; // 详细表达式
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -56,11 +55,11 @@ export interface DicePoolResult {
|
|||
* 解析后的令牌
|
||||
*/
|
||||
export type Token =
|
||||
| { type: "number"; value: number }
|
||||
| { type: "dice"; count: number; sides: number }
|
||||
| { type: "pool_start" }
|
||||
| { type: "pool_end" }
|
||||
| { type: "comma" }
|
||||
| { type: "plus" }
|
||||
| { type: "minus" }
|
||||
| { type: "modifier"; modType: "kh" | "kl" | "dh" | "dl"; count: number };
|
||||
| { type: 'number'; value: number }
|
||||
| { type: 'dice'; count: number; sides: number }
|
||||
| { type: 'pool_start' }
|
||||
| { type: 'pool_end' }
|
||||
| { type: 'comma' }
|
||||
| { type: 'plus' }
|
||||
| { type: 'minus' }
|
||||
| { type: 'modifier'; modType: 'kh' | 'kl' | 'dh' | 'dl'; count: number };
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ export interface DiceRollerResult {
|
|||
result: {
|
||||
total: number;
|
||||
detail: string;
|
||||
plainDetail: string;
|
||||
pools: Array<{
|
||||
rolls: number[];
|
||||
keptRolls: number[];
|
||||
|
|
@ -39,7 +38,6 @@ export function rollFormula(formula: string): DiceRollerResult {
|
|||
result: {
|
||||
total: result.total,
|
||||
detail: result.detail,
|
||||
plainDetail: result.plainDetail,
|
||||
pools: result.pools.map((pool) => ({
|
||||
rolls: pool.rolls.map((r) => r.value),
|
||||
keptRolls: pool.keptRolls.map((r) => r.value),
|
||||
|
|
@ -54,7 +52,6 @@ export function rollFormula(formula: string): DiceRollerResult {
|
|||
result: {
|
||||
total: 0,
|
||||
detail: "",
|
||||
plainDetail: "",
|
||||
pools: [],
|
||||
},
|
||||
success: false,
|
||||
|
|
@ -67,10 +64,7 @@ export function rollFormula(formula: string): DiceRollerResult {
|
|||
* 简化版掷骰,返回 HTML 格式结果
|
||||
* 格式:[1] [2] [3] = <strong>6</strong>
|
||||
*/
|
||||
export function rollSimple(formula: string): {
|
||||
text: string;
|
||||
isHtml?: boolean;
|
||||
} {
|
||||
export function rollSimple(formula: string): { text: string; isHtml?: boolean } {
|
||||
try {
|
||||
const result = rollDice(formula);
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -57,9 +57,9 @@ customElement("md-dice", { key: "" }, (props, { element }) => {
|
|||
const handleRoll = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
// 点击骰子图标:总是重 roll
|
||||
const rollResult = rollFormula(formula);
|
||||
setResult(rollResult.result.total);
|
||||
setRollDetail(rollResult.result.plainDetail);
|
||||
const rollResult = rollFormula(formula).result;
|
||||
setResult(rollResult.total);
|
||||
setRollDetail(rollResult.detail);
|
||||
setIsRolled(true);
|
||||
if (effectiveKey()) {
|
||||
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";
|
||||
@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,
|
||||
pull,
|
||||
pull-2 {
|
||||
icon, pull, pull-2{
|
||||
@apply inline-block;
|
||||
width: 1.5em;
|
||||
height: 1.28em;
|
||||
vertical-align: text-bottom;
|
||||
--icon-src: "";
|
||||
--icon-src: '';
|
||||
background-image: var(--icon-src);
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
position: relative;
|
||||
}
|
||||
icon.masked {
|
||||
mask-image: var(--icon-src);
|
||||
mask-size: contain;
|
||||
mask-position: center;
|
||||
mask-repeat: no-repeat;
|
||||
background-image: unset;
|
||||
}
|
||||
icon.big{
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
pull{
|
||||
margin-right: -0.5em;
|
||||
margin-right: -.5em;
|
||||
width: 0;
|
||||
}
|
||||
pull-2{
|
||||
margin-right: -1em;
|
||||
width: 0;
|
||||
}
|
||||
icon.big .icon-label,
|
||||
icon.big .icon-label-stroke {
|
||||
icon.big .icon-label, icon.big .icon-label-stroke{
|
||||
line-height: 2;
|
||||
}
|
||||
.icon-label,
|
||||
.icon-label-stroke {
|
||||
.icon-label, .icon-label-stroke{
|
||||
position: absolute;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
line-height: 1.28;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0; right: 0; top: 0; bottom: 0;
|
||||
}
|
||||
.icon-label{
|
||||
color: white;
|
||||
}
|
||||
.icon-label-stroke{
|
||||
color: black;
|
||||
-webkit-text-stroke: 0.14em black;
|
||||
-webkit-text-stroke: .14em black;
|
||||
}
|
||||
|
||||
/* prose */
|
||||
|
|
@ -67,8 +55,8 @@ icon.big .icon-label-stroke {
|
|||
code{
|
||||
background: #0001;
|
||||
@apply rounded;
|
||||
padding: 0.2em 0.4em;
|
||||
margin: 0 0.2em;
|
||||
padding: .2em .4em;
|
||||
margin: 0 .2em;
|
||||
&::before,
|
||||
&::after {
|
||||
content: none;
|
||||
|
|
@ -90,7 +78,8 @@ icon.big .icon-label-stroke {
|
|||
@apply hidden;
|
||||
}
|
||||
|
||||
.markdown-alert-title + * {
|
||||
.markdown-alert-title + *
|
||||
{
|
||||
@apply mt-0;
|
||||
}
|
||||
|
||||
|
|
@ -120,21 +109,9 @@ icon.big .icon-label-stroke {
|
|||
|
||||
/* cols */
|
||||
|
||||
.cols {
|
||||
@apply lg:flex lg:gap-4;
|
||||
}
|
||||
.col {
|
||||
@apply lg:flex-1;
|
||||
}
|
||||
.col-2 {
|
||||
@apply lg:flex-2;
|
||||
}
|
||||
.col-3 {
|
||||
@apply lg:flex-3;
|
||||
}
|
||||
.col-4 {
|
||||
@apply lg:flex-4;
|
||||
}
|
||||
.col-5 {
|
||||
@apply lg:flex-5;
|
||||
}
|
||||
.cols{ @apply lg:flex lg:gap-4; }
|
||||
.col{ @apply lg:flex-1; }
|
||||
.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