From 53e72ed44989f1761fc0ab66292e996cf3ddd404 Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Thu, 29 Oct 2026 07:16:38 +0000 Subject: [PATCH 01/13] chore(TaskAutomation): Add the Rollup build tooling Signed-off-by: Steve Roberts --- TaskAutomation/.gitattributes | 9 + TaskAutomation/.gitignore | 5 + TaskAutomation/.prettierignore | 8 + TaskAutomation/.prettierrc.json | 5 + TaskAutomation/package-lock.json | 509 +++++++++++++++++ TaskAutomation/package.json | 21 + TaskAutomation/rollup.config.js | 110 ++++ TaskAutomation/script.json | 48 ++ TaskAutomation/scripts/build.mjs | 82 +++ TaskAutomation/scripts/bump-version.mjs | 83 +++ TaskAutomation/scripts/sync-locales.mjs | 724 ++++++++++++++++++++++++ 11 files changed, 1604 insertions(+) create mode 100644 TaskAutomation/.gitattributes create mode 100644 TaskAutomation/.gitignore create mode 100644 TaskAutomation/.prettierignore create mode 100644 TaskAutomation/.prettierrc.json create mode 100644 TaskAutomation/package-lock.json create mode 100644 TaskAutomation/package.json create mode 100644 TaskAutomation/rollup.config.js create mode 100644 TaskAutomation/script.json create mode 100644 TaskAutomation/scripts/build.mjs create mode 100644 TaskAutomation/scripts/bump-version.mjs create mode 100644 TaskAutomation/scripts/sync-locales.mjs diff --git a/TaskAutomation/.gitattributes b/TaskAutomation/.gitattributes new file mode 100644 index 0000000000..3f638fbc97 --- /dev/null +++ b/TaskAutomation/.gitattributes @@ -0,0 +1,9 @@ +# Enforce LF line endings for all text files in this project +* text=auto eol=lf + +# Treat these as binary to avoid any line-ending normalisation +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary diff --git a/TaskAutomation/.gitignore b/TaskAutomation/.gitignore new file mode 100644 index 0000000000..a48cb1b60c --- /dev/null +++ b/TaskAutomation/.gitignore @@ -0,0 +1,5 @@ +node_modules + +*.zip +*alpha* +*beta* diff --git a/TaskAutomation/.prettierignore b/TaskAutomation/.prettierignore new file mode 100644 index 0000000000..ad37eaf5df --- /dev/null +++ b/TaskAutomation/.prettierignore @@ -0,0 +1,8 @@ +node_modules +package-lock.json + +# Written by scripts/bump-version.mjs. +script.json + +# Written by scripts/write-examples.mjs. +examples/ diff --git a/TaskAutomation/.prettierrc.json b/TaskAutomation/.prettierrc.json new file mode 100644 index 0000000000..d440171380 --- /dev/null +++ b/TaskAutomation/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 100 +} diff --git a/TaskAutomation/package-lock.json b/TaskAutomation/package-lock.json new file mode 100644 index 0000000000..78b6e9f493 --- /dev/null +++ b/TaskAutomation/package-lock.json @@ -0,0 +1,509 @@ +{ + "name": "taskautomation-roll20", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "taskautomation-roll20", + "version": "1.0.0", + "devDependencies": { + "prettier": "^3.5.3", + "rollup": "^4.34.8" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.66.0.tgz", + "integrity": "sha512-7y4XeoUHPecjfUDRmap8U0q6bjfLAPn7uOGT8BKyqy8uNDOjsGS8rlBhOx6sZLVwwjN6hapvGl2mW9/IS6UNww==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.66.0.tgz", + "integrity": "sha512-e8WugYZXpgqrlMXLCudYVRAxAcK7AIY36JxRDwFRD38L9q62G1VsEUW6ijXxzjxYjNB8i1zo3B/ycIQ/qjLC/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.66.0.tgz", + "integrity": "sha512-6vkDCfyDjNLokh+Rt1D2KtNgFFMEPdkWha/mbtP4cSjh9xiN8T/JBS/A7nDYKjmMAXHrvR9J3AzUk2LtFbIBLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.66.0.tgz", + "integrity": "sha512-/mzZAX0+Q6eXxvkR0HB0RZCCrsbmFD8DpsvCQoMYABDfq/DjqkbfytuJ7GvZ4AhR/Mgis2fXbZrGqfbcK2Z5fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.66.0.tgz", + "integrity": "sha512-JQJx9E8tUjGqed1RevDmoHaS3V30ODdV8uyvGxlwd4K5se6bkPrMeltSybqAtpJ+ltDRfo7FORQaJG2Hd5nGog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.66.0.tgz", + "integrity": "sha512-GEaUQQjMaG7X1304qMgqLjzCKv6CdDirzJSS+UqUs3xhJ9X2Kjzu0JDbLoUU42FWxwmZqz+SsgU1SlQv9GNRVA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.66.0.tgz", + "integrity": "sha512-DgmAU9N0NhDQDzHgFUocDio7zRO8jR/nY/obDGvCR8zbJ6J77Fg6X49yPuExJUpYPbC82EOaRhG969pWzUSnHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.66.0.tgz", + "integrity": "sha512-zG09YZ+sk/HoHq+P9gYB0EseQ3M20RbDuKGWey6fbiMDV1LxfXvhgSW9jDaCcvFPMgfLXpimzXY+UYvA8R2mLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.66.0.tgz", + "integrity": "sha512-vlArhgyXbcVc1D4WZPoiYuqJIaMiSHBj6etRXua0KlfjlZeuiQyVAPYi0/N49O4mSp3u1iGUBLEjNa8ec81XZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.66.0.tgz", + "integrity": "sha512-RxJZtk0G87YPotDaRDvRqStV8cbqLSC5TroHvQHHKsqBg0ok64RxKgtXeFsa7FBcy7mb4crBrG3xK8USWIqWNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.66.0.tgz", + "integrity": "sha512-qlGcw959PrLLkZhiBxF3OZ6SQvj+OKY85mCqGNgSRs4LZlDJ6ICqmz6UdxtINdhcXYwNIbZoapQVBAmZlD2hUQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.66.0.tgz", + "integrity": "sha512-TvTI/7Cy0WvRA1SXgwzu6bwXBnMpmeT/ln5q7WvSpqmXy1VbmEnY8HVIDQNBiaHJvoWqRr0M4FNo7qiqtyMaFw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.66.0.tgz", + "integrity": "sha512-UZEDkSoDRZ50NyLj70nO6mpIK3dvs2cPTTvQb1bA/PBDmr9fvn5KiESuGfh6srd7Zi6Lt9Sv2sVrFr1QoHLvPQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.66.0.tgz", + "integrity": "sha512-VRbxTvagLh3HRcysx1CNq+HHcvJbSebNI6fCbk7jwmmWHkXjrSIV3igXBpRHUTFdg0pagWGu50xoSZXnrZHjVQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.66.0.tgz", + "integrity": "sha512-uWdJpvYqh0LE/i+YBL3sMX0a2kUkICENjmrPmyBmMOFc3jMrs6MSQH/pMD+VIpGEoUcr3XkHGLLfymzIqPSRRg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.66.0.tgz", + "integrity": "sha512-3FUd88pxgWAjK60OgCrika1AcNG+u8zaCdWjg4TlcZTDkt5TGyci8ZU64l3OJ9ax0MEjw3P80B/3PoSQDCLwJQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.66.0.tgz", + "integrity": "sha512-qT2rMmgyej62g5AdwktK+Bt2lBu497pzWZe12TTQ5JmZjOYPnEwcPEAOSLTBxBV+6gIES75rXm219v/QkT397A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.66.0.tgz", + "integrity": "sha512-E46rnZgp7J84Qwe8RMlF+rgimnDo/9fuiGbUArGB0ZkMWf97O7yeSCfKls5x/YJOF0RcuSe9RctuKe1hlxICiA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.66.0.tgz", + "integrity": "sha512-Uz1HiZe+qBzqgdPsON+z0wRx3WdATuyzwvGT6cr2VZVrqqko0ZJVfkrQJtwDMuDcAfXYdo7XucaxJ7lTLjEcgg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.66.0.tgz", + "integrity": "sha512-Ph7TX3Ex5qdfcb7Tc6eEPugVkRfJ501eTovO4T5029YObfpZ7dzn3RiwpFvGp50nk2QUhvBMZ0PFGlEj6pOYTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.66.0.tgz", + "integrity": "sha512-nZYPEya7ZkeNxUEiF7qmNZbyLTyRN3MK5cO22KqsaoneUzXJuzBIQRw+7BEQy9LGj2O5vrCE9V3EpK06FYo1tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.66.0.tgz", + "integrity": "sha512-u7aTfS3R6C4hRsqXSzaO6qLZYLVuMkHMvcWlux4IM8IJif1VCl2cgPBSo5gSNoFoStQypQP+qwq8W1IyFBf1QQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.66.0.tgz", + "integrity": "sha512-e+i9ueHrH1Bafb0cf0woAIVJbYcoC8vRaD+vz+Xi8w6jz9cYJSV3vyKO6ELGSZ6g4W4MJOm2WPLN4/nQf77h7w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.66.0.tgz", + "integrity": "sha512-KfTCbrtr2zXTqH2E1F+uYlFTrYibxHKqPAHobpd8Kfg49cEHmuHebOidCTDRgqG1suaPFX7oejFQeGE71zWKfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.66.0.tgz", + "integrity": "sha512-xA+neBh1bqkqaS3b48T2UHZnDsTIpb9B0h+TdBzynjF/ymCpYndhfY10RqW0qSKB1HqqQA83mINVhAdwA+mETg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.66.0.tgz", + "integrity": "sha512-Onq6/qO4ARhxIs+jYiHE0pYehadLyMECAiGEqQ+0/aWND3+2PmifJIIqBLYtuBODqHTlOUEQFtIVPyCy0Lck7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.66.0", + "@rollup/rollup-android-arm64": "4.66.0", + "@rollup/rollup-darwin-arm64": "4.66.0", + "@rollup/rollup-darwin-x64": "4.66.0", + "@rollup/rollup-freebsd-arm64": "4.66.0", + "@rollup/rollup-freebsd-x64": "4.66.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.66.0", + "@rollup/rollup-linux-arm-musleabihf": "4.66.0", + "@rollup/rollup-linux-arm64-gnu": "4.66.0", + "@rollup/rollup-linux-arm64-musl": "4.66.0", + "@rollup/rollup-linux-loong64-gnu": "4.66.0", + "@rollup/rollup-linux-loong64-musl": "4.66.0", + "@rollup/rollup-linux-ppc64-gnu": "4.66.0", + "@rollup/rollup-linux-ppc64-musl": "4.66.0", + "@rollup/rollup-linux-riscv64-gnu": "4.66.0", + "@rollup/rollup-linux-riscv64-musl": "4.66.0", + "@rollup/rollup-linux-s390x-gnu": "4.66.0", + "@rollup/rollup-linux-x64-gnu": "4.66.0", + "@rollup/rollup-linux-x64-musl": "4.66.0", + "@rollup/rollup-openbsd-x64": "4.66.0", + "@rollup/rollup-openharmony-arm64": "4.66.0", + "@rollup/rollup-win32-arm64-msvc": "4.66.0", + "@rollup/rollup-win32-ia32-msvc": "4.66.0", + "@rollup/rollup-win32-x64-gnu": "4.66.0", + "@rollup/rollup-win32-x64-msvc": "4.66.0", + "fsevents": "~2.3.2" + } + } + } +} diff --git a/TaskAutomation/package.json b/TaskAutomation/package.json new file mode 100644 index 0000000000..f45d7c65dc --- /dev/null +++ b/TaskAutomation/package.json @@ -0,0 +1,21 @@ +{ + "name": "taskautomation-roll20", + "version": "1.0.0", + "description": "Roll20 API Mod that runs GM-defined automation tasks: schedules, waits, loops, conditions, parallel branches and event triggers, with a handout dashboard and restart recovery.", + "private": true, + "type": "module", + "scripts": { + "build": "node scripts/build.mjs", + "set-version": "node scripts/bump-version.mjs", + "watch": "node scripts/build.mjs --watch", + "test": "node --test \"tests/*.test.mjs\"", + "examples": "node scripts/write-examples.mjs", + "format": "prettier --write \"TaskAutomation.js\" \"rollup.config.js\" \"src/**/*.js\" \"tests/**/*.mjs\" \"*.md\" \"docs/*.md\" \"scripts/**/*.mjs\"", + "sync-locales": "node scripts/sync-locales.mjs", + "regenerate-locales": "node scripts/sync-locales.mjs --regenerate" + }, + "devDependencies": { + "prettier": "^3.5.3", + "rollup": "^4.34.8" + } +} diff --git a/TaskAutomation/rollup.config.js b/TaskAutomation/rollup.config.js new file mode 100644 index 0000000000..58d4ed3e41 --- /dev/null +++ b/TaskAutomation/rollup.config.js @@ -0,0 +1,110 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { format as prettierFormat, resolveConfig } from 'prettier'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const scriptJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'script.json'), 'utf8')); +const buildTimestamp = new Date().toISOString(); +const scriptName = scriptJson.name; +const scriptFile = scriptJson.script; +const buildVersion = scriptJson.version; + +const banner = [ + '/**', + ' * NOTE: GENERATED FILE - DO NOT EDIT DIRECTLY.', + ' * NOTE: Source files live under src/ and are bundled with `npm run build`.', + ' * ------------------------------------------------', + ` * Name: ${scriptName}`, + ` * Script: ${scriptFile}`, + ` * Version: ${buildVersion}`, + ` * Built: ${buildTimestamp}`, + ' */', +].join('\n'); + +/** + * Formats generated JavaScript chunks using the project's .prettierrc.json config. + * + * @returns {import("rollup").Plugin} Rollup output plugin. + */ +function formatOutputPlugin() { + return { + name: 'format-output', + /** + * Formats each emitted chunk with Prettier, resolving options from + * .prettierrc.json so the config file is the single source of truth. + * + * @param {import("rollup").NormalizedOutputOptions} options Finalized output options. + * @param {import("rollup").OutputBundle} bundle Emitted output bundle. + * @returns {Promise} + */ + async generateBundle(options, bundle) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk') { + continue; + } + + const fileConfig = (await resolveConfig(options.file)) ?? {}; + output.code = await prettierFormat(output.code, { + ...fileConfig, + parser: 'babel', + }); + } + }, + }; +} + +// The IIFE binding is named TaskAutomationMod so the public API can be published +// as globalThis.TaskAutomation without a lexical name clash. +const iifeName = scriptName.replace(/[^a-zA-Z0-9]/g, '') + 'Mod'; + +/** @type {import("rollup").RollupOptions} */ +export default { + input: path.join(__dirname, 'src', 'index.js'), + plugins: [ + { + name: 'inject-build-metadata', + /** + * Replaces metadata placeholders in constants.js with build-time values. + * + * @param {string} code Module source code. + * @param {string} id Absolute module id. + * @returns {{code: string, map: null} | null} + */ + transform(code, id) { + if (!id.endsWith(path.join('src', 'constants.js'))) { + return null; + } + + return { + code: code + .replaceAll('__SCRIPT_NAME__', scriptName) + .replaceAll('__SCRIPT_FILE__', scriptFile) + .replaceAll('__BUILD_VERSION__', buildVersion) + .replaceAll('__BUILD_DATE__', buildTimestamp), + map: null, + }; + }, + }, + ], + output: [ + { + file: path.join(__dirname, scriptFile), + format: 'es', + banner, + intro: `const ${iifeName} = (() => {\n 'use strict';`, + outro: '})();', + plugins: [formatOutputPlugin()], + }, + { + file: path.join(__dirname, buildVersion, scriptFile), + format: 'es', + banner, + intro: `const ${iifeName} = (() => {\n 'use strict';`, + outro: '})();', + plugins: [formatOutputPlugin()], + }, + ], +}; diff --git a/TaskAutomation/script.json b/TaskAutomation/script.json new file mode 100644 index 0000000000..7b85997ac2 --- /dev/null +++ b/TaskAutomation/script.json @@ -0,0 +1,48 @@ +{ + "name": "TaskAutomation", + "script": "TaskAutomation.js", + "version": "1.0.0", + "previousversions": [], + "description": "# TaskAutomation\n\nAutomate your game without writing JavaScript: describe a task as data and let the Mod run it on time, on an event or on a button press.\n\n## Getting started\n\n1. Type `!taskautomation` for the menu, or open the **TaskAutomation - Dashboard** handout.\n2. Click **Templates** and add one of the worked examples (torch reminder, HP threshold, turn response, parallel scene, session reminder, other-Mod command, door sequence).\n3. Click **Start** next to a task, or let its schedule or trigger start it.\n\nType `!taskautomation help` for every command. The **TaskAutomation - Help** handout (GM only) is created in the Journal on startup.\n\n## Features\n\n* Tasks made of steps: chat messages, token, side, bar, marker and attribute changes, pings, effects, jukebox control, other-Mod commands and trusted Mod adapters.\n* Waits (for a time, until a moment or for an event), conditions, counted and conditional loops, calls to other tasks and parallel branches.\n* Start tasks by hand, at a set time, every so often, daily or weekly (with an explicit UTC offset), or when tokens, attributes, the turn order, the player page, players or signals change.\n* One timer for all the work, so idle games cost nothing.\n* Tasks survive API restarts. Work missed while the sandbox was off is reported and handled the way you choose: skip, run once, catch up a little, or ask you.\n* A GM-only dashboard handout with pause, resume, cancel and recovery buttons, and an optional player view.\n* GM-only by default; tasks can be published to listed players with tight limits.\n\nCreated by MidNiteShadow7.", + "authors": "MidNiteShadow7", + "roll20userid": "16506286", + "useroptions": [ + { + "name": "dashboardRows", + "displayname": "Dashboard Rows", + "type": "select", + "options": ["10", "25", "50"], + "default": "25", + "description": "How many rows each dashboard section shows per page." + }, + { + "name": "playerView", + "displayname": "Player View Handout", + "type": "select", + "options": ["off", "on"], + "default": "off", + "description": "on creates a separate TaskAutomation - Player View handout listing only tasks published to players." + }, + { + "name": "diagnostics", + "displayname": "Diagnostic Logging", + "type": "select", + "options": ["off", "on"], + "default": "off", + "description": "on writes scheduler and recovery details to the API console." + } + ], + "dependencies": [], + "modifies": { + "graphic": "read,write", + "character": "read", + "attribute": "read,write", + "page": "read", + "player": "read", + "jukeboxtrack": "read,write", + "campaign.turnorder": "read", + "campaign.playerpageid": "read", + "handouts": "read,write" + }, + "conflicts": [] +} diff --git a/TaskAutomation/scripts/build.mjs b/TaskAutomation/scripts/build.mjs new file mode 100644 index 0000000000..4fdff7628e --- /dev/null +++ b/TaskAutomation/scripts/build.mjs @@ -0,0 +1,82 @@ +/** + * Builds TaskAutomation.js from src/ using Rollup. + * + * Version bumping is handled here (not via a package.json prebuild hook) so + * that an explicit version can be passed cleanly through npm run build. + * + * Usage: + * npm run build → auto-bump patch version, then build + * npm run build -- 1.1.0 → set version to 1.1.0, then build + * npm run build -- 1.1.0-alpha.1 → enter prerelease cycle, then build + * npm run build -- --watch → watch mode (no version bump) + * npm run build -- --no-bump → build without changing the version + * node scripts/build.mjs → same as npm run build (auto-bump) + * node scripts/build.mjs 1.1.0 → same as npm run build -- 1.1.0 + */ + +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { rollup, watch as rollupWatch } from 'rollup'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.join(__dirname, '..'); + +const isWatch = process.argv.includes('--watch'); +const noBump = process.argv.includes('--no-bump'); +// Pick the first argument that doesn't start with '--' as an explicit version. +const versionArg = process.argv.slice(2).find((a) => !a.startsWith('--')); + +async function loadRollupConfig() { + // Load config lazily after any version bump and bypass ESM cache so + // rollup.config.js re-reads script.json with the latest version. + const configPath = path.join(rootDir, 'rollup.config.js'); + const configUrl = pathToFileURL(configPath); + configUrl.searchParams.set('t', Date.now().toString()); + const mod = await import(configUrl.href); + return mod.default; +} + +// Bump (or set) the version before every non-watch, non-no-bump build. +if (!isWatch && !noBump) { + const bumpArgs = versionArg ? [versionArg] : []; + const result = spawnSync(process.execPath, ['scripts/bump-version.mjs', ...bumpArgs], { + cwd: rootDir, + stdio: 'inherit', + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +async function build() { + const config = await loadRollupConfig(); + const bundle = await rollup(config); + for (const output of config.output) { + await bundle.write(output); + console.log(`Built: ${path.relative(rootDir, output.file)}`); + } + await bundle.close(); +} + +if (isWatch) { + const config = await loadRollupConfig(); + const watcher = rollupWatch(config.output.map((output) => ({ ...config, output }))); + + watcher.on('event', (event) => { + if (event.code === 'BUNDLE_END') { + console.log(`Rebuilt at ${new Date().toLocaleTimeString()}`); + event.result?.close(); + } + if (event.code === 'ERROR') { + console.error('Build error:', event.error.message); + } + }); + + console.log('Watching for changes...'); +} else { + build().catch((err) => { + console.error('Build failed:', err.message); + process.exit(1); + }); +} diff --git a/TaskAutomation/scripts/bump-version.mjs b/TaskAutomation/scripts/bump-version.mjs new file mode 100644 index 0000000000..be579509f7 --- /dev/null +++ b/TaskAutomation/scripts/bump-version.mjs @@ -0,0 +1,83 @@ +/** + * Increments the version in script.json and package.json. + * + * Auto-bump behaviour (no argument): + * - Stable release (e.g. 1.0.0) → increments patch: 1.0.1 + * - Prerelease with number → increments prerelease: 1.0.0-alpha.3 → 1.0.0-alpha.4 + * - Prerelease without number → adds .1: 1.0.0-alpha → 1.0.0-alpha.1 + * + * Explicit version (node scripts/bump-version.mjs ): + * - Sets exactly the given version string. + * - Use this to enter a prerelease cycle: node scripts/bump-version.mjs 1.1.0-alpha.1 + * - Use this to cut a release: node scripts/bump-version.mjs 1.1.0 + * + * When building prerelease versions (alpha/beta), always pass an explicit version + * rather than relying on auto-bump — otherwise each `npm run build` will keep + * incrementing the prerelease counter instead of cutting the final release. + * To promote a prerelease to a stable release, set the version explicitly: + * npm run build -- 1.0.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const scriptJsonPath = path.join(__dirname, '..', 'script.json'); +const packageJsonPath = path.join(__dirname, '..', 'package.json'); + +const scriptJson = JSON.parse(fs.readFileSync(scriptJsonPath, 'utf8')); +const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + +const explicitVersion = process.argv[2]; +const currentVersion = scriptJson.version; +let nextVersion; + +if (explicitVersion) { + nextVersion = explicitVersion; +} else { + const current = currentVersion; + const dashIdx = current.indexOf('-'); + + if (dashIdx === -1) { + // Stable: 1.0.0 → 1.0.1 + const [major, minor, patch] = current.split('.').map(Number); + if (!Number.isFinite(patch)) { + console.error( + `ERROR: Cannot auto-bump malformed version "${current}". Use an explicit version.` + ); + process.exit(1); + } + nextVersion = `${major}.${minor}.${patch + 1}`; + } else { + // Prerelease: 1.0.0-alpha.3 → 1.0.0-alpha.4 / 1.0.0-alpha → 1.0.0-alpha.1 + const semver = current.slice(0, dashIdx); + const prerelease = current.slice(dashIdx + 1); + const trailingNumber = prerelease.match(/^(.*?)(\d+)$/); + if (trailingNumber) { + nextVersion = `${semver}-${trailingNumber[1]}${Number(trailingNumber[2]) + 1}`; + } else { + nextVersion = `${semver}-${prerelease}.1`; + } + } +} + +const isPrerelease = (version) => version.includes('-'); + +// Append current stable version to history only when the version actually +// changes (oldest-first, matching Roll20 corpus convention), deduplicate, then +// keep the last 5. Prerelease versions are intentionally skipped. +const historySeed = (scriptJson.previousversions || []).filter((v) => !isPrerelease(v)); +const shouldAppendCurrentStable = !isPrerelease(currentVersion) && currentVersion !== nextVersion; +const nextHistory = shouldAppendCurrentStable + ? [...new Set([...historySeed, currentVersion])].slice(-5) + : [...new Set(historySeed)].slice(-5); + +scriptJson.previousversions = nextHistory; +scriptJson.version = nextVersion; +packageJson.version = nextVersion; + +fs.writeFileSync(scriptJsonPath, JSON.stringify(scriptJson, null, 2) + '\n'); +fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); + +console.log(`Version set to ${nextVersion}`); diff --git a/TaskAutomation/scripts/sync-locales.mjs b/TaskAutomation/scripts/sync-locales.mjs new file mode 100644 index 0000000000..d9c514edfc --- /dev/null +++ b/TaskAutomation/scripts/sync-locales.mjs @@ -0,0 +1,724 @@ +import console from 'node:console'; +import { readdirSync, statSync, writeFileSync } from 'node:fs'; +import https from 'node:https'; +import { dirname, resolve } from 'node:path'; +import process from 'node:process'; +import { createInterface } from 'node:readline/promises'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import prettier from 'prettier'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const LOCALE_DIR = resolve(__dirname, '../src/locales/locale'); +const SOURCE_LOCALE = 'en-US'; +const GOOGLE_TRANSLATE_HOST = 'translate.googleapis.com'; +const GOOGLE_TRANSLATE_PATH = '/translate_a/single?client=gtx'; + +/** + * @typedef {{ timeoutMs: number, maxRetries: number, retryDelayMs: number, interRequestDelayMs: number }} TranslateConfig + */ + +/** @type {TranslateConfig} */ +const SYNC_CONFIG = { + timeoutMs: 30000, + maxRetries: 2, + retryDelayMs: 8000, + interRequestDelayMs: 2000, +}; + +/** @type {TranslateConfig} */ +const REGENERATE_CONFIG = { + timeoutMs: 60000, + maxRetries: 4, + retryDelayMs: 10000, + interRequestDelayMs: 3000, +}; + +const PLACEHOLDER_PATTERN = /(\{[^{}]+\}|<[^&]+>|--[a-zA-Z0-9_-]+)/g; + +/** + * Loads a locale module from disk and returns its translation object. + * + * @param {string} localeCode Locale code to load from `src/locales/locale`. + * @returns {Promise>} Parsed translation object exported by the locale module. + */ +async function loadLocale(localeCode) { + const filePath = resolve(LOCALE_DIR, `${localeCode}.js`); + const moduleUrl = pathToFileURL(filePath).href; + const loaded = await import(moduleUrl); + return loaded.default; +} + +/** + * Replaces templated tokens with stable placeholders before translation. + * + * @param {string} text Source string that may include placeholders like `{name}`. + * @returns {{ masked: string, placeholders: string[] }} Masked string and placeholder list for later restoration. + */ +function maskPlaceholders(text) { + const placeholders = []; + const masked = text.replaceAll(PLACEHOLDER_PATTERN, (match) => { + const token = `__TC_PLACEHOLDER_${placeholders.length}__`; + placeholders.push(match); + return token; + }); + return { masked, placeholders }; +} + +/** + * Restores templated tokens after translation has completed. + * + * @param {string} text Translated string containing placeholder tokens. + * @param {string[]} placeholders Original placeholders captured before translation. + * @returns {string} Restored string with original placeholders reinserted. + */ +function unmaskPlaceholders(text, placeholders) { + return placeholders.reduce( + (restored, placeholder, index) => + restored + .replaceAll(`__TC_PLACEHOLDER_${index}__`, placeholder) + .replaceAll(`__TC_PLACEHOLDER_${index}__`, placeholder), + text + ); +} + +/** + * Fetches translated payload JSON from Google Translate. + * + * @param {string} pathnameWithQuery Request path and query string for the fixed translate host. + * @param {number} timeoutMs Request timeout in milliseconds. + * @returns {Promise} Parsed JSON payload from the translate endpoint. + */ +function fetchTranslatePayload(pathnameWithQuery, timeoutMs) { + return new Promise((resolvePayload, rejectPayload) => { + const request = https.request( + { + hostname: GOOGLE_TRANSLATE_HOST, + method: 'GET', + path: pathnameWithQuery, + }, + (response) => { + const statusCode = response.statusCode ?? 0; + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => { + body += chunk; + }); + response.on('end', () => { + if (statusCode < 200 || statusCode >= 300) { + rejectPayload(new Error(`HTTP ${statusCode}`)); + return; + } + try { + resolvePayload(JSON.parse(body)); + } catch (error) { + rejectPayload(error); + } + }); + } + ); + + request.setTimeout(timeoutMs, () => { + request.destroy(new Error('Request timed out')); + }); + request.on('error', (error) => rejectPayload(error)); + request.end(); + }); +} + +/** + * Translates a single English string into a target locale with retry logic. + * + * @param {string} text English source text to translate. + * @param {string} targetLocale Destination locale code. + * @param {Map} cache In-memory cache for locale/text translation results. + * @param {TranslateConfig} config Translation configuration. + * @param {number} [attempt=0] Current retry attempt number. + * @returns {Promise} Translated string (or original text if all retries fail). + */ +async function translateSingle(text, targetLocale, cache, config, attempt = 0) { + if (!text.trim()) { + return text; + } + + const cacheKey = `${targetLocale}::${text}`; + if (cache.has(cacheKey)) { + return cache.get(cacheKey); + } + + const { masked, placeholders } = maskPlaceholders(text); + const query = `&sl=en&tl=${encodeURIComponent(targetLocale)}&dt=t&q=${encodeURIComponent(masked)}`; + const requestPath = `${GOOGLE_TRANSLATE_PATH}${query}`; + + // Add inter-request delay to avoid rate limiting. + await new Promise((resolve) => setTimeout(resolve, config.interRequestDelayMs)); + + try { + const payload = await fetchTranslatePayload(requestPath, config.timeoutMs); + const translatedMasked = Array.isArray(payload?.[0]) + ? payload[0].map((part) => part?.[0] || '').join('') + : text; + + const translated = unmaskPlaceholders(translatedMasked, placeholders); + cache.set(cacheKey, translated); + return translated; + } catch (error) { + if (attempt < config.maxRetries && error.message.includes('timed out')) { + const delayMs = config.retryDelayMs * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return translateSingle(text, targetLocale, cache, config, attempt + 1); + } + + if (attempt === config.maxRetries) { + console.error( + `[sync-locales] Translation failed for ${targetLocale} after ${config.maxRetries} retries: ${error.message}` + ); + } + cache.set(cacheKey, text); + return text; + } +} + +/** + * Creates a mutable translation placeholder that is finalized later. + * + * @param {string} sourceValue English source value that needs translation. + * @param {Array<{ set: (value: string) => void, text: string }>} translationTasks Task list used to backfill translated values. + * @returns {{ readonly value: string }} Deferred wrapper whose `value` is set after translation tasks complete. + */ +function createDeferredTranslation(sourceValue, translationTasks) { + let translatedValue = sourceValue; + translationTasks.push({ + text: sourceValue, + set: (value) => { + translatedValue = value; + }, + }); + return { + get value() { + return translatedValue; + }, + }; +} + +/** + * Counts the number of strings that need translation in a locale. + * + * @param {unknown} sourceValue Source locale node from `en-US`. + * @param {unknown} targetValue Target locale node at the same path. + * @param {boolean} [forceRetranslate=false] Whether to count already-translated strings. + * @returns {number} Count of strings needing translation. + */ +function countTranslationNeeds(sourceValue, targetValue, forceRetranslate = false) { + if (typeof sourceValue === 'string') { + return forceRetranslate || typeof targetValue !== 'string' || targetValue === sourceValue + ? 1 + : 0; + } + + if (Array.isArray(sourceValue)) { + const targetArray = Array.isArray(targetValue) ? targetValue : []; + return sourceValue.reduce( + (sum, item, index) => sum + countTranslationNeeds(item, targetArray[index], forceRetranslate), + 0 + ); + } + + if (sourceValue && typeof sourceValue === 'object') { + const targetObj = + targetValue && typeof targetValue === 'object' && !Array.isArray(targetValue) + ? /** @type {Record} */ (targetValue) + : {}; + return Object.keys(/** @type {Record} */ (sourceValue)).reduce( + (sum, key) => + sum + + countTranslationNeeds( + /** @type {Record} */ (sourceValue)[key], + targetObj[key], + forceRetranslate + ), + 0 + ); + } + + return 0; +} + +/** + * Synchronizes array entries recursively against source locale structure. + * + * @param {unknown[]} sourceArray Source array from `en-US`. + * @param {unknown} targetValue Current locale value for the same key path. + * @param {{ missing: number, translated: number }} stats Mutable counters for reporting sync actions. + * @param {Array<{ set: (value: string) => void, text: string }>} translationTasks Deferred translation tasks collected during traversal. + * @param {(sourceValue: unknown, targetValue: unknown, stats: { missing: number, translated: number }, translationTasks: Array<{ set: (value: string) => void, text: string }>) => unknown} syncEntry Recursive sync function used for nested values. + * @returns {unknown[]} Synchronized array preserving source structure. + */ +function syncArray(sourceArray, targetValue, stats, translationTasks, syncEntry) { + const targetArray = Array.isArray(targetValue) ? targetValue : []; + const synced = []; + + for (let index = 0; index < sourceArray.length; index += 1) { + if (index >= targetArray.length) { + stats.missing += 1; + } + + synced.push(syncEntry(sourceArray[index], targetArray[index], stats, translationTasks)); + } + + return synced; +} + +/** + * Synchronizes object keys recursively against the source schema. + * Keys present in the target but absent from the source are pruned (counted in stats.pruned). + * + * @param {Record} sourceObject Source object from `en-US`. + * @param {unknown} targetValue Current locale value for the same object path. + * @param {{ missing: number, translated: number, pruned: number }} stats Mutable counters for reporting sync actions. + * @param {Array<{ set: (value: string) => void, text: string }>} translationTasks Deferred translation tasks collected during traversal. + * @param {(sourceValue: unknown, targetValue: unknown, stats: { missing: number, translated: number, pruned: number }, translationTasks: Array<{ set: (value: string) => void, text: string }>) => unknown} syncEntry Recursive sync function used for nested values. + * @returns {Record} Synchronized object with schema-only keys retained. + */ +function syncObject(sourceObject, targetValue, stats, translationTasks, syncEntry) { + const targetObject = + targetValue && typeof targetValue === 'object' && !Array.isArray(targetValue) + ? /** @type {Record} */ (targetValue) + : {}; + const synced = {}; + + for (const key of Object.keys(sourceObject)) { + if (!(key in targetObject)) { + stats.missing += 1; + } + + synced[key] = syncEntry(sourceObject[key], targetObject[key], stats, translationTasks); + } + + for (const key of Object.keys(targetObject)) { + if (!(key in synced)) { + stats.pruned += 1; + } + } + + return synced; +} + +/** + * Synchronizes a single string value against the English source string. + * + * @param {unknown} sourceValue Source locale value expected to be a string. + * @param {unknown} targetValue Target locale value to compare. + * @param {{ missing: number, translated: number, forceRetranslate: boolean }} stats Mutable counters for reporting sync actions. + * @param {Array<{ set: (value: string) => void, text: string }>} translationTasks Deferred translation tasks collected during traversal. + * @returns {unknown} Existing localized string or deferred translated value wrapper. + */ +function syncStringValue(sourceValue, targetValue, stats, translationTasks) { + if (typeof targetValue !== 'string') { + stats.missing += 1; + stats.translated += 1; + return createDeferredTranslation(sourceValue, translationTasks); + } + + if (stats.forceRetranslate || targetValue === sourceValue) { + stats.translated += 1; + return createDeferredTranslation(sourceValue, translationTasks); + } + + return targetValue; +} + +/** + * Recursively synchronizes any translation node (string, array, object, or primitive). + * + * @param {unknown} sourceValue Source locale node from `en-US`. + * @param {unknown} targetValue Target locale node at the same path. + * @param {{ missing: number, translated: number }} stats Mutable counters for reporting sync actions. + * @param {Array<{ set: (value: string) => void, text: string }>} translationTasks Deferred translation tasks collected during traversal. + * @returns {unknown} Synchronized node value. + */ +function syncValue(sourceValue, targetValue, stats, translationTasks) { + if (typeof sourceValue === 'string') { + return syncStringValue(sourceValue, targetValue, stats, translationTasks); + } + + if (Array.isArray(sourceValue)) { + return syncArray(sourceValue, targetValue, stats, translationTasks, syncValue); + } + + if (sourceValue && typeof sourceValue === 'object') { + return syncObject( + /** @type {Record} */ (sourceValue), + targetValue, + stats, + translationTasks, + syncValue + ); + } + + return targetValue ?? sourceValue; +} + +/** + * Resolves deferred translation placeholders into plain serializable values. + * + * @param {unknown} value Node value that may contain deferred translation wrappers. + * @returns {unknown} Fully materialized value safe for JSON serialization. + */ +function finalizeValue(value) { + if (Array.isArray(value)) { + return value.map((entry) => finalizeValue(entry)); + } + + if (value && typeof value === 'object') { + if ('value' in value && Object.keys(value).length === 1) { + return value.value; + } + + const finalized = {}; + for (const [key, entryValue] of Object.entries(value)) { + finalized[key] = finalizeValue(entryValue); + } + return finalized; + } + + return value; +} + +/** + * Writes a locale translation object to its locale module file and formats it with Prettier. + * + * @param {string} localeCode Locale code for the output file name. + * @param {Record} localeData Synchronized translation data to persist. + * @returns {Promise} Resolves after the file has been written and formatted. + */ +async function writeLocale(localeCode, localeData) { + const filePath = resolve(LOCALE_DIR, `${localeCode}.js`); + const content = `const TRANSLATION = ${JSON.stringify(localeData, null, 2)};\n\nexport default TRANSLATION;\n`; + writeFileSync(filePath, content, 'utf8'); + const formatted = await prettier.format(content, { filepath: filePath }); + writeFileSync(filePath, formatted, 'utf8'); +} + +/** + * Processes translation tasks for a single locale and writes the result to disk. + * + * @param {string} localeCode Locale code to process. + * @param {Record} source Source locale data from `en-US`. + * @param {Record} localeData Current locale data. + * @param {Map} cache In-memory cache for translation results. + * @param {TranslateConfig} config Translation configuration. + * @param {boolean} forceRetranslate Whether to re-translate already-translated strings. + * @returns {Promise} + */ +async function processLocale(localeCode, source, localeData, cache, config, forceRetranslate) { + const stats = { missing: 0, translated: 0, pruned: 0, forceRetranslate }; + const translationTasks = []; + const syncedDraft = syncValue(source, localeData, stats, translationTasks); + + if (translationTasks.length === 0 && stats.pruned === 0) { + console.info(`[sync-locales] ${localeCode}: up to date`); + return; + } + + const uniqueTexts = [...new Set(translationTasks.map((task) => task.text))]; + const translatedByText = new Map(); + + for (const text of uniqueTexts) { + const translated = await translateSingle(text, localeCode, cache, config); + translatedByText.set(text, translated); + } + + for (const task of translationTasks) { + task.set(translatedByText.get(task.text) ?? task.text); + } + + const synced = finalizeValue(syncedDraft); + await writeLocale(localeCode, synced); + + const prunedNote = stats.pruned > 0 ? `, pruned ${stats.pruned}` : ''; + console.info( + `[sync-locales] ${localeCode}: missing ${stats.missing}, translated ${stats.translated}${prunedNote}` + ); +} + +/** + * Synchronizes all non-English locale files against `en-US`. + * Processes locales in order of most strings needing translation first, + * then oldest by last-modified date, so interrupted runs make the most progress. + * + * @param {string} [localeFilter] Optional locale code to limit sync to a single locale. + * @returns {Promise} Resolves after all targeted locale files have been synchronized and written. + */ +export async function syncLocales(localeFilter) { + const files = getLocaleCodes(); + + if (localeFilter === SOURCE_LOCALE) { + console.error( + `[sync-locales] ${SOURCE_LOCALE} is the source locale and cannot be synchronized.` + ); + process.exitCode = 1; + return; + } + + const source = await loadLocale(SOURCE_LOCALE); + let targetLocaleCodes = files.filter((code) => code !== SOURCE_LOCALE); + + if (localeFilter) { + targetLocaleCodes = targetLocaleCodes.filter((code) => code === localeFilter); + if (targetLocaleCodes.length === 0) { + console.error(`[sync-locales] No locale found matching: ${localeFilter}`); + process.exitCode = 1; + return; + } + } + + // Pre-scan all locales to determine priority ordering without making any HTTP requests. + const localeMetadata = await Promise.all( + targetLocaleCodes.map(async (localeCode) => { + const filePath = resolve(LOCALE_DIR, `${localeCode}.js`); + const [localeData, { mtimeMs }] = [await loadLocale(localeCode), statSync(filePath)]; + const needsTranslation = countTranslationNeeds(source, localeData); + return { localeCode, localeData, mtimeMs, needsTranslation }; + }) + ); + + // Sort: most strings needing translation first, then oldest file as tiebreaker. + localeMetadata.sort((a, b) => { + if (b.needsTranslation !== a.needsTranslation) { + return b.needsTranslation - a.needsTranslation; + } + return a.mtimeMs - b.mtimeMs; + }); + + const cache = new Map(); + + for (const { localeCode, localeData } of localeMetadata) { + await processLocale(localeCode, source, localeData, cache, SYNC_CONFIG, false); + } +} + +/** + * Regenerates translations for all (or a specific) locale, re-translating every string + * regardless of whether it already has a translation. Uses longer timeouts. + * + * @param {string} [localeFilter] Optional locale code to limit regeneration to a single locale. + * @returns {Promise} Resolves after all targeted locale files have been regenerated. + */ +export async function regenerateLocales(localeFilter) { + const files = readdirSync(LOCALE_DIR) + .filter((file) => file.endsWith('.js')) + .map((file) => file.replace(/\.js$/u, '')); + + if (localeFilter === SOURCE_LOCALE) { + console.error( + `[sync-locales] ${SOURCE_LOCALE} is the source locale and cannot be regenerated.` + ); + process.exitCode = 1; + return; + } + + const source = await loadLocale(SOURCE_LOCALE); + let targetLocaleCodes = files.filter((code) => code !== SOURCE_LOCALE); + + if (localeFilter) { + targetLocaleCodes = targetLocaleCodes.filter((code) => code === localeFilter); + if (targetLocaleCodes.length === 0) { + console.error(`[sync-locales] No locale found matching: ${localeFilter}`); + process.exitCode = 1; + return; + } + } + + const cache = new Map(); + + for (const localeCode of targetLocaleCodes) { + const localeData = await loadLocale(localeCode); + await processLocale(localeCode, source, localeData, cache, REGENERATE_CONFIG, true); + } +} + +/** + * Parses CLI locale filter for regenerate mode. + * Supports: --locale=, --locale , or a positional locale argument. + * + * @param {string[]} args CLI arguments excluding node and script paths. + * @returns {string | undefined} Parsed locale filter. + */ +function parseLocaleFilter(args) { + const isFlag = (arg) => arg.startsWith('-'); + const localeEq = args.find((arg) => arg.startsWith('--locale='))?.split('=')[1]; + const localeFlagIndex = args.indexOf('--locale'); + const localeNext = + localeFlagIndex !== -1 && args[localeFlagIndex + 1] && !isFlag(args[localeFlagIndex + 1]) + ? args[localeFlagIndex + 1] + : undefined; + const positional = args.find((arg) => arg !== '--regenerate' && !isFlag(arg)); + + const candidates = [localeEq, localeNext, positional].filter( + (value, index, values) => + typeof value === 'string' && value.length > 0 && values.indexOf(value) === index + ); + + if (localeFlagIndex !== -1 && !localeNext && !localeEq) { + console.error('[sync-locales] Missing locale value after --locale.'); + process.exitCode = 1; + return undefined; + } + + if (candidates.length > 1) { + console.error( + `[sync-locales] Conflicting locale values provided: ${candidates.join(', ')}. Use a single locale.` + ); + process.exitCode = 1; + return undefined; + } + + return candidates[0]; +} + +/** + * Collects locale codes from the locale directory (without `.js` extension). + * + * @returns {string[]} Locale codes discovered under `src/locales/locale`. + */ +function getLocaleCodes() { + return readdirSync(LOCALE_DIR) + .filter((file) => file.endsWith('.js')) + .map((file) => file.replace(/\.js$/u, '')); +} + +/** + * Builds a sync timing estimate used for pre-run warnings. + * + * Estimated formula: + * - minimum: 4 minutes × locale count + * - maximum: 7 minutes × locales needing translation + * + * @param {string} [localeFilter] Optional locale code to limit estimate scope. + * @returns {Promise<{ totalLocales: number, localesNeedingTranslation: number, estimatedMinMinutes: number, estimatedMaxMinutes: number } | null>} + */ +async function estimateSyncTiming(localeFilter) { + const files = getLocaleCodes(); + const source = await loadLocale(SOURCE_LOCALE); + let targetLocaleCodes = files.filter((code) => code !== SOURCE_LOCALE); + + if (localeFilter) { + if (localeFilter === SOURCE_LOCALE) { + console.error( + `[sync-locales] ${SOURCE_LOCALE} is the source locale and cannot be synchronized.` + ); + process.exitCode = 1; + return null; + } + + targetLocaleCodes = targetLocaleCodes.filter((code) => code === localeFilter); + if (targetLocaleCodes.length === 0) { + console.error(`[sync-locales] No locale found matching: ${localeFilter}`); + process.exitCode = 1; + return null; + } + } + + const localeMetadata = await Promise.all( + targetLocaleCodes.map(async (localeCode) => { + const localeData = await loadLocale(localeCode); + const needsTranslation = countTranslationNeeds(source, localeData); + return { localeCode, needsTranslation }; + }) + ); + + const localesNeedingTranslation = localeMetadata.filter( + ({ needsTranslation }) => needsTranslation > 0 + ).length; + + return { + totalLocales: targetLocaleCodes.length, + localesNeedingTranslation, + estimatedMinMinutes: targetLocaleCodes.length * 4, + estimatedMaxMinutes: localesNeedingTranslation * 7, + }; +} + +/** + * Prompts the user to confirm whether the run should proceed. + * + * @param {string[]} args CLI arguments excluding node and script paths. + * @param {string} message Prompt message shown before confirmation. + * @returns {Promise} True when user confirms, otherwise false. + */ +async function confirmProceed(args, message) { + if (args.includes('--yes') || args.includes('-y')) { + return true; + } + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.warn( + '[sync-locales] Non-interactive terminal detected; proceeding without confirmation.' + ); + return true; + } + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await rl.question(`${message}\n[sync-locales] Proceed? (y/N): `); + return ['y', 'yes'].includes(answer.trim().toLowerCase()); + } finally { + rl.close(); + } +} + +/** + * If this module is executed directly, run syncLocales or regenerateLocales based on CLI flags. + * Flags: + * --regenerate Force re-translation of all strings (uses longer timeouts). + * --locale= Limit regeneration to a single locale (only valid with --regenerate). + * --yes / -y Skip the confirmation prompt. + */ +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + const isRegenerate = args.includes('--regenerate'); + const localeFilter = parseLocaleFilter(args); + + if (process.exitCode) { + process.exit(process.exitCode); + } + + if (isRegenerate) { + const localeCodes = getLocaleCodes().filter((code) => code !== SOURCE_LOCALE); + const targetCount = localeFilter ? 1 : localeCodes.length; + const targetLabel = localeFilter ? `${localeFilter}` : `${targetCount} locales`; + const totalMin = targetCount * 30; + const totalMax = targetCount * 60; + + const shouldProceed = await confirmProceed( + args, + `[sync-locales] Regenerate mode selected for ${targetLabel}. This can take 30-60 minutes per locale (about ${totalMin}-${totalMax} minutes total).` + ); + + if (!shouldProceed) { + console.info('[sync-locales] Cancelled by user.'); + process.exit(0); + } + } else { + const syncEstimate = await estimateSyncTiming(localeFilter); + if (!syncEstimate) { + process.exit(process.exitCode || 1); + } + const shouldProceed = await confirmProceed( + args, + `[sync-locales] Sync mode selected. This usually takes several minutes. Estimated minimum: about ${syncEstimate.estimatedMinMinutes} minutes (4 × ${syncEstimate.totalLocales} locales). Estimated maximum: about ${syncEstimate.estimatedMaxMinutes} minutes (7 × ${syncEstimate.localesNeedingTranslation} locales needing translation).` + ); + + if (!shouldProceed) { + console.info('[sync-locales] Cancelled by user.'); + process.exit(0); + } + } + + if (isRegenerate) { + await regenerateLocales(localeFilter); + } else { + await syncLocales(localeFilter); + } +} From af5be879c600b007a084e5f7c4752fcf859046ef Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Thu, 29 Oct 2026 08:05:49 +0000 Subject: [PATCH 02/13] feat(TaskAutomation): Add the task automation engine Tasks are validated JSON definitions with published revisions; runs pin a revision and keep persistent frames, so they survive API restarts. - One scheduler owns the only timer: a min-heap of jobs, one re-armed timeout, generation guards and bounded draining. - Steps: waits, awaited events, conditions, count/while/until/forever loops, calls, cooperative parallel branches with joins, and the action catalogue (chat, tokens, sides, markers, attributes, pings, effects, jukebox, other-Mod commands and trusted adapters). - Triggers: manual, one-off, fixed-rate/fixed-delay intervals, daily and weekly at a fixed UTC offset, token/attribute/turn order/page/player events, signals and opt-in chat, with cooldown, debounce and burst caps. - Missed work after downtime or pauses follows skip, runOnce, catchUpBounded, review or expire, counted arithmetically. - An action journal reconciles interrupted effects on restart; uncertain non-idempotent effects go to GM review instead of repeating. - GM-only by default, with narrowly published player tasks, effect-time control checks, revision-bound links and single-use confirmations. - A throttled GM dashboard handout, an optional player view and a Help handout; seven worked examples plus the montage fixture as templates. Signed-off-by: Steve Roberts --- TaskAutomation/examples/door-sequence.json | 82 + TaskAutomation/examples/hp-zero.json | 66 + TaskAutomation/examples/montage.json | 60 + TaskAutomation/examples/parallel-scene.json | 113 + TaskAutomation/examples/scene-cleanup.json | 45 + TaskAutomation/examples/session-reminder.json | 44 + .../examples/token-mod-command.json | 43 + TaskAutomation/examples/torch-reminder.json | 62 + TaskAutomation/examples/turn-response.json | 90 + TaskAutomation/scripts/write-examples.mjs | 21 + TaskAutomation/src/actions.js | 738 +++++ TaskAutomation/src/audit.js | 231 ++ TaskAutomation/src/catalogue.js | 325 ++ TaskAutomation/src/chat.js | 241 ++ TaskAutomation/src/commands.js | 2556 +++++++++++++++ TaskAutomation/src/constants.js | 325 ++ TaskAutomation/src/dashboard.js | 1061 ++++++ TaskAutomation/src/definitions.js | 663 ++++ TaskAutomation/src/errors.js | 58 + TaskAutomation/src/executor.js | 2868 +++++++++++++++++ TaskAutomation/src/expressions.js | 539 ++++ TaskAutomation/src/heap.js | 121 + TaskAutomation/src/host.js | 235 ++ TaskAutomation/src/i18n.js | 123 + TaskAutomation/src/index.js | 327 ++ TaskAutomation/src/integrations.js | 158 + TaskAutomation/src/journals.js | 144 + TaskAutomation/src/locales/index.js | 15 + TaskAutomation/src/locales/locale/en-US.js | 728 +++++ TaskAutomation/src/locales/metadata.js | 38 + TaskAutomation/src/objects.js | 418 +++ TaskAutomation/src/permissions.js | 185 ++ TaskAutomation/src/recovery.js | 189 ++ TaskAutomation/src/recurrence.js | 317 ++ TaskAutomation/src/runtime.js | 135 + TaskAutomation/src/scheduler.js | 351 ++ TaskAutomation/src/schedules.js | 858 +++++ TaskAutomation/src/schema.js | 1216 +++++++ TaskAutomation/src/state.js | 386 +++ TaskAutomation/src/templates.js | 360 +++ TaskAutomation/src/triggers.js | 1119 +++++++ TaskAutomation/src/utils.js | 562 ++++ 42 files changed, 18216 insertions(+) create mode 100644 TaskAutomation/examples/door-sequence.json create mode 100644 TaskAutomation/examples/hp-zero.json create mode 100644 TaskAutomation/examples/montage.json create mode 100644 TaskAutomation/examples/parallel-scene.json create mode 100644 TaskAutomation/examples/scene-cleanup.json create mode 100644 TaskAutomation/examples/session-reminder.json create mode 100644 TaskAutomation/examples/token-mod-command.json create mode 100644 TaskAutomation/examples/torch-reminder.json create mode 100644 TaskAutomation/examples/turn-response.json create mode 100644 TaskAutomation/scripts/write-examples.mjs create mode 100644 TaskAutomation/src/actions.js create mode 100644 TaskAutomation/src/audit.js create mode 100644 TaskAutomation/src/catalogue.js create mode 100644 TaskAutomation/src/chat.js create mode 100644 TaskAutomation/src/commands.js create mode 100644 TaskAutomation/src/constants.js create mode 100644 TaskAutomation/src/dashboard.js create mode 100644 TaskAutomation/src/definitions.js create mode 100644 TaskAutomation/src/errors.js create mode 100644 TaskAutomation/src/executor.js create mode 100644 TaskAutomation/src/expressions.js create mode 100644 TaskAutomation/src/heap.js create mode 100644 TaskAutomation/src/host.js create mode 100644 TaskAutomation/src/i18n.js create mode 100644 TaskAutomation/src/index.js create mode 100644 TaskAutomation/src/integrations.js create mode 100644 TaskAutomation/src/journals.js create mode 100644 TaskAutomation/src/locales/index.js create mode 100644 TaskAutomation/src/locales/locale/en-US.js create mode 100644 TaskAutomation/src/locales/metadata.js create mode 100644 TaskAutomation/src/objects.js create mode 100644 TaskAutomation/src/permissions.js create mode 100644 TaskAutomation/src/recovery.js create mode 100644 TaskAutomation/src/recurrence.js create mode 100644 TaskAutomation/src/runtime.js create mode 100644 TaskAutomation/src/scheduler.js create mode 100644 TaskAutomation/src/schedules.js create mode 100644 TaskAutomation/src/schema.js create mode 100644 TaskAutomation/src/state.js create mode 100644 TaskAutomation/src/templates.js create mode 100644 TaskAutomation/src/triggers.js create mode 100644 TaskAutomation/src/utils.js diff --git a/TaskAutomation/examples/door-sequence.json b/TaskAutomation/examples/door-sequence.json new file mode 100644 index 0000000000..c101afc903 --- /dev/null +++ b/TaskAutomation/examples/door-sequence.json @@ -0,0 +1,82 @@ +{ + "schemaVersion": 1, + "id": "door-sequence", + "revision": 1, + "name": "Door sequence", + "description": "Announce a door, wait up to a minute for the door-open signal, then open it or report that it stayed shut.", + "enabled": true, + "parameters": {}, + "bindings": { + "door": { + "from": "selected", + "objectType": "graphic", + "max": 1 + } + }, + "access": { + "mode": "gmOnly", + "authority": "initiator" + }, + "triggers": [ + { + "id": "manual", + "type": "manual" + } + ], + "policy": { + "concurrency": "skipIfRunning", + "maxConcurrent": 1, + "onError": "fail", + "restart": "reconcile" + }, + "steps": [ + { + "id": "announce", + "type": "chat.send", + "mode": "say", + "text": "The heavy door groans. Someone must pull the lever..." + }, + { + "id": "lever", + "type": "awaitEvent", + "event": "signal", + "filter": { + "signal": "door-open" + }, + "timeoutMs": 60000, + "saveAs": "lever" + }, + { + "id": "opened", + "type": "if", + "condition": { + "ref": "var.lever.matched" + }, + "then": [ + { + "id": "swing", + "type": "token.advanceSide", + "target": { + "binding": "door" + }, + "delta": 1, + "wrap": false + }, + { + "id": "open-msg", + "type": "chat.send", + "mode": "say", + "text": "The door swings open." + } + ], + "else": [ + { + "id": "shut-msg", + "type": "chat.send", + "mode": "say", + "text": "The door stays shut." + } + ] + } + ] +} diff --git a/TaskAutomation/examples/hp-zero.json b/TaskAutomation/examples/hp-zero.json new file mode 100644 index 0000000000..66d6c902e2 --- /dev/null +++ b/TaskAutomation/examples/hp-zero.json @@ -0,0 +1,66 @@ +{ + "schemaVersion": 1, + "id": "hp-zero", + "revision": 1, + "name": "HP threshold", + "description": "When a token's bar 1 drops from above 0 to 0 or less, mark it dead and tell the GM.", + "enabled": false, + "parameters": {}, + "bindings": { + "token": { + "from": "event", + "objectType": "graphic", + "max": 1 + } + }, + "access": { + "mode": "gmOnly", + "authority": "gmService" + }, + "triggers": [ + { + "id": "bar-crossing", + "type": "event", + "event": "token.change", + "filter": { + "properties": [ + "bar1_value" + ], + "crossing": { + "property": "bar1_value", + "direction": "below", + "threshold": 0 + } + }, + "bind": { + "binding": "token" + }, + "cooldownMs": 1000 + } + ], + "policy": { + "concurrency": "allow", + "maxConcurrent": 10, + "onError": "skip" + }, + "steps": [ + { + "id": "mark", + "type": "marker.set", + "target": { + "binding": "token" + }, + "marker": "dead", + "op": "add" + }, + { + "id": "tell", + "type": "chat.send", + "mode": "whisper", + "to": { + "gm": true + }, + "text": "{{target.token.name}} has dropped to {{event.new.bar1_value}} HP." + } + ] +} diff --git a/TaskAutomation/examples/montage.json b/TaskAutomation/examples/montage.json new file mode 100644 index 0000000000..e30787e03c --- /dev/null +++ b/TaskAutomation/examples/montage.json @@ -0,0 +1,60 @@ +{ + "schemaVersion": 1, + "id": "montage", + "revision": 1, + "name": "Adventure montage", + "description": "Cycle selected multi-sided tokens ten times, eight seconds apart.", + "enabled": true, + "parameters": {}, + "bindings": { + "tokens": { + "from": "selected", + "objectType": "graphic", + "max": 5 + } + }, + "access": { + "mode": "gmOnly", + "authority": "initiator" + }, + "triggers": [ + { + "id": "manual", + "type": "manual" + } + ], + "policy": { + "concurrency": "skipIfRunning", + "maxConcurrent": 1, + "onError": "fail", + "overdue": { + "mode": "skip", + "graceMs": 250 + }, + "restart": "reconcile" + }, + "steps": [ + { + "id": "frames", + "type": "loop", + "mode": "count", + "count": 10, + "steps": [ + { + "id": "advance", + "type": "token.advanceSide", + "target": { + "binding": "tokens" + }, + "delta": 1, + "wrap": true + }, + { + "id": "hold", + "type": "wait", + "durationMs": 8000 + } + ] + } + ] +} diff --git a/TaskAutomation/examples/parallel-scene.json b/TaskAutomation/examples/parallel-scene.json new file mode 100644 index 0000000000..130a01ae8d --- /dev/null +++ b/TaskAutomation/examples/parallel-scene.json @@ -0,0 +1,113 @@ +{ + "schemaVersion": 1, + "id": "parallel-scene", + "revision": 1, + "name": "Parallel scene", + "description": "Fade the \"Ambience\" jukebox track in steps while a door token changes after a wait; when both finish, chain Scene cleanup.", + "enabled": true, + "parameters": { + "track": { + "type": "string", + "default": "Ambience", + "maxLength": 100 + } + }, + "bindings": { + "door": { + "from": "selected", + "objectType": "graphic", + "max": 1 + } + }, + "access": { + "mode": "gmOnly", + "authority": "initiator" + }, + "triggers": [ + { + "id": "manual", + "type": "manual" + } + ], + "policy": { + "concurrency": "skipIfRunning", + "maxConcurrent": 1, + "onError": "fail", + "restart": "reconcile" + }, + "steps": [ + { + "id": "scene", + "type": "parallel", + "join": "all", + "branches": [ + { + "id": "music", + "steps": [ + { + "id": "fade", + "type": "loop", + "mode": "count", + "count": 5, + "steps": [ + { + "id": "volume", + "type": "audio.control", + "track": "{{param.track}}", + "op": "volume", + "volume": { + "op": "sub", + "left": 60, + "right": { + "op": "mul", + "left": { + "ref": "run.iteration" + }, + "right": 10 + } + }, + "onError": "skip" + }, + { + "id": "fade-step", + "type": "wait", + "durationMs": 1000 + } + ] + } + ] + }, + { + "id": "door", + "steps": [ + { + "id": "door-wait", + "type": "wait", + "durationMs": 3000 + }, + { + "id": "door-open", + "type": "token.advanceSide", + "target": { + "binding": "door" + }, + "delta": 1, + "wrap": true + } + ] + } + ] + }, + { + "id": "cleanup", + "type": "call", + "definition": "scene-cleanup", + "await": true, + "params": { + "track": { + "ref": "param.track" + } + } + } + ] +} diff --git a/TaskAutomation/examples/scene-cleanup.json b/TaskAutomation/examples/scene-cleanup.json new file mode 100644 index 0000000000..df7f021e48 --- /dev/null +++ b/TaskAutomation/examples/scene-cleanup.json @@ -0,0 +1,45 @@ +{ + "schemaVersion": 1, + "id": "scene-cleanup", + "revision": 1, + "name": "Scene cleanup", + "description": "Stop a jukebox track and announce the scene is over. Called by Parallel scene.", + "enabled": true, + "parameters": { + "track": { + "type": "string", + "required": true, + "maxLength": 100 + } + }, + "bindings": {}, + "access": { + "mode": "gmOnly", + "authority": "initiator" + }, + "triggers": [ + { + "id": "manual", + "type": "manual" + } + ], + "policy": { + "concurrency": "allow", + "maxConcurrent": 5, + "onError": "skip" + }, + "steps": [ + { + "id": "silence", + "type": "audio.control", + "track": "{{param.track}}", + "op": "stop" + }, + { + "id": "done", + "type": "chat.send", + "mode": "desc", + "text": "The scene falls quiet." + } + ] +} diff --git a/TaskAutomation/examples/session-reminder.json b/TaskAutomation/examples/session-reminder.json new file mode 100644 index 0000000000..bc5280f96a --- /dev/null +++ b/TaskAutomation/examples/session-reminder.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 1, + "id": "session-reminder", + "revision": 1, + "name": "Session reminder", + "description": "Every Friday at 19:00 (UTC+00:00) remind the table that the session starts soon. Update the offset when the clocks change.", + "enabled": false, + "parameters": {}, + "bindings": {}, + "access": { + "mode": "gmOnly", + "authority": "gmService" + }, + "triggers": [ + { + "id": "weekly", + "type": "weekly", + "days": [ + "fri" + ], + "time": "19:00", + "offset": "+00:00", + "overdue": { + "mode": "runOnce", + "windowMs": 900000, + "maxAgeMs": 7200000 + } + } + ], + "policy": { + "concurrency": "skipIfRunning", + "maxConcurrent": 1, + "onError": "skip", + "lifetimeMs": 3600000 + }, + "steps": [ + { + "id": "remind", + "type": "chat.send", + "mode": "say", + "text": "Session starts in 15 minutes. Grab your dice!" + } + ] +} diff --git a/TaskAutomation/examples/token-mod-command.json b/TaskAutomation/examples/token-mod-command.json new file mode 100644 index 0000000000..b69f259da6 --- /dev/null +++ b/TaskAutomation/examples/token-mod-command.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 1, + "id": "token-mod-command", + "revision": 1, + "name": "Other-Mod command", + "description": "Send an approved TokenMod command for the selected token, by ID. Shown as dispatched, not confirmed.", + "enabled": true, + "parameters": {}, + "bindings": { + "token": { + "from": "selected", + "objectType": "graphic", + "max": 1 + } + }, + "access": { + "mode": "gmOnly", + "authority": "initiator" + }, + "triggers": [ + { + "id": "manual", + "type": "manual" + } + ], + "policy": { + "concurrency": "allow", + "maxConcurrent": 5, + "onError": "fail" + }, + "steps": [ + { + "id": "mark-blue", + "type": "integration.command", + "command": "!token-mod --ids {{args.token}} --set statusmarkers|+blue", + "args": { + "token": { + "ref": "target.token.id" + } + } + } + ] +} diff --git a/TaskAutomation/examples/torch-reminder.json b/TaskAutomation/examples/torch-reminder.json new file mode 100644 index 0000000000..9e31381d61 --- /dev/null +++ b/TaskAutomation/examples/torch-reminder.json @@ -0,0 +1,62 @@ +{ + "schemaVersion": 1, + "id": "torch-reminder", + "revision": 1, + "name": "Torch reminder", + "description": "Warn the torchbearer after 50 real-world minutes and put the torch out 10 minutes later. Real minutes, not game time.", + "enabled": true, + "parameters": {}, + "bindings": { + "torchbearer": { + "from": "selected", + "objectType": "graphic", + "max": 1 + } + }, + "access": { + "mode": "gmOnly", + "authority": "initiator" + }, + "triggers": [ + { + "id": "manual", + "type": "manual" + } + ], + "policy": { + "concurrency": "allow", + "maxConcurrent": 10, + "onError": "fail", + "restart": "reconcile" + }, + "steps": [ + { + "id": "burn", + "type": "wait", + "durationMs": 3000000, + "overdue": "review" + }, + { + "id": "warn", + "type": "chat.send", + "mode": "whisper", + "to": { + "gm": true, + "controllers": "torchbearer" + }, + "text": "{{target.torchbearer.name}}'s torch is guttering: about 10 minutes of light left." + }, + { + "id": "last-light", + "type": "wait", + "durationMs": 600000, + "overdue": "review" + }, + { + "id": "out", + "type": "chat.send", + "mode": "say", + "text": "{{target.torchbearer.name}}'s torch burns out." + } + ] +} diff --git a/TaskAutomation/examples/turn-response.json b/TaskAutomation/examples/turn-response.json new file mode 100644 index 0000000000..03c57453cb --- /dev/null +++ b/TaskAutomation/examples/turn-response.json @@ -0,0 +1,90 @@ +{ + "schemaVersion": 1, + "id": "turn-response", + "revision": 1, + "name": "Turn response", + "description": "When the current turn changes to a token, wait briefly for an optional ready signal, then ping it for the GM.", + "enabled": false, + "parameters": {}, + "bindings": { + "token": { + "from": "event", + "objectType": "graphic", + "max": 1, + "min": 0 + } + }, + "access": { + "mode": "gmOnly", + "authority": "gmService" + }, + "triggers": [ + { + "id": "turn", + "type": "event", + "event": "turn.change", + "bind": { + "binding": "token" + } + } + ], + "policy": { + "concurrency": "replace", + "maxConcurrent": 1, + "onError": "skip" + }, + "steps": [ + { + "id": "has-token", + "type": "if", + "condition": { + "op": "gt", + "left": { + "ref": "target.token.count" + }, + "right": 0 + }, + "then": [ + { + "id": "ready", + "type": "awaitEvent", + "event": "signal", + "filter": { + "signal": "turn-ready" + }, + "timeoutMs": 15000 + }, + { + "id": "ping", + "type": "ping", + "target": { + "binding": "token" + }, + "to": { + "gm": true + } + }, + { + "id": "notify", + "type": "chat.send", + "mode": "whisper", + "to": { + "gm": true + }, + "text": "It is {{target.token.name}}'s turn." + } + ], + "else": [ + { + "id": "notify-custom", + "type": "chat.send", + "mode": "whisper", + "to": { + "gm": true + }, + "text": "Turn: {{event.name}}" + } + ] + } + ] +} diff --git a/TaskAutomation/scripts/write-examples.mjs b/TaskAutomation/scripts/write-examples.mjs new file mode 100644 index 0000000000..8f97c918b6 --- /dev/null +++ b/TaskAutomation/scripts/write-examples.mjs @@ -0,0 +1,21 @@ +/** + * Writes each template in src/templates.js to examples/.json, so the + * worked examples can be imported with `definition import --json`. + * + * Usage: npm run examples + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { TEMPLATES } from '../src/templates.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const dir = path.join(__dirname, '..', 'examples'); + +fs.mkdirSync(dir, { recursive: true }); +for (const tpl of TEMPLATES) { + fs.writeFileSync(path.join(dir, `${tpl.id}.json`), `${JSON.stringify(tpl, null, 2)}\n`); + console.log(`Wrote examples/${tpl.id}.json`); +} diff --git a/TaskAutomation/src/actions.js b/TaskAutomation/src/actions.js new file mode 100644 index 0000000000..c0d7bd3cc5 --- /dev/null +++ b/TaskAutomation/src/actions.js @@ -0,0 +1,738 @@ +// Built-in actions (plan §4.4). Each action validates live values, checks +// authority for every object it touches, records what it intends before +// writing, and reports a result. Flow steps live in executor.js. + +import { + ERROR_CODES, + FX_COLORS, + FX_TYPES, + LAYERS, + SCRIPT_NAME, + TOKEN_GM_PROPERTIES, + TOKEN_WRITE_PROPERTIES, +} from './constants.js'; +import { taskError } from './errors.js'; +import { MISSING } from './expressions.js'; +import { + chat, + createObject, + findObjects, + getObject, + gmPlayerIds, + isGm, + now, + ping, + playerName, + spawnEffect, +} from './host.js'; +import { getAdapter } from './integrations.js'; +import { + characterOf, + currentSideIndex, + findAttribute, + readProperty, + resolveRecipients, + snapshot, + tokenSides, + writableImage, +} from './objects.js'; +import { getLimits } from './state.js'; +import { + clamp, + escapeHtml, + formatMarkers, + neutraliseChatText, + parseMarkers, + parseStrictNumber, + toText, +} from './utils.js'; + +const rateWindows = new Map(); + +/** + * Applies a rolling per-minute limit. + * + * @param {string} key Limit key. + * @param {number} max Maximum per minute. + * @returns {void} + * @throws {Error} resourceLimit when exceeded. + */ +function rateLimit(key, max) { + const at = now(); + const window = rateWindows.get(key) || { start: at, count: 0 }; + if (at - window.start > 60000) { + window.start = at; + window.count = 0; + } + window.count += 1; + rateWindows.set(key, window); + if (window.count > max) { + throw taskError(ERROR_CODES.RESOURCE_LIMIT, 'rateLimited', { what: key.split(':')[0], max }); + } +} + +/** + * Returns true when a run acts with GM authority. + * + * @param {object} inst Instance. + * @returns {boolean} True for GM-service runs and runs a GM started. + */ +function hasGmAuthority(inst) { + return inst.authority === 'gmService' || isGm(inst.initiatorPlayerId); +} + +/** + * Evaluates an expression and rejects missing values. + * + * @param {object} ctx Action context. + * @param {*} expr Expression. + * @param {string} field Field name for the error. + * @returns {*} Value. + */ +function required(ctx, expr, field) { + const value = ctx.evaluate(expr); + if (value === MISSING || value === undefined) { + throw taskError(ERROR_CODES.VALIDATION, 'valueMissing', { field }); + } + return value; +} + +/** + * Iterates targets in bounded chunks. Targets already handled by an earlier + * chunk of the same attempt are skipped. + * + * @param {object} ctx Action context. + * @param {{id: string, obj: object, type: string}[]} targets Targets. + * @param {(target: object) => void} fn Per-target work. + * @returns {{done: string[], partial: boolean}} Progress. + */ +function forTargets(ctx, targets, fn) { + const done = new Set(ctx.progress || []); + let writes = 0; + for (const target of targets) { + if (done.has(target.id)) { + continue; + } + if (writes >= getLimits().targetsPerChunk) { + return { done: [...done], partial: true }; + } + fn(target); + done.add(target.id); + writes += 1; + } + return { done: [...done], partial: false }; +} + +/** + * Writes token properties and announces the change internally, since + * Roll20 does not raise change events for API writes. + * + * @param {object} ctx Action context. + * @param {object} obj Roll20 graphic. + * @param {object} changes Properties to set. + * @returns {void} + */ +function writeToken(ctx, obj, changes) { + const before = snapshot(obj, 'graphic'); + obj.set(changes); + ctx.noteWrite(obj.id, changes); + ctx.emit({ + type: 'token.change', + objectType: 'graphic', + objectId: obj.id, + old: before, + new: snapshot(obj, 'graphic'), + pageId: obj.get('_pageid'), + }); +} + +/** + * Converts a value for a token property. + * + * @param {string} prop Property. + * @param {*} value Value. + * @param {object} inst Instance. + * @returns {*} Converted value. + */ +function tokenValue(prop, value, inst) { + const kind = TOKEN_WRITE_PROPERTIES[prop]; + if (TOKEN_GM_PROPERTIES.has(prop) && !hasGmAuthority(inst)) { + throw taskError(ERROR_CODES.UNAUTHORIZED, 'gmProperty', { property: prop }); + } + switch (kind) { + case 'number': { + const n = parseStrictNumber(value); + if (n === null) { + throw taskError(ERROR_CODES.VALIDATION, 'notNumber', { field: prop }); + } + return n; + } + case 'bar': + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.length <= 100) { + return value; + } + throw taskError(ERROR_CODES.VALIDATION, 'badValue', { field: prop }); + case 'layer': + if (!LAYERS.includes(value)) { + throw taskError(ERROR_CODES.VALIDATION, 'badValue', { field: prop }); + } + return value; + case 'color': + if ( + typeof value === 'string' && + (/^#[0-9a-fA-F]{6}$/.test(value) || value === 'transparent') + ) { + return value; + } + throw taskError(ERROR_CODES.VALIDATION, 'badValue', { field: prop }); + case 'boolean': + if (typeof value !== 'boolean') { + throw taskError(ERROR_CODES.VALIDATION, 'badValue', { field: prop }); + } + return value; + case 'string': + if (typeof value !== 'string' || value.length > 200) { + throw taskError(ERROR_CODES.VALIDATION, 'badValue', { field: prop }); + } + return value; + default: + throw taskError(ERROR_CODES.VALIDATION, 'badValue', { field: prop }); + } +} + +/** + * Returns the characters for a set of targets (characters, or tokens' + * represented characters). + * + * @param {object[]} targets Targets. + * @returns {{id: string, obj: object, type: string}[]} Characters. + */ +function characterTargets(targets) { + const out = []; + for (const target of targets) { + const character = characterOf(target.obj, target.type); + if (!character) { + throw taskError(ERROR_CODES.MISSING_TARGET, 'noCharacter', { id: target.id }); + } + if (!out.some((c) => c.id === character.id)) { + out.push({ id: character.id, obj: character, type: 'character' }); + } + } + return out; +} + +/** + * Returns the pixel position and page of the first target, or of `at`. + * + * @param {object} ctx Action context. + * @param {object} step Step. + * @returns {{left: number, top: number, pageId: string}} Location. + */ +function locate(ctx, step) { + if (step.target) { + const [target] = ctx.targets(step.target, 'graphic'); + return { + left: Number(target.obj.get('left')), + top: Number(target.obj.get('top')), + pageId: target.obj.get('_pageid'), + }; + } + const left = parseStrictNumber(required(ctx, step.at.left, 'at.left')); + const top = parseStrictNumber(required(ctx, step.at.top, 'at.top')); + const pageId = toText(required(ctx, step.at.page, 'at.page')); + if (left === null || top === null || !getObject('page', pageId)) { + throw taskError(ERROR_CODES.MISSING_TARGET, 'badLocation', {}); + } + return { left, top, pageId }; +} + +/** + * Formats a chat speaker name. + * + * @param {object} step Step. + * @returns {string} Speaker. + */ +function speaker(step) { + return ( + neutraliseChatText(step.as || SCRIPT_NAME) + .replaceAll('|', '') + .slice(0, 60) || SCRIPT_NAME + ); +} + +export const ACTIONS = { + 'chat.send'(step, ctx) { + rateLimit(`chat:${ctx.inst.definitionId}`, getLimits().chatPerMinute); + const text = neutraliseChatText( + ctx.render(step.text, (v) => escapeHtml(neutraliseChatText(v))) + ); + if (!text) { + throw taskError(ERROR_CODES.VALIDATION, 'emptyMessage', {}); + } + const mode = step.mode || (step.to ? 'whisper' : 'say'); + const who = speaker(step); + if (mode === 'whisper') { + const recipients = resolveRecipients(step.to, ctx.inst); + if (!recipients.gm && recipients.players.length === 0) { + throw taskError(ERROR_CODES.MISSING_TARGET, 'noRecipients', {}); + } + if (recipients.gm) { + chat(who, `/w gm ${text}`); + } + for (const playerId of recipients.players) { + chat(who, `/w "${playerName(playerId).replaceAll('"', '')}" ${text}`); + } + return { result: { sent: recipients.players.length + (recipients.gm ? 1 : 0) } }; + } + const prefix = { say: '', emote: '/em ', desc: '/desc ' }[mode]; + if (mode === 'desc' && !hasGmAuthority(ctx.inst)) { + throw taskError(ERROR_CODES.UNAUTHORIZED, 'gmOnlyMode', { mode }); + } + chat(who, `${prefix}${text}`); + return { result: { sent: 1 } }; + }, + + 'chat.command'(step, ctx) { + if (!hasGmAuthority(ctx.inst)) { + throw taskError(ERROR_CODES.UNAUTHORIZED, 'gmOnlyAction', { type: step.type }); + } + rateLimit(`chat:${ctx.inst.definitionId}`, getLimits().chatPerMinute); + const text = ctx + .render(step.text, (v) => escapeHtml(neutraliseChatText(v))) + .replaceAll(/[\r\n]+/g, ' ') + .trim(); + if (!text || text.startsWith('!')) { + throw taskError(ERROR_CODES.VALIDATION, 'badCommand', {}); + } + chat(speaker(step), text); + return { result: { sent: 1 } }; + }, + + 'token.set'(step, ctx) { + const targets = ctx.targets(step.target, 'graphic'); + ctx.authorise(targets.map((t) => t.id)); + const changes = {}; + for (const [prop, expr] of Object.entries(step.set)) { + changes[prop] = tokenValue(prop, required(ctx, expr, prop), ctx.inst); + } + const expect = {}; + for (const [prop, expr] of Object.entries(step.expect || {})) { + expect[prop] = ctx.evaluate(expr); + } + ctx.prepare({ set: changes }); + const progress = forTargets(ctx, targets, (target) => { + for (const [prop, value] of Object.entries(expect)) { + const current = readProperty(target.obj, 'graphic', prop); + if (!current.found || current.value !== value) { + throw taskError(ERROR_CODES.CONFLICT, 'expectedValue', { id: target.id, property: prop }); + } + } + writeToken(ctx, target.obj, changes); + }); + return { result: { changed: progress.done.length }, ...progress }; + }, + + 'token.get'(step, ctx) { + const [target] = ctx.targets(step.target, 'graphic'); + const value = readProperty(target.obj, 'graphic', step.property); + return { result: value.found ? value.value : null }; + }, + + 'token.advanceSide'(step, ctx) { + const targets = ctx.targets(step.target, 'graphic'); + ctx.authorise(targets.map((t) => t.id)); + const delta = step.delta ?? 1; + const wrap = step.wrap !== false; + const plan = {}; + for (const target of targets) { + const sides = tokenSides(target.obj); + if (sides.length < 2) { + throw taskError(ERROR_CODES.VALIDATION, 'notMultiSided', { id: target.id }); + } + const current = currentSideIndex(target.obj); + const dest = wrap + ? (((current + delta) % sides.length) + sides.length) % sides.length + : clamp(current + delta, 0, sides.length - 1); + const image = writableImage(sides[dest]); + if (!image) { + throw taskError( + ERROR_CODES.UNAVAILABLE, + 'sideImageUnsupported', + { id: target.id, side: dest + 1 }, + { transient: false } + ); + } + plan[target.id] = { from: current, to: dest, image }; + } + // Persist the destination before writing so a restart can reconcile it. + ctx.prepare({ + resolvedSide: Object.fromEntries(Object.entries(plan).map(([id, p]) => [id, p.to])), + }); + const progress = forTargets(ctx, targets, (target) => { + const p = plan[target.id]; + writeToken(ctx, target.obj, { currentSide: p.to, imgsrc: p.image }); + }); + return { + result: { sides: Object.fromEntries(Object.entries(plan).map(([id, p]) => [id, p.to + 1])) }, + ...progress, + }; + }, + + 'token.setSide'(step, ctx) { + const targets = ctx.targets(step.target, 'graphic'); + ctx.authorise(targets.map((t) => t.id)); + const side = parseStrictNumber(required(ctx, step.side, 'side')); + const plan = {}; + for (const target of targets) { + const sides = tokenSides(target.obj); + if (!Number.isInteger(side) || side < 1 || side > sides.length) { + throw taskError(ERROR_CODES.VALIDATION, 'sideOutOfRange', { + id: target.id, + max: sides.length, + }); + } + const image = writableImage(sides[side - 1]); + if (!image) { + throw taskError( + ERROR_CODES.UNAVAILABLE, + 'sideImageUnsupported', + { id: target.id, side }, + { transient: false } + ); + } + plan[target.id] = image; + } + ctx.prepare({ resolvedSide: Object.fromEntries(targets.map((t) => [t.id, side - 1])) }); + const progress = forTargets(ctx, targets, (target) => { + writeToken(ctx, target.obj, { currentSide: side - 1, imgsrc: plan[target.id] }); + }); + return { result: { side }, ...progress }; + }, + + 'attribute.set'(step, ctx) { + const characters = characterTargets(ctx.targets(step.target, 'character')); + ctx.authorise(characters.map((c) => c.id)); + const field = step.field || 'current'; + const value = required(ctx, step.value, 'value'); + if (typeof value !== 'number' && typeof value !== 'string') { + throw taskError(ERROR_CODES.VALIDATION, 'badValue', { field: 'value' }); + } + const expect = step.expect === undefined ? MISSING : ctx.evaluate(step.expect); + const old = {}; + for (const c of characters) { + const attr = findAttribute(c.id, step.attribute); + if (!attr && !step.create) { + throw taskError(ERROR_CODES.MISSING_TARGET, 'attributeMissing', { + id: c.id, + attribute: step.attribute, + }); + } + old[c.id] = attr ? attr.get(field) : null; + if (expect !== MISSING && String(old[c.id]) !== String(expect)) { + throw taskError(ERROR_CODES.CONFLICT, 'expectedValue', { + id: c.id, + property: step.attribute, + }); + } + } + ctx.prepare({ attribute: step.attribute, field, old, value }); + const progress = forTargets(ctx, characters, (c) => { + let attr = findAttribute(c.id, step.attribute); + if (!attr) { + attr = createObject('attribute', { + characterid: c.id, + name: step.attribute, + [field]: value, + }); + } else { + setAttribute(attr, field, value, step.useWorker); + } + ctx.emit({ + type: 'attribute.change', + objectType: 'attribute', + objectId: attr?.id, + characterId: c.id, + name: step.attribute, + old: { [field]: old[c.id] }, + new: { [field]: value }, + }); + }); + return { result: { value }, ...progress }; + }, + + 'attribute.adjust'(step, ctx) { + const characters = characterTargets(ctx.targets(step.target, 'character')); + ctx.authorise(characters.map((c) => c.id)); + const field = step.field || 'current'; + const by = parseStrictNumber(required(ctx, step.by, 'by')); + if (by === null) { + throw taskError(ERROR_CODES.VALIDATION, 'notNumber', { field: 'by' }); + } + const plan = {}; + for (const c of characters) { + const attr = findAttribute(c.id, step.attribute); + if (!attr) { + throw taskError(ERROR_CODES.MISSING_TARGET, 'attributeMissing', { + id: c.id, + attribute: step.attribute, + }); + } + const oldValue = parseStrictNumber(attr.get(field)); + if (oldValue === null) { + throw taskError(ERROR_CODES.VALIDATION, 'notNumeric', { attribute: step.attribute }); + } + let next = oldValue + by; + if (Number.isFinite(step.min)) { + next = Math.max(step.min, next); + } + if (Number.isFinite(step.max)) { + next = Math.min(step.max, next); + } + plan[c.id] = { attrId: attr.id, old: oldValue, new: next }; + } + // Old and new values are recorded first: a restart compares, it never adds twice. + ctx.prepare({ attribute: step.attribute, field, plan }); + const progress = forTargets(ctx, characters, (c) => { + const attr = getObject('attribute', plan[c.id].attrId); + setAttribute(attr, field, plan[c.id].new, step.useWorker); + ctx.emit({ + type: 'attribute.change', + objectType: 'attribute', + objectId: attr.id, + characterId: c.id, + name: step.attribute, + old: { [field]: plan[c.id].old }, + new: { [field]: plan[c.id].new }, + }); + }); + return { + result: { values: Object.fromEntries(Object.entries(plan).map(([id, p]) => [id, p.new])) }, + ...progress, + }; + }, + + 'attribute.get'(step, ctx) { + const [character] = characterTargets(ctx.targets(step.target, 'character')); + const attr = findAttribute(character.id, step.attribute); + return { result: attr ? attr.get(step.field || 'current') : null }; + }, + + 'marker.set'(step, ctx) { + const targets = ctx.targets(step.target, 'graphic'); + ctx.authorise(targets.map((t) => t.id)); + if (!/^[A-Za-z0-9_:.-]{1,80}$/.test(step.marker)) { + throw taskError(ERROR_CODES.VALIDATION, 'badMarker', { marker: step.marker }); + } + const op = step.op || 'add'; + let count = null; + if (step.count !== undefined) { + count = parseStrictNumber(required(ctx, step.count, 'count')); + if (count === null || !Number.isInteger(count)) { + throw taskError(ERROR_CODES.VALIDATION, 'notNumber', { field: 'count' }); + } + } + ctx.prepare({ marker: step.marker, op, count }); + const progress = forTargets(ctx, targets, (target) => { + const markers = parseMarkers(target.obj.get('statusmarkers')); + const existing = markers.find((m) => m.tag === step.marker); + const others = markers.filter((m) => m.tag !== step.marker); + let next = existing || null; + switch (op) { + case 'add': + next = existing || { + tag: step.marker, + count: count !== null ? clamp(count, 0, 9) : null, + }; + break; + case 'remove': + next = null; + break; + case 'toggle': + next = existing ? null : { tag: step.marker, count: null }; + break; + case 'set': + next = + count !== null && count <= 0 + ? null + : { tag: step.marker, count: count === null ? null : clamp(count, 1, 9) }; + break; + case 'adjust': { + const value = (existing?.count ?? 0) + (count ?? 1); + next = value <= 0 ? null : { tag: step.marker, count: clamp(value, 1, 9) }; + break; + } + default: + break; + } + writeToken(ctx, target.obj, { + statusmarkers: formatMarkers(next ? [...others, next] : others), + }); + }); + return { result: { changed: progress.done.length }, ...progress }; + }, + + ping(step, ctx) { + const where = locate(ctx, step); + if (step.moveView && !hasGmAuthority(ctx.inst)) { + throw taskError(ERROR_CODES.UNAUTHORIZED, 'gmOnlyMode', { mode: 'moveView' }); + } + let visibleTo; + if (step.to) { + const recipients = resolveRecipients(step.to, ctx.inst); + visibleTo = [...recipients.players, ...(recipients.gm ? gmPlayerIds() : [])]; + if (!visibleTo.length) { + throw taskError(ERROR_CODES.MISSING_TARGET, 'noRecipients', {}); + } + } + ping(where.left, where.top, where.pageId, null, Boolean(step.moveView), visibleTo); + return { result: { pinged: true } }; + }, + + 'effect.spawn'(step, ctx) { + rateLimit(`effects:${ctx.inst.definitionId}`, 30); + const where = locate(ctx, step); + const [type, color] = String(step.fx).split('-'); + const spec = { left: where.left, top: where.top, pageId: where.pageId }; + if (FX_TYPES.includes(type) && FX_COLORS.includes(color)) { + spec.fx = step.fx; + } else if (['beam', 'breath', 'splatter'].includes(type) && FX_COLORS.includes(color)) { + spec.fx = step.fx; + } else { + // Custom effects are GM-approved definitions only. + if (!hasGmAuthority(ctx.inst)) { + throw taskError(ERROR_CODES.UNAUTHORIZED, 'customFx', { fx: step.fx }); + } + const custom = findObjects({ _type: 'custfx', name: step.fx })[0]; + if (!custom) { + throw taskError(ERROR_CODES.MISSING_TARGET, 'fxMissing', { fx: step.fx }); + } + spec.definition = custom.get('definition'); + } + if (step.to) { + const left = parseStrictNumber(required(ctx, step.to.left, 'to.left')); + const top = parseStrictNumber(required(ctx, step.to.top, 'to.top')); + if (left === null || top === null) { + throw taskError(ERROR_CODES.VALIDATION, 'badLocation', {}); + } + spec.to = { left, top }; + } + spawnEffect(spec); + return { result: { spawned: true } }; + }, + + 'audio.control'(step, ctx) { + if (!hasGmAuthority(ctx.inst)) { + throw taskError(ERROR_CODES.UNAUTHORIZED, 'gmOnlyAction', { type: step.type }); + } + const name = ctx.render(step.track, (v) => v).trim(); + const track = + getObject('jukeboxtrack', name) || + findObjects({ _type: 'jukeboxtrack', title: name })[0] || + null; + if (!track) { + throw taskError(ERROR_CODES.MISSING_TARGET, 'trackMissing', { track: name }); + } + const changes = {}; + if (step.op === 'play') { + Object.assign(changes, { playing: true, softstop: false }); + if (typeof step.loop === 'boolean') { + changes.loop = step.loop; + } + } else if (step.op === 'stop') { + Object.assign(changes, { playing: false, softstop: false }); + } + if (step.op === 'volume' || step.volume !== undefined) { + const volume = parseStrictNumber(required(ctx, step.volume, 'volume')); + if (volume === null) { + throw taskError(ERROR_CODES.VALIDATION, 'notNumber', { field: 'volume' }); + } + changes.volume = clamp(Math.round(volume), 0, 100); + } + ctx.prepare({ track: track.id, changes }); + track.set(changes); + return { result: { track: track.id, ...changes } }; + }, + + 'integration.command'(step, ctx) { + if (!hasGmAuthority(ctx.inst)) { + throw taskError(ERROR_CODES.UNAUTHORIZED, 'gmOnlyAction', { type: step.type }); + } + rateLimit(`commands:${ctx.inst.definitionId}`, getLimits().chatPerMinute); + const args = {}; + for (const [name, expr] of Object.entries(step.args || {})) { + args[name] = required(ctx, expr, `args.${name}`); + } + const command = ctx.render( + step.command, + (value) => { + // Exact argument encoding: only plain tokens may be inserted, so a + // value can never add flags, commands or line breaks. + if (!/^[A-Za-z0-9_\-.:#@+]*$/.test(value)) { + throw taskError(ERROR_CODES.VALIDATION, 'unsafeArgument', {}); + } + return value; + }, + args + ); + if (!/^![A-Za-z0-9_-]+/.test(command) || /[\r\n]/.test(command)) { + throw taskError(ERROR_CODES.VALIDATION, 'badCommand', {}); + } + chat(SCRIPT_NAME, command); + // Dispatch is not proof that the other Mod did anything. + return { result: { dispatched: true }, outcome: 'dispatched' }; + }, + + 'integration.call'(step, ctx) { + const adapter = getAdapter(step.adapter); + if (!adapter) { + throw taskError( + ERROR_CODES.UNAVAILABLE, + 'adapterUnavailable', + { adapter: step.adapter }, + { transient: false } + ); + } + const action = adapter.actions[step.action]; + if (!action) { + throw taskError( + ERROR_CODES.UNAVAILABLE, + 'adapterAction', + { adapter: step.adapter, action: step.action }, + { transient: false } + ); + } + if (adapter.authority !== 'any' && !hasGmAuthority(ctx.inst)) { + throw taskError(ERROR_CODES.UNAUTHORIZED, 'adapterAuthority', { adapter: step.adapter }); + } + const input = {}; + for (const [name, expr] of Object.entries(step.input || {})) { + const value = ctx.evaluate(expr); + input[name] = value === MISSING ? null : value; + } + ctx.prepare({ adapter: step.adapter, action: step.action }); + return { async: { adapter, action, input, timeoutMs: step.timeoutMs || action.timeoutMs } }; + }, +}; + +/** + * Sets an attribute, through sheet workers when asked and supported. + * + * @param {object} attr Roll20 attribute. + * @param {string} field current or max. + * @param {*} value Value. + * @param {boolean} useWorker Trigger sheet workers. + * @returns {void} + */ +function setAttribute(attr, field, value, useWorker) { + if (useWorker) { + if (typeof attr.setWithWorker !== 'function') { + throw taskError(ERROR_CODES.UNAVAILABLE, 'workerUnavailable', {}, { transient: false }); + } + attr.setWithWorker({ [field]: value }); + return; + } + attr.set(field, value); +} diff --git a/TaskAutomation/src/audit.js b/TaskAutomation/src/audit.js new file mode 100644 index 0000000000..29eb1415fb --- /dev/null +++ b/TaskAutomation/src/audit.js @@ -0,0 +1,231 @@ +// Bounded audit trail, terminal-run history and missed-work incidents +// (plan §7.2, §9). Limits trim the oldest records; unresolved incident +// counts are aggregated, never dropped. + +import { now } from './host.js'; +import { maybeCall } from './runtime.js'; +import { getLimits, getState, nextId } from './state.js'; +import { truncate } from './utils.js'; + +const MAX_RESOLVED_INCIDENTS = 200; + +/** + * Appends an audit entry. + * + * @param {string} kind Entry kind (e.g. `run.start`, `definition.publish`). + * @param {object} [detail] Short, redacted detail. + * @returns {void} + */ +export function audit(kind, detail = {}) { + const s = getState(); + const entry = { at: now(), kind }; + for (const [key, value] of Object.entries(detail)) { + if (value !== undefined && value !== null) { + entry[key] = typeof value === 'string' ? truncate(value, 200) : value; + } + } + s.audit.push(entry); + const max = getLimits().auditEntries; + if (s.audit.length > max) { + s.audit.splice(0, s.audit.length - max); + } +} + +/** + * Records the summary of a finished run and trims history. + * + * @param {object} summary Run summary. + * @returns {void} + */ +export function recordHistory(summary) { + const s = getState(); + s.history.push(summary); + const max = getLimits().historyRuns; + if (s.history.length > max) { + s.history.splice(0, s.history.length - max); + } + maybeCall('dashboardDirty', 'history'); +} + +/** + * Bumps a statistics counter. + * + * @param {string} key Counter. + * @param {number} [by=1] Amount. + * @returns {void} + */ +export function count(key, by = 1) { + const stats = getState().stats; + stats[key] = (stats[key] || 0) + by; +} + +/** + * Returns open incidents (open or acknowledged), newest first. + * + * @returns {object[]} Incidents. + */ +export function openIncidents() { + return Object.values(getState().incidents) + .filter((i) => i.status !== 'resolved') + .sort((a, b) => b.createdAt - a.createdAt); +} + +/** + * Creates a missed-work or review incident, merging it into an open + * incident of the same group so repeats do not flood the dashboard. + * + * @param {object} spec Incident fields. + * @param {string} spec.kind missed | review | uncertain | gap | security | circuit | adapter | fatal. + * @param {string} spec.cause Cause key (downtime, pause, concurrency, expiredQueue, …). + * @param {object} [spec.range] {from, to} epoch range. + * @param {number} [spec.count] Occurrences affected. + * @param {string} [spec.group] Merge key; defaults to kind + target + cause. + * @returns {object} The incident. + */ +export function createIncident(spec) { + const s = getState(); + const at = now(); + const group = + spec.group || + [spec.kind, spec.scheduleId || spec.runId || spec.definitionId || '', spec.cause].join(':'); + const existing = Object.values(s.incidents).find((i) => i.status === 'open' && i.group === group); + if (existing) { + existing.count += spec.count ?? 1; + if (spec.range) { + existing.range = { + from: Math.min(existing.range?.from ?? spec.range.from, spec.range.from), + to: Math.max(existing.range?.to ?? spec.range.to, spec.range.to), + }; + } + existing.updatedAt = at; + if (spec.proposal) { + existing.proposal = spec.proposal; + } + maybeCall('dashboardDirty', 'incidents'); + return existing; + } + const open = Object.values(s.incidents).filter((i) => i.status !== 'resolved'); + if (open.length >= getLimits().openIncidents) { + return mergeOverflow(spec, at); + } + const incident = { + id: nextId('incident'), + group, + kind: spec.kind, + cause: spec.cause, + evidence: spec.evidence ? truncate(spec.evidence, 300) : null, + range: spec.range || null, + count: spec.count ?? 1, + definitionId: spec.definitionId || null, + scheduleId: spec.scheduleId || null, + runId: spec.runId || null, + policy: spec.policy || null, + proposal: spec.proposal || null, + status: spec.status || 'open', + createdAt: at, + updatedAt: at, + resolution: null, + }; + s.incidents[incident.id] = incident; + audit('incident.create', { incident: incident.id, cause: incident.cause, count: incident.count }); + pruneResolved(); + maybeCall('dashboardDirty', 'incidents'); + return incident; +} + +/** + * Folds an incident into the overflow aggregate when the open limit is hit. + * + * @param {object} spec Incident fields. + * @param {number} at Current time. + * @returns {object} Overflow incident. + */ +function mergeOverflow(spec, at) { + const s = getState(); + let overflow = Object.values(s.incidents).find( + (i) => i.group === 'overflow' && i.status === 'open' + ); + if (!overflow) { + // Make room by folding the oldest open incident into the new aggregate. + const oldest = Object.values(s.incidents) + .filter((i) => i.status === 'open') + .sort((a, b) => a.createdAt - b.createdAt)[0]; + overflow = { + id: nextId('incident'), + group: 'overflow', + kind: 'missed', + cause: 'overflow', + evidence: null, + range: null, + count: 0, + status: 'open', + createdAt: at, + updatedAt: at, + resolution: null, + }; + if (oldest) { + overflow.count += oldest.count; + delete s.incidents[oldest.id]; + } + s.incidents[overflow.id] = overflow; + } + overflow.count += spec.count ?? 1; + overflow.updatedAt = at; + return overflow; +} + +/** + * Marks an incident acknowledged. Acknowledging does not run anything. + * + * @param {string} id Incident ID. + * @param {string} by Player ID. + * @returns {object|null} Incident. + */ +export function acknowledgeIncident(id, by) { + const incident = getState().incidents[id]; + if (!incident || incident.status === 'resolved') { + return null; + } + incident.status = 'acknowledged'; + incident.updatedAt = now(); + audit('incident.acknowledge', { incident: id, by }); + maybeCall('dashboardDirty', 'incidents'); + return incident; +} + +/** + * Resolves an incident with an audited decision. + * + * @param {string} id Incident ID. + * @param {string} decision What was done. + * @param {string|null} by Player ID (null for automatic). + * @returns {object|null} Incident. + */ +export function resolveIncident(id, decision, by) { + const incident = getState().incidents[id]; + if (!incident || incident.status === 'resolved') { + return null; + } + incident.status = 'resolved'; + incident.resolution = { decision, by: by || null, at: now() }; + incident.updatedAt = now(); + audit('incident.resolve', { incident: id, decision, by }); + pruneResolved(); + maybeCall('dashboardDirty', 'incidents'); + return incident; +} + +/** + * Removes the oldest resolved incidents beyond the retention limit. + * + * @returns {void} + */ +function pruneResolved() { + const s = getState(); + const resolved = Object.values(s.incidents) + .filter((i) => i.status === 'resolved') + .sort((a, b) => a.updatedAt - b.updatedAt); + for (const incident of resolved.slice(0, Math.max(0, resolved.length - MAX_RESOLVED_INCIDENTS))) { + delete s.incidents[incident.id]; + } +} diff --git a/TaskAutomation/src/catalogue.js b/TaskAutomation/src/catalogue.js new file mode 100644 index 0000000000..2ef3e7b103 --- /dev/null +++ b/TaskAutomation/src/catalogue.js @@ -0,0 +1,325 @@ +// Declarative description of every step type: its fields, replay class and +// who may use it (plan §4.4). The executor (actions.js) supplies behaviour; +// the validator (schema.js) uses these specs. Pure: no Roll20 globals. + +import { REPLAY } from './constants.js'; + +// Field kinds: +// expr a safe expression (expressions.js) +// template text with {{placeholders}} +// string literal text +// id literal identifier text +// number literal finite number +// integer literal whole number +// boolean literal true/false +// enum one of `values` +// target {binding} | {ids: [...]} | {ref} +// exprMap object of name -> expression +// recipients {gm: true} | {players: [...]} | {binding} | {controllers: binding} +// point {left, top, page} expressions +// steps a nested block +// any plain data checked by the action itself + +/** + * Builds a field spec. + * + * @param {string} kind Field kind. + * @param {object} [extra] Extra options (required, values, min, max, default). + * @returns {object} Field spec. + */ +const f = (kind, extra = {}) => ({ kind, ...extra }); + +const COMMON = { + id: f('id', { required: true }), + type: f('string', { required: true }), + name: f('string'), + onError: f('enum', { values: ['fail', 'skip', 'retry', 'review'] }), + retry: f('any'), + timeoutMs: f('integer', { min: 100 }), + saveAs: f('id'), +}; + +export const STEP_SPECS = { + // ---- Flow --------------------------------------------------------------- + wait: { + flow: true, + replay: REPLAY.INTERNAL, + fields: { + durationMs: f('expr'), + until: f('expr'), + overdue: f('enum', { values: ['continue', 'review', 'expire', 'skipNext'] }), + }, + }, + awaitEvent: { + flow: true, + replay: REPLAY.INTERNAL, + fields: { + event: f('string', { required: true }), + filter: f('any'), + timeoutMs: f('integer', { required: true, min: 100 }), + }, + }, + if: { + flow: true, + replay: REPLAY.PURE, + fields: { condition: f('expr', { required: true }), then: f('steps'), else: f('steps') }, + }, + loop: { + flow: true, + replay: REPLAY.INTERNAL, + fields: { + mode: f('enum', { required: true, values: ['count', 'while', 'until', 'forever'] }), + count: f('expr'), + condition: f('expr'), + maxIterations: f('integer', { min: 1 }), + steps: f('steps', { required: true }), + }, + }, + call: { + flow: true, + replay: REPLAY.INTERNAL, + fields: { + definition: f('id', { required: true }), + revision: f('integer', { min: 1 }), + await: f('boolean'), + params: f('exprMap'), + bindings: f('any'), + onChildFail: f('enum', { values: ['fail', 'continue'] }), + }, + }, + parallel: { + flow: true, + replay: REPLAY.INTERNAL, + fields: { + join: f('enum', { required: true, values: ['all', 'anySuccess', 'race'] }), + branches: f('any', { required: true }), + cancelLosers: f('boolean'), + merge: f('any'), + }, + }, + stop: { + flow: true, + replay: REPLAY.INTERNAL, + fields: { + outcome: f('enum', { values: ['completed', 'failed', 'cancelled'] }), + message: f('template'), + }, + }, + 'variable.set': { + replay: REPLAY.PURE, + playerSafe: true, + fields: { name: f('id', { required: true }), value: f('expr', { required: true }) }, + }, + 'event.emit': { + replay: REPLAY.INTERNAL, + playerSafe: true, + fields: { signal: f('id', { required: true }), value: f('expr') }, + }, + // ---- Actions ------------------------------------------------------------ + 'chat.send': { + replay: REPLAY.NON_IDEMPOTENT, + playerSafe: true, + fields: { + mode: f('enum', { values: ['say', 'emote', 'desc', 'whisper'] }), + text: f('template', { required: true }), + to: f('recipients'), + as: f('string'), + }, + }, + 'chat.command': { + replay: REPLAY.NON_IDEMPOTENT, + gmOnly: true, + fields: { text: f('template', { required: true }), as: f('string') }, + }, + 'token.set': { + replay: REPLAY.IDEMPOTENT, + playerSafe: true, + fields: { + target: f('target', { required: true }), + set: f('exprMap', { required: true }), + expect: f('exprMap'), + }, + }, + 'token.get': { + replay: REPLAY.PURE, + playerSafe: true, + fields: { target: f('target', { required: true }), property: f('string', { required: true }) }, + }, + 'token.advanceSide': { + replay: REPLAY.NON_IDEMPOTENT, + playerSafe: true, + fields: { target: f('target', { required: true }), delta: f('integer'), wrap: f('boolean') }, + }, + 'token.setSide': { + replay: REPLAY.IDEMPOTENT, + playerSafe: true, + fields: { target: f('target', { required: true }), side: f('expr', { required: true }) }, + }, + 'attribute.set': { + replay: REPLAY.IDEMPOTENT, + fields: { + target: f('target', { required: true }), + attribute: f('string', { required: true }), + field: f('enum', { values: ['current', 'max'] }), + value: f('expr', { required: true }), + expect: f('expr'), + create: f('boolean'), + useWorker: f('boolean'), + }, + }, + 'attribute.adjust': { + replay: REPLAY.NON_IDEMPOTENT, + fields: { + target: f('target', { required: true }), + attribute: f('string', { required: true }), + field: f('enum', { values: ['current', 'max'] }), + by: f('expr', { required: true }), + min: f('number'), + max: f('number'), + useWorker: f('boolean'), + }, + }, + 'attribute.get': { + replay: REPLAY.PURE, + playerSafe: true, + fields: { + target: f('target', { required: true }), + attribute: f('string', { required: true }), + field: f('enum', { values: ['current', 'max'] }), + }, + }, + 'marker.set': { + replay: REPLAY.IDEMPOTENT, + playerSafe: true, + fields: { + target: f('target', { required: true }), + marker: f('string', { required: true }), + op: f('enum', { values: ['add', 'remove', 'toggle', 'set', 'adjust'] }), + count: f('expr'), + }, + }, + ping: { + replay: REPLAY.EPHEMERAL, + playerSafe: true, + fields: { + target: f('target'), + at: f('point'), + to: f('recipients'), + moveView: f('boolean'), + }, + }, + 'effect.spawn': { + replay: REPLAY.EPHEMERAL, + playerSafe: true, + fields: { + target: f('target'), + at: f('point'), + to: f('point'), + fx: f('string', { required: true }), + }, + }, + 'audio.control': { + replay: REPLAY.IDEMPOTENT, + fields: { + track: f('template', { required: true }), + op: f('enum', { required: true, values: ['play', 'stop', 'volume'] }), + volume: f('expr'), + loop: f('boolean'), + }, + }, + 'task.start': { + replay: REPLAY.INTERNAL, + fields: { definition: f('id', { required: true }), params: f('exprMap'), bindings: f('any') }, + }, + 'task.cancel': { + replay: REPLAY.INTERNAL, + playerSafe: true, + fields: { run: f('expr'), scope: f('enum', { values: ['run', 'children'] }) }, + }, + 'task.pause': { + replay: REPLAY.INTERNAL, + playerSafe: true, + fields: { run: f('expr'), scope: f('enum', { values: ['run', 'children'] }) }, + }, + 'integration.command': { + replay: REPLAY.NON_IDEMPOTENT, + gmOnly: true, + fields: { command: f('template', { required: true }), args: f('exprMap') }, + }, + 'integration.call': { + replay: REPLAY.ACKNOWLEDGED, + fields: { + adapter: f('id', { required: true }), + action: f('id', { required: true }), + input: f('exprMap'), + }, + }, +}; + +// Step types that suspend a branch on every pass. +export const SUSPENDING = new Set(['wait', 'awaitEvent']); + +/** + * Returns the full allowed field map of a step type (common + specific). + * + * @param {string} type Step type. + * @returns {object|null} Field specs, or null for unknown types. + */ +export function stepFields(type) { + const spec = STEP_SPECS[type]; + return spec ? { ...COMMON, ...spec.fields } : null; +} + +/** + * Returns the replay class of a step, allowing per-operation refinements + * (marker counters and toggles are non-idempotent). + * + * @param {object} step Step. + * @param {object} [adapter] Integration adapter for integration.call. + * @returns {string} Replay class. + */ +export function replayClass(step, adapter) { + if (step.type === 'marker.set' && ['toggle', 'adjust'].includes(step.op)) { + return REPLAY.NON_IDEMPOTENT; + } + if (step.type === 'integration.call' && adapter?.replay) { + return adapter.replay; + } + return STEP_SPECS[step.type]?.replay ?? REPLAY.NON_IDEMPOTENT; +} + +export const EVENT_TYPES = [ + 'token.add', + 'token.change', + 'token.destroy', + 'attribute.add', + 'attribute.change', + 'attribute.destroy', + 'turn.change', + 'turn.round', + 'turnorder.edit', + 'tracker.open', + 'tracker.close', + 'page.change', + 'player.online', + 'player.offline', + 'signal', + 'chat', + 'task.started', + 'task.completed', + 'task.failed', + 'task.cancelled', + 'integration', +]; + +export const TRIGGER_TYPES = [ + 'manual', + 'at', + 'interval', + 'daily', + 'weekly', + 'event', + 'signal', + 'chat', +]; +export const TIME_TRIGGER_TYPES = new Set(['at', 'interval', 'daily', 'weekly']); diff --git a/TaskAutomation/src/chat.js b/TaskAutomation/src/chat.js new file mode 100644 index 0000000000..d9d3ea485e --- /dev/null +++ b/TaskAutomation/src/chat.js @@ -0,0 +1,241 @@ +import { + COLOR_ACCENT, + COLOR_BORDER, + COLOR_BUTTON_ALT_BG, + COLOR_BUTTON_ALT_BORDER, + COLOR_BUTTON_BG, + COLOR_BUTTON_TEXT, + COLOR_CARD_BG_BOTTOM, + COLOR_CARD_BG_TOP, + COLOR_ERROR_BG, + COLOR_ERROR_BORDER, + COLOR_ERROR_TEXT, + COLOR_HEADER_BG, + COLOR_HEADER_TEXT, + COLOR_MUTED, + COLOR_TEXT, + COLOR_WARNING_BG, + COLOR_WARNING_BORDER, + COLOR_WARNING_TEXT, + SCRIPT_NAME, +} from './constants.js'; +import { chat, getObject } from './host.js'; +import { escapeHtml } from './utils.js'; + +const CHAT_CARD_STYLE = [ + 'width:100%', + 'border-radius:4px', + 'box-shadow:1px 1px 3px rgba(0,0,0,0.4)', + 'text-align:left', + 'margin:2px auto', + `border:1px solid ${COLOR_BORDER}`, + `color:${COLOR_TEXT}`, + `background-image:-webkit-linear-gradient(135deg,${COLOR_CARD_BG_TOP} 0%,${COLOR_CARD_BG_BOTTOM} 100%)`, + `background-color:${COLOR_CARD_BG_BOTTOM}`, + 'overflow:hidden', +].join(';'); + +const CHAT_HEADER_STYLE = [ + `background:${COLOR_HEADER_BG}`, + `color:${COLOR_HEADER_TEXT}`, + 'padding:4px 8px', + `border-bottom:2px solid ${COLOR_ACCENT}`, + 'font-variant:small-caps', + 'font-weight:bold', + 'font-size:1.05em', + 'text-align:center', + 'letter-spacing:0.05em', +].join(';'); + +const CHAT_CONTENT_STYLE = 'padding:5px 8px;line-height:1.5'; + +const BUTTON_STYLE = [ + `background:${COLOR_BUTTON_BG}`, + `color:${COLOR_BUTTON_TEXT}`, + 'padding:2px 7px', + 'border-radius:3px', + 'text-decoration:none', + 'font-size:0.9em', + 'display:inline-block', + 'margin:1px 2px', +].join(';'); + +const BUTTON_SECONDARY_STYLE = [ + `background:${COLOR_BUTTON_ALT_BG}`, + `color:${COLOR_TEXT}`, + `border:1px solid ${COLOR_BUTTON_ALT_BORDER}`, + 'padding:2px 7px', + 'border-radius:3px', + 'text-decoration:none', + 'font-size:0.9em', + 'display:inline-block', + 'margin:1px 2px', +].join(';'); + +const SECTION_LABEL_STYLE = [ + `color:${COLOR_MUTED}`, + 'font-size:0.8em', + 'text-transform:uppercase', + 'letter-spacing:0.08em', + 'margin-top:4px', + 'margin-bottom:2px', +].join(';'); + +const DIVIDER_STYLE = `border:none;border-top:1px solid ${COLOR_BORDER};margin:4px 0`; + +/** + * Returns the whisper target for a player, falling back to all GMs. + * + * Double quotes are stripped so the name is safe inside /w "name". + * + * @param {string} [playerId] Roll20 player ID. + * @returns {string} Whisper target text. + */ +function whisperTarget(playerId) { + const player = playerId && playerId !== 'API' ? getObject('player', playerId) : null; + if (!player) { + return 'gm'; + } + return `"${String(player.get('displayname') || playerId).replaceAll('"', '')}"`; +} + +/** + * Sends a styled card whispered to a player, or to every GM when no player is given. + * + * @param {string|null} playerId Roll20 player ID. + * @param {string} title Card header text. + * @param {string|string[]} content HTML body content. + * @returns {void} + */ +export function whisper(playerId, title, content) { + const body = Array.isArray(content) ? content.join('') : content; + chat(SCRIPT_NAME, `/w ${whisperTarget(playerId)} ${buildCard(title, body)}`, { noarchive: true }); +} + +/** + * Sends a styled card to everyone. + * + * @param {string} title Card header text. + * @param {string} body HTML body content. + * @returns {void} + */ +export function broadcast(title, body) { + chat(SCRIPT_NAME, buildCard(title, body)); +} + +/** + * Sends a warning whispered to a player (or every GM). + * + * @param {string|null} playerId Roll20 player ID. + * @param {string} message Warning text. + * @returns {void} + */ +export function whisperWarning(playerId, message) { + const style = [ + `background:${COLOR_WARNING_BG}`, + `color:${COLOR_WARNING_TEXT}`, + `border:1px solid ${COLOR_WARNING_BORDER}`, + 'padding:4px 8px', + 'border-radius:4px', + 'font-size:0.95em', + ].join(';'); + const html = `
${escapeHtml(message)}
`; + chat(SCRIPT_NAME, `/w ${whisperTarget(playerId)} ${html}`, { noarchive: true }); +} + +/** + * Sends an error whispered to a player (or every GM). + * + * @param {string|null} playerId Roll20 player ID. + * @param {string} message What went wrong. + * @param {string} [hint] How to fix it. + * @returns {void} + */ +export function whisperError(playerId, message, hint) { + const style = [ + `background:${COLOR_ERROR_BG}`, + `color:${COLOR_ERROR_TEXT}`, + `border:1px solid ${COLOR_ERROR_BORDER}`, + 'padding:4px 8px', + 'border-radius:4px', + 'font-size:0.95em', + ].join(';'); + const hintHtml = hint + ? `
${escapeHtml(hint)}
` + : ''; + const html = `
${escapeHtml(message)}${hintHtml}
`; + chat(SCRIPT_NAME, `/w ${whisperTarget(playerId)} ${html}`, { noarchive: true }); +} + +/** + * Builds a styled chat card. + * + * @param {string} title Header text (escaped). + * @param {string} body Body HTML (trusted). + * @returns {string} HTML string. + */ +export function buildCard(title, body) { + return [ + `
`, + `
${escapeHtml(title)}
`, + `
${body}
`, + '
', + ].join(''); +} + +/** + * Builds a primary action button. + * + * @param {string} label Button label. + * @param {string} command Chat command the button runs. + * @returns {string} HTML anchor. + */ +export function buildButton(label, command) { + return `${escapeHtml(label)}`; +} + +/** + * Builds a secondary action button. + * + * @param {string} label Button label. + * @param {string} command Chat command the button runs. + * @returns {string} HTML anchor. + */ +export function buildSecondaryButton(label, command) { + return `${escapeHtml(label)}`; +} + +/** + * Builds a section label. + * + * @param {string} text Label text. + * @returns {string} HTML string. + */ +export function buildSectionLabel(text) { + return `
${escapeHtml(text)}
`; +} + +/** + * Builds a horizontal divider. + * + * @returns {string} HTML string. + */ +export function buildDivider() { + return `
`; +} + +/** + * Builds a label/value row. The value may be trusted HTML (e.g. a button). + * + * @param {string} label Label text (escaped). + * @param {string} valueHtml Value HTML. + * @returns {string} HTML string. + */ +export function buildRow(label, valueHtml) { + return [ + '', + ``, + ``, + '
${escapeHtml(label)}${valueHtml}
', + ].join(''); +} diff --git a/TaskAutomation/src/commands.js b/TaskAutomation/src/commands.js new file mode 100644 index 0000000000..59c7c6a62e --- /dev/null +++ b/TaskAutomation/src/commands.js @@ -0,0 +1,2556 @@ +// Command controller (plan §13): `!taskautomation [options]`. +// Authenticates the real sender, applies size and rate limits, parses an +// allowlisted grammar, checks revisions and confirmations, then calls the +// services. Replies are short private whispers; detail lives in the dashboard. + +import { acknowledgeIncident, audit, openIncidents } from './audit.js'; +import { + buildButton, + buildDivider, + buildRow, + buildSecondaryButton, + buildSectionLabel, + whisper, + whisperError, +} from './chat.js'; +import { STEP_SPECS } from './catalogue.js'; +import { + ACTIVE_STATUSES, + COMMAND, + DEFAULT_CONFIG, + HANDOUT_EXPORT_NAME, + HELP_COMMANDS, + SCRIPT_NAME, + SCRIPT_VERSION, + STATUS, +} from './constants.js'; +import { + dashboardUrl, + decisionsFor, + ensureDashboard, + flushDashboard, + markDirty, + repairDashboard, + setView, +} from './dashboard.js'; +import { + archiveRecord, + blankDefinition, + blockList, + cloneRecord, + createRecord, + dependencies, + exportDefinition, + findStepLocation, + freeId, + getRecord, + getSnapshot, + importDefinition, + listRecords, + publish, + purgeRecord, + setEnabled, + updateDraft, + validateBody, +} from './definitions.js'; +import { + admitRun, + cancelRun, + currentStep, + decideReview, + getInstance, + listInstances, + pauseRun, + resolveParams, + rerun, + resumeRun, +} from './executor.js'; +import { createObject, findObjects, isGm, logLine, now, playerName } from './host.js'; +import { getLocale, t } from './i18n.js'; +import { listAdapters } from './integrations.js'; +import { resolveBindings, targetLabel } from './objects.js'; +import { + allowCommand, + canManageRun, + canStart, + consumeConfirmation, + issueConfirmation, +} from './permissions.js'; +import { listFrom, normaliseSpec, parseClockTime, parseWeekdays } from './recurrence.js'; +import { runtime } from './runtime.js'; +import { + addSchedule, + applyOffset, + applyRecovery, + listSchedules, + pauseSchedule, + previewOffset, + removeSchedule, + resumeSchedule, +} from './schedules.js'; +import { ID_PATTERN, describePath, durationValue } from './schema.js'; +import { getConfig, getLimits, getState, setConfigValue, stateSize } from './state.js'; +import { TEMPLATE_DEPENDENCIES, TEMPLATES, getTemplate } from './templates.js'; +import { playerSignals, raiseSignal } from './triggers.js'; +import { + byteLength, + clone, + escapeHtml, + formatDuration, + formatUtc, + formatUtcOffset, + isRecord, + parseIsoInstant, + parseJsonSafe, + parseStrictInt, + parseUtcOffset, + quoteArg, + tokenize, + truncate, +} from './utils.js'; + +/** + * Returns the GM-facing language. + * + * @returns {string} Locale. + */ +function lang() { + return getLocale(getState()?.config?.language); +} + +/** + * Builds a command string from words. + * + * @param {...string} words Words after the prefix. + * @returns {string} Command. + */ +function cmd(...words) { + return [COMMAND, ...words.map((w) => quoteArg(w))].join(' '); +} + +/** + * Whispers a translated error with an optional hint. + * + * @param {string} playerId Player. + * @param {string} code Error key under `errors.`. + * @param {object} [vars] Values. + * @returns {void} + */ +function fail(playerId, code, vars = {}) { + const hintKey = `hints.${code}`; + const hint = t(hintKey, lang(), vars); + whisperError(playerId, t(`errors.${code}`, lang(), vars), hint === hintKey ? undefined : hint); +} + +/** + * Whispers a short success line, with optional buttons. + * + * @param {string} playerId Player. + * @param {string} text Text. + * @param {string[]} [buttons] Button HTML. + * @returns {void} + */ +function ok(playerId, text, buttons = []) { + whisper( + playerId, + SCRIPT_NAME, + `
${escapeHtml(text)}
${buttons.length ? `
${buttons.join('')}
` : ''}` + ); +} + +/** + * Reports a service result: success text or the translated error. + * + * @param {string} playerId Player. + * @param {{ok: boolean, error?: string, vars?: object}} result Result. + * @param {string} successText Text on success. + * @param {string[]} [buttons] Buttons on success. + * @returns {boolean} result.ok. + */ +function report(playerId, result, successText, buttons) { + if (!result.ok) { + fail(playerId, result.error || result.reason || 'internal', result.vars || {}); + return false; + } + ok(playerId, successText, buttons); + return true; +} + +/** + * Parses `--name value` options against an allowlist. Unknown options fail + * with help rather than being ignored. + * + * @param {{text: string, quoted: boolean}[]} words Words. + * @param {object} spec Option kinds: 'value' | 'flag' | 'multi'. + * @returns {{opts?: object, positional?: string[], error?: string, vars?: object}} Parsed. + */ +export function parseOptions(words, spec) { + const opts = {}; + const positional = []; + for (let i = 0; i < words.length; i += 1) { + const word = words[i]; + if (!word.quoted && word.text.startsWith('--')) { + const name = word.text.slice(2); + const kind = spec[name]; + if (!kind) { + return { error: 'unknownOption', vars: { option: word.text } }; + } + if (kind === 'flag') { + opts[name] = true; + continue; + } + const next = words[i + 1]; + if (!next || (!next.quoted && next.text.startsWith('--'))) { + return { error: 'missingValue', vars: { option: word.text } }; + } + i += 1; + if (kind === 'multi') { + (opts[name] = opts[name] || []).push(next.text); + } else { + opts[name] = next.text; + } + } else { + positional.push(word.text); + } + } + return { opts, positional }; +} + +/** + * Parses an optional `--rev` value. + * + * @param {object} opts Options. + * @returns {number|undefined|null} Revision, undefined when absent, null when invalid. + */ +function revOf(opts) { + if (opts.rev === undefined) { + return undefined; + } + const rev = parseStrictInt(opts.rev); + return rev === null || rev < 1 ? null : rev; +} + +/** + * Parses repeated `name=value` options. + * + * @param {string[]|undefined} list Values. + * @returns {object|null} Map, or null when malformed. + */ +function pairs(list) { + const out = {}; + for (const item of list || []) { + const at = item.indexOf('='); + if (at < 1) { + return null; + } + out[item.slice(0, at)] = item.slice(at + 1); + } + return out; +} + +/** + * Returns the selected object IDs from a command message. + * + * @param {object} msg Chat message. + * @returns {string[]} IDs. + */ +function selectedIds(msg) { + return (Array.isArray(msg.selected) ? msg.selected : []) + .map((s) => s?._id) + .filter((id) => typeof id === 'string'); +} + +// --------------------------------------------------------------------------- +// Entry +// --------------------------------------------------------------------------- + +/** + * Handles a chat message. Only exactly `!taskautomation` is accepted, so + * `!taskautomationx` or `!task` never reach this Mod. + * + * @param {object} msg Roll20 chat message. + * @returns {void} + */ +export function handleInput(msg) { + if (msg.type !== 'api') { + return; + } + const content = String(msg.content ?? ''); + const prefix = content.split(/\s/, 1)[0]; + if (prefix.toLowerCase() !== COMMAND) { + return; + } + const playerId = msg.playerid; + // API-origin messages are never a person, whatever name they show. + if (!playerId || playerId === 'API') { + return; + } + if (byteLength(content) > getLimits().payloadBytes) { + fail(playerId, 'tooLarge', { max: getLimits().payloadBytes }); + return; + } + if (!allowCommand(playerId)) { + fail(playerId, 'rateLimited'); + return; + } + if (runtime.disabled && isGm(playerId)) { + fail(playerId, 'modDisabled', { reason: runtime.disabled.status }); + return; + } + const parsed = tokenize(content.slice(prefix.length)); + if (parsed.error) { + fail(playerId, parsed.error); + return; + } + try { + route(msg, playerId, parsed.words, parsed.json); + getState().boot.lastObservedAt = now(); + } catch (err) { + logLine(`ERROR handling "${truncate(content, 200)}": ${err?.message || err}`); + if (err?.stack) { + log(err.stack); + } + fail(playerId, 'internal'); + } +} + +const PLAYER_ROUTES = new Set(['help', 'instance', 'signal']); + +/** + * Routes a parsed command. + * + * @param {object} msg Message. + * @param {string} playerId Real sender ID. + * @param {{text: string, quoted: boolean}[]} words Words. + * @param {string|null} json Raw `--json` payload. + * @returns {void} + */ +function route(msg, playerId, words, json) { + const gm = isGm(playerId); + const resource = (words[0]?.text || '').toLowerCase(); + if (!gm && !PLAYER_ROUTES.has(resource)) { + fail(playerId, 'gmOnly'); + return; + } + const rest = words.slice(1); + switch (resource) { + case '': + case 'menu': + case 'status': + showMenu(playerId); + return; + case 'help': + showHelp(playerId, gm); + return; + case 'dashboard': + return commandDashboard(playerId, rest); + case 'definition': + return commandDefinition(msg, playerId, rest, json); + case 'template': + return commandTemplate(playerId, rest); + case 'instance': + return commandInstance(msg, playerId, rest, gm); + case 'schedule': + return commandSchedule(playerId, rest); + case 'signal': + return commandSignal(playerId, rest, gm); + case 'report': + return commandReport(playerId, rest); + case 'recovery': + return commandRecovery(playerId, rest); + case 'config': + return commandConfig(playerId, rest); + case 'adapter': + return commandAdapter(playerId); + case 'stop-all': + return commandStopAll(playerId, rest); + case 'resume-all': + return commandResumeAll(playerId); + default: + fail(playerId, 'unknownCommand', { command: resource }); + } +} + +// --------------------------------------------------------------------------- +// Menu and help +// --------------------------------------------------------------------------- + +/** + * Shows the main menu. + * + * @param {string} playerId Player. + * @returns {void} + */ +export function showMenu(playerId) { + const l = lang(); + const s = getState(); + const active = listInstances((i) => ACTIVE_STATUSES.has(i.status)); + const status = runtime.disabled + ? t('dashboard.statusDisabled', l) + : s.halted + ? t('dashboard.statusHalted', l) + : t('dashboard.statusRunning', l); + const url = dashboardUrl(); + const lines = [ + buildRow(t('menu.status', l), escapeHtml(status)), + buildRow(t('menu.definitions', l), escapeHtml(String(listRecords().length))), + buildRow(t('menu.active', l), escapeHtml(String(active.length))), + buildRow( + t('menu.review', l), + escapeHtml(String(active.filter((i) => i.status === STATUS.REVIEW).length)) + ), + buildRow(t('menu.incidents', l), escapeHtml(String(openIncidents().length))), + buildDivider(), + url + ? `${escapeHtml(t('buttons.dashboard', l))}` + : '', + buildButton(t('buttons.refresh', l), cmd('dashboard', 'open')), + buildButton(t('buttons.definitions', l), cmd('definition', 'list')), + buildButton(t('buttons.runs', l), cmd('instance', 'list')), + buildButton(t('buttons.templates', l), cmd('template', 'list')), + buildSecondaryButton(t('buttons.incidents', l), cmd('report', 'missed')), + buildSecondaryButton(t('buttons.help', l), cmd('help')), + buildSecondaryButton( + s.halted ? t('dashboard.resumeAll', l) : t('dashboard.stopAll', l), + s.halted ? cmd('resume-all') : cmd('stop-all', '--preview') + ), + ]; + whisper(playerId, `${SCRIPT_NAME} v${SCRIPT_VERSION}`, lines); +} + +/** + * Shows command help. Players see only the commands they may use. + * + * @param {string} playerId Player. + * @param {boolean} gm Whether the caller is a GM. + * @returns {void} + */ +function showHelp(playerId, gm) { + const l = lang(); + const keys = gm + ? HELP_COMMANDS + : HELP_COMMANDS.filter(([, key]) => + ['help', 'instanceStart', 'instance', 'signal'].includes(key) + ); + const rows = keys.map( + ([text, key]) => + `
${escapeHtml(`${COMMAND} ${text}`.trim())}
${escapeHtml(t(`help.${key}`, l))}
` + ); + whisper(playerId, t('titles.help', l), rows); +} + +// --------------------------------------------------------------------------- +// Dashboard +// --------------------------------------------------------------------------- + +/** + * `dashboard open|refresh|view|repair`. + * + * @param {string} playerId Player. + * @param {object[]} words Words. + * @returns {void} + */ +function commandDashboard(playerId, words) { + const verb = (words[0]?.text || 'open').toLowerCase(); + const l = lang(); + switch (verb) { + case 'open': { + ensureDashboard(); + flushDashboard({ force: true }); + const url = dashboardUrl(); + whisper( + playerId, + SCRIPT_NAME, + url + ? `${escapeHtml(t('buttons.dashboard', l))}` + : escapeHtml(t('errors.dashboardMissing', l)) + ); + return; + } + case 'refresh': + markDirty(); + ok(playerId, t('messages.dashboardRefresh', l)); + return; + case 'view': { + const page = words[2] ? parseStrictInt(words[2].text) : 1; + if (!setView(words[1]?.text || '', page || 1)) { + fail(playerId, 'badView', { + views: 'overview, definitions, instances, queue, incidents, history, integrations', + }); + } + return; + } + case 'repair': + report( + playerId, + { ok: repairDashboard(), error: 'dashboardMissing' }, + t('messages.dashboardRepaired', l) + ); + return; + default: + fail(playerId, 'unknownCommand', { command: `dashboard ${verb}` }); + } +} + +// --------------------------------------------------------------------------- +// Definitions +// --------------------------------------------------------------------------- + +/** + * Formats validation errors for chat. + * + * @param {object[]} errors Errors. + * @returns {string} HTML. + */ +function formatErrors(errors) { + const l = lang(); + return errors + .slice(0, 15) + .map( + (e) => + `
• ${escapeHtml(describePath(e.path))}: ${escapeHtml(t(`validation.${e.code}`, l, e.vars || {}))}
` + ) + .join(''); +} + +/** + * `definition …` commands. + * + * @param {object} msg Message. + * @param {string} playerId GM. + * @param {object[]} words Words. + * @param {string|null} json JSON payload. + * @returns {void} + */ +function commandDefinition(msg, playerId, words, json) { + const verb = (words[0]?.text || 'list').toLowerCase(); + const sub = (words[1]?.text || '').toLowerCase(); + const nested = ['step', 'trigger', 'import'].includes(verb) && sub && !sub.startsWith('--'); + const parsed = parseOptions(words.slice(nested ? 2 : 1), { + id: 'value', + name: 'value', + rev: 'value', + 'new-id': 'value', + field: 'value', + value: 'value', + step: 'value', + trigger: 'value', + block: 'value', + index: 'value', + after: 'value', + parts: 'value', + n: 'value', + preview: 'flag', + confirm: 'value', + replace: 'flag', + draft: 'flag', + handout: 'flag', + archived: 'flag', + remove: 'flag', + }); + if (parsed.error) { + fail(playerId, parsed.error, parsed.vars); + return; + } + const { opts } = parsed; + const l = lang(); + const rev = revOf(opts); + if (rev === null) { + fail(playerId, 'badRev'); + return; + } + const needId = () => { + if (!opts.id || !getRecord(opts.id)) { + fail(playerId, 'definitionNotFound', { id: opts.id || '' }); + return false; + } + return true; + }; + switch (verb) { + case 'list': + return listDefinitions(playerId, Boolean(opts.archived)); + case 'show': + if (needId()) { + showDefinition(playerId, opts.id); + } + return; + case 'create': { + const name = String(opts.name || '').trim(); + if (!name || name.length > getLimits().nameLength) { + fail(playerId, 'badName', { max: getLimits().nameLength }); + return; + } + const id = opts.id || freeId(name); + const result = createRecord(blankDefinition(id, name), playerId); + if ( + report(playerId, result, t('messages.definitionCreated', l, { id, name }), [ + buildButton(t('buttons.show', l), cmd('definition', 'show', '--id', id)), + ]) + ) { + markDirty(); + } + return; + } + case 'validate': { + if (!needId()) { + return; + } + const record = getRecord(opts.id); + const check = validateBody({ ...clone(record.draft || getSnapshot(opts.id)), id: opts.id }); + const warnings = check.warnings.length + ? `
${formatErrors(check.warnings)}
` + : ''; + if (check.ok) { + whisper( + playerId, + t('titles.validation', l), + `
${escapeHtml(t('messages.valid', l, { id: opts.id }))}
${warnings}${buildButton(t('buttons.publish', l), cmd('definition', 'publish', '--id', opts.id, '--rev', String(record.rev)))}` + ); + } else { + whisper( + playerId, + t('titles.validation', l), + `
${escapeHtml(t('messages.invalid', l, { id: opts.id, count: check.errors.length }))}
${formatErrors(check.errors)}${warnings}` + ); + } + return; + } + case 'publish': { + if (!needId()) { + return; + } + const result = publish(opts.id, playerId, rev); + if (!result.ok && result.errors) { + whisper( + playerId, + t('titles.validation', l), + `
${escapeHtml(t('messages.invalid', l, { id: opts.id, count: result.errors.length }))}
${formatErrors(result.errors)}` + ); + return; + } + report( + playerId, + result, + t('messages.published', l, { id: opts.id, revision: result.revision }), + [buildButton(t('buttons.start', l), cmd('instance', 'start', '--definition', opts.id))] + ); + return; + } + case 'enable': + case 'disable': + if (needId()) { + report( + playerId, + setEnabled(opts.id, verb === 'enable', playerId, { expectedRev: rev }), + t(verb === 'enable' ? 'messages.enabled' : 'messages.disabled', l, { id: opts.id }) + ); + } + return; + case 'clone': { + if (!needId()) { + return; + } + const name = String(opts.name || `${getRecord(opts.id).name} (copy)`).slice( + 0, + getLimits().nameLength + ); + const result = cloneRecord(opts.id, name, playerId, opts['new-id']); + report( + playerId, + result, + t('messages.cloned', l, { id: result.record?.id || '', name }), + result.record + ? [buildButton(t('buttons.show', l), cmd('definition', 'show', '--id', result.record.id))] + : [] + ); + return; + } + case 'archive': + if (needId()) { + report( + playerId, + archiveRecord(opts.id, playerId), + t('messages.archived', l, { id: opts.id }) + ); + } + return; + case 'purge': + return purgeCommand(playerId, opts); + case 'export': + if (needId()) { + exportCommand(playerId, opts); + } + return; + case 'import': + return importCommand(playerId, nested ? sub : '', opts, json); + case 'set': + if (needId()) { + setField(playerId, opts, rev); + } + return; + case 'param': + case 'binding': + if (needId()) { + editMapEntry(playerId, verb === 'param' ? 'parameters' : 'bindings', opts, json, rev); + } + return; + case 'access': + case 'policy': + if (needId()) { + editObject(playerId, verb, json, rev, opts.id); + } + return; + case 'trigger': + if (needId()) { + editTrigger(playerId, sub, opts, json, rev); + } + return; + case 'step': + if (needId()) { + editStep(playerId, sub, opts, json, rev); + } + return; + default: + fail(playerId, 'unknownCommand', { command: `definition ${verb}` }); + } +} + +/** + * Lists definitions. + * + * @param {string} playerId GM. + * @param {boolean} archived Include archived. + * @returns {void} + */ +function listDefinitions(playerId, archived) { + const l = lang(); + const records = listRecords({ archived }); + if (!records.length) { + whisper( + playerId, + t('titles.definitions', l), + `
${escapeHtml(t('dashboard.noDefinitions', l))}
${buildButton(t('buttons.templates', l), cmd('template', 'list'))}` + ); + return; + } + const rows = records.slice(0, 40).map((r) => { + const state = r.enabled ? '✓' : '✗'; + return `
${state} ${escapeHtml(truncate(r.name, 40))} ${escapeHtml(r.id)} r${r.current || '—'} ${buildSecondaryButton('⋯', cmd('definition', 'show', '--id', r.id))}${r.enabled ? buildButton(t('buttons.start', l), cmd('instance', 'start', '--definition', r.id)) : ''}
`; + }); + whisper(playerId, t('titles.definitions', l), rows); +} + +/** + * Outlines steps for a definition card. + * + * @param {object[]} steps Steps. + * @param {number} depth Indent. + * @param {string[]} out Lines. + * @returns {void} + */ +function outline(steps, depth, out) { + for (const step of steps || []) { + if (out.length >= 40) { + return; + } + out.push( + `
${escapeHtml(step.id)} ${escapeHtml(step.type)}
` + ); + outline(step.steps, depth + 1, out); + if (step.then?.length) { + out.push( + `
then
` + ); + outline(step.then, depth + 2, out); + } + if (step.else?.length) { + out.push( + `
else
` + ); + outline(step.else, depth + 2, out); + } + for (const branch of step.branches || []) { + out.push( + `
${escapeHtml(branch.id)}
` + ); + outline(branch.steps, depth + 2, out); + } + } +} + +/** + * Shows a definition card with its controls. + * + * @param {string} playerId GM. + * @param {string} id Definition ID. + * @returns {void} + */ +function showDefinition(playerId, id) { + const l = lang(); + const record = getRecord(id); + const body = record.draft || getSnapshot(id); + const rev = String(record.rev); + const steps = []; + outline(body.steps, 0, steps); + const draftAhead = record.draft && record.draft.revision > (record.current || 0); + const schedules = listSchedules(id).map( + (sc) => + `${sc.id}: ${sc.paused ? t('statuses.paused', l) : sc.cursor.nextDueAt ? formatUtc(sc.cursor.nextDueAt) : '—'}` + ); + const lines = [ + buildRow(t('labels.id', l), escapeHtml(id)), + buildRow( + t('labels.revision', l), + escapeHtml(`${record.current || '—'}${draftAhead ? ` (${t('labels.draft', l)})` : ''}`) + ), + buildRow( + t('labels.enabled', l), + escapeHtml( + record.enabled + ? t('labels.yes', l) + : t('dashboard.disabled', l, { reason: record.disabledReason || '—' }) + ) + ), + buildRow( + t('labels.triggers', l), + escapeHtml( + (body.triggers || []) + .map((tr) => `${tr.id}:${tr.type === 'event' ? tr.event : tr.type}`) + .join(', ') || '—' + ) + ), + schedules.length ? buildRow(t('labels.schedules', l), escapeHtml(schedules.join('; '))) : '', + body.description + ? `
${escapeHtml(truncate(body.description, 300))}
` + : '', + buildSectionLabel(t('labels.steps', l)), + steps.join('') || `
${escapeHtml(t('labels.noSteps', l))}
`, + buildDivider(), + buildButton(t('buttons.validate', l), cmd('definition', 'validate', '--id', id)), + draftAhead || !record.current + ? buildButton(t('buttons.publish', l), cmd('definition', 'publish', '--id', id, '--rev', rev)) + : '', + record.current + ? buildButton( + record.enabled ? t('buttons.disable', l) : t('buttons.enable', l), + cmd('definition', record.enabled ? 'disable' : 'enable', '--id', id, '--rev', rev) + ) + : '', + record.enabled + ? buildButton(t('buttons.start', l), cmd('instance', 'start', '--definition', id)) + : '', + record.current + ? buildSecondaryButton(t('buttons.dryRun', l), cmd('instance', 'dry-run', '--definition', id)) + : '', + buildSecondaryButton(t('buttons.export', l), cmd('definition', 'export', '--id', id)), + buildSecondaryButton(t('buttons.clone', l), cmd('definition', 'clone', '--id', id)), + buildSecondaryButton(t('buttons.archive', l), cmd('definition', 'archive', '--id', id)), + buildSecondaryButton( + t('buttons.purge', l), + cmd('definition', 'purge', '--id', id, '--preview') + ), + ]; + whisper(playerId, truncate(record.name, 60), lines); +} + +/** + * `definition purge --preview` / `--confirm `. + * + * @param {string} playerId GM. + * @param {object} opts Options. + * @returns {void} + */ +function purgeCommand(playerId, opts) { + const l = lang(); + if (opts.confirm) { + const confirmation = consumeConfirmation(opts.confirm, playerId); + if (!confirmation.ok || confirmation.op !== 'purge') { + fail(playerId, confirmation.reason || 'confirmationUnknown'); + return; + } + const id = confirmation.payload.id; + const record = getRecord(id); + if (!record || record.rev !== confirmation.payload.rev) { + fail(playerId, 'stale', { id, rev: record?.rev ?? '—' }); + return; + } + report(playerId, purgeRecord(id, playerId), t('messages.purged', l, { id })); + return; + } + const record = opts.id ? getRecord(opts.id) : null; + if (!record) { + fail(playerId, 'definitionNotFound', { id: opts.id || '' }); + return; + } + const deps = dependencies(opts.id); + if (deps.runs || deps.queued || deps.callers.length) { + fail(playerId, 'purgeBlocked', { + runs: deps.runs, + queued: deps.queued, + callers: deps.callers.join(', ') || '—', + }); + return; + } + const token = issueConfirmation(playerId, 'purge', { id: opts.id, rev: record.rev }); + whisper(playerId, t('titles.confirm', l), [ + `
${escapeHtml(t('messages.purgePreview', l, { id: opts.id, schedules: deps.schedules }))}
`, + buildButton(t('buttons.confirmPurge', l), cmd('definition', 'purge', '--confirm', token)), + ]); +} + +/** + * `definition export`: escaped JSON in bounded chat pages, or a private handout. + * + * @param {string} playerId GM. + * @param {object} opts Options. + * @returns {void} + */ +function exportCommand(playerId, opts) { + const l = lang(); + const body = exportDefinition(opts.id, { draft: Boolean(opts.draft) }); + const text = JSON.stringify(body); + if (opts.handout) { + const name = `${HANDOUT_EXPORT_NAME}: ${opts.id}`; + const handout = + findObjects({ _type: 'handout', name })[0] || + createObject('handout', { name, inplayerjournals: '', controlledby: '' }); + handout?.set('gmnotes', `
${escapeHtml(text)}
`); + ok(playerId, t('messages.exportedHandout', l, { name })); + return; + } + const size = 1500; + const pages = Math.ceil(text.length / size); + for (let i = 0; i < Math.min(pages, 20); i += 1) { + whisper( + playerId, + t('titles.export', l, { page: i + 1, pages }), + `
${escapeHtml(text.slice(i * size, (i + 1) * size))}
` + ); + } + ok(playerId, t('messages.exportHint', l, { command: `${COMMAND} definition import --json` })); +} + +/** + * `definition import` (single message or begin/part/commit). + * + * @param {string} playerId GM. + * @param {string} sub Sub-verb. + * @param {object} opts Options. + * @param {string|null} json JSON. + * @returns {void} + */ +function importCommand(playerId, sub, opts, json) { + const l = lang(); + const s = getState(); + const limits = getLimits(); + const finish = (text) => { + const parsed = parseJsonSafe(text, limits.importBytes); + if (!parsed.ok) { + fail(playerId, parsed.error, { path: parsed.path || '', max: limits.importBytes }); + return; + } + const result = importDefinition(parsed.value, playerId, { replace: Boolean(opts.replace) }); + if (!result.ok && result.errors) { + whisper( + playerId, + t('titles.validation', l), + `
${escapeHtml(t('messages.invalid', l, { id: String(parsed.value?.id ?? '?'), count: result.errors.length }))}
${formatErrors(result.errors)}` + ); + return; + } + report( + playerId, + result, + t('messages.imported', l, { id: parsed.value.id, revision: result.revision }), + [buildButton(t('buttons.show', l), cmd('definition', 'show', '--id', parsed.value.id))] + ); + }; + switch (sub) { + case '': + if (!json) { + fail(playerId, 'emptyJson'); + return; + } + finish(json); + return; + case 'begin': { + const parts = parseStrictInt(opts.parts); + if (!parts || parts < 1 || parts > 64) { + fail(playerId, 'badParts'); + return; + } + s.imports[playerId] = { parts, received: [], expiresAt: now() + 10 * 60 * 1000 }; + ok(playerId, t('messages.importBegun', l, { parts })); + return; + } + case 'part': { + const pending = s.imports[playerId]; + const n = parseStrictInt(opts.n); + if (!pending || pending.expiresAt < now()) { + delete s.imports[playerId]; + fail(playerId, 'noImport'); + return; + } + if (n !== pending.received.length + 1 || n > pending.parts || !json) { + fail(playerId, 'importOrder', { expected: pending.received.length + 1 }); + return; + } + const total = pending.received.reduce((sum, p) => sum + p.length, 0) + json.length; + if (total > limits.importBytes) { + delete s.imports[playerId]; + fail(playerId, 'tooLarge', { max: limits.importBytes }); + return; + } + pending.received.push(json); + ok(playerId, t('messages.importPart', l, { n, parts: pending.parts })); + return; + } + case 'commit': { + const pending = s.imports[playerId]; + if (!pending || pending.expiresAt < now() || pending.received.length !== pending.parts) { + fail(playerId, 'noImport'); + return; + } + delete s.imports[playerId]; + finish(pending.received.join('')); + return; + } + case 'cancel': + delete s.imports[playerId]; + ok(playerId, t('messages.importCancelled', l)); + return; + default: + fail(playerId, 'unknownCommand', { command: `definition import ${sub}` }); + } +} + +/** + * `definition set --field name|description --value …`. + * + * @param {string} playerId GM. + * @param {object} opts Options. + * @param {number|undefined} rev Revision guard. + * @returns {void} + */ +function setField(playerId, opts, rev) { + const l = lang(); + const field = opts.field; + if (!['name', 'description'].includes(field) || typeof opts.value !== 'string') { + fail(playerId, 'badField', { fields: 'name, description' }); + return; + } + const result = updateDraft( + opts.id, + (draft) => { + draft[field] = opts.value.slice( + 0, + field === 'name' ? getLimits().nameLength : getLimits().stringLength + ); + }, + playerId, + rev + ); + report(playerId, result, t('messages.draftUpdated', l, { id: opts.id }), draftButtons(opts.id)); +} + +/** + * Buttons after a draft edit. + * + * @param {string} id Definition ID. + * @returns {string[]} Buttons. + */ +function draftButtons(id) { + const l = lang(); + const record = getRecord(id); + return [ + buildButton(t('buttons.validate', l), cmd('definition', 'validate', '--id', id)), + buildButton( + t('buttons.publish', l), + cmd('definition', 'publish', '--id', id, '--rev', String(record?.rev ?? 1)) + ), + buildSecondaryButton(t('buttons.show', l), cmd('definition', 'show', '--id', id)), + ]; +} + +/** + * Parses the JSON payload of an edit command. + * + * @param {string} playerId GM. + * @param {string|null} json JSON. + * @returns {*} Value, or undefined after reporting the error. + */ +function editJson(playerId, json) { + const parsed = parseJsonSafe(json || '', getLimits().payloadBytes); + if (!parsed.ok) { + fail(playerId, parsed.error, { path: parsed.path || '', max: getLimits().payloadBytes }); + return undefined; + } + return parsed.value; +} + +/** + * `definition param|binding --id --name n --json {…}` or `--remove`. + * + * @param {string} playerId GM. + * @param {string} key parameters or bindings. + * @param {object} opts Options. + * @param {string|null} json JSON. + * @param {number|undefined} rev Revision guard. + * @returns {void} + */ +function editMapEntry(playerId, key, opts, json, rev) { + if (!opts.name || !ID_PATTERN.test(opts.name)) { + fail(playerId, 'badId', { id: opts.name || '' }); + return; + } + const value = opts.remove ? null : editJson(playerId, json); + if (value === undefined) { + return; + } + const result = updateDraft( + opts.id, + (draft) => { + draft[key] = isRecord(draft[key]) ? draft[key] : {}; + if (opts.remove) { + delete draft[key][opts.name]; + } else { + draft[key][opts.name] = value; + } + }, + playerId, + rev + ); + report( + playerId, + result, + t('messages.draftUpdated', lang(), { id: opts.id }), + draftButtons(opts.id) + ); +} + +/** + * `definition access|policy --id --json {…}` (replaces the object). + * + * @param {string} playerId GM. + * @param {string} key access or policy. + * @param {string|null} json JSON. + * @param {number|undefined} rev Revision guard. + * @param {string} id Definition ID. + * @returns {void} + */ +function editObject(playerId, key, json, rev, id) { + const value = editJson(playerId, json); + if (value === undefined) { + return; + } + if (!isRecord(value)) { + fail(playerId, 'notObject'); + return; + } + const result = updateDraft( + id, + (draft) => { + draft[key] = value; + }, + playerId, + rev + ); + report(playerId, result, t('messages.draftUpdated', lang(), { id }), draftButtons(id)); +} + +/** + * `definition trigger add|remove`. + * + * @param {string} playerId GM. + * @param {string} sub add or remove. + * @param {object} opts Options. + * @param {string|null} json JSON. + * @param {number|undefined} rev Revision guard. + * @returns {void} + */ +function editTrigger(playerId, sub, opts, json, rev) { + let result; + if (sub === 'add') { + const value = editJson(playerId, json); + if (value === undefined) { + return; + } + result = updateDraft( + opts.id, + (draft) => { + draft.triggers = Array.isArray(draft.triggers) ? draft.triggers : []; + if (draft.triggers.some((tr) => tr.id === value?.id)) { + return { ok: false, error: 'duplicateTrigger', vars: { id: String(value?.id) } }; + } + draft.triggers.push(value); + }, + playerId, + rev + ); + } else if (sub === 'remove') { + result = updateDraft( + opts.id, + (draft) => { + const before = (draft.triggers || []).length; + draft.triggers = (draft.triggers || []).filter((tr) => tr.id !== opts.trigger); + if (draft.triggers.length === before) { + return { ok: false, error: 'triggerNotFound', vars: { id: opts.trigger || '' } }; + } + }, + playerId, + rev + ); + } else { + fail(playerId, 'unknownCommand', { command: `definition trigger ${sub}` }); + return; + } + report( + playerId, + result, + t('messages.draftUpdated', lang(), { id: opts.id }), + draftButtons(opts.id) + ); +} + +/** + * `definition step add|edit|remove|move` on the draft. + * + * @param {string} playerId GM. + * @param {string} sub Sub-verb. + * @param {object} opts Options. + * @param {string|null} json JSON. + * @param {number|undefined} rev Revision guard. + * @returns {void} + */ +function editStep(playerId, sub, opts, json, rev) { + let value; + if (sub === 'add' || sub === 'edit') { + value = editJson(playerId, json); + if (value === undefined) { + return; + } + if (!isRecord(value)) { + fail(playerId, 'notObject'); + return; + } + } + const result = updateDraft( + opts.id, + (draft) => { + draft.steps = Array.isArray(draft.steps) ? draft.steps : []; + switch (sub) { + case 'add': { + if (!STEP_SPECS[value.type]) { + return { ok: false, error: 'unknownAction', vars: { type: String(value.type) } }; + } + if (findStepLocation(draft.steps, value.id)) { + return { ok: false, error: 'duplicateStep', vars: { id: String(value.id) } }; + } + let list = blockList(draft, opts.block || 'root'); + let index = list ? list.length : 0; + if (opts.after) { + const location = findStepLocation(draft.steps, opts.after); + if (!location) { + return { ok: false, error: 'stepNotFound', vars: { id: opts.after } }; + } + list = location.list; + index = location.index + 1; + } else if (opts.index !== undefined) { + index = parseStrictInt(opts.index); + } + if (!list || index === null || index < 0 || index > list.length) { + return { ok: false, error: 'badBlock', vars: { block: opts.block || 'root' } }; + } + list.splice(index, 0, value); + return { ok: true }; + } + case 'edit': { + const location = findStepLocation(draft.steps, opts.step); + if (!location) { + return { ok: false, error: 'stepNotFound', vars: { id: opts.step || '' } }; + } + // A patch: given fields replace, `null` removes, the ID stays. + const step = location.list[location.index]; + for (const [key, v] of Object.entries(value)) { + if (key === 'id') { + continue; + } + if (v === null) { + delete step[key]; + } else { + step[key] = v; + } + } + return { ok: true }; + } + case 'remove': { + const location = findStepLocation(draft.steps, opts.step); + if (!location) { + return { ok: false, error: 'stepNotFound', vars: { id: opts.step || '' } }; + } + location.list.splice(location.index, 1); + return { ok: true }; + } + case 'move': { + const location = findStepLocation(draft.steps, opts.step); + if (!location) { + return { ok: false, error: 'stepNotFound', vars: { id: opts.step || '' } }; + } + const [step] = location.list.splice(location.index, 1); + const target = opts.block ? blockList(draft, opts.block) : location.list; + const index = opts.index === undefined ? target?.length : parseStrictInt(opts.index); + if (!target || index === null || index < 0 || index > target.length) { + location.list.splice(location.index, 0, step); + return { ok: false, error: 'badBlock', vars: { block: opts.block || '' } }; + } + target.splice(index, 0, step); + return { ok: true }; + } + default: + return { + ok: false, + error: 'unknownCommand', + vars: { command: `definition step ${sub}` }, + }; + } + }, + playerId, + rev + ); + report( + playerId, + result, + t('messages.draftUpdated', lang(), { id: opts.id }), + draftButtons(opts.id) + ); +} + +// --------------------------------------------------------------------------- +// Templates +// --------------------------------------------------------------------------- + +/** + * `template list|add `. + * + * @param {string} playerId GM. + * @param {object[]} words Words. + * @returns {void} + */ +function commandTemplate(playerId, words) { + const l = lang(); + const verb = (words[0]?.text || 'list').toLowerCase(); + if (verb === 'list') { + const rows = TEMPLATES.map( + (tpl) => + `
${escapeHtml(tpl.name)} ${escapeHtml(tpl.id)} ${getRecord(tpl.id) ? escapeHtml(t('labels.installed', l)) : buildButton(t('buttons.add', l), cmd('template', 'add', tpl.id))}
${escapeHtml(tpl.description)}
` + ); + whisper(playerId, t('titles.templates', l), rows); + return; + } + if (verb !== 'add') { + fail(playerId, 'unknownCommand', { command: `template ${verb}` }); + return; + } + const id = words[1]?.text; + const tpl = id ? getTemplate(id) : null; + if (!tpl) { + fail(playerId, 'templateNotFound', { id: id || '' }); + return; + } + const added = []; + for (const depId of [...(TEMPLATE_DEPENDENCIES[id] || []), id]) { + if (getRecord(depId)) { + continue; + } + const result = importDefinition(clone(getTemplate(depId)), playerId); + if (!result.ok) { + fail(playerId, result.error || 'invalidDefinition', result.vars || {}); + return; + } + added.push(depId); + } + audit('template.add', { template: id, by: playerId }); + const enabled = getRecord(id)?.enabled; + ok( + playerId, + t(enabled ? 'messages.templateAdded' : 'messages.templateAddedDisabled', l, { + ids: added.join(', ') || id, + }), + [buildButton(t('buttons.show', l), cmd('definition', 'show', '--id', id))] + ); +} + +// --------------------------------------------------------------------------- +// Instances +// --------------------------------------------------------------------------- + +/** + * `instance …` commands. Players may start published tasks and manage + * their own runs. + * + * @param {object} msg Message. + * @param {string} playerId Real sender. + * @param {object[]} words Words. + * @param {boolean} gm Caller is a GM. + * @returns {void} + */ +function commandInstance(msg, playerId, words, gm) { + const verb = (words[0]?.text || 'list').toLowerCase(); + const parsed = parseOptions(words.slice(1), { + definition: 'value', + id: 'value', + rev: 'value', + bind: 'multi', + param: 'multi', + status: 'value', + page: 'value', + action: 'value', + confirm: 'value', + 'dry-run': 'flag', + }); + if (parsed.error) { + fail(playerId, parsed.error, parsed.vars); + return; + } + const { opts } = parsed; + const rev = revOf(opts); + if (rev === null) { + fail(playerId, 'badRev'); + return; + } + const l = lang(); + const runFor = () => { + const inst = opts.id ? getInstance(opts.id) : null; + if (!inst || !canManageRun(playerId, inst)) { + fail(playerId, 'runNotFound', { id: opts.id || '' }); + return null; + } + if (rev !== undefined && inst.revision !== rev) { + fail(playerId, 'stale', { id: inst.id, rev: inst.revision }); + return null; + } + return inst; + }; + switch (verb) { + case 'start': + case 'dry-run': + startCommand(msg, playerId, opts, verb === 'dry-run' || Boolean(opts['dry-run'])); + return; + case 'list': + listRuns(playerId, opts, gm); + return; + case 'inspect': { + const inst = runFor(); + if (inst) { + inspectRun(playerId, inst, gm); + } + return; + } + case 'pause': { + const inst = runFor(); + if (inst) { + report(playerId, pauseRun(inst.id, playerId), t('messages.paused', l, { id: inst.id }), [ + buildButton( + t('buttons.resume', l), + cmd('instance', 'resume', '--id', inst.id, '--rev', String(inst.revision)) + ), + ]); + } + return; + } + case 'resume': { + const inst = runFor(); + if (inst) { + report(playerId, resumeRun(inst.id, playerId), t('messages.resumed', l, { id: inst.id })); + } + return; + } + case 'cancel': { + const inst = runFor(); + if (inst) { + report( + playerId, + cancelRun(inst.id, playerId), + t('messages.cancelled', l, { id: inst.id, effects: inst.effects }) + ); + } + return; + } + case 'rerun': { + const source = opts.id + ? getInstance(opts.id) || getState().history.find((h) => h.id === opts.id) + : null; + if ( + !source || + (!gm && source.initiatorPlayerId !== playerId && source.initiator !== playerId) + ) { + fail(playerId, 'runNotFound', { id: opts.id || '' }); + return; + } + const def = getSnapshot(source.definitionId); + if (!def || !canStart(playerId, def, source.bindings).ok) { + fail(playerId, 'notAllowed'); + return; + } + const result = rerun(opts.id, playerId); + startReply(playerId, result, source.definitionId); + return; + } + case 'decide': + if (!gm) { + fail(playerId, 'gmOnly'); + return; + } + decideCommand(playerId, opts, rev); + return; + default: + fail(playerId, 'unknownCommand', { command: `instance ${verb}` }); + } +} + +/** + * Starts (or dry-runs) a definition from a command. Selection is captured + * now and stored as IDs. + * + * @param {object} msg Message. + * @param {string} playerId Real sender. + * @param {object} opts Options. + * @param {boolean} dryRun Validate only. + * @returns {void} + */ +function startCommand(msg, playerId, opts, dryRun) { + const l = lang(); + const record = opts.definition ? getRecord(opts.definition) : null; + const def = record ? getSnapshot(record.id) : null; + if (!def || (!isGm(playerId) && def.access?.mode !== 'players')) { + fail(playerId, 'definitionNotFound', { id: opts.definition || '' }); + return; + } + const rawParams = pairs(opts.param); + const rawBind = pairs(opts.bind); + if (!rawParams || !rawBind) { + fail(playerId, 'badPair'); + return; + } + const params = resolveParams(def, rawParams); + if (!params.ok) { + fail(playerId, params.error, params.vars); + return; + } + const explicit = {}; + for (const [name, value] of Object.entries(rawBind)) { + if (!def.bindings?.[name]) { + fail(playerId, 'unknownBinding', { name }); + return; + } + // Only a GM may aim a task at objects by ID; players use their selection. + if (value !== 'selected' && !isGm(playerId)) { + fail(playerId, 'notController'); + return; + } + explicit[name] = value === 'selected' ? 'selected' : value.split(',').filter(Boolean); + } + const bindings = resolveBindings(def, { + selected: selectedIds(msg), + explicit, + params: params.params, + }); + if (!bindings.ok) { + const first = bindings.errors[0]; + fail(playerId, `binding_${first.code}`, { + binding: first.binding, + id: first.id || '', + max: first.max || '', + }); + return; + } + const allowed = canStart(playerId, def, bindings.bindings); + if (!allowed.ok) { + fail(playerId, allowed.reason); + return; + } + if (dryRun) { + const check = validateBody(def); + const targets = Object.entries(bindings.bindings).map( + ([name, ids]) => `${name}: ${ids.map(targetLabel).join(', ') || '—'}` + ); + whisper(playerId, t('titles.dryRun', l), [ + `
${escapeHtml(t(check.ok ? 'messages.dryRunOk' : 'messages.invalid', l, { id: def.id, count: check.errors.length }))}
`, + targets.length ? `
${escapeHtml(targets.join('; '))}
` : '', + check.ok ? '' : formatErrors(check.errors), + check.warnings.length ? formatErrors(check.warnings) : '', + `
${escapeHtml(t('messages.dryRunNote', l))}
`, + ]); + return; + } + const result = admitRun(record.id, { + initiatorId: def.access?.authority === 'gmService' && isGm(playerId) ? null : playerId, + params: params.params, + bindings: bindings.bindings, + trigger: { type: 'manual', id: 'manual' }, + }); + startReply(playerId, result, record.id); +} + +/** + * Replies to a start. + * + * @param {string} playerId Player. + * @param {object} result Admission result. + * @param {string} defId Definition ID. + * @returns {void} + */ +function startReply(playerId, result, defId) { + const l = lang(); + if (result.status === 'started') { + const inst = result.instance; + ok(playerId, t('messages.started', l, { id: inst.id, definition: defId }), [ + buildSecondaryButton( + t('buttons.pause', l), + cmd('instance', 'pause', '--id', inst.id, '--rev', String(inst.revision)) + ), + buildSecondaryButton( + t('buttons.cancel', l), + cmd('instance', 'cancel', '--id', inst.id, '--rev', String(inst.revision)) + ), + buildSecondaryButton(t('buttons.inspect', l), cmd('instance', 'inspect', '--id', inst.id)), + ]); + return; + } + if (result.status === 'queued') { + ok(playerId, t('messages.queued', l, { definition: defId })); + return; + } + if (result.status === 'skipped') { + fail(playerId, 'alreadyRunning', { id: defId }); + return; + } + fail(playerId, result.reason || 'rejected', result.vars || {}); +} + +/** + * Lists runs (players see their own). + * + * @param {string} playerId Player. + * @param {object} opts Options. + * @param {boolean} gm Caller is a GM. + * @returns {void} + */ +function listRuns(playerId, opts, gm) { + const l = lang(); + const runs = listInstances( + (i) => + (gm || i.initiatorPlayerId === playerId) && + (!opts.status + ? ACTIVE_STATUSES.has(i.status) + : opts.status === 'all' || i.status === opts.status) && + (!opts.definition || i.definitionId === opts.definition) + ); + if (!runs.length) { + whisper(playerId, t('titles.runs', l), escapeHtml(t('dashboard.noRuns', l))); + return; + } + const page = Math.max(1, parseStrictInt(opts.page) || 1); + const rows = runs.slice((page - 1) * 15, page * 15).map((i) => { + const controls = []; + if (i.status === STATUS.RUNNING || i.status === STATUS.WAITING) { + controls.push( + buildSecondaryButton( + t('buttons.pause', l), + cmd('instance', 'pause', '--id', i.id, '--rev', String(i.revision)) + ) + ); + } + if (i.status === STATUS.PAUSED) { + controls.push( + buildButton( + t('buttons.resume', l), + cmd('instance', 'resume', '--id', i.id, '--rev', String(i.revision)) + ) + ); + } + controls.push(buildSecondaryButton('⋯', cmd('instance', 'inspect', '--id', i.id))); + return `
${escapeHtml(i.id)} ${escapeHtml(i.definitionId)} · ${escapeHtml(t(`statuses.${i.status}`, l))} ${controls.join('')}
`; + }); + if (runs.length > page * 15) { + rows.push( + buildSecondaryButton( + t('dashboard.next', l), + cmd('instance', 'list', '--page', String(page + 1)) + ) + ); + } + whisper(playerId, t('titles.runs', l), rows); +} + +/** + * Shows one run in detail. Players see a reduced card for their own runs. + * + * @param {string} playerId Player. + * @param {object} inst Instance. + * @param {boolean} gm Caller is a GM. + * @returns {void} + */ +function inspectRun(playerId, inst, gm) { + const l = lang(); + const rev = String(inst.revision); + const lines = [ + buildRow( + t('labels.definition', l), + escapeHtml(`${inst.definitionId} r${inst.definitionRevision}`) + ), + buildRow( + t('labels.status', l), + escapeHtml( + t(`statuses.${inst.status}`, l) + + (inst.review ? ` (${t(`review.${inst.review.reason}`, l)})` : '') + ) + ), + buildRow(t('labels.step', l), escapeHtml(currentStep(inst) || '—')), + buildRow(t('labels.started', l), escapeHtml(formatUtc(inst.startedAt))), + ]; + if (gm) { + lines.push( + buildRow( + t('labels.initiator', l), + escapeHtml( + inst.initiatorPlayerId ? playerName(inst.initiatorPlayerId) : t('labels.gmService', l) + ) + ) + ); + lines.push( + buildRow( + t('labels.targets', l), + escapeHtml( + Object.entries(inst.bindings) + .map(([k, ids]) => `${k}: ${ids.map(targetLabel).join(', ')}`) + .join('; ') || '—' + ) + ) + ); + if (inst.intendedDueAt) { + lines.push(buildRow(t('labels.lateness', l), escapeHtml(formatDuration(inst.latenessMs)))); + } + if (inst.error) { + lines.push( + `
${escapeHtml(t(`runErrors.${inst.error.reason}`, l, inst.error.vars || {}))}${inst.error.stepId ? ` @ ${escapeHtml(inst.error.stepId)}` : ''}
` + ); + } + const journal = Object.entries(inst.journal).slice(-8); + if (journal.length) { + lines.push(buildSectionLabel(t('labels.journal', l))); + for (const [key, entry] of journal) { + lines.push( + `
${escapeHtml(key)}: ${escapeHtml(t(`journal.${entry.status}`, l))}
` + ); + } + } + lines.push(buildSectionLabel(t('labels.timeline', l))); + for (const entry of inst.timeline.slice(-10)) { + lines.push( + `
${escapeHtml(formatUtc(entry.at).slice(11))} ${escapeHtml(t(`timeline.${entry.kind}`, l, entry))}
` + ); + } + } + lines.push(buildDivider()); + if (inst.status === STATUS.RUNNING || inst.status === STATUS.WAITING) { + lines.push( + buildButton(t('buttons.pause', l), cmd('instance', 'pause', '--id', inst.id, '--rev', rev)) + ); + } + if (inst.status === STATUS.PAUSED) { + lines.push( + buildButton(t('buttons.resume', l), cmd('instance', 'resume', '--id', inst.id, '--rev', rev)) + ); + } + if (inst.status === STATUS.REVIEW && gm) { + for (const decision of decisionsFor(inst)) { + lines.push( + buildButton( + t(`decisions.${decision}`, l), + cmd('instance', 'decide', '--id', inst.id, '--action', decision, '--rev', rev) + ) + ); + } + } + if (ACTIVE_STATUSES.has(inst.status) && inst.status !== STATUS.REVIEW) { + lines.push( + buildSecondaryButton( + t('buttons.cancel', l), + cmd('instance', 'cancel', '--id', inst.id, '--rev', rev) + ) + ); + } + if (!ACTIVE_STATUSES.has(inst.status)) { + lines.push( + buildSecondaryButton(t('buttons.rerun', l), cmd('instance', 'rerun', '--id', inst.id)) + ); + } + whisper(playerId, inst.id, lines); +} + +/** + * `instance decide` for runs in review. Retrying an action whose outcome is + * uncertain could duplicate it, so that needs a confirmation. + * + * @param {string} playerId GM. + * @param {object} opts Options. + * @param {number|undefined} rev Revision guard. + * @returns {void} + */ +function decideCommand(playerId, opts, rev) { + const l = lang(); + if (opts.confirm) { + const confirmation = consumeConfirmation(opts.confirm, playerId); + if (!confirmation.ok || confirmation.op !== 'decide') { + fail(playerId, confirmation.reason || 'confirmationUnknown'); + return; + } + const inst = getInstance(confirmation.payload.id); + if (!inst || inst.revision !== confirmation.payload.rev) { + fail(playerId, 'stale', { id: confirmation.payload.id, rev: inst?.revision ?? '—' }); + return; + } + report( + playerId, + decideReview(inst.id, confirmation.payload.action, playerId), + t('messages.decided', l, { + id: inst.id, + action: t(`decisions.${confirmation.payload.action}`, l), + }) + ); + return; + } + const inst = opts.id ? getInstance(opts.id) : null; + if (!inst) { + fail(playerId, 'runNotFound', { id: opts.id || '' }); + return; + } + if (rev !== undefined && inst.revision !== rev) { + fail(playerId, 'stale', { id: inst.id, rev: inst.revision }); + return; + } + const action = opts.action; + if ( + !['retry', 'skip', 'complete', 'skipNext', 'cancel'].includes(action) || + !decisionsFor(inst).includes(action) + ) { + fail(playerId, 'badDecision', { actions: decisionsFor(inst).join(', ') }); + return; + } + const risky = + action === 'retry' && ['uncertainOutcome', 'restartConflict'].includes(inst.review?.reason); + if (risky) { + const token = issueConfirmation(playerId, 'decide', { + id: inst.id, + rev: inst.revision, + action, + }); + whisper(playerId, t('titles.confirm', l), [ + `
${escapeHtml(t('messages.retryWarning', l, { id: inst.id, step: inst.review.stepId || '' }))}
`, + buildButton(t('buttons.confirmRetry', l), cmd('instance', 'decide', '--confirm', token)), + ]); + return; + } + report( + playerId, + decideReview(inst.id, action, playerId), + t('messages.decided', l, { id: inst.id, action: t(`decisions.${action}`, l) }) + ); +} + +// --------------------------------------------------------------------------- +// Schedules +// --------------------------------------------------------------------------- + +/** + * `schedule add|list|pause|resume|remove|offset|preview`. + * + * @param {string} playerId GM. + * @param {object[]} words Words. + * @returns {void} + */ +function commandSchedule(playerId, words) { + const verb = (words[0]?.text || 'list').toLowerCase(); + const parsed = parseOptions(words.slice(1), { + definition: 'value', + id: 'value', + rev: 'value', + at: 'value', + every: 'value', + anchor: 'value', + mode: 'value', + daily: 'value', + weekly: 'value', + time: 'value', + offset: 'value', + start: 'value', + end: 'value', + count: 'value', + overdue: 'value', + bind: 'multi', + param: 'multi', + yes: 'flag', + }); + if (parsed.error) { + fail(playerId, parsed.error, parsed.vars); + return; + } + const { opts } = parsed; + const l = lang(); + const rev = revOf(opts); + if (rev === null) { + fail(playerId, 'badRev'); + return; + } + switch (verb) { + case 'add': + return addScheduleCommand(playerId, opts); + case 'list': { + const list = listSchedules(opts.definition); + const rows = list.slice(0, 30).map((sc) => { + const state = sc.paused + ? t('statuses.paused', l) + : sc.ended + ? t('labels.ended', l) + : formatUtc(sc.cursor.nextDueAt); + const control = sc.paused + ? buildButton( + t('buttons.resume', l), + cmd('schedule', 'resume', '--id', sc.id, '--rev', String(sc.rev)) + ) + : sc.ended + ? '' + : buildSecondaryButton( + t('buttons.pause', l), + cmd('schedule', 'pause', '--id', sc.id, '--rev', String(sc.rev)) + ); + return `
${escapeHtml(sc.id)} ${escapeHtml(sc.definitionId)} · ${escapeHtml(describeSpec(sc.spec))} · ${escapeHtml(state)} ${control}${buildSecondaryButton('×', cmd('schedule', 'remove', '--id', sc.id))}
`; + }); + whisper( + playerId, + t('titles.schedules', l), + rows.length ? rows : escapeHtml(t('messages.noSchedules', l)) + ); + return; + } + case 'pause': + report( + playerId, + pauseSchedule(opts.id, playerId, rev), + t('messages.schedulePaused', l, { id: opts.id || '' }), + [ + buildButton( + t('buttons.resumeSkip', l), + cmd('schedule', 'resume', '--id', opts.id || '', '--mode', 'skipPausedRange') + ), + buildSecondaryButton( + t('buttons.resumeCatchUp', l), + cmd('schedule', 'resume', '--id', opts.id || '', '--mode', 'catchUp') + ), + ] + ); + return; + case 'resume': { + const mode = opts.mode || 'skipPausedRange'; + if (!['skipPausedRange', 'catchUp'].includes(mode)) { + fail(playerId, 'badMode', { modes: 'skipPausedRange, catchUp' }); + return; + } + const result = resumeSchedule(opts.id, playerId, mode, rev); + report( + playerId, + result, + t('messages.scheduleResumed', l, { + id: opts.id || '', + skipped: result.skipped ?? result.summary?.missed ?? 0, + }) + ); + return; + } + case 'remove': + report( + playerId, + { + ok: removeSchedule(opts.id, playerId), + error: 'scheduleNotFound', + vars: { id: opts.id || '' }, + }, + t('messages.scheduleRemoved', l, { id: opts.id || '' }) + ); + return; + case 'preview': { + const sched = getState().schedules[opts.id]; + if (!sched) { + fail(playerId, 'scheduleNotFound', { id: opts.id || '' }); + return; + } + const times = listFrom(sched.spec, now(), 5).map(formatUtc); + ok(playerId, t('messages.nextTimes', l, { id: sched.id, times: times.join(', ') || '—' })); + return; + } + case 'offset': { + const offset = parseUtcOffset(opts.offset); + if (offset === null) { + fail(playerId, 'badOffset'); + return; + } + if (!opts.yes) { + const preview = previewOffset(opts.id, offset); + if (!preview.ok) { + fail(playerId, preview.error, preview.vars); + return; + } + whisper(playerId, t('titles.offset', l), [ + `
${escapeHtml(t('messages.offsetPreview', l, { id: opts.id, offset: formatUtcOffset(offset) }))}
`, + `
${escapeHtml(t('labels.before', l))}: ${escapeHtml(preview.before.map(formatUtc).join(', '))}
`, + `
${escapeHtml(t('labels.after', l))}: ${escapeHtml(preview.after.map(formatUtc).join(', '))}
`, + buildButton( + t('buttons.apply', l), + cmd('schedule', 'offset', '--id', opts.id, '--offset', formatUtcOffset(offset), '--yes') + ), + ]); + return; + } + report( + playerId, + applyOffset(opts.id, offset, playerId), + t('messages.offsetApplied', l, { id: opts.id, offset: formatUtcOffset(offset) }) + ); + return; + } + default: + fail(playerId, 'unknownCommand', { command: `schedule ${verb}` }); + } +} + +/** + * Describes a recurrence spec. + * + * @param {object} spec Spec. + * @returns {string} Text. + */ +function describeSpec(spec) { + switch (spec.kind) { + case 'once': + return `at ${formatUtc(spec.at)}`; + case 'interval': + return `every ${formatDuration(spec.everyMs)} (${spec.mode})`; + case 'daily': + return `daily ${String(Math.floor(spec.timeMs / 3600000)).padStart(2, '0')}:${String(Math.floor((spec.timeMs % 3600000) / 60000)).padStart(2, '0')} ${formatUtcOffset(spec.offsetMinutes)}`; + case 'weekly': + return `weekly ${spec.days.map((d) => ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'][d]).join(',')} ${String(Math.floor(spec.timeMs / 3600000)).padStart(2, '0')}:${String(Math.floor((spec.timeMs % 3600000) / 60000)).padStart(2, '0')} ${formatUtcOffset(spec.offsetMinutes)}`; + default: + return spec.kind; + } +} + +/** + * `schedule add`: parses the times, shows them in UTC and asks for `--yes` + * before anything is scheduled. + * + * @param {string} playerId GM. + * @param {object} opts Options. + * @returns {void} + */ +function addScheduleCommand(playerId, opts) { + const l = lang(); + const record = opts.definition ? getRecord(opts.definition) : null; + const def = record ? getSnapshot(record.id) : null; + if (!def) { + fail(playerId, 'definitionNotFound', { id: opts.definition || '' }); + return; + } + if (def.access?.authority !== 'gmService') { + fail(playerId, 'scheduleNeedsService', { id: record.id }); + return; + } + const window = {}; + for (const key of ['start', 'end']) { + if (opts[key] !== undefined) { + const value = parseIsoInstant(opts[key]); + if (value === null) { + fail(playerId, 'badInstant', { value: opts[key] }); + return; + } + window[key === 'start' ? 'startAt' : 'endAt'] = value; + } + } + if (opts.count !== undefined) { + window.maxCount = parseStrictInt(opts.count); + } + let raw; + if (opts.at) { + raw = { kind: 'once', at: parseIsoInstant(opts.at) }; + if (raw.at === null) { + fail(playerId, 'badInstant', { value: opts.at }); + return; + } + } else if (opts.every) { + const anchor = opts.anchor ? parseIsoInstant(opts.anchor) : now(); + if (anchor === null) { + fail(playerId, 'badInstant', { value: opts.anchor }); + return; + } + raw = { + ...window, + kind: 'interval', + everyMs: durationValue(opts.every), + anchor, + mode: opts.mode || 'fixedRate', + }; + } else if (opts.daily || opts.weekly) { + const time = opts.daily || opts.time; + raw = { + ...window, + kind: opts.weekly ? 'weekly' : 'daily', + timeMs: parseClockTime(time), + offsetMinutes: parseUtcOffset(opts.offset), + days: opts.weekly ? parseWeekdays(opts.weekly) : undefined, + }; + if (raw.offsetMinutes === null) { + fail(playerId, 'offsetRequired'); + return; + } + } else { + fail(playerId, 'scheduleKind'); + return; + } + const built = normaliseSpec(raw, getLimits()); + if (!built.ok) { + fail(playerId, `spec_${built.error}`, { min: formatDuration(getLimits().minIntervalMs) }); + return; + } + const bindPairs = pairs(opts.bind); + const paramPairs = pairs(opts.param); + if (!bindPairs || !paramPairs) { + fail(playerId, 'badPair'); + return; + } + const params = resolveParams(def, paramPairs); + if (!params.ok) { + fail(playerId, params.error, params.vars); + return; + } + const bindings = Object.fromEntries( + Object.entries(bindPairs).map(([k, v]) => [k, v.split(',').filter(Boolean)]) + ); + const check = resolveBindings(def, { + explicit: bindings, + params: params.params, + unattended: true, + }); + if (!check.ok) { + const first = check.errors[0]; + fail(playerId, `binding_${first.code}`, { + binding: first.binding, + id: first.id || '', + max: first.max || '', + }); + return; + } + if ( + opts.overdue && + !['skip', 'runOnce', 'catchUpBounded', 'review', 'expire'].includes(opts.overdue) + ) { + fail(playerId, 'badMode', { modes: 'skip, runOnce, catchUpBounded, review, expire' }); + return; + } + const times = listFrom(built.spec, now(), 3); + if (!opts.yes) { + const again = ['schedule', 'add', '--definition', record.id]; + for (const key of [ + 'at', + 'every', + 'anchor', + 'mode', + 'daily', + 'weekly', + 'time', + 'offset', + 'start', + 'end', + 'count', + 'overdue', + ]) { + if (opts[key] !== undefined) { + again.push(`--${key}`, opts[key]); + } + } + for (const b of opts.bind || []) { + again.push('--bind', b); + } + for (const p of opts.param || []) { + again.push('--param', p); + } + whisper(playerId, t('titles.schedulePreview', l), [ + buildRow(t('labels.definition', l), escapeHtml(record.id)), + buildRow(t('labels.schedule', l), escapeHtml(describeSpec(built.spec))), + buildRow( + t('labels.nextTimes', l), + escapeHtml(times.map(formatUtc).join(', ') || t('messages.noFutureTimes', l)) + ), + built.spec.kind === 'daily' || built.spec.kind === 'weekly' + ? `
${escapeHtml(t('messages.offsetNote', l))}
` + : '', + buildButton(t('buttons.confirmSchedule', l), cmd(...again, '--yes')), + ]); + return; + } + if (!times.length) { + fail(playerId, 'noFutureTimes'); + return; + } + const sched = addSchedule({ + definitionId: record.id, + spec: built.spec, + overdue: opts.overdue ? { mode: opts.overdue } : null, + bindings: check.bindings, + params: params.params, + by: playerId, + }); + ok( + playerId, + t('messages.scheduleAdded', l, { id: sched.id, next: formatUtc(sched.cursor.nextDueAt) }), + [ + buildSecondaryButton( + t('buttons.pause', l), + cmd('schedule', 'pause', '--id', sched.id, '--rev', String(sched.rev)) + ), + ] + ); +} + +// --------------------------------------------------------------------------- +// Signals, reports, recovery, config, adapters, emergency +// --------------------------------------------------------------------------- + +/** + * `signal [--value v]`. Players may raise only signals a published + * task listens for. + * + * @param {string} playerId Real sender. + * @param {object[]} words Words. + * @param {boolean} gm Caller is a GM. + * @returns {void} + */ +function commandSignal(playerId, words, gm) { + const name = words[0]?.text || ''; + const parsed = parseOptions(words.slice(1), { value: 'value' }); + if (parsed.error) { + fail(playerId, parsed.error, parsed.vars); + return; + } + if (!ID_PATTERN.test(name)) { + fail(playerId, 'badId', { id: name }); + return; + } + if (!gm && !playerSignals().has(name)) { + fail(playerId, 'signalNotAllowed', { name }); + return; + } + const raw = parsed.opts.value; + const value = + raw === undefined ? null : /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : truncate(raw, 200); + raiseSignal(name, value, playerId, 'command'); + ok(playerId, t('messages.signalled', lang(), { name })); +} + +/** + * `report missed|runs|audit|diagnostics`. + * + * @param {string} playerId GM. + * @param {object[]} words Words. + * @returns {void} + */ +function commandReport(playerId, words) { + const verb = (words[0]?.text || 'missed').toLowerCase(); + const parsed = parseOptions(words.slice(1), { status: 'value', page: 'value' }); + if (parsed.error) { + fail(playerId, parsed.error, parsed.vars); + return; + } + const l = lang(); + const page = Math.max(1, parseStrictInt(parsed.opts.page) || 1); + const s = getState(); + const pageOf = (list) => list.slice((page - 1) * 15, page * 15); + switch (verb) { + case 'missed': { + const status = parsed.opts.status || 'open'; + const list = Object.values(s.incidents) + .filter( + (i) => + status === 'all' || (status === 'open' ? i.status !== 'resolved' : i.status === status) + ) + .sort((a, b) => b.createdAt - a.createdAt); + const rows = pageOf(list).map( + (i) => + `
${escapeHtml(i.id)} ${escapeHtml(t(`causes.${i.cause}`, l))} ×${i.count} · ${escapeHtml(t(`incidentStatus.${i.status}`, l))}${i.status === 'open' ? buildSecondaryButton(t('buttons.acknowledge', l), cmd('recovery', 'acknowledge', '--incident', i.id)) : ''}${i.proposal?.mode === 'runOnce' && i.status !== 'resolved' ? buildButton(t('buttons.recoverRun', l), cmd('recovery', 'preview', '--incident', i.id, '--mode', 'runOnce')) : ''}
` + ); + whisper( + playerId, + t('titles.incidents', l), + rows.length ? rows : escapeHtml(t('dashboard.noIncidents', l)) + ); + return; + } + case 'runs': { + const rows = pageOf([...s.history].reverse()).map( + (h) => + `
${escapeHtml(h.id)} ${escapeHtml(h.definitionId)} · ${escapeHtml(t(`statuses.${h.status}`, l))} · ${escapeHtml(formatDuration(h.durationMs))}
` + ); + whisper( + playerId, + t('titles.history', l), + rows.length ? rows : escapeHtml(t('dashboard.noHistory', l)) + ); + return; + } + case 'audit': { + const rows = pageOf([...s.audit].reverse()).map( + (a) => + `
${escapeHtml(formatUtc(a.at))} ${escapeHtml(a.kind)} ${escapeHtml( + truncate( + Object.entries(a) + .filter(([k]) => !['at', 'kind'].includes(k)) + .map(([k, v]) => `${k}=${v}`) + .join(' '), + 120 + ) + )}
` + ); + whisper(playerId, t('titles.audit', l), rows); + return; + } + case 'diagnostics': { + const stats = runtime.scheduler?.stats() || {}; + const rows = [ + buildRow('jobs', escapeHtml(`${stats.jobs} (${stats.stale} stale)`)), + buildRow('next', escapeHtml(stats.nextDueAt ? formatUtc(stats.nextDueAt) : '—')), + buildRow('armed', escapeHtml(stats.armed ? 'yes' : 'no')), + buildRow('drains/yields', escapeHtml(`${stats.drains}/${stats.yields}`)), + buildRow('maxDrainMs', escapeHtml(String(stats.maxDrainMs))), + buildRow('clockJumps', escapeHtml(String(stats.clockJumps))), + buildRow('state', escapeHtml(`${Math.round(stateSize() / 1024)} KiB`)), + buildRow('boot', escapeHtml(`${s.boot.id} @ ${formatUtc(s.boot.at)}`)), + buildRow( + 'adapters', + escapeHtml( + listAdapters() + .map((a) => a.name) + .join(', ') || '—' + ) + ), + ]; + whisper(playerId, t('titles.diagnostics', l), rows); + return; + } + default: + fail(playerId, 'unknownCommand', { command: `report ${verb}` }); + } +} + +/** + * `recovery preview|apply|acknowledge`. + * + * @param {string} playerId GM. + * @param {object[]} words Words. + * @returns {void} + */ +function commandRecovery(playerId, words) { + const verb = (words[0]?.text || '').toLowerCase(); + const parsed = parseOptions(words.slice(1), { incident: 'value', mode: 'value', token: 'value' }); + if (parsed.error) { + fail(playerId, parsed.error, parsed.vars); + return; + } + const { opts } = parsed; + const l = lang(); + const s = getState(); + switch (verb) { + case 'acknowledge': + report( + playerId, + { + ok: Boolean(acknowledgeIncident(opts.incident, playerId)), + error: 'incidentNotFound', + vars: { id: opts.incident || '' }, + }, + t('messages.acknowledged', l, { id: opts.incident || '' }) + ); + return; + case 'preview': { + const incident = s.incidents[opts.incident]; + if (!incident || incident.status === 'resolved') { + fail(playerId, 'incidentNotFound', { id: opts.incident || '' }); + return; + } + const mode = opts.mode || 'runOnce'; + if (!['runOnce', 'skip'].includes(mode)) { + fail(playerId, 'badMode', { modes: 'runOnce, skip' }); + return; + } + const token = issueConfirmation(playerId, 'recovery', { + id: incident.id, + mode, + updatedAt: incident.updatedAt, + }); + whisper(playerId, t('titles.recovery', l), [ + buildRow(t('labels.incident', l), escapeHtml(incident.id)), + buildRow(t('labels.cause', l), escapeHtml(t(`causes.${incident.cause}`, l))), + buildRow(t('labels.missed', l), escapeHtml(String(incident.count))), + incident.range + ? buildRow( + t('labels.range', l), + escapeHtml(`${formatUtc(incident.range.from)} → ${formatUtc(incident.range.to)}`) + ) + : '', + `
${escapeHtml(t(mode === 'runOnce' ? 'messages.recoveryRunOnce' : 'messages.recoverySkip', l))}
`, + buildButton(t('buttons.apply', l), cmd('recovery', 'apply', '--token', token)), + ]); + return; + } + case 'apply': { + const confirmation = consumeConfirmation(opts.token || '', playerId); + if (!confirmation.ok || confirmation.op !== 'recovery') { + fail(playerId, confirmation.reason || 'confirmationUnknown'); + return; + } + const incident = s.incidents[confirmation.payload.id]; + if ( + !incident || + incident.status === 'resolved' || + incident.updatedAt !== confirmation.payload.updatedAt + ) { + fail(playerId, 'stale', { id: confirmation.payload.id, rev: '—' }); + return; + } + const result = applyRecovery(incident, confirmation.payload.mode, playerId); + report( + playerId, + result, + t('messages.recovered', l, { id: incident.id, run: result.run || '—' }) + ); + return; + } + default: + fail(playerId, 'unknownCommand', { command: `recovery ${verb}` }); + } +} + +/** + * `config show|set `. + * + * @param {string} playerId GM. + * @param {object[]} words Words. + * @returns {void} + */ +function commandConfig(playerId, words) { + const verb = (words[0]?.text || 'show').toLowerCase(); + const l = lang(); + if (verb === 'show') { + const rows = Object.keys(DEFAULT_CONFIG).map((key) => + buildRow(key, escapeHtml(String(getConfig()[key]))) + ); + whisper(playerId, t('titles.config', l), rows); + return; + } + if (verb === 'set') { + const key = words[1]?.text || ''; + const result = setConfigValue(key, words[2]?.text); + if (!result.valid) { + fail(playerId, 'badConfig', { key }); + return; + } + markDirty(); + ok(playerId, t('messages.configSet', l, { key, value: String(result.value) })); + return; + } + fail(playerId, 'unknownCommand', { command: `config ${verb}` }); +} + +/** + * `adapter list`. + * + * @param {string} playerId GM. + * @returns {void} + */ +function commandAdapter(playerId) { + const l = lang(); + const rows = listAdapters().map( + (a) => + `
${escapeHtml(a.name)} ${escapeHtml(String(a.version || ''))}: ${escapeHtml(Object.keys(a.actions).join(', '))}
` + ); + whisper( + playerId, + t('titles.adapters', l), + rows.length ? rows : escapeHtml(t('dashboard.noAdapters', l)) + ); +} + +/** + * `stop-all --preview` / `--confirm `: holds new admissions and + * cancels active work. Already dispatched actions cannot be retracted. + * + * @param {string} playerId GM. + * @param {object[]} words Words. + * @returns {void} + */ +function commandStopAll(playerId, words) { + const parsed = parseOptions(words, { preview: 'flag', confirm: 'value' }); + if (parsed.error) { + fail(playerId, parsed.error, parsed.vars); + return; + } + const l = lang(); + const s = getState(); + const active = listInstances((i) => ACTIVE_STATUSES.has(i.status)); + const queued = Object.values(s.queues).reduce((n, q) => n + q.length, 0); + if (!parsed.opts.confirm) { + const token = issueConfirmation(playerId, 'stopAll', { count: active.length }); + whisper(playerId, t('titles.confirm', l), [ + `
${escapeHtml(t('messages.stopAllPreview', l, { runs: active.length, queued }))}
`, + buildButton(t('buttons.confirmStop', l), cmd('stop-all', '--confirm', token)), + ]); + return; + } + const confirmation = consumeConfirmation(parsed.opts.confirm, playerId); + if (!confirmation.ok || confirmation.op !== 'stopAll') { + fail(playerId, confirmation.reason || 'confirmationUnknown'); + return; + } + s.halted = { at: now(), by: playerId }; + let cancelled = 0; + for (const inst of active) { + if ( + ACTIVE_STATUSES.has(getInstance(inst.id)?.status) && + cancelRun(inst.id, playerId, 'stopAll').ok + ) { + cancelled += 1; + } + } + s.queues = {}; + audit('stopAll', { by: playerId, cancelled }); + markDirty(); + ok(playerId, t('messages.stopped', l, { count: cancelled }), [ + buildButton(t('dashboard.resumeAll', l), cmd('resume-all')), + ]); +} + +/** + * `resume-all`: allows new admissions again after stop-all. + * + * @param {string} playerId GM. + * @returns {void} + */ +function commandResumeAll(playerId) { + const s = getState(); + if (!s.halted) { + fail(playerId, 'notHalted'); + return; + } + s.halted = null; + audit('resumeAll', { by: playerId }); + markDirty(); + ok(playerId, t('messages.resumedAll', lang())); +} diff --git a/TaskAutomation/src/constants.js b/TaskAutomation/src/constants.js new file mode 100644 index 0000000000..4ab4d7972e --- /dev/null +++ b/TaskAutomation/src/constants.js @@ -0,0 +1,325 @@ +// Build-time placeholders replaced by rollup.config.js inject-build-metadata plugin. +export const SCRIPT_NAME = '__SCRIPT_NAME__'; +export const SCRIPT_VERSION = '__BUILD_VERSION__'; +export const SCRIPT_LAST_UPDATED = '__BUILD_DATE__'; + +export const DEFAULT_LOCALE = 'en-US'; + +export const STATE_KEY = 'TaskAutomation'; +// Persistent state layout version. Definitions carry their own schemaVersion. +export const SCHEMA_VERSION = 1; +export const DEFINITION_SCHEMA_VERSION = 1; +export const COMMAND = '!taskautomation'; + +export const HANDOUT_DASHBOARD_NAME = 'TaskAutomation - Dashboard'; +export const HANDOUT_HELP_NAME = 'TaskAutomation - Help'; +export const HANDOUT_PLAYER_NAME = 'TaskAutomation - Player View'; +export const HANDOUT_EXPORT_NAME = 'TaskAutomation - Export'; + +export const AUTHOR_LINKS = { + maintainer: { + name: 'MidNiteShadow7', + profile: 'https://app.roll20.net/users/16506286/midniteshadow7', + github: 'https://github.com/steverobertsuk', + }, +}; + +// Instance states (plan §4). +export const STATUS = Object.freeze({ + QUEUED: 'queued', + RUNNING: 'running', + WAITING: 'waiting', + PAUSED: 'paused', + REVIEW: 'review', + COMPLETED: 'completed', + FAILED: 'failed', + CANCELLED: 'cancelled', + EXPIRED: 'expired', +}); +export const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'expired']); +export const ACTIVE_STATUSES = new Set(['queued', 'running', 'waiting', 'paused', 'review']); + +// Typed error codes shared by commands, run journals and the dashboard (plan §10). +export const ERROR_CODES = Object.freeze({ + VALIDATION: 'validation', + UNAUTHORIZED: 'unauthorized', + MISSING_TARGET: 'missingTarget', + UNAVAILABLE: 'unavailable', + CONFLICT: 'conflict', + TIMEOUT: 'timeout', + UNCERTAIN: 'uncertain', + RESOURCE_LIMIT: 'resourceLimit', + RUNAWAY: 'runaway', + STALE: 'stale', + INTERNAL: 'internal', +}); + +// Replay classes for actions (plan §8.1). `ephemeral` is a non-idempotent +// visual effect (ping, spawned effect) whose uncertain outcome is skipped +// rather than replayed or reviewed. +export const REPLAY = Object.freeze({ + PURE: 'pure', + IDEMPOTENT: 'idempotent', + NON_IDEMPOTENT: 'nonIdempotent', + ACKNOWLEDGED: 'acknowledged', + EPHEMERAL: 'ephemeral', + INTERNAL: 'internal', +}); + +// Scheduler job kinds. +export const JOB = Object.freeze({ + OCCURRENCE: 'occurrence', + CONTINUE: 'continue', + RETRY: 'retry', + DEBOUNCE: 'debounce', + AWAIT_TIMEOUT: 'awaitTimeout', + ASYNC_TIMEOUT: 'asyncTimeout', + QUEUE_EXPIRY: 'queueExpiry', + DASHBOARD: 'dashboard', + MAINTENANCE: 'maintenance', + CHECKPOINT: 'checkpoint', + RECOVERY: 'recovery', +}); + +// Lower numbers run first when two jobs share a deadline. Time always wins. +export const JOB_PRIORITY = Object.freeze({ + recovery: 0, + continue: 10, + retry: 10, + awaitTimeout: 10, + asyncTimeout: 10, + occurrence: 20, + debounce: 20, + queueExpiry: 30, + dashboard: 40, + checkpoint: 50, + maintenance: 60, +}); + +// Scheduler safeguards (plan §3.1). Tunable application limits, not Roll20 quotas. +export const SCHEDULER_DEFAULTS = Object.freeze({ + maxJobsPerDrain: 25, + maxDrainMs: 15, + yieldMs: 10, + // Conservative ceiling below the 2^31-1 ms host limit; long waits re-check on wake. + maxHostDelayMs: 2_147_000_000, + compactRatio: 0.5, + compactMinimum: 64, +}); + +// Product defaults from plan §12.1, enforced again at runtime. +export const DEFAULT_LIMITS = Object.freeze({ + definitions: 100, + activeInstances: 100, + branchesPerRun: 10, + nestingDepth: 8, + loopIterations: 100, + consecutiveSteps: 500, + stepsPerJob: 50, + queuedPerDefinition: 100, + catchUpPerGroup: 10, + minIntervalMs: 1000, + minWaitMs: 100, + targetsPerChunk: 20, + payloadBytes: 32 * 1024, + importBytes: 256 * 1024, + stateBytes: 1024 * 1024, + ancestryDepth: 8, + descendantsPerRun: 50, + stepsPerDefinition: 500, + expressionDepth: 16, + expressionNodes: 200, + stringLength: 2000, + nameLength: 100, + historyRuns: 200, + auditEntries: 1000, + openIncidents: 100, + timelinePerRun: 50, + queryResults: 20, + admissionsPerMinute: 60, + playerCommandsPerMinute: 30, + burstPerTrigger: 20, + echoWindowMs: 1500, + chatPerMinute: 30, + writesPerJob: 40, + awaitTimeoutMaxMs: 7 * 24 * 60 * 60 * 1000, +}); + +// Timing defaults (plan §7.2, §9). +export const TIMING = Object.freeze({ + graceMs: 250, + lateWindowMs: 5 * 60 * 1000, + dashboardSpacingMs: 2000, + dashboardCoalesceMs: 500, + dashboardMaxDirtyMs: 5000, + confirmationTtlMs: 5 * 60 * 1000, + importTtlMs: 10 * 60 * 1000, + asyncTimeoutMs: 30 * 1000, + maintenanceMs: 10 * 60 * 1000, + recreateHandoutMs: 60 * 1000, + recoverySummaryMs: 60 * 1000, + clockJumpMs: 5000, +}); + +// Default configuration for new installs. +export const DEFAULT_CONFIG = { + language: DEFAULT_LOCALE, + dashboardRows: 25, + playerView: false, + diagnostics: false, + checkpointMs: 0, + privateChatTriggers: false, + roundAnchor: 'Round', + graceMs: TIMING.graceMs, + lateWindowMs: TIMING.lateWindowMs, +}; + +// Settings the GM can change on the Mod's page in the API Scripts screen. +export const USEROPTION_KEYS = ['dashboardRows', 'playerView', 'diagnostics']; + +export const DASHBOARD_ROW_OPTIONS = [10, 25, 50]; + +// Dashboard sections, in navigation order. +export const DASHBOARD_VIEWS = [ + 'overview', + 'definitions', + 'instances', + 'queue', + 'incidents', + 'history', + 'integrations', +]; + +// Duration units accepted by commands (plan §13). Months and years are +// deliberately absent: they are not fixed lengths. +export const DURATION_UNITS = Object.freeze({ + ms: 1, + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, +}); + +export const WEEKDAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; + +// Token properties a task may read or watch, and the ones `token.set` may write. +export const TOKEN_READ_PROPERTIES = [ + 'name', + 'left', + 'top', + 'width', + 'height', + 'rotation', + 'layer', + 'pageid', + 'represents', + 'bar1_value', + 'bar1_max', + 'bar2_value', + 'bar2_max', + 'bar3_value', + 'bar3_max', + 'statusmarkers', + 'tint_color', + 'currentSide', + 'controlledby', + 'fliph', + 'flipv', + 'showname', +]; +export const TOKEN_WRITE_PROPERTIES = { + left: 'number', + top: 'number', + rotation: 'number', + layer: 'layer', + bar1_value: 'bar', + bar1_max: 'bar', + bar2_value: 'bar', + bar2_max: 'bar', + bar3_value: 'bar', + bar3_max: 'bar', + tint_color: 'color', + fliph: 'boolean', + flipv: 'boolean', + showname: 'boolean', + name: 'string', +}; +// Writes only a GM-authority run may make. +export const TOKEN_GM_PROPERTIES = new Set(['layer', 'name']); +export const LAYERS = ['objects', 'gmlayer', 'map', 'walls', 'foreground']; + +export const ATTRIBUTE_READ_PROPERTIES = ['name', 'current', 'max', 'characterid']; +export const CHARACTER_READ_PROPERTIES = ['name', 'controlledby', 'inplayerjournals']; +export const PLAYER_READ_PROPERTIES = ['displayname', 'online']; +export const PAGE_READ_PROPERTIES = ['name', 'width', 'height']; + +// Built-in visual effects that `effect.spawn` accepts without GM approval. +export const FX_TYPES = [ + 'bomb', + 'bubbling', + 'burn', + 'burst', + 'explode', + 'glow', + 'missile', + 'nova', + 'splatter', +]; +export const FX_COLORS = [ + 'acid', + 'blood', + 'charm', + 'death', + 'fire', + 'frost', + 'holy', + 'magic', + 'slime', + 'smoke', + 'water', +]; + +// Commands listed by `help` and the Help handout, with their help keys. +export const HELP_COMMANDS = [ + ['', 'menu'], + ['help', 'help'], + ['dashboard open|refresh|view
[]', 'dashboard'], + ['definition list|show|create|validate|publish', 'definition'], + ['definition enable|disable|clone|archive|purge', 'definitionManage'], + ['definition set|param|binding|access|policy|trigger|step', 'definitionEdit'], + ['definition export|import', 'definitionTransfer'], + ['template list|add ', 'template'], + ['instance start|dry-run --definition ', 'instanceStart'], + ['instance list|inspect|pause|resume|cancel|rerun', 'instance'], + ['instance decide --id --action ', 'instanceDecide'], + ['schedule add|list|pause|resume|remove|offset', 'schedule'], + ['signal [--value ]', 'signal'], + ['report missed|runs|audit|diagnostics', 'report'], + ['recovery preview|apply|acknowledge', 'recovery'], + ['config show|set ', 'config'], + ['adapter list', 'adapter'], + ['stop-all --preview|--confirm ', 'stopAll'], + ['resume-all', 'resumeAll'], +]; + +// Colour palette for chat cards and handouts, shared with the other MidNiteShadow7 Mods. +export const COLOR_HEADER_BG = '#1d2a3a'; +export const COLOR_HEADER_TEXT = '#e6edf5'; +export const COLOR_CARD_BG_TOP = '#26394f'; +export const COLOR_CARD_BG_BOTTOM = '#17222f'; +export const COLOR_BORDER = '#3a5573'; +export const COLOR_TEXT = '#e2e8f0'; +export const COLOR_MUTED = '#9aabbd'; +export const COLOR_ACCENT = '#f6ae2d'; +export const COLOR_BUTTON_BG = '#33658a'; +export const COLOR_BUTTON_TEXT = '#ffffff'; +export const COLOR_BUTTON_ALT_BG = '#24384d'; +export const COLOR_BUTTON_ALT_BORDER = '#4a6a8c'; +export const COLOR_DANGER_BG = '#8a2c2c'; +export const COLOR_OK = '#7bd389'; +export const COLOR_WARNING_BG = '#fef3c7'; +export const COLOR_WARNING_BORDER = '#92400e'; +export const COLOR_WARNING_TEXT = '#92400e'; +export const COLOR_ERROR_BG = '#fee2e2'; +export const COLOR_ERROR_BORDER = '#991b1b'; +export const COLOR_ERROR_TEXT = '#991b1b'; diff --git a/TaskAutomation/src/dashboard.js b/TaskAutomation/src/dashboard.js new file mode 100644 index 0000000000..5d98813dc4 --- /dev/null +++ b/TaskAutomation/src/dashboard.js @@ -0,0 +1,1061 @@ +// GM dashboard handout and optional player view (plan §9). Content is a +// snapshot rendered from state, written at most every two seconds through +// one scheduler job, skipped when unchanged, and never trusted back. + +import { openIncidents } from './audit.js'; +import { whisper } from './chat.js'; +import { + ACTIVE_STATUSES, + COLOR_ACCENT, + COLOR_BORDER, + COLOR_BUTTON_ALT_BG, + COLOR_BUTTON_ALT_BORDER, + COLOR_BUTTON_BG, + COLOR_BUTTON_TEXT, + COLOR_CARD_BG_BOTTOM, + COLOR_DANGER_BG, + COLOR_HEADER_BG, + COLOR_HEADER_TEXT, + COLOR_MUTED, + COLOR_OK, + COLOR_TEXT, + COMMAND, + DASHBOARD_VIEWS, + HANDOUT_DASHBOARD_NAME, + HANDOUT_PLAYER_NAME, + JOB, + SCRIPT_NAME, + SCRIPT_VERSION, + STATUS, + TIMING, +} from './constants.js'; +import { getSnapshot, listRecords } from './definitions.js'; +import { currentStep, listInstances } from './executor.js'; +import { createObject, findObjects, getObject, logLine, now } from './host.js'; +import { t } from './i18n.js'; +import { listAdapters } from './integrations.js'; +import { targetLabel } from './objects.js'; +import { onJob, runtime, schedule } from './runtime.js'; +import { listSchedules } from './schedules.js'; +import { getConfig, getState, stateSize, getLimits } from './state.js'; +import { escapeHtml, formatDuration, formatUtc, hashText, quoteArg, truncate } from './utils.js'; + +const S = { + outer: `background:${COLOR_CARD_BG_BOTTOM};color:${COLOR_TEXT};padding:12px 16px;border:1px solid ${COLOR_BORDER};border-radius:6px;font-family:Arial,sans-serif;line-height:1.45;`, + header: `background:${COLOR_HEADER_BG};padding:10px;border-radius:4px;text-align:center;border-bottom:3px solid ${COLOR_ACCENT};margin-bottom:8px;`, + h1: `color:${COLOR_HEADER_TEXT};margin:0;font-size:1.5em;letter-spacing:0.05em;`, + sub: `color:${COLOR_MUTED};margin:3px 0 0 0;font-size:0.85em;`, + h2: `color:${COLOR_ACCENT};border-bottom:1px solid ${COLOR_BORDER};padding-bottom:2px;margin:14px 0 6px 0;font-size:1.15em;`, + p: `color:${COLOR_TEXT};margin:4px 0;`, + muted: `color:${COLOR_MUTED};font-size:0.85em;`, + table: 'width:100%;border-collapse:collapse;margin:4px 0;font-size:0.88em;', + th: `background:${COLOR_HEADER_BG};color:${COLOR_HEADER_TEXT};padding:4px 6px;text-align:left;border:1px solid ${COLOR_BORDER};`, + td: `padding:4px 6px;border:1px solid ${COLOR_BORDER};vertical-align:top;color:${COLOR_TEXT};`, + btn: `background:${COLOR_BUTTON_BG};color:${COLOR_BUTTON_TEXT};padding:2px 7px;border-radius:3px;text-decoration:none;font-size:0.85em;display:inline-block;margin:1px 2px;`, + btnAlt: `background:${COLOR_BUTTON_ALT_BG};color:${COLOR_TEXT};border:1px solid ${COLOR_BUTTON_ALT_BORDER};padding:2px 7px;border-radius:3px;text-decoration:none;font-size:0.85em;display:inline-block;margin:1px 2px;`, + btnDanger: `background:${COLOR_DANGER_BG};color:#ffffff;padding:2px 7px;border-radius:3px;text-decoration:none;font-size:0.85em;display:inline-block;margin:1px 2px;font-weight:bold;`, + navOn: `background:${COLOR_ACCENT};color:#1d2a3a;padding:2px 8px;border-radius:3px;text-decoration:none;font-size:0.85em;display:inline-block;margin:1px 2px;font-weight:bold;`, + ok: `color:${COLOR_OK};font-weight:bold;`, + warn: `color:${COLOR_ACCENT};font-weight:bold;`, +}; + +const memory = { dirtySince: null, writing: false, generation: 0, notices: new Map() }; + +/** + * Builds a command link. Only command words, opaque IDs and numbers go into + * links; every value is quoted for the tokenizer and escaped for HTML. + * + * @param {string} label Link text. + * @param {string[]} words Command words after the prefix. + * @param {string} [style] Inline style. + * @returns {string} HTML anchor. + */ +export function commandLink(label, words, style = S.btn) { + const command = [COMMAND, ...words.map((w) => quoteArg(w))].join(' '); + return `${escapeHtml(label)}`; +} + +/** + * Builds a table. + * + * @param {string[]} headings Headings. + * @param {string[][]} rows Cells (trusted HTML). + * @returns {string} HTML. + */ +function table(headings, rows) { + if (!rows.length) { + return ''; + } + const head = headings.map((h) => `${escapeHtml(h)}`).join(''); + const body = rows + .map((cells) => `${cells.map((c) => `${c}`).join('')}`) + .join(''); + return `${head}${body}
`; +} + +/** + * Returns one page of rows. + * + * @param {object[]} items Items. + * @param {number} page 1-based page. + * @returns {{items: object[], page: number, pages: number}} Page. + */ +function paginate(items, page) { + const size = getConfig().dashboardRows; + const pages = Math.max(1, Math.ceil(items.length / size)); + const current = Math.min(Math.max(1, page), pages); + return { items: items.slice((current - 1) * size, current * size), page: current, pages }; +} + +/** + * Renders page navigation for a section. + * + * @param {string} view View. + * @param {{page: number, pages: number}} paged Page info. + * @param {string} lang Locale. + * @returns {string} HTML. + */ +function pager(view, paged, lang) { + if (paged.pages <= 1) { + return ''; + } + const parts = []; + if (paged.page > 1) { + parts.push( + commandLink( + t('dashboard.prev', lang), + ['dashboard', 'view', view, String(paged.page - 1)], + S.btnAlt + ) + ); + } + parts.push( + `${escapeHtml(t('dashboard.page', lang, { page: paged.page, pages: paged.pages }))}` + ); + if (paged.page < paged.pages) { + parts.push( + commandLink( + t('dashboard.next', lang), + ['dashboard', 'view', view, String(paged.page + 1)], + S.btnAlt + ) + ); + } + return `
${parts.join(' ')}
`; +} + +/** + * Formats a deadline as UTC with the remaining time as of this render. + * + * @param {number|null} at Deadline. + * @param {number} renderedAt Render time. + * @returns {string} HTML. + */ +function deadline(at, renderedAt) { + if (!Number.isFinite(at)) { + return '—'; + } + const left = at - renderedAt; + return `${escapeHtml(formatUtc(at))}
${escapeHtml(left >= 0 ? `in ${formatDuration(left)}` : `${formatDuration(-left)} ago`)}`; +} + +/** + * Describes what a run is waiting for. + * + * @param {object} inst Instance. + * @param {string} lang Locale. + * @returns {{text: string, at: number|null}} Wait. + */ +function waitOf(inst, lang) { + const wait = inst.branches.main?.wait; + if (inst.status === STATUS.REVIEW) { + return { text: t(`review.${inst.review?.reason}`, lang), at: null }; + } + if (!wait) { + return { text: '', at: null }; + } + const at = wait.dueAt ?? wait.timeoutAt ?? null; + return { + text: t(`waits.${wait.kind}`, lang, { event: wait.event || '', child: wait.childId || '' }), + at: inst.status === STATUS.PAUSED ? null : at, + }; +} + +/** + * Summarises a definition's triggers. + * + * @param {object|null} def Definition snapshot. + * @returns {string} Text. + */ +function triggerSummary(def) { + return (def?.triggers || []) + .map((tr) => + tr.type === 'event' ? tr.event : tr.type === 'signal' ? `signal:${tr.name}` : tr.type + ) + .join(', '); +} + +/** + * Renders the dashboard HTML for a view. + * + * @param {string} view View name. + * @param {number} page Page. + * @returns {string} HTML. + */ +export function renderDashboard(view, page) { + const s = getState(); + const lang = getConfig().language; + const at = now(); + const nav = DASHBOARD_VIEWS.map((v) => + commandLink( + t(`dashboard.views.${v}`, lang), + ['dashboard', 'view', v], + v === view ? S.navOn : S.btnAlt + ) + ).join(''); + const status = runtime.disabled + ? `${escapeHtml(t('dashboard.statusDisabled', lang))}` + : s.halted + ? `${escapeHtml(t('dashboard.statusHalted', lang))}` + : `${escapeHtml(t('dashboard.statusRunning', lang))}`; + const controls = [ + commandLink(t('dashboard.refresh', lang), ['dashboard', 'refresh'], S.btnAlt), + s.halted + ? commandLink(t('dashboard.resumeAll', lang), ['resume-all'], S.btn) + : commandLink(t('dashboard.stopAll', lang), ['stop-all', '--preview'], S.btnDanger), + commandLink(t('dashboard.templates', lang), ['template', 'list'], S.btnAlt), + commandLink(t('dashboard.help', lang), ['help'], S.btnAlt), + ].join(''); + const sections = { + overview: renderOverview, + definitions: renderDefinitions, + instances: renderInstances, + queue: renderQueue, + incidents: renderIncidents, + history: renderHistory, + integrations: renderIntegrations, + }; + const body = (sections[view] || renderOverview)(page, lang, at); + return [ + `
`, + `

${escapeHtml(SCRIPT_NAME)}

`, + `

${escapeHtml(t('dashboard.generated', lang, { at: formatUtc(at), version: SCRIPT_VERSION }))}

`, + `

${escapeHtml(t('dashboard.generatedNote', lang))}

`, + `
${status} ${controls}
`, + `
${nav}
`, + body, + '
', + ].join(''); +} + +/** + * Overview: counts, runs needing a decision, top incidents and next times. + * + * @param {number} page Page. + * @param {string} lang Locale. + * @param {number} at Render time. + * @returns {string} HTML. + */ +function renderOverview(page, lang, at) { + const s = getState(); + const active = listInstances((i) => ACTIVE_STATUSES.has(i.status)); + const byStatus = (status) => active.filter((i) => i.status === status).length; + const incidents = openIncidents(); + const sched = runtime.scheduler?.stats() || {}; + const counts = [ + [ + t('dashboard.counts.definitions', lang), + listRecords().filter((r) => r.enabled).length + ' / ' + listRecords().length, + ], + [t('dashboard.counts.running', lang), byStatus(STATUS.RUNNING) + byStatus(STATUS.WAITING)], + [t('dashboard.counts.paused', lang), byStatus(STATUS.PAUSED)], + [t('dashboard.counts.review', lang), byStatus(STATUS.REVIEW)], + [t('dashboard.counts.queued', lang), Object.values(s.queues).reduce((n, q) => n + q.length, 0)], + [t('dashboard.counts.incidents', lang), incidents.length], + [t('dashboard.counts.nextJob', lang), sched.nextDueAt ? formatUtc(sched.nextDueAt) : '—'], + ].map(([label, value]) => [escapeHtml(label), escapeHtml(String(value))]); + const review = active + .filter((i) => i.status === STATUS.REVIEW) + .map((inst) => instanceRow(inst, lang, at)); + const next = listSchedules() + .filter((sc) => !sc.paused && !sc.ended && sc.cursor.nextDueAt) + .slice(0, 5) + .map((sc) => [ + escapeHtml(sc.id), + escapeHtml(sc.definitionId), + deadline(sc.cursor.nextDueAt, at), + ]); + return [ + `

${escapeHtml(t('dashboard.views.overview', lang))}

`, + table([t('dashboard.cols.item', lang), t('dashboard.cols.value', lang)], counts), + review.length + ? `

${escapeHtml(t('dashboard.needsDecision', lang))}

` + : '', + table(instanceHeadings(lang), review), + incidents.length + ? `

${escapeHtml(t('dashboard.views.incidents', lang))}

` + : '', + table( + incidentHeadings(lang), + incidents.slice(0, 5).map((i) => incidentRow(i, lang)) + ), + next.length ? `

${escapeHtml(t('dashboard.nextTimes', lang))}

` : '', + table( + [ + t('dashboard.cols.schedule', lang), + t('dashboard.cols.definition', lang), + t('dashboard.cols.due', lang), + ], + next + ), + warnings(lang), + ].join(''); +} + +/** + * Resource and circuit-breaker warnings. + * + * @param {string} lang Locale. + * @returns {string} HTML. + */ +function warnings(lang) { + const items = []; + const size = stateSize(); + if (size > getLimits().stateBytes * 0.75) { + items.push( + t('dashboard.warnState', lang, { + kb: Math.round(size / 1024), + max: Math.round(getLimits().stateBytes / 1024), + }) + ); + } + for (const record of listRecords()) { + if (record.disabledReason === 'circuit') { + items.push(t('dashboard.warnCircuit', lang, { id: record.id })); + } + } + if (getState().dashboard.drift) { + items.push(t('dashboard.warnDrift', lang)); + } + return items.length + ? `

${escapeHtml(t('dashboard.warnings', lang))}

${items.map((x) => `

${escapeHtml(x)}

`).join('')}` + : ''; +} + +/** + * Definitions view. + * + * @param {number} page Page. + * @param {string} lang Locale. + * @param {number} at Render time. + * @returns {string} HTML. + */ +function renderDefinitions(page, lang, at) { + const paged = paginate(listRecords(), page); + const rows = paged.items.map((record) => { + const def = getSnapshot(record.id); + const next = + listSchedules(record.id).find((sc) => !sc.paused && !sc.ended)?.cursor.nextDueAt ?? null; + const actions = [ + def && record.enabled + ? commandLink(t('buttons.start', lang), ['instance', 'start', '--definition', record.id]) + : '', + record.enabled + ? commandLink( + t('buttons.disable', lang), + ['definition', 'disable', '--id', record.id, '--rev', String(record.rev)], + S.btnAlt + ) + : def + ? commandLink( + t('buttons.enable', lang), + ['definition', 'enable', '--id', record.id, '--rev', String(record.rev)], + S.btnAlt + ) + : '', + commandLink(t('buttons.show', lang), ['definition', 'show', '--id', record.id], S.btnAlt), + ].join(''); + const state = record.enabled + ? t('dashboard.enabled', lang) + : t('dashboard.disabled', lang, { reason: record.disabledReason || '—' }); + return [ + `${escapeHtml(truncate(record.name, 60))}
${escapeHtml(record.id)} r${record.current || '—'}${record.draft && record.current && record.draft.revision > record.current ? ' +draft' : ''}`, + escapeHtml(state), + escapeHtml(triggerSummary(def)), + deadline(next, at), + actions, + ]; + }); + return [ + `

${escapeHtml(t('dashboard.views.definitions', lang))}

`, + rows.length + ? table( + [ + t('dashboard.cols.definition', lang), + t('dashboard.cols.state', lang), + t('dashboard.cols.triggers', lang), + t('dashboard.cols.next', lang), + '', + ], + rows + ) + : `

${escapeHtml(t('dashboard.noDefinitions', lang))}

`, + pager('definitions', paged, lang), + ].join(''); +} + +/** + * Column headings for run rows. + * + * @param {string} lang Locale. + * @returns {string[]} Headings. + */ +function instanceHeadings(lang) { + return [ + t('dashboard.cols.run', lang), + t('dashboard.cols.status', lang), + t('dashboard.cols.targets', lang), + t('dashboard.cols.step', lang), + t('dashboard.cols.due', lang), + '', + ]; +} + +/** + * Renders one run row with its controls. + * + * @param {object} inst Instance. + * @param {string} lang Locale. + * @param {number} at Render time. + * @returns {string[]} Cells. + */ +function instanceRow(inst, lang, at) { + const rev = String(inst.revision); + const wait = waitOf(inst, lang); + const targets = Object.values(inst.bindings).flat(); + const actions = []; + if (inst.status === STATUS.RUNNING || inst.status === STATUS.WAITING) { + actions.push( + commandLink( + t('buttons.pause', lang), + ['instance', 'pause', '--id', inst.id, '--rev', rev], + S.btnAlt + ) + ); + } + if (inst.status === STATUS.PAUSED) { + actions.push( + commandLink(t('buttons.resume', lang), ['instance', 'resume', '--id', inst.id, '--rev', rev]) + ); + } + if (inst.status === STATUS.REVIEW) { + for (const decision of decisionsFor(inst)) { + actions.push( + commandLink(t(`decisions.${decision}`, lang), [ + 'instance', + 'decide', + '--id', + inst.id, + '--action', + decision, + '--rev', + rev, + ]) + ); + } + } + if (ACTIVE_STATUSES.has(inst.status) && inst.status !== STATUS.REVIEW) { + actions.push( + commandLink( + t('buttons.cancel', lang), + ['instance', 'cancel', '--id', inst.id, '--rev', rev], + S.btnAlt + ) + ); + } + actions.push( + commandLink(t('buttons.inspect', lang), ['instance', 'inspect', '--id', inst.id], S.btnAlt) + ); + const late = + inst.latenessMs > getConfig().graceMs + ? `
${escapeHtml(t('dashboard.late', lang, { late: formatDuration(inst.latenessMs) }))}` + : ''; + return [ + `${escapeHtml(inst.id)}
${escapeHtml(inst.definitionId)} r${inst.definitionRevision}`, + `${escapeHtml(t(`statuses.${inst.status}`, lang))}${wait.text ? `
${escapeHtml(wait.text)}` : ''}${late}`, + escapeHtml( + targets.slice(0, 3).map(targetLabel).join(', ') + + (targets.length > 3 ? ` +${targets.length - 3}` : '') + ), + escapeHtml(currentStep(inst)), + deadline(wait.at, at), + actions.join(''), + ]; +} + +/** + * Lists the decisions offered for a run in review. + * + * @param {object} inst Instance. + * @returns {string[]} Decisions. + */ +export function decisionsFor(inst) { + switch (inst.review?.reason) { + case 'overdueWait': + return ['complete', 'skipNext', 'cancel']; + case 'uncertainOutcome': + case 'restartConflict': + return ['complete', 'retry', 'skip', 'cancel']; + default: + return ['retry', 'skip', 'cancel']; + } +} + +/** + * Runs view: everything not finished. + * + * @param {number} page Page. + * @param {string} lang Locale. + * @param {number} at Render time. + * @returns {string} HTML. + */ +function renderInstances(page, lang, at) { + const order = { review: 0, paused: 1, running: 2, waiting: 3, queued: 4 }; + const active = listInstances((i) => ACTIVE_STATUSES.has(i.status)).sort( + (a, b) => order[a.status] - order[b.status] + ); + const paged = paginate(active, page); + return [ + `

${escapeHtml(t('dashboard.views.instances', lang))}

`, + paged.items.length + ? table( + instanceHeadings(lang), + paged.items.map((i) => instanceRow(i, lang, at)) + ) + : `

${escapeHtml(t('dashboard.noRuns', lang))}

`, + pager('instances', paged, lang), + ].join(''); +} + +/** + * Queue view. + * + * @param {number} page Page. + * @param {string} lang Locale. + * @param {number} at Render time. + * @returns {string} HTML. + */ +function renderQueue(page, lang, at) { + const entries = Object.values(getState().queues).flat(); + const paged = paginate(entries, page); + const rows = paged.items.map((e) => [ + escapeHtml(e.id), + escapeHtml(e.definitionId), + escapeHtml(formatUtc(e.admittedAt)), + deadline(e.expiresAt, at), + ]); + return [ + `

${escapeHtml(t('dashboard.views.queue', lang))}

`, + rows.length + ? table( + [ + t('dashboard.cols.entry', lang), + t('dashboard.cols.definition', lang), + t('dashboard.cols.admitted', lang), + t('dashboard.cols.expires', lang), + ], + rows + ) + : `

${escapeHtml(t('dashboard.noQueue', lang))}

`, + pager('queue', paged, lang), + ].join(''); +} + +/** + * Column headings for incident rows. + * + * @param {string} lang Locale. + * @returns {string[]} Headings. + */ +function incidentHeadings(lang) { + return [ + t('dashboard.cols.incident', lang), + t('dashboard.cols.cause', lang), + t('dashboard.cols.range', lang), + t('dashboard.cols.count', lang), + '', + ]; +} + +/** + * Renders one incident row. + * + * @param {object} incident Incident. + * @param {string} lang Locale. + * @returns {string[]} Cells. + */ +function incidentRow(incident, lang) { + const actions = []; + if (incident.status === 'open') { + actions.push( + commandLink( + t('buttons.acknowledge', lang), + ['recovery', 'acknowledge', '--incident', incident.id], + S.btnAlt + ) + ); + } + if (incident.kind === 'missed' && incident.proposal?.mode === 'runOnce') { + actions.push( + commandLink(t('buttons.recoverRun', lang), [ + 'recovery', + 'preview', + '--incident', + incident.id, + '--mode', + 'runOnce', + ]) + ); + actions.push( + commandLink( + t('buttons.recoverSkip', lang), + ['recovery', 'preview', '--incident', incident.id, '--mode', 'skip'], + S.btnAlt + ) + ); + } + if (incident.runId) { + actions.push( + commandLink( + t('buttons.inspect', lang), + ['instance', 'inspect', '--id', incident.runId], + S.btnAlt + ) + ); + } + const target = incident.scheduleId || incident.runId || incident.definitionId || ''; + const range = incident.range + ? `${formatUtc(incident.range.from)} → ${formatUtc(incident.range.to)}` + : '—'; + return [ + `${escapeHtml(incident.id)}
${escapeHtml(target)} · ${escapeHtml(t(`incidentStatus.${incident.status}`, lang))}`, + `${escapeHtml(t(`causes.${incident.cause}`, lang))}${incident.policy ? `
${escapeHtml(incident.policy)}` : ''}`, + escapeHtml(range), + escapeHtml(String(incident.count)), + actions.join(''), + ]; +} + +/** + * Incidents view. + * + * @param {number} page Page. + * @param {string} lang Locale. + * @returns {string} HTML. + */ +function renderIncidents(page, lang) { + const paged = paginate(openIncidents(), page); + return [ + `

${escapeHtml(t('dashboard.views.incidents', lang))}

`, + paged.items.length + ? table( + incidentHeadings(lang), + paged.items.map((i) => incidentRow(i, lang)) + ) + : `

${escapeHtml(t('dashboard.noIncidents', lang))}

`, + pager('incidents', paged, lang), + ].join(''); +} + +/** + * History view: recent finished runs. + * + * @param {number} page Page. + * @param {string} lang Locale. + * @returns {string} HTML. + */ +function renderHistory(page, lang) { + const items = [...getState().history].reverse(); + const paged = paginate(items, page); + const rows = paged.items.map((h) => [ + `${escapeHtml(h.id)}
${escapeHtml(h.definitionId)} r${h.revision}`, + `${escapeHtml(t(`statuses.${h.status}`, lang))}${h.error ? `
${escapeHtml(truncate(t(`runErrors.${h.error.reason}`, lang, h.error.vars || {}), 120))}${h.error.stepId ? ` @ ${escapeHtml(h.error.stepId)}` : ''}` : ''}`, + escapeHtml(formatUtc(h.completedAt)), + escapeHtml(formatDuration(h.durationMs)), + escapeHtml( + `${h.effects}${h.uncertain ? ` / ${t('dashboard.uncertain', lang, { count: h.uncertain })}` : ''}` + ), + commandLink(t('buttons.rerun', lang), ['instance', 'rerun', '--id', h.id], S.btnAlt), + ]); + return [ + `

${escapeHtml(t('dashboard.views.history', lang))}

`, + rows.length + ? table( + [ + t('dashboard.cols.run', lang), + t('dashboard.cols.status', lang), + t('dashboard.cols.finished', lang), + t('dashboard.cols.duration', lang), + t('dashboard.cols.effects', lang), + '', + ], + rows + ) + : `

${escapeHtml(t('dashboard.noHistory', lang))}

`, + pager('history', paged, lang), + ].join(''); +} + +/** + * Integrations and diagnostics view. + * + * @param {number} page Page. + * @param {string} lang Locale. + * @returns {string} HTML. + */ +function renderIntegrations(page, lang) { + const adapters = listAdapters().map((a) => [ + escapeHtml(a.name), + escapeHtml(String(a.version || '')), + escapeHtml(Object.keys(a.actions).join(', ')), + escapeHtml(formatUtc(a.registeredAt)), + ]); + const stats = runtime.scheduler?.stats() || {}; + const s = getState(); + const diag = [ + ['jobs', stats.jobs], + ['stale', stats.stale], + ['next', stats.nextDueAt ? formatUtc(stats.nextDueAt) : '—'], + ['armed', stats.armed ? 'yes' : 'no'], + ['drains', stats.drains], + ['yields', stats.yields], + ['maxDrainMs', stats.maxDrainMs], + ['clockJumps', stats.clockJumps], + ['stateKb', Math.round(stateSize() / 1024)], + ['boot', `${s.boot.id} (${formatUtc(s.boot.at)})`], + ['admitted', s.stats.admitted], + ['suppressed', s.stats.suppressed], + ['dropped', s.stats.dropped], + ['coalesced', s.stats.coalesced], + ].map(([k, v]) => [escapeHtml(t(`dashboard.diag.${k}`, lang)), escapeHtml(String(v ?? '—'))]); + return [ + `

${escapeHtml(t('dashboard.views.integrations', lang))}

`, + adapters.length + ? table( + [ + t('dashboard.cols.adapter', lang), + t('dashboard.cols.version', lang), + t('dashboard.cols.actions', lang), + t('dashboard.cols.registered', lang), + ], + adapters + ) + : `

${escapeHtml(t('dashboard.noAdapters', lang))}

`, + `

${escapeHtml(t('dashboard.diagnostics', lang))}

`, + table([t('dashboard.cols.item', lang), t('dashboard.cols.value', lang)], diag), + ].join(''); +} + +/** + * Renders the player view: only definitions published to players and the + * status of their runs. Never GM rows hidden with styling. + * + * @returns {string} HTML. + */ +export function renderPlayerView() { + const lang = getConfig().language; + const published = listRecords().filter( + (r) => r.enabled && getSnapshot(r.id)?.access?.mode === 'players' + ); + const rows = published.map((r) => { + const def = getSnapshot(r.id); + return [ + escapeHtml(truncate(def.name, 60)), + escapeHtml(truncate(def.description || '', 160)), + commandLink(t('buttons.start', lang), ['instance', 'start', '--definition', r.id]), + ]; + }); + const runs = listInstances( + (i) => + ACTIVE_STATUSES.has(i.status) && + i.authority === 'initiator' && + getSnapshot(i.definitionId, i.definitionRevision)?.access?.mode === 'players' + ).map((i) => [ + escapeHtml(i.id), + escapeHtml(truncate(i.name, 60)), + escapeHtml(t(`statuses.${i.status}`, lang)), + ]); + return [ + `
`, + `

${escapeHtml(t('dashboard.playerTitle', lang))}

`, + `

${escapeHtml(t('dashboard.generated', lang, { at: formatUtc(now()), version: SCRIPT_VERSION }))}

`, + rows.length + ? table([t('dashboard.cols.task', lang), t('dashboard.cols.description', lang), ''], rows) + : `

${escapeHtml(t('dashboard.noPlayerTasks', lang))}

`, + runs.length + ? `

${escapeHtml(t('dashboard.activeRuns', lang))}

${table([t('dashboard.cols.run', lang), t('dashboard.cols.task', lang), t('dashboard.cols.status', lang)], runs)}` + : '', + '
', + ].join(''); +} + +/** + * Returns the dashboard handout, creating it GM-only when missing. A + * deleted handout is recreated at most once a minute. + * + * @returns {object|null} Handout. + */ +export function ensureDashboard() { + const s = getState(); + let handout = s.dashboard.handoutId ? getObject('handout', s.dashboard.handoutId) : null; + if (!handout) { + handout = findObjects({ _type: 'handout', name: HANDOUT_DASHBOARD_NAME })[0] || null; + } + if (!handout) { + const recreating = Boolean(s.dashboard.handoutId); + // Recreate a deleted dashboard at most once a minute, so a GM deleting + // it repeatedly does not start a create/delete loop. + if (recreating && now() - s.dashboard.recreatedAt < TIMING.recreateHandoutMs) { + schedule({ + kind: JOB.DASHBOARD, + dueAt: s.dashboard.recreatedAt + TIMING.recreateHandoutMs, + key: 'dashboard', + }); + return null; + } + handout = createObject('handout', { + name: HANDOUT_DASHBOARD_NAME, + inplayerjournals: '', + controlledby: '', + archived: false, + }); + if (recreating) { + s.dashboard.recreatedAt = now(); + } + s.dashboard.lastHash = null; + } + s.dashboard.handoutId = handout?.id || null; + return handout; +} + +/** + * Checks that the dashboard is still GM-only. Permission drift stops + * sensitive writes until the GM repairs it. + * + * @param {object} handout Handout. + * @returns {boolean} True when private. + */ +function isPrivate(handout) { + const journals = String(handout.get('inplayerjournals') || '').trim(); + const control = String(handout.get('controlledby') || '').trim(); + const s = getState(); + if (journals || control) { + if (!s.dashboard.drift) { + s.dashboard.drift = { at: now(), journals, control }; + noticeGm('drift', t('notices.drift', getConfig().language)); + } + return false; + } + s.dashboard.drift = null; + return true; +} + +/** + * Removes player access from the dashboard after drift (GM command). + * + * @returns {boolean} True when repaired. + */ +export function repairDashboard() { + const handout = ensureDashboard(); + if (!handout) { + return false; + } + handout.set({ inplayerjournals: '', controlledby: '' }); + getState().dashboard.drift = null; + // Content may be stale after the drift: write it again. + getState().dashboard.lastHash = null; + markDirty('repair'); + return true; +} + +/** + * Marks the dashboard dirty. One job writes it: no sooner than the + * coalescing delay, no sooner than two seconds after the last write, and + * no later than five seconds after it first became dirty under normal load. + * + * @returns {void} + */ +export function markDirty() { + if (!runtime.ready || runtime.disabled) { + return; + } + const s = getState(); + const at = now(); + if (memory.dirtySince === null) { + memory.dirtySince = at; + } + const earliest = Math.max( + at + TIMING.dashboardCoalesceMs, + (s.dashboard.lastWriteAt || 0) + TIMING.dashboardSpacingMs + ); + const latest = Math.max( + memory.dirtySince + TIMING.dashboardMaxDirtyMs, + (s.dashboard.lastWriteAt || 0) + TIMING.dashboardSpacingMs + ); + const dueAt = Math.min(earliest, latest); + const existing = runtime.scheduler?.getKey('dashboard'); + if (existing && existing.dueAt <= dueAt) { + return; + } + schedule({ kind: JOB.DASHBOARD, dueAt, key: 'dashboard' }); +} + +/** + * Writes the dashboard (and player view) when content changed. + * + * @param {{force?: boolean}} [options] Write even when the hash matches. + * @returns {void} + */ +export function flushDashboard({ force = false } = {}) { + const s = getState(); + memory.dirtySince = null; + memory.generation += 1; + const handout = ensureDashboard(); + if (!handout) { + return; + } + if (!isPrivate(handout)) { + return; + } + const html = renderDashboard(s.dashboard.view, s.dashboard.page); + const hash = hashText(html.replace(/Generated [^<]*/, '')); + if (force || hash !== s.dashboard.lastHash) { + handout.set('notes', html); + s.dashboard.lastHash = hash; + s.dashboard.lastWriteAt = now(); + } + if (getConfig().playerView) { + writePlayerView(); + } +} + +/** + * Writes the player view handout, visible to the players tasks are published to. + * + * @returns {void} + */ +function writePlayerView() { + const s = getState(); + let handout = s.dashboard.playerHandoutId + ? getObject('handout', s.dashboard.playerHandoutId) + : null; + if (!handout) { + handout = + findObjects({ _type: 'handout', name: HANDOUT_PLAYER_NAME })[0] || + createObject('handout', { + name: HANDOUT_PLAYER_NAME, + inplayerjournals: '', + controlledby: '', + }); + s.dashboard.playerHandoutId = handout?.id || null; + } + if (!handout) { + return; + } + const players = new Set(); + for (const record of listRecords()) { + const access = getSnapshot(record.id)?.access; + if (record.enabled && access?.mode === 'players') { + for (const p of access.players || []) { + players.add(p); + } + } + } + const journals = players.has('all') ? 'all' : [...players].join(','); + if (handout.get('inplayerjournals') !== journals) { + handout.set('inplayerjournals', journals); + } + const html = renderPlayerView(); + const hash = hashText(html.replace(/Generated [^<]*/, '')); + if (hash !== s.dashboard.playerHash) { + handout.set('notes', html); + s.dashboard.playerHash = hash; + } +} + +/** + * Handles a deleted handout: the dashboard is recreated on the next write. + * + * @param {object} obj Destroyed handout. + * @returns {void} + */ +export function onHandoutDestroyed(obj) { + const s = getState(); + if (obj.id === s.dashboard.handoutId) { + logLine('dashboard handout deleted; it will be recreated'); + s.dashboard.lastHash = null; + markDirty(); + } + if (obj.id === s.dashboard.playerHandoutId) { + s.dashboard.playerHandoutId = null; + } +} + +/** + * Changes the shared dashboard view. + * + * @param {string} view View. + * @param {number} page Page. + * @returns {boolean} True when the view exists. + */ +export function setView(view, page = 1) { + if (!DASHBOARD_VIEWS.includes(view)) { + return false; + } + const s = getState(); + s.dashboard.view = view; + s.dashboard.page = Math.max(1, page); + markDirty(); + return true; +} + +/** + * Returns a link to open the dashboard handout from chat. + * + * @returns {string|null} URL. + */ +export function dashboardUrl() { + const id = getState().dashboard.handoutId; + return id ? `http://journal.roll20.net/handout/${id}` : null; +} + +/** + * Whispers a short, rate-limited notice to the GM with a dashboard link. + * Routine history stays in the dashboard; chat stays quiet. + * + * @param {string} kind Notice kind (one per kind per minute). + * @param {string} text Text. + * @returns {void} + */ +export function noticeGm(kind, text) { + const at = now(); + const last = memory.notices.get(kind) || 0; + if (at - last < TIMING.recoverySummaryMs) { + return; + } + memory.notices.set(kind, at); + const url = dashboardUrl(); + const link = url + ? ` ${escapeHtml(t('buttons.dashboard', getConfig().language))}` + : ''; + whisper(null, SCRIPT_NAME, `
${escapeHtml(text)}${link}
`); +} + +/** + * Registers the dashboard job handler. + * + * @returns {void} + */ +export function registerDashboardJobs() { + onJob(JOB.DASHBOARD, () => flushDashboard()); +} diff --git a/TaskAutomation/src/definitions.js b/TaskAutomation/src/definitions.js new file mode 100644 index 0000000000..5d23899f53 --- /dev/null +++ b/TaskAutomation/src/definitions.js @@ -0,0 +1,663 @@ +// Definition service (plan §3, §4): drafts, validation, immutable published +// revisions, enable/disable, clone, import/export and purge. Runs pin a +// revision; editing a definition only affects future runs. + +import { ACTIVE_STATUSES, DEFINITION_SCHEMA_VERSION } from './constants.js'; +import { audit } from './audit.js'; +import { now } from './host.js'; +import { call, maybeCall } from './runtime.js'; +import { ID_PATTERN, compileDefinition, findCallCycle, validateDefinition } from './schema.js'; +import { getLimits, getState } from './state.js'; +import { clone, isRecord, slugify } from './utils.js'; + +const compiledCache = new Map(); + +/** + * Returns a new empty definition body. + * + * @param {string} id Definition ID. + * @param {string} name Name. + * @returns {object} Definition. + */ +export function blankDefinition(id, name) { + return { + schemaVersion: DEFINITION_SCHEMA_VERSION, + id, + revision: 1, + name, + enabled: true, + description: '', + parameters: {}, + bindings: {}, + access: { mode: 'gmOnly', authority: 'initiator' }, + triggers: [{ id: 'manual', type: 'manual' }], + policy: { concurrency: 'skipIfRunning', maxConcurrent: 1, onError: 'fail' }, + steps: [], + }; +} + +/** + * Returns a definition record. + * + * @param {string} id Definition ID. + * @returns {object|null} Record. + */ +export function getRecord(id) { + return getState().definitions[id] || null; +} + +/** + * Returns definition records, sorted by name. + * + * @param {{archived?: boolean}} [options] Include archived records. + * @returns {object[]} Records. + */ +export function listRecords({ archived = false } = {}) { + return Object.values(getState().definitions) + .filter((r) => archived || !r.archived) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Returns a published revision. + * + * @param {string} id Definition ID. + * @param {number} [revision] Revision (current when omitted). + * @returns {object|null} Definition snapshot. + */ +export function getSnapshot(id, revision) { + const record = getRecord(id); + if (!record) { + return null; + } + const rev = revision ?? record.current; + return record.revisions[rev] || null; +} + +/** + * Returns the compiled program of a revision, cached in memory. + * + * @param {string} id Definition ID. + * @param {number} revision Revision. + * @returns {object|null} Compiled program. + */ +export function getCompiled(id, revision) { + const key = `${id}@${revision}`; + if (!compiledCache.has(key)) { + const snap = getSnapshot(id, revision); + if (!snap) { + return null; + } + compiledCache.set(key, compileDefinition(snap)); + } + return compiledCache.get(key); +} + +/** + * Returns a free definition ID based on a name. + * + * @param {string} name Name. + * @returns {string} ID. + */ +export function freeId(name) { + const base = slugify(name) || 'task'; + const defs = getState().definitions; + if (!defs[base]) { + return base; + } + for (let i = 2; i < 1000; i += 1) { + const id = `${base.slice(0, 44)}-${i}`; + if (!defs[id]) { + return id; + } + } + return `${base.slice(0, 36)}-${now().toString(36)}`; +} + +/** + * Creates a definition record with a draft. + * + * @param {object} body Definition body (validated at publish). + * @param {string} by Player ID. + * @returns {{ok: boolean, record?: object, error?: string, vars?: object}} Result. + */ +export function createRecord(body, by) { + const s = getState(); + if (!ID_PATTERN.test(String(body.id ?? ''))) { + return { ok: false, error: 'badId', vars: { id: String(body.id) } }; + } + if (s.definitions[body.id]) { + return { ok: false, error: 'definitionExists', vars: { id: body.id } }; + } + if (Object.keys(s.definitions).length >= getLimits().definitions) { + return { ok: false, error: 'tooManyDefinitions', vars: { max: getLimits().definitions } }; + } + const at = now(); + const record = { + id: body.id, + name: String(body.name || body.id), + rev: 1, + enabled: body.enabled !== false, + archived: false, + disabledReason: null, + draft: clone(body), + current: 0, + revisions: {}, + createdAt: at, + createdBy: by || null, + updatedAt: at, + publishedAt: null, + publishedBy: null, + pausedSchedules: false, + }; + s.definitions[record.id] = record; + audit('definition.create', { definition: record.id, by }); + maybeCall('dashboardDirty', 'definitions'); + return { ok: true, record }; +} + +/** + * Checks an expected management revision. + * + * @param {object} record Record. + * @param {number|null|undefined} expected Revision from a link, if any. + * @returns {boolean} True when it matches or none was given. + */ +export function revisionMatches(record, expected) { + return expected === undefined || expected === null || record.rev === expected; +} + +/** + * Changes a draft through a function and bumps the management revision. + * + * @param {string} id Definition ID. + * @param {(draft: object) => ({ok: boolean, error?: string, vars?: object}|void)} change Mutator. + * @param {string} by Player ID. + * @param {number} [expectedRev] Stale-link guard. + * @returns {{ok: boolean, record?: object, error?: string, vars?: object}} Result. + */ +export function updateDraft(id, change, by, expectedRev) { + const record = getRecord(id); + if (!record) { + return { ok: false, error: 'definitionNotFound', vars: { id } }; + } + if (!revisionMatches(record, expectedRev)) { + return { ok: false, error: 'stale', vars: { id, rev: record.rev } }; + } + const draft = record.draft ? clone(record.draft) : clone(getSnapshot(id)); + const result = change(draft) || { ok: true }; + if (!result.ok) { + return result; + } + record.draft = draft; + if (typeof draft.name === 'string' && draft.name.trim()) { + record.name = draft.name.trim(); + } + record.rev += 1; + record.updatedAt = now(); + audit('definition.edit', { definition: id, by }); + maybeCall('dashboardDirty', 'definitions'); + return { ok: true, record }; +} + +/** + * Validates the draft (or a given body) including cross-definition checks. + * + * @param {object} body Definition body. + * @returns {{ok: boolean, errors: object[], warnings: object[]}} Result. + */ +export function validateBody(body) { + const result = validateDefinition(body, { limits: getLimits() }); + const errors = [...result.errors]; + const warnings = []; + if (result.ok) { + for (const target of result.calls) { + if (target !== body.id && !getSnapshot(target)) { + warnings.push({ path: 'steps', code: 'lateBoundDefinition', vars: { id: target } }); + } + } + const recursion = body.policy?.recursion; + if (!recursion?.allow) { + const callsOf = (id) => (id === body.id ? result.calls : callsInSnapshot(getSnapshot(id))); + const cycle = findCallCycle(body.id, callsOf); + if (cycle) { + errors.push({ path: 'steps', code: 'callCycle', vars: { cycle: cycle.join(' → ') } }); + } + } else if ( + !Number.isInteger(recursion.maxDepth) || + recursion.maxDepth < 1 || + recursion.maxDepth > getLimits().nestingDepth + ) { + errors.push({ + path: 'policy.recursion.maxDepth', + code: 'outOfRange', + vars: { min: 1, max: getLimits().nestingDepth }, + }); + } + for (const adapter of adaptersUsed(body)) { + if (!maybeCall('adapterKnown', adapter)) { + warnings.push({ path: 'steps', code: 'adapterUnknown', vars: { adapter } }); + } + } + } + return { ok: errors.length === 0, errors, warnings }; +} + +/** + * Lists the definitions a snapshot calls or starts. + * + * @param {object|null} snap Definition snapshot. + * @returns {string[]} IDs. + */ +function callsInSnapshot(snap) { + if (!snap) { + return []; + } + const out = []; + walkSteps(snap.steps, (step) => { + if (step.type === 'call' || step.type === 'task.start') { + out.push(step.definition); + } + }); + return out; +} + +/** + * Lists the adapters a definition calls. + * + * @param {object} body Definition. + * @returns {string[]} Adapter names. + */ +export function adaptersUsed(body) { + const out = new Set(); + walkSteps(body.steps || [], (step) => { + if (step.type === 'integration.call') { + out.add(step.adapter); + } + }); + return [...out]; +} + +/** + * Visits every step in nested blocks. + * + * @param {object[]} steps Steps. + * @param {(step: object) => void} visit Visitor. + * @returns {void} + */ +export function walkSteps(steps, visit) { + for (const step of Array.isArray(steps) ? steps : []) { + if (!isRecord(step)) { + continue; + } + visit(step); + for (const key of ['steps', 'then', 'else']) { + if (Array.isArray(step[key])) { + walkSteps(step[key], visit); + } + } + if (Array.isArray(step.branches)) { + for (const branch of step.branches) { + walkSteps(branch?.steps, visit); + } + } + } +} + +/** + * Publishes the draft as a new immutable revision. + * + * @param {string} id Definition ID. + * @param {string} by Player ID of the GM publishing. + * @param {number} [expectedRev] Stale-link guard. + * @returns {{ok: boolean, record?: object, revision?: number, errors?: object[], warnings?: object[], error?: string, vars?: object}} + * Result. + */ +export function publish(id, by, expectedRev) { + const record = getRecord(id); + if (!record) { + return { ok: false, error: 'definitionNotFound', vars: { id } }; + } + if (!revisionMatches(record, expectedRev)) { + return { ok: false, error: 'stale', vars: { id, rev: record.rev } }; + } + if (!record.draft) { + return { ok: false, error: 'nothingToPublish', vars: { id } }; + } + const body = clone(record.draft); + body.id = id; + body.revision = record.current + 1; + const check = validateBody(body); + if (!check.ok) { + return { + ok: false, + error: 'invalidDefinition', + errors: check.errors, + warnings: check.warnings, + }; + } + record.revisions[body.revision] = body; + record.current = body.revision; + record.draft = clone(body); + record.draft.revision = body.revision + 1; + record.name = body.name; + record.rev += 1; + record.publishedAt = now(); + record.publishedBy = by || null; + if (record.disabledReason === 'adapter') { + record.disabledReason = null; + } + pruneRevisions(id); + audit('definition.publish', { definition: id, revision: body.revision, by }); + maybeCall('definitionChanged', id); + maybeCall('dashboardDirty', 'definitions'); + return { ok: true, record, revision: body.revision, warnings: check.warnings }; +} + +/** + * Enables or disables new runs of a definition. Existing runs carry on. + * + * @param {string} id Definition ID. + * @param {boolean} enabled New value. + * @param {string|null} by Player ID (null for automatic changes). + * @param {object} [options] Options. + * @param {number} [options.expectedRev] Stale-link guard. + * @param {string} [options.reason] Why (circuit, adapter, gm). + * @returns {{ok: boolean, record?: object, error?: string, vars?: object}} Result. + */ +export function setEnabled(id, enabled, by, { expectedRev, reason = 'gm' } = {}) { + const record = getRecord(id); + if (!record) { + return { ok: false, error: 'definitionNotFound', vars: { id } }; + } + if (!revisionMatches(record, expectedRev)) { + return { ok: false, error: 'stale', vars: { id, rev: record.rev } }; + } + if (enabled && !record.current) { + return { ok: false, error: 'notPublished', vars: { id } }; + } + record.enabled = enabled; + record.disabledReason = enabled ? null : reason; + record.rev += 1; + record.updatedAt = now(); + audit(enabled ? 'definition.enable' : 'definition.disable', { + definition: id, + by, + reason: enabled ? null : reason, + }); + maybeCall('definitionChanged', id); + maybeCall('dashboardDirty', 'definitions'); + return { ok: true, record }; +} + +/** + * Copies a definition (its draft or current revision) under a new ID. + * + * @param {string} id Source ID. + * @param {string} name New name. + * @param {string} by Player ID. + * @param {string} [newId] New ID (derived from the name when omitted). + * @returns {{ok: boolean, record?: object, error?: string, vars?: object}} Result. + */ +export function cloneRecord(id, name, by, newId) { + const record = getRecord(id); + if (!record) { + return { ok: false, error: 'definitionNotFound', vars: { id } }; + } + const body = clone(record.draft || getSnapshot(id)); + body.id = newId || freeId(name); + body.name = name; + body.revision = 1; + return createRecord(body, by); +} + +/** + * Returns the revisions of a definition that runs or queues still use. + * + * @param {string} id Definition ID. + * @returns {Set} Revisions. + */ +export function referencedRevisions(id) { + const s = getState(); + const revs = new Set(); + for (const inst of Object.values(s.instances)) { + if (inst.definitionId === id) { + revs.add(inst.definitionRevision); + } + } + for (const entry of s.queues[id] || []) { + revs.add(entry.revision); + } + return revs; +} + +/** + * Removes revisions no run, queue entry or the current pointer needs. + * + * @param {string} id Definition ID. + * @returns {void} + */ +export function pruneRevisions(id) { + const record = getRecord(id); + if (!record) { + return; + } + const keep = referencedRevisions(id); + keep.add(record.current); + for (const rev of Object.keys(record.revisions).map(Number)) { + if (!keep.has(rev)) { + delete record.revisions[rev]; + compiledCache.delete(`${id}@${rev}`); + } + } +} + +/** + * Lists what still depends on a definition (blocks a purge). + * + * @param {string} id Definition ID. + * @returns {{runs: number, queued: number, schedules: number, callers: string[]}} Dependencies. + */ +export function dependencies(id) { + const s = getState(); + const runs = Object.values(s.instances).filter( + (i) => i.definitionId === id && ACTIVE_STATUSES.has(i.status) + ).length; + const queued = (s.queues[id] || []).length; + const schedules = Object.values(s.schedules).filter( + (sc) => sc.definitionId === id && !sc.ended + ).length; + const callers = Object.values(s.definitions) + .filter((r) => r.id !== id && callsInSnapshot(getSnapshot(r.id)).includes(id)) + .map((r) => r.id); + return { runs, queued, schedules, callers }; +} + +/** + * Archives a definition: disabled and hidden, kept while referenced. + * + * @param {string} id Definition ID. + * @param {string} by Player ID. + * @returns {{ok: boolean, error?: string, vars?: object}} Result. + */ +export function archiveRecord(id, by) { + const record = getRecord(id); + if (!record) { + return { ok: false, error: 'definitionNotFound', vars: { id } }; + } + record.archived = true; + record.enabled = false; + record.disabledReason = 'archived'; + record.rev += 1; + audit('definition.archive', { definition: id, by }); + maybeCall('definitionChanged', id); + maybeCall('dashboardDirty', 'definitions'); + return { ok: true }; +} + +/** + * Deletes a definition permanently. Refused while anything depends on it. + * + * @param {string} id Definition ID. + * @param {string} by Player ID. + * @returns {{ok: boolean, error?: string, vars?: object}} Result. + */ +export function purgeRecord(id, by) { + const record = getRecord(id); + if (!record) { + return { ok: false, error: 'definitionNotFound', vars: { id } }; + } + const deps = dependencies(id); + if (deps.runs || deps.queued || deps.callers.length) { + return { + ok: false, + error: 'purgeBlocked', + vars: { runs: deps.runs, queued: deps.queued, callers: deps.callers.join(', ') || '—' }, + }; + } + const s = getState(); + for (const sc of Object.values(s.schedules)) { + if (sc.definitionId === id) { + call('removeSchedule', sc.id, by); + } + } + for (const key of Object.keys(s.triggerState)) { + if (key.startsWith(`${id}/`)) { + delete s.triggerState[key]; + } + } + for (const inst of Object.values(s.instances)) { + if (inst.definitionId === id) { + delete s.instances[inst.id]; + } + } + delete s.definitions[id]; + for (const key of [...compiledCache.keys()]) { + if (key.startsWith(`${id}@`)) { + compiledCache.delete(key); + } + } + audit('definition.purge', { definition: id, by }); + maybeCall('definitionChanged', id); + maybeCall('dashboardDirty', 'definitions'); + return { ok: true }; +} + +/** + * Imports a complete definition as a new record or a new revision. + * + * @param {object} body Definition JSON (untrusted). + * @param {string} by Player ID. + * @param {{replace?: boolean}} [options] Publish over an existing ID. + * @returns {{ok: boolean, record?: object, revision?: number, errors?: object[], warnings?: object[], error?: string, vars?: object}} + * Result. + */ +export function importDefinition(body, by, { replace = false } = {}) { + const check = validateDefinition(body, { limits: getLimits() }); + if (!check.ok) { + return { ok: false, error: 'invalidDefinition', errors: check.errors }; + } + const existing = getRecord(body.id); + if (existing && !replace) { + return { ok: false, error: 'definitionExists', vars: { id: body.id } }; + } + if (!existing) { + const created = createRecord(body, by); + if (!created.ok) { + return created; + } + } else { + existing.draft = clone(body); + existing.rev += 1; + } + const result = publish(body.id, by); + if (!result.ok) { + if (!existing) { + delete getState().definitions[body.id]; + } + return result; + } + if (body.enabled === false) { + setEnabled(body.id, false, by, { reason: 'gm' }); + } + audit('definition.import', { definition: body.id, by }); + return result; +} + +/** + * Returns an exportable copy of a definition. + * + * @param {string} id Definition ID. + * @param {{draft?: boolean}} [options] Export the draft instead of the current revision. + * @returns {object|null} Definition JSON. + */ +export function exportDefinition(id, { draft = false } = {}) { + const record = getRecord(id); + if (!record) { + return null; + } + const body = clone(draft && record.draft ? record.draft : getSnapshot(id) || record.draft); + body.enabled = record.enabled; + return body; +} + +/** + * Finds the list and index holding a step. + * + * @param {object[]} steps Root steps. + * @param {string} stepId Step ID. + * @returns {{list: object[], index: number}|null} Location. + */ +export function findStepLocation(steps, stepId) { + let found = null; + const search = (list) => { + if (!Array.isArray(list) || found) { + return; + } + list.forEach((step, index) => { + if (found || !isRecord(step)) { + return; + } + if (step.id === stepId) { + found = { list, index }; + return; + } + search(step.steps); + search(step.then); + search(step.else); + (step.branches || []).forEach((b) => search(b?.steps)); + }); + }; + search(steps); + return found; +} + +/** + * Returns the step list addressed by a block key (`root`, `/steps`, + * `/then`, `/else`, `/branch/`). + * + * @param {object} draft Definition draft. + * @param {string} block Block key. + * @returns {object[]|null} Step list (created when it is an empty then/else). + */ +export function blockList(draft, block) { + if (!block || block === 'root') { + return draft.steps; + } + const [stepId, kind, branchId] = block.split('/'); + const location = findStepLocation(draft.steps, stepId); + if (!location) { + return null; + } + const step = location.list[location.index]; + if (kind === 'branch') { + return (step.branches || []).find((b) => b.id === branchId)?.steps || null; + } + if (!['steps', 'then', 'else'].includes(kind)) { + return null; + } + if (!Array.isArray(step[kind]) && (kind === 'then' || kind === 'else') && step.type === 'if') { + step[kind] = []; + } + return Array.isArray(step[kind]) ? step[kind] : null; +} diff --git a/TaskAutomation/src/errors.js b/TaskAutomation/src/errors.js new file mode 100644 index 0000000000..935b039834 --- /dev/null +++ b/TaskAutomation/src/errors.js @@ -0,0 +1,58 @@ +// Typed task errors (plan §10, §14). Commands, run journals and the dashboard +// distinguish these codes so every failure says what kind of problem it is. + +import { ERROR_CODES } from './constants.js'; + +// Codes that may succeed if tried again later. +const TRANSIENT = new Set([ + ERROR_CODES.UNAVAILABLE, + ERROR_CODES.TIMEOUT, + ERROR_CODES.RESOURCE_LIMIT, +]); + +/** + * Creates a typed error. + * + * @param {string} code One of ERROR_CODES. + * @param {string} reason Translation key suffix under `runErrors.`. + * @param {object} [vars] Interpolation values (IDs, names; never secrets). + * @param {{transient?: boolean, uncertain?: boolean}} [flags] Overrides. + * @returns {Error} Error with code, reason, vars, transient and uncertain. + */ +export function taskError(code, reason, vars = {}, flags = {}) { + const err = new Error(`${code}:${reason}`); + err.taskError = true; + err.code = code; + err.reason = reason; + err.vars = vars; + err.transient = flags.transient ?? TRANSIENT.has(code); + err.uncertain = flags.uncertain ?? code === ERROR_CODES.UNCERTAIN; + return err; +} + +/** + * Converts anything thrown into a typed error. + * + * @param {*} err Thrown value. + * @returns {Error} Typed error. + */ +export function asTaskError(err) { + if (err?.taskError) { + return err; + } + return taskError(ERROR_CODES.INTERNAL, 'internal', { + message: String(err?.message || err).slice(0, 200), + }); +} + +/** + * Returns a compact, storable description of an error. + * + * @param {Error} err Typed error. + * @param {string} [stepId] Step that failed. + * @returns {{code: string, reason: string, vars: object, stepId: string|null}} Record. + */ +export function errorRecord(err, stepId) { + const typed = asTaskError(err); + return { code: typed.code, reason: typed.reason, vars: typed.vars, stepId: stepId || null }; +} diff --git a/TaskAutomation/src/executor.js b/TaskAutomation/src/executor.js new file mode 100644 index 0000000000..d8d089f5c4 --- /dev/null +++ b/TaskAutomation/src/executor.js @@ -0,0 +1,2868 @@ +// The executor (plan §4, §7, §8): admission, persistent frames, waits, +// conditions, loops, calls, cooperative parallel branches, the action +// journal, retries, pause/resume/cancel and review. Every continuation is a +// scheduler job carrying generations, so cancelled or superseded work is a +// harmless no-op when its job fires. + +import { ACTIONS } from './actions.js'; +import { audit, count, createIncident, recordHistory, resolveIncident } from './audit.js'; +import { replayClass } from './catalogue.js'; +import { + ACTIVE_STATUSES, + ERROR_CODES, + JOB, + REPLAY, + STATUS, + TERMINAL_STATUSES, +} from './constants.js'; +import { getCompiled, getRecord, getSnapshot, setEnabled } from './definitions.js'; +import { asTaskError, errorRecord, taskError } from './errors.js'; +import { MISSING, evaluate, renderTemplate, truthy } from './expressions.js'; +import { isGm, now } from './host.js'; +import { findAnyTarget, getTarget, readProperty, runQuery } from './objects.js'; +import { checkEffectAuthority } from './permissions.js'; +import { call, diag, maybeCall, onJob, runtime, schedule } from './runtime.js'; +import { checkParamValue, durationValue } from './schema.js'; +import { getConfig, getLimits, getState, nextId, stateNearlyFull } from './state.js'; +import { clone, isRecord, parseIsoInstant, readPath, truncate } from './utils.js'; + +const RETAIN_TERMINAL = 20; +const JOURNAL_LIMIT = 60; + +// --------------------------------------------------------------------------- +// Lookups +// --------------------------------------------------------------------------- + +/** + * Returns an instance. + * + * @param {string} id Run ID. + * @returns {object|null} Instance. + */ +export function getInstance(id) { + return getState().instances[id] || null; +} + +/** + * Returns instances, newest first. + * + * @param {(inst: object) => boolean} [filter] Filter. + * @returns {object[]} Instances. + */ +export function listInstances(filter = () => true) { + return Object.values(getState().instances) + .filter(filter) + .sort((a, b) => b.createdAt - a.createdAt); +} + +/** + * Returns the active (not finished) runs of a definition, excluding queued. + * + * @param {string} defId Definition ID. + * @returns {object[]} Instances. + */ +function activeRunsOf(defId) { + return Object.values(getState().instances).filter( + (i) => i.definitionId === defId && ACTIVE_STATUSES.has(i.status) && i.status !== STATUS.QUEUED + ); +} + +/** + * Returns the definition snapshot a run is pinned to. + * + * @param {object} inst Instance. + * @returns {object|null} Snapshot. + */ +function defOf(inst) { + return getSnapshot(inst.definitionId, inst.definitionRevision); +} + +/** + * Adds a timeline entry to a run, keeping the timeline bounded. + * + * @param {object} inst Instance. + * @param {string} kind Entry kind. + * @param {object} [detail] Detail. + * @returns {void} + */ +function timeline(inst, kind, detail = {}) { + inst.timeline.push({ at: now(), kind, ...detail }); + const max = getLimits().timelinePerRun; + if (inst.timeline.length > max) { + inst.timeline.splice(1, inst.timeline.length - max); + } +} + +/** + * Asks for a dashboard refresh after a run changed. + * + * @param {object} inst Instance. + * @returns {void} + */ +function touch(inst) { + maybeCall('dashboardDirty', 'instances', inst.id); +} + +/** + * Bumps a run's management revision after a status change, so links made + * before the change are refused as stale. + * + * @param {object} inst Instance. + * @returns {void} + */ +function bump(inst) { + inst.revision += 1; + touch(inst); +} + +// --------------------------------------------------------------------------- +// Admission (plan §5 admission policies) +// --------------------------------------------------------------------------- + +/** + * Resolves start parameters against the definition's declarations. + * + * @param {object} def Definition snapshot. + * @param {object} raw Raw parameters. + * @returns {{ok: boolean, params?: object, error?: string, vars?: object}} Result. + */ +export function resolveParams(def, raw = {}) { + const decls = def.parameters || {}; + const params = {}; + for (const key of Object.keys(raw)) { + if (!decls[key]) { + return { ok: false, error: 'unknownParam', vars: { name: key } }; + } + } + for (const [name, spec] of Object.entries(decls)) { + let value = raw[name]; + if (value === undefined) { + if (spec.default !== undefined) { + params[name] = spec.default; + continue; + } + if (spec.required) { + return { ok: false, error: 'paramRequired', vars: { name } }; + } + continue; + } + if (spec.type === 'number' && typeof value === 'string') { + value = Number(value); + } + if (spec.type === 'boolean' && typeof value === 'string') { + value = value === 'true' ? true : value === 'false' ? false : value; + } + const checked = checkParamValue(spec, value); + if (!checked.ok) { + return { + ok: false, + error: 'badParam', + vars: { name, ...checked.vars, reason: checked.error }, + }; + } + params[name] = checked.value; + } + return { ok: true, params }; +} + +/** + * Admits a new run of a definition according to its concurrency policy. + * + * @param {string} defId Definition ID. + * @param {object} opts Start options. + * @param {string|null} opts.initiatorId Real player ID, or null for unattended starts. + * @param {object} [opts.params] Resolved parameters. + * @param {object} [opts.bindings] Resolved bindings {name: [ids]}. + * @param {object|null} [opts.event] Normalised event snapshot. + * @param {object} [opts.trigger] {type, id, scheduleId}. + * @param {object|null} [opts.occurrence] {key, intendedDueAt, missedCount}. + * @param {object|null} [opts.parent] {runId, branchId, stepId, owned}. + * @param {number} [opts.revision] Revision to pin (current by default). + * @param {object} [opts.correlation] {rootId, depth}. + * @param {string|null} [opts.rerunOf] Run this one repeats. + * @param {boolean} [opts.fromQueue] True when draining the queue. + * @returns {{ok: boolean, status: string, instance?: object, reason?: string, vars?: object}} Result. + */ +export function admitRun(defId, opts) { + const s = getState(); + const limits = getLimits(); + const record = getRecord(defId); + const reject = (reason, vars = {}) => { + count('rejected'); + diag(`admission rejected ${defId}: ${reason}`); + return { ok: false, status: 'rejected', reason, vars }; + }; + if (runtime.disabled) { + return reject('disabled'); + } + if (s.halted) { + return reject('halted'); + } + if (!record || record.archived) { + return reject('definitionNotFound', { id: defId }); + } + if (!record.enabled) { + return reject('definitionDisabled', { id: defId }); + } + const revision = opts.revision ?? record.current; + const def = getSnapshot(defId, revision); + if (!def) { + return reject('revisionMissing', { id: defId, revision }); + } + const depth = opts.correlation?.depth ?? 0; + if (depth > limits.ancestryDepth) { + return reject('ancestryLimit', { max: limits.ancestryDepth }); + } + if (opts.parent) { + const parent = getInstance(opts.parent.runId); + const recursion = def.policy?.recursion; + if ( + parent && + parent.definitionId === defId && + !(recursion?.allow && depth <= recursion.maxDepth) + ) { + return reject('recursionLimit', {}); + } + if (parent && countDescendants(parent.correlation.rootId) >= limits.descendantsPerRun) { + return reject('descendantLimit', { max: limits.descendantsPerRun }); + } + } + const active = Object.values(s.instances).filter((i) => ACTIVE_STATUSES.has(i.status)).length; + if (active >= limits.activeInstances) { + return reject('tooManyRuns', { max: limits.activeInstances }); + } + if (stateNearlyFull(now())) { + return reject('stateFull', {}); + } + if (!opts.fromQueue && !checkCircuit(defId, def)) { + return reject('circuitOpen', { id: defId }); + } + const policy = def.policy || {}; + const mode = policy.concurrency || 'skipIfRunning'; + const running = activeRunsOf(defId); + const max = policy.maxConcurrent ?? (mode === 'allow' ? 5 : 1); + if (running.length >= max && !opts.parent) { + if (mode === 'skipIfRunning') { + count('suppressed'); + return { ok: false, status: 'skipped', reason: 'alreadyRunning', vars: { id: defId } }; + } + if (mode === 'allow') { + return reject('concurrencyLimit', { max }); + } + if (mode === 'queue') { + return enqueue(defId, def, opts); + } + if (mode === 'replace') { + for (const inst of running) { + cancelRun(inst.id, null, 'replaced'); + } + } + } + const instance = createInstance(def, revision, opts); + return { ok: true, status: 'started', instance }; +} + +/** + * Counts the runs started from one root run. + * + * @param {string} rootId Root run ID. + * @returns {number} Count. + */ +function countDescendants(rootId) { + return Object.values(getState().instances).filter( + (i) => i.correlation?.rootId === rootId && i.id !== rootId + ).length; +} + +/** + * Applies the per-definition circuit breaker: too many admissions in a + * minute disable the definition and raise an incident. + * + * @param {string} defId Definition ID. + * @param {object} def Definition snapshot. + * @returns {boolean} True when the admission may proceed. + */ +function checkCircuit(defId, def) { + const s = getState(); + const at = now(); + const max = def.policy?.circuit?.admissionsPerMinute ?? getLimits().admissionsPerMinute; + const window = s.circuit[defId] || { start: at, count: 0 }; + if (at - window.start > 60000) { + window.start = at; + window.count = 0; + } + window.count += 1; + s.circuit[defId] = window; + if (window.count > max) { + setEnabled(defId, false, null, { reason: 'circuit' }); + createIncident({ + kind: 'circuit', + cause: 'circuitBreaker', + definitionId: defId, + count: window.count, + range: { from: window.start, to: at }, + }); + maybeCall('notifyGm', 'circuit', { id: defId, count: window.count }); + return false; + } + return true; +} + +/** + * Adds an admission to a definition's queue. Parameters and targets are + * pinned now, not taken from later selection. + * + * @param {string} defId Definition ID. + * @param {object} def Definition snapshot. + * @param {object} opts Start options. + * @returns {object} Admission result. + */ +function enqueue(defId, def, opts) { + const s = getState(); + const queue = s.queues[defId] || (s.queues[defId] = []); + const max = Math.min(def.policy?.maxQueue ?? 10, getLimits().queuedPerDefinition); + if (queue.length >= max) { + count('rejected'); + createIncident({ kind: 'missed', cause: 'queueFull', definitionId: defId, count: 1 }); + return { ok: false, status: 'rejected', reason: 'queueFull', vars: { max } }; + } + const at = now(); + const entry = { + id: nextId('queued'), + definitionId: defId, + revision: opts.revision ?? getRecord(defId).current, + admittedAt: at, + expiresAt: def.policy?.queueExpiryMs ? at + def.policy.queueExpiryMs : null, + opts: clone({ ...opts, fromQueue: true }), + }; + queue.push(entry); + if (entry.expiresAt) { + schedule({ + kind: JOB.QUEUE_EXPIRY, + dueAt: entry.expiresAt, + key: `queue:${entry.id}`, + target: { defId, entryId: entry.id }, + }); + } + audit('run.queue', { definition: defId, entry: entry.id }); + maybeCall('dashboardDirty', 'queue'); + return { ok: true, status: 'queued', entry }; +} + +/** + * Starts the next queued admission of a definition when there is room. + * + * @param {string} defId Definition ID. + * @returns {void} + */ +export function drainQueue(defId) { + const s = getState(); + const queue = s.queues[defId]; + if (!queue?.length) { + return; + } + const def = getSnapshot(defId); + const max = def?.policy?.maxConcurrent ?? 1; + while (queue.length && activeRunsOf(defId).length < max) { + const entry = queue.shift(); + runtime.scheduler?.cancelKey(`queue:${entry.id}`); + if (entry.expiresAt && entry.expiresAt < now()) { + createIncident({ + kind: 'missed', + cause: 'expiredQueue', + definitionId: defId, + count: 1, + range: { from: entry.admittedAt, to: entry.expiresAt }, + }); + continue; + } + const result = admitRun(defId, entry.opts); + if (!result.ok && result.status === 'rejected') { + createIncident({ + kind: 'missed', + cause: 'queueRejected', + definitionId: defId, + count: 1, + evidence: result.reason, + }); + } + } + if (!queue.length) { + delete s.queues[defId]; + } + maybeCall('dashboardDirty', 'queue'); +} + +/** + * Drops an expired queue entry (job handler). + * + * @param {object} job Job. + * @returns {void} + */ +function onQueueExpiry(job) { + const s = getState(); + const queue = s.queues[job.target.defId] || []; + const index = queue.findIndex((e) => e.id === job.target.entryId); + if (index >= 0) { + const [entry] = queue.splice(index, 1); + createIncident({ + kind: 'missed', + cause: 'expiredQueue', + definitionId: entry.definitionId, + count: 1, + range: { from: entry.admittedAt, to: entry.expiresAt }, + }); + maybeCall('dashboardDirty', 'queue'); + } +} + +/** + * Creates and starts a run. + * + * @param {object} def Definition snapshot. + * @param {number} revision Pinned revision. + * @param {object} opts Start options. + * @returns {object} Instance. + */ +function createInstance(def, revision, opts) { + const s = getState(); + const at = now(); + const id = nextId('run'); + const parent = opts.parent ? getInstance(opts.parent.runId) : null; + const inst = { + id, + revision: 1, + generation: 1, + definitionId: def.id, + definitionRevision: revision, + name: def.name, + status: STATUS.RUNNING, + initiatorPlayerId: opts.initiatorId || null, + authority: def.access?.authority === 'gmService' ? 'gmService' : 'initiator', + params: clone(opts.params || {}), + bindings: clone(opts.bindings || {}), + event: opts.event ? clone(opts.event) : null, + trigger: opts.trigger || { type: 'manual', id: 'manual' }, + occurrence: opts.occurrence || null, + parentId: parent?.id || null, + parentBranchId: opts.parent?.branchId || null, + parentStepId: opts.parent?.stepId || null, + owned: Boolean(opts.parent?.owned), + children: [], + correlation: { + rootId: parent?.correlation.rootId || id, + depth: opts.correlation?.depth ?? (parent ? parent.correlation.depth + 1 : 0), + }, + rerunOf: opts.rerunOf || null, + createdAt: at, + startedAt: at, + completedAt: null, + intendedDueAt: opts.occurrence?.intendedDueAt ?? null, + latenessMs: opts.occurrence?.intendedDueAt + ? Math.max(0, at - opts.occurrence.intendedDueAt) + : 0, + branches: { main: newBranch('main', null, null, 'root', {}) }, + nextBranch: 1, + journal: {}, + timeline: [], + pause: null, + review: null, + outcome: null, + error: null, + effects: 0, + bootId: runtime.bootId, + }; + s.instances[id] = inst; + if (parent) { + parent.children.push(id); + } + timeline(inst, 'start', { + trigger: inst.trigger.type, + by: inst.initiatorPlayerId || 'gmService', + }); + count('admitted'); + audit('run.start', { + run: id, + definition: def.id, + revision, + by: inst.initiatorPlayerId, + trigger: inst.trigger.type, + }); + const policy = def.policy || {}; + if (policy.timeoutMs) { + schedule({ + kind: JOB.CONTINUE, + dueAt: at + policy.timeoutMs, + key: `life:${id}`, + target: { runId: id, lifetime: 'timeout', gen: inst.generation }, + }); + } else if (policy.lifetimeMs) { + schedule({ + kind: JOB.CONTINUE, + dueAt: at + policy.lifetimeMs, + key: `life:${id}`, + target: { runId: id, lifetime: 'expire', gen: inst.generation }, + }); + } + emitTaskEvent(inst, 'task.started'); + scheduleBranch(inst, inst.branches.main, at); + maybeCall('dashboardDirty', 'instances'); + return inst; +} + +/** + * Creates a branch record. + * + * @param {string} id Branch ID. + * @param {string|null} parentId Parent branch. + * @param {string|null} stepId Parallel step that spawned it. + * @param {string} block Starting block key. + * @param {object} variables Copy-on-start variables. + * @param {string} [label] Display label. + * @returns {object} Branch. + */ +function newBranch(id, parentId, stepId, block, variables, label) { + return { + id, + label: label || id, + parentId, + stepId, + status: 'running', + frames: [{ block, index: 0 }], + variables, + wait: null, + gen: 1, + consecutive: 0, + attempts: {}, + error: null, + }; +} + +// --------------------------------------------------------------------------- +// Jobs +// --------------------------------------------------------------------------- + +/** + * Schedules a branch continuation. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {number} dueAt When. + * @param {string} [kind] Job kind. + * @returns {void} + */ +function scheduleBranch(inst, branch, dueAt, kind = JOB.CONTINUE) { + schedule({ + kind, + dueAt, + key: `run:${inst.id}:${branch.id}`, + target: { runId: inst.id, branchId: branch.id, gen: inst.generation, bgen: branch.gen }, + }); +} + +/** + * Cancels every job of a run. + * + * @param {object} inst Instance. + * @param {boolean} [includeLifetime=false] Also cancel the run timeout. + * @returns {void} + */ +function cancelJobs(inst, includeLifetime = false) { + runtime.scheduler?.cancelWhere( + (job) => + job.target?.runId === inst.id && (includeLifetime || !String(job.key).startsWith('life:')) + ); +} + +/** + * Returns the branch a job targets when the job is still current. + * + * @param {object} job Job. + * @returns {{inst: object, branch: object}|null} Live target. + */ +function liveTarget(job) { + const inst = getInstance(job.target.runId); + if (!inst || inst.generation !== job.target.gen) { + return null; + } + const branch = inst.branches[job.target.branchId]; + if (!branch || branch.gen !== job.target.bgen) { + return null; + } + return { inst, branch }; +} + +/** + * Handles continuation, retry and wait-expiry jobs. + * + * @param {object} job Job. + * @returns {void} + */ +function onContinueJob(job) { + if (job.target.lifetime) { + const inst = getInstance(job.target.runId); + if (inst && ACTIVE_STATUSES.has(inst.status)) { + if (job.target.lifetime === 'timeout') { + finalize( + inst, + STATUS.FAILED, + errorRecord(taskError(ERROR_CODES.TIMEOUT, 'runTimeout', {})) + ); + } else { + finalize( + inst, + STATUS.EXPIRED, + errorRecord(taskError(ERROR_CODES.TIMEOUT, 'runExpired', {})) + ); + } + } + return; + } + const live = liveTarget(job); + if (!live) { + return; + } + const { inst, branch } = live; + if (inst.status !== STATUS.RUNNING && inst.status !== STATUS.WAITING) { + return; + } + const wait = branch.wait; + if (wait?.kind === 'time') { + const lateness = now() - wait.dueAt; + if (wait.dueAt > now()) { + // A clock moved backwards: wait for the real deadline. + scheduleBranch(inst, branch, wait.dueAt); + return; + } + branch.wait = null; + branch.status = 'running'; + if (lateness > lateWindow(inst)) { + if (!applyWaitOverdue(inst, branch, wait, lateness)) { + return; + } + } + } else if (wait?.kind === 'retry' || wait?.kind === 'yield') { + branch.wait = null; + branch.status = 'running'; + } else if (wait?.kind === 'child' || wait?.kind === 'join' || wait?.kind === 'async') { + if (!wait.completion) { + return; + } + branch.wait = null; + branch.status = 'running'; + if (wait.kind === 'async' && !finishAsync(inst, branch, wait)) { + return; + } + if (wait.kind === 'child' && !finishChildWait(inst, branch, wait)) { + return; + } + } else if (wait) { + return; + } + inst.status = STATUS.RUNNING; + runBranch(inst, branch); +} + +/** + * Returns the lateness window after which overdue policy applies. + * + * @param {object} inst Instance. + * @returns {number} Milliseconds. + */ +function lateWindow(inst) { + return defOf(inst)?.policy?.overdue?.windowMs ?? getConfig().lateWindowMs; +} + +/** + * Applies the overdue policy to a wait that ended much later than due + * (typically after the sandbox was off). + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {object} wait Expired wait. + * @param {number} lateness Milliseconds late. + * @returns {boolean} True when the branch should continue now. + */ +function applyWaitOverdue(inst, branch, wait, lateness) { + const policyMode = defOf(inst)?.policy?.overdue?.mode; + const mode = wait.overdue || { review: 'review', expire: 'expire' }[policyMode] || 'continue'; + timeline(inst, 'late', { stepId: wait.stepId, latenessMs: lateness, mode }); + if (mode === 'review') { + enterReview(inst, branch, 'overdueWait', { + stepId: wait.stepId, + latenessMs: lateness, + indexAdvanced: true, + }); + return false; + } + if (mode === 'expire') { + finalize( + inst, + STATUS.EXPIRED, + errorRecord( + taskError(ERROR_CODES.TIMEOUT, 'overdueExpired', { latenessMs: lateness }), + wait.stepId + ) + ); + return false; + } + if (mode === 'skipNext') { + const frame = branch.frames.at(-1); + if (frame) { + frame.index += 1; + } + } + return true; +} + +// --------------------------------------------------------------------------- +// Running steps +// --------------------------------------------------------------------------- + +/** + * Runs a branch until it suspends, finishes or uses up its job budget. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @returns {void} + */ +export function runBranch(inst, branch) { + const compiled = getCompiled(inst.definitionId, inst.definitionRevision); + if (!compiled) { + finalize( + inst, + STATUS.FAILED, + errorRecord(taskError(ERROR_CODES.VALIDATION, 'revisionMissing', { id: inst.definitionId })) + ); + return; + } + const limits = getLimits(); + let steps = 0; + while (branch.status === 'running' && inst.status === STATUS.RUNNING) { + if (steps >= limits.stepsPerJob) { + // Bounded yield: let other due work run, then carry on. + branch.wait = { kind: 'yield' }; + branch.status = 'waiting'; + scheduleBranch(inst, branch, now() + 10); + return; + } + const frame = branch.frames.at(-1); + if (!frame) { + completeBranch(inst, branch); + return; + } + const block = compiled.blocks.get(frame.block) || []; + if (frame.index >= block.length) { + popFrame(inst, branch, compiled); + steps += 1; + continue; + } + branch.consecutive += 1; + if (branch.consecutive > limits.consecutiveSteps) { + failBranch( + inst, + branch, + errorRecord( + taskError(ERROR_CODES.RUNAWAY, 'runaway', { max: limits.consecutiveSteps }), + block[frame.index].id + ) + ); + return; + } + const step = block[frame.index]; + let outcome; + try { + outcome = execStep(inst, branch, frame, step); + } catch (err) { + outcome = handleStepError(inst, branch, step, err, false); + } + steps += 1; + if (outcome === 'next') { + frame.index += 1; + } else if (outcome === 'suspend' || outcome === 'stop') { + return; + } + } +} + +/** + * Leaves a finished block: loops decide whether to go round again; other + * compound steps simply continue after themselves. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {object} compiled Compiled program. + * @returns {void} + */ +function popFrame(inst, branch, compiled) { + const frame = branch.frames.pop(); + const parent = branch.frames.at(-1); + if (!parent || !frame.owner) { + return; + } + const owner = compiled.steps.get(frame.owner)?.step; + if (owner?.type === 'loop') { + const next = loopContinues(inst, branch, owner, frame); + if (next) { + branch.frames.push({ + block: frame.block, + index: 0, + owner: frame.owner, + loop: { ...frame.loop, iteration: frame.loop.iteration + 1 }, + }); + return; + } + } + parent.index += 1; +} + +/** + * Decides whether a loop runs another iteration. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {object} step Loop step. + * @param {object} frame Finished iteration frame. + * @returns {boolean} True to iterate again. + */ +function loopContinues(inst, branch, step, frame) { + const done = frame.loop.iteration; + const cap = + step.mode === 'forever' + ? Infinity + : Math.min(step.maxIterations ?? getLimits().loopIterations, getLimits().loopIterations); + if (done >= cap) { + if (step.mode === 'while' || step.mode === 'until') { + timeline(inst, 'loopCap', { stepId: step.id, iterations: done }); + } + return false; + } + switch (step.mode) { + case 'count': + return done < frame.loop.count; + case 'while': + return truthy(evalExpr(inst, branch, step.condition)); + case 'until': + return !truthy(evalExpr(inst, branch, step.condition)); + case 'forever': + return true; + default: + return false; + } +} + +/** + * Executes one step. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {object} frame Current frame. + * @param {object} step Step. + * @returns {'next'|'pushed'|'suspend'|'stop'} What happens next. + */ +function execStep(inst, branch, frame, step) { + switch (step.type) { + case 'wait': + return execWait(inst, branch, frame, step); + case 'awaitEvent': + return execAwait(inst, branch, frame, step); + case 'if': { + const value = evalExpr(inst, branch, step.condition); + const choice = truthy(value) ? 'then' : 'else'; + timeline(inst, 'if', { stepId: step.id, choice, missing: value === MISSING || undefined }); + const list = step[choice] || []; + if (!list.length) { + return 'next'; + } + // The chosen branch is stored in the frame, so a restart resumes it. + branch.frames.push({ block: `${step.id}/${choice}`, index: 0, owner: step.id, choice }); + return 'pushed'; + } + case 'loop': + return execLoop(inst, branch, step); + case 'call': + return execCall(inst, branch, frame, step); + case 'parallel': + return execParallel(inst, branch, frame, step); + case 'stop': { + const message = step.message ? render(inst, branch, step.message, (v) => v) : null; + const outcome = step.outcome || 'completed'; + const status = { + completed: STATUS.COMPLETED, + failed: STATUS.FAILED, + cancelled: STATUS.CANCELLED, + }[outcome]; + finalize( + inst, + status, + outcome === 'completed' + ? null + : { + code: 'stopped', + reason: 'stopStep', + vars: { message: truncate(message || '', 200) }, + stepId: step.id, + } + ); + return 'stop'; + } + case 'variable.set': { + const value = evalExpr(inst, branch, step.value); + branch.variables[step.name] = value === MISSING ? null : clone(value); + return 'next'; + } + case 'event.emit': { + const value = step.value === undefined ? null : evalExpr(inst, branch, step.value); + call('emitEvent', { + type: 'signal', + name: step.signal, + value: value === MISSING ? null : value, + source: 'internal', + correlation: { + runId: inst.id, + rootId: inst.correlation.rootId, + depth: inst.correlation.depth + 1, + }, + }); + return 'next'; + } + case 'task.start': + return execTaskStart(inst, branch, step); + case 'task.cancel': + case 'task.pause': + return execTaskControl(inst, branch, step); + default: + return execAction(inst, branch, frame, step); + } +} + +/** + * Suspends a branch for a duration or until a moment. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {object} frame Frame. + * @param {object} step Wait step. + * @returns {'suspend'} Always suspends. + */ +function execWait(inst, branch, frame, step) { + const at = now(); + let dueAt; + let absolute = false; + if (step.durationMs !== undefined) { + const raw = evalExpr(inst, branch, step.durationMs); + const ms = typeof raw === 'number' ? raw : durationValue(raw); + if (ms === null || !Number.isFinite(ms) || ms < 0) { + throw taskError(ERROR_CODES.VALIDATION, 'badDuration', {}); + } + dueAt = at + Math.max(ms, getLimits().minWaitMs); + } else { + const raw = evalExpr(inst, branch, step.until); + dueAt = typeof raw === 'number' ? raw : parseIsoInstant(raw); + if (!Number.isFinite(dueAt)) { + throw taskError(ERROR_CODES.VALIDATION, 'badInstant', {}); + } + absolute = true; + } + frame.index += 1; + branch.consecutive = 0; + branch.gen += 1; + branch.wait = { kind: 'time', dueAt, stepId: step.id, absolute, overdue: step.overdue || null }; + branch.status = 'waiting'; + inst.status = anyRunning(inst) ? STATUS.RUNNING : STATUS.WAITING; + scheduleBranch(inst, branch, dueAt); + touch(inst); + return 'suspend'; +} + +/** + * Registers an awaited event with a mandatory timeout. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {object} frame Frame. + * @param {object} step awaitEvent step. + * @returns {'suspend'} Always suspends. + */ +function execAwait(inst, branch, frame, step) { + const at = now(); + frame.index += 1; + branch.consecutive = 0; + branch.gen += 1; + branch.wait = { + kind: 'event', + event: step.event, + filter: step.filter || {}, + stepId: step.id, + saveAs: step.saveAs || step.id, + registeredAt: at, + timeoutAt: at + step.timeoutMs, + }; + branch.status = 'waiting'; + inst.status = anyRunning(inst) ? STATUS.RUNNING : STATUS.WAITING; + // Registration is saved before any further event is processed. + maybeCall('awaitsChanged'); + schedule({ + kind: JOB.AWAIT_TIMEOUT, + dueAt: branch.wait.timeoutAt, + key: `await:${inst.id}:${branch.id}`, + target: { runId: inst.id, branchId: branch.id, gen: inst.generation, bgen: branch.gen }, + }); + touch(inst); + return 'suspend'; +} + +/** + * Starts a loop. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {object} step Loop step. + * @returns {'next'|'pushed'} Outcome. + */ +function execLoop(inst, branch, step) { + let total = null; + if (step.mode === 'count') { + const raw = evalExpr(inst, branch, step.count); + total = typeof raw === 'number' && Number.isInteger(raw) ? raw : Number.NaN; + if (!Number.isInteger(total) || total < 0) { + throw taskError(ERROR_CODES.VALIDATION, 'badCount', {}); + } + total = Math.min(total, getLimits().loopIterations); + if (total === 0) { + return 'next'; + } + } + if (step.mode === 'while' && !truthy(evalExpr(inst, branch, step.condition))) { + return 'next'; + } + branch.frames.push({ + block: `${step.id}/steps`, + index: 0, + owner: step.id, + loop: { iteration: 1, count: total }, + }); + return 'pushed'; +} + +/** + * Calls another definition as a child run. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {object} frame Frame. + * @param {object} step Call step. + * @returns {'next'|'suspend'} Outcome. + */ +function execCall(inst, branch, frame, step) { + const awaitChild = step.await !== false; + if (!awaitChild && !hasGmAuthority(inst)) { + throw taskError(ERROR_CODES.UNAUTHORIZED, 'detachNeedsGm', {}); + } + const child = startChild(inst, branch, step, awaitChild); + if (!awaitChild) { + branch.variables[step.saveAs || step.id] = { runId: child.id }; + return 'next'; + } + frame.index += 1; + branch.gen += 1; + branch.wait = { + kind: 'child', + childId: child.id, + stepId: step.id, + saveAs: step.saveAs || step.id, + onChildFail: step.onChildFail || 'fail', + }; + branch.status = 'waiting'; + branch.consecutive = 0; + inst.status = anyRunning(inst) ? STATUS.RUNNING : STATUS.WAITING; + touch(inst); + return 'suspend'; +} + +/** + * Admits a child run with mapped parameters and targets. + * + * @param {object} inst Parent instance. + * @param {object} branch Parent branch. + * @param {object} step call or task.start step. + * @param {boolean} owned Whether the parent owns (and awaits) it. + * @returns {object} Child instance. + */ +function startChild(inst, branch, step, owned) { + const childDef = getSnapshot(step.definition, step.revision); + if (!childDef) { + throw taskError(ERROR_CODES.MISSING_TARGET, 'definitionMissing', { id: step.definition }); + } + const params = {}; + for (const [name, expr] of Object.entries(step.params || {})) { + const value = evalExpr(inst, branch, expr); + if (value !== MISSING) { + params[name] = value; + } + } + const resolved = resolveParams(childDef, params); + if (!resolved.ok) { + throw taskError(ERROR_CODES.VALIDATION, resolved.error, resolved.vars); + } + const bindings = {}; + for (const [name, spec] of Object.entries(step.bindings || {})) { + bindings[name] = resolveTargets( + inst, + branch, + spec, + childDef.bindings?.[name]?.objectType || 'graphic' + ).map((t) => t.id); + } + const result = admitRun(step.definition, { + initiatorId: inst.initiatorPlayerId, + params: resolved.params, + bindings, + event: inst.event, + trigger: { type: 'call', id: step.id }, + parent: { runId: inst.id, branchId: branch.id, stepId: step.id, owned }, + revision: step.revision, + correlation: { depth: inst.correlation.depth + 1 }, + }); + if (!result.ok || !result.instance) { + throw taskError( + ERROR_CODES.RESOURCE_LIMIT, + 'childRejected', + { id: step.definition, reason: result.reason || result.status }, + { transient: false } + ); + } + timeline(inst, 'call', { stepId: step.id, child: result.instance.id }); + return result.instance; +} + +/** + * Starts a detached run (`task.start`). + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {object} step Step. + * @returns {'next'} Outcome. + */ +function execTaskStart(inst, branch, step) { + const child = startChild(inst, branch, step, false); + if (step.saveAs) { + branch.variables[step.saveAs] = child.id; + } + return 'next'; +} + +/** + * Cancels or pauses child runs, or another run with GM authority. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {object} step Step. + * @returns {'next'} Outcome. + */ +function execTaskControl(inst, branch, step) { + let ids; + if ((step.scope || 'children') === 'children') { + ids = inst.children; + } else { + const raw = evalExpr(inst, branch, step.run); + ids = (Array.isArray(raw) ? raw : [raw]).filter((x) => typeof x === 'string'); + for (const id of ids) { + const target = getInstance(id); + const descendant = target && target.correlation.rootId === inst.correlation.rootId; + if (target && !descendant && !hasGmAuthority(inst)) { + throw taskError(ERROR_CODES.UNAUTHORIZED, 'runNotOwned', { id }); + } + } + } + for (const id of ids) { + const target = getInstance(id); + if (!target || !ACTIVE_STATUSES.has(target.status) || id === inst.id) { + continue; + } + if (step.type === 'task.cancel') { + cancelRun(id, null, 'cancelledByTask'); + } else { + pauseRun(id, null); + } + } + return 'next'; +} + +/** + * Spawns cooperative parallel branches and waits for their join. + * + * @param {object} inst Instance. + * @param {object} branch Parent branch. + * @param {object} frame Frame. + * @param {object} step Parallel step. + * @returns {'suspend'} Always suspends. + */ +function execParallel(inst, branch, frame, step) { + const activeBranches = Object.values(inst.branches).filter( + (b) => b.status === 'running' || b.status === 'waiting' + ).length; + if (activeBranches + step.branches.length > getLimits().branchesPerRun + 1) { + throw taskError( + ERROR_CODES.RESOURCE_LIMIT, + 'tooManyBranches', + { max: getLimits().branchesPerRun }, + { transient: false } + ); + } + const children = []; + for (const spec of step.branches) { + const id = `b${inst.nextBranch++}`; + const child = newBranch( + id, + branch.id, + step.id, + `${step.id}/branch/${spec.id}`, + clone(branch.variables), + spec.id + ); + inst.branches[id] = child; + children.push(id); + } + frame.index += 1; + branch.gen += 1; + branch.wait = { + kind: 'join', + stepId: step.id, + join: step.join, + children, + cancelLosers: step.cancelLosers !== false, + }; + branch.status = 'waiting'; + branch.consecutive = 0; + timeline(inst, 'parallel', { stepId: step.id, branches: children.length }); + for (const id of children) { + scheduleBranch(inst, inst.branches[id], now()); + } + touch(inst); + return 'suspend'; +} + +/** + * Runs an action step through the journal. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {object} frame Frame. + * @param {object} step Action step. + * @returns {'next'|'suspend'} Outcome. + */ +function execAction(inst, branch, frame, step) { + const impl = ACTIONS[step.type]; + if (!impl) { + throw taskError( + ERROR_CODES.UNAVAILABLE, + 'unknownAction', + { type: step.type }, + { transient: false } + ); + } + const def = defOf(inst); + const attempt = branch.attempts[step.id] || 1; + const key = journalKey(branch, step, attempt); + const replay = replayClass(step); + const journaled = replay !== REPLAY.PURE && replay !== REPLAY.INTERNAL; + const entry = inst.journal[key] || null; + if (journaled && !entry) { + inst.journal[key] = { + status: 'prepared', + at: now(), + stepId: step.id, + branchId: branch.id, + attempt, + replay, + data: {}, + }; + pruneJournal(inst); + } + const ctx = actionContext(inst, branch, step, def, key); + const res = impl(step, ctx); + if (res.async) { + return startAsync(inst, branch, frame, step, key, res.async); + } + if (res.partial) { + // Bounded chunk: remember progress, yield, then run this step again. + inst.journal[key].progress = res.done; + branch.wait = { kind: 'yield' }; + branch.status = 'waiting'; + scheduleBranch(inst, branch, now() + 10); + return 'suspend'; + } + if (journaled) { + const record = inst.journal[key]; + record.status = res.outcome === 'dispatched' ? 'dispatched' : 'confirmed'; + record.completedAt = now(); + record.result = compactResult(res.result); + inst.effects += 1; + } + if (step.saveAs) { + branch.variables[step.saveAs] = res.result === undefined ? null : clone(res.result); + } + delete branch.attempts[step.id]; + return 'next'; +} + +/** + * Shrinks a result for storage. + * + * @param {*} result Result. + * @returns {*} Compact result. + */ +function compactResult(result) { + const text = JSON.stringify(result ?? null); + return text.length > 500 ? { truncated: true } : (result ?? null); +} + +/** + * Builds the journal key for an action attempt: + * `branch/step/iterationPath/attempt`. + * + * @param {object} branch Branch. + * @param {object} step Step. + * @param {number} attempt Attempt number. + * @returns {string} Key. + */ +function journalKey(branch, step, attempt) { + const path = + branch.frames + .filter((f) => f.loop) + .map((f) => f.loop.iteration) + .join('.') || '0'; + return `${branch.id}/${step.id}/${path}/${attempt}`; +} + +/** + * Keeps the journal bounded, dropping the oldest settled entries. + * + * @param {object} inst Instance. + * @returns {void} + */ +function pruneJournal(inst) { + const keys = Object.keys(inst.journal); + if (keys.length <= JOURNAL_LIMIT) { + return; + } + const settled = keys + .filter((k) => + ['confirmed', 'dispatched', 'skipped', 'failed'].includes(inst.journal[k].status) + ) + .sort((a, b) => inst.journal[a].at - inst.journal[b].at); + for (const k of settled.slice(0, keys.length - JOURNAL_LIMIT)) { + delete inst.journal[k]; + } +} + +/** + * Builds the context an action uses. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {object} step Step. + * @param {object} def Definition snapshot. + * @param {string} key Journal key. + * @returns {object} Context. + */ +function actionContext(inst, branch, step, def, key) { + return { + inst, + def, + progress: inst.journal[key]?.progress || [], + evaluate: (expr) => evalExpr(inst, branch, expr), + render: (template, sanitise, args) => render(inst, branch, template, sanitise, args), + targets: (spec, type) => { + const list = resolveTargets(inst, branch, spec, type); + if (!list.length) { + throw taskError(ERROR_CODES.MISSING_TARGET, 'noTargets', { stepId: step.id }); + } + return list; + }, + authorise: (ids) => { + const decision = checkEffectAuthority(inst, ids, def); + if (!decision.ok) { + throw taskError(ERROR_CODES.UNAUTHORIZED, decision.reason, { id: decision.id || '' }); + } + }, + prepare: (data) => { + if (inst.journal[key]) { + inst.journal[key].data = { ...inst.journal[key].data, ...clone(data) }; + } + }, + emit: (event) => + call('emitEvent', { + ...event, + source: 'internal', + correlation: { + runId: inst.id, + rootId: inst.correlation.rootId, + depth: inst.correlation.depth + 1, + }, + }), + noteWrite: (objectId, changes) => maybeCall('noteOwnWrite', objectId, changes), + }; +} + +// --------------------------------------------------------------------------- +// Asynchronous actions +// --------------------------------------------------------------------------- + +/** + * Starts an asynchronous adapter call and suspends the branch. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {object} frame Frame. + * @param {object} step Step. + * @param {string} key Journal key. + * @param {object} op {adapter, action, input, timeoutMs}. + * @returns {'suspend'} Always suspends. + */ +function startAsync(inst, branch, frame, step, key, op) { + const at = now(); + const opId = `${inst.id}/${key}`; + frame.index += 1; + branch.gen += 1; + branch.consecutive = 0; + branch.wait = { + kind: 'async', + opId, + key, + stepId: step.id, + saveAs: step.saveAs || null, + adapter: op.adapter.name, + action: step.action, + timeoutAt: at + (op.timeoutMs || 30000), + bootId: runtime.bootId, + completion: null, + }; + branch.status = 'waiting'; + inst.status = anyRunning(inst) ? STATUS.RUNNING : STATUS.WAITING; + inst.journal[key].status = 'dispatched'; + schedule({ + kind: JOB.ASYNC_TIMEOUT, + dueAt: branch.wait.timeoutAt, + key: `async:${inst.id}:${branch.id}`, + target: { runId: inst.id, branchId: branch.id, gen: inst.generation, bgen: branch.gen, opId }, + }); + const bootId = runtime.bootId; + let called = false; + const done = (err, result) => { + if (called) { + return; + } + called = true; + asyncComplete(inst.id, branch.id, opId, bootId, err, result); + }; + try { + op.action.invoke(clone(op.input), { idempotencyKey: key, runId: inst.id, opId }, done); + } catch (err) { + done(err); + } + touch(inst); + return 'suspend'; +} + +/** + * Receives an adapter callback. Callbacks from an earlier boot, a cancelled + * run or a superseded attempt are ignored. + * + * @param {string} runId Run ID. + * @param {string} branchId Branch ID. + * @param {string} opId Operation ID. + * @param {string} bootId Boot that issued the call. + * @param {*} err Error, if any. + * @param {*} result Result. + * @returns {void} + */ +function asyncComplete(runId, branchId, opId, bootId, err, result) { + if (bootId !== runtime.bootId) { + return; + } + const inst = getInstance(runId); + const branch = inst?.branches[branchId]; + if ( + !inst || + !branch || + branch.wait?.kind !== 'async' || + branch.wait.opId !== opId || + TERMINAL_STATUSES.has(inst.status) + ) { + return; + } + runtime.scheduler?.cancelKey(`async:${runId}:${branchId}`); + branch.wait.completion = { + ok: !err, + error: err ? truncate(String(err?.message || err), 200) : null, + uncertain: Boolean(err?.uncertain), + result: compactResult(result), + }; + if (inst.status === STATUS.PAUSED || inst.status === STATUS.REVIEW) { + // Recorded while paused; the run advances on resume. + return; + } + scheduleBranch(inst, branch, now()); +} + +/** + * Applies a recorded adapter completion. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {object} wait Async wait. + * @returns {boolean} True when the branch continues. + */ +function finishAsync(inst, branch, wait) { + const entry = inst.journal[wait.key]; + const done = wait.completion; + if (entry) { + entry.status = done.ok ? 'confirmed' : 'failed'; + entry.completedAt = now(); + entry.result = done.result; + } + if (!done.ok) { + const err = taskError( + done.uncertain ? ERROR_CODES.UNCERTAIN : ERROR_CODES.UNAVAILABLE, + 'adapterFailed', + { adapter: wait.adapter, message: done.error || '' }, + { transient: false } + ); + const step = getCompiled(inst.definitionId, inst.definitionRevision).steps.get( + wait.stepId + ).step; + return handleStepError(inst, branch, step, err, true) === 'next-advanced'; + } + inst.effects += 1; + if (wait.saveAs) { + branch.variables[wait.saveAs] = done.result; + } + return true; +} + +/** + * Handles an adapter call that did not answer in time. The outcome is + * unknown, so the adapter's status lookup is tried before review. + * + * @param {object} job Job. + * @returns {void} + */ +function onAsyncTimeout(job) { + const live = liveTarget(job); + if (!live || live.branch.wait?.kind !== 'async' || live.branch.wait.opId !== job.target.opId) { + return; + } + const { inst, branch } = live; + const wait = branch.wait; + const status = maybeCall('adapterStatus', wait.adapter, wait.action, wait.key); + if (status?.done) { + wait.completion = { ok: true, result: compactResult(status.result) }; + scheduleBranch(inst, branch, now()); + return; + } + if (inst.journal[wait.key]) { + inst.journal[wait.key].status = 'uncertain'; + } + timeline(inst, 'asyncTimeout', { stepId: wait.stepId }); + enterReview(inst, branch, 'uncertainOutcome', { + stepId: wait.stepId, + journalKey: wait.key, + indexAdvanced: true, + }); +} + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +/** + * Delivers a matching event to a waiting branch, consuming the wait once. + * + * @param {string} runId Run ID. + * @param {string} branchId Branch ID. + * @param {object} event Normalised event. + * @returns {boolean} True when consumed. + */ +export function deliverEvent(runId, branchId, event) { + const inst = getInstance(runId); + const branch = inst?.branches[branchId]; + if ( + !inst || + !branch || + branch.wait?.kind !== 'event' || + inst.status === STATUS.PAUSED || + inst.status === STATUS.REVIEW + ) { + return false; + } + runtime.scheduler?.cancelKey(`await:${runId}:${branchId}`); + branch.variables[branch.wait.saveAs] = { + matched: true, + timedOut: false, + event: compactEvent(event), + }; + timeline(inst, 'event', { stepId: branch.wait.stepId, type: event.type }); + branch.wait = null; + branch.status = 'running'; + branch.gen += 1; + inst.status = STATUS.RUNNING; + maybeCall('awaitsChanged'); + scheduleBranch(inst, branch, now()); + touch(inst); + return true; +} + +/** + * Handles an awaited event's timeout. + * + * @param {object} job Job. + * @returns {void} + */ +function onAwaitTimeout(job) { + const live = liveTarget(job); + if (!live || live.branch.wait?.kind !== 'event') { + return; + } + const { inst, branch } = live; + branch.variables[branch.wait.saveAs] = { matched: false, timedOut: true, event: null }; + timeline(inst, 'awaitTimeout', { stepId: branch.wait.stepId }); + branch.wait = null; + branch.status = 'running'; + branch.gen += 1; + inst.status = STATUS.RUNNING; + maybeCall('awaitsChanged'); + runBranch(inst, branch); +} + +/** + * Returns a storable copy of an event. + * + * @param {object} event Event. + * @returns {object} Compact event. + */ +export function compactEvent(event) { + const out = {}; + for (const key of [ + 'id', + 'type', + 'source', + 'objectType', + 'objectId', + 'pageId', + 'characterId', + 'name', + 'value', + 'actor', + 'observedAt', + 'old', + 'new', + 'count', + ]) { + if (event[key] !== undefined) { + out[key] = event[key]; + } + } + return clone(out); +} + +/** + * Returns the branches waiting for events of a type, for the router. + * + * @returns {{runId: string, branchId: string, wait: object, inst: object}[]} Waits. + */ +export function eventWaits() { + const out = []; + for (const inst of Object.values(getState().instances)) { + if (inst.status !== STATUS.RUNNING && inst.status !== STATUS.WAITING) { + continue; + } + for (const branch of Object.values(inst.branches)) { + if (branch.wait?.kind === 'event') { + out.push({ runId: inst.id, branchId: branch.id, wait: branch.wait, inst, branch }); + } + } + } + return out; +} + +/** + * Emits an internal task lifecycle event. + * + * @param {object} inst Instance. + * @param {string} type Event type. + * @returns {void} + */ +function emitTaskEvent(inst, type) { + maybeCall('emitEvent', { + type, + objectType: 'run', + objectId: inst.id, + name: inst.definitionId, + value: inst.status, + source: 'internal', + correlation: { + runId: inst.id, + rootId: inst.correlation.rootId, + depth: inst.correlation.depth + 1, + }, + }); +} + +// --------------------------------------------------------------------------- +// Expressions and targets +// --------------------------------------------------------------------------- + +/** + * Evaluates an expression in a run's context. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {*} expr Expression. + * @returns {*} Value or MISSING. + */ +export function evalExpr(inst, branch, expr) { + return evaluate(expr, { resolve: (path) => resolveRef(inst, branch, path) }); +} + +/** + * Renders a template in a run's context. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {string} template Template. + * @param {(value: string) => string} sanitise Escaping for the destination. + * @param {object} [args] Extra `args.*` values. + * @returns {string} Text. + */ +function render(inst, branch, template, sanitise, args) { + const resolve = (path) => { + if (path.startsWith('args.') && args) { + return readPath(args, path.split('.').slice(1)); + } + return resolveRef(inst, branch, path); + }; + return renderTemplate(template, resolve, sanitise).text; +} + +/** + * Resolves a reference path for expressions and templates. Only declared + * roots and allowlisted object properties are readable. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {string} path Dotted path. + * @returns {{found: boolean, value?: *}} Result. + */ +function resolveRef(inst, branch, path) { + const parts = path.split('.'); + const root = parts.shift(); + switch (root) { + case 'param': + return readPath(inst.params, parts); + case 'var': + return readPath(branch.variables, parts); + case 'event': + return inst.event ? readPath(inst.event, parts) : { found: false }; + case 'now': + return parts.length ? { found: false } : { found: true, value: now() }; + case 'bindings': { + const ids = inst.bindings[parts[0]]; + if (!ids) { + return { found: false }; + } + if (parts[1] === 'count') { + return { found: true, value: ids.length }; + } + return parts.length === 1 ? { found: true, value: [...ids] } : { found: false }; + } + case 'run': { + const loop = branch.frames.filter((f) => f.loop).at(-1); + const values = { + id: inst.id, + definition: inst.definitionId, + revision: inst.definitionRevision, + initiator: inst.initiatorPlayerId, + startedAt: inst.startedAt, + iteration: loop ? loop.loop.iteration : 0, + intendedDueAt: inst.intendedDueAt, + missedCount: inst.occurrence?.missedCount ?? 0, + branch: branch.label, + }; + return parts.length === 1 && parts[0] in values && values[parts[0]] !== null + ? { found: true, value: values[parts[0]] } + : { found: false }; + } + case 'target': { + const [name, prop] = parts; + const ids = inst.bindings[name] || []; + if (prop === 'count') { + return { found: true, value: ids.length }; + } + const found = ids.length ? findAnyTarget(ids[0]) : null; + if (!found || parts.length !== 2) { + return { found: false }; + } + return readProperty(found.obj, found.type, prop); + } + default: + return { found: false }; + } +} + +/** + * Resolves a step target to live objects. Missing objects are skipped; + * the action decides whether that is an error. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {object} spec {binding} | {ids} | {ref}. + * @param {string} type Expected object type. + * @returns {{id: string, obj: object, type: string}[]} Targets. + */ +function resolveTargets(inst, branch, spec, type) { + let ids = []; + if (isRecord(spec) && 'binding' in spec) { + const decl = defOf(inst)?.bindings?.[spec.binding]; + ids = + decl?.from === 'query' && decl.query.evaluate === 'step' + ? runQuery(decl.query, decl.objectType) + : inst.bindings[spec.binding] || []; + } else if (isRecord(spec) && Array.isArray(spec.ids)) { + ids = spec.ids; + } else if (isRecord(spec) && 'ref' in spec) { + const value = evalExpr(inst, branch, { ref: spec.ref }); + ids = Array.isArray(value) ? value : typeof value === 'string' ? [value] : []; + } + const out = []; + for (const id of ids.slice(0, 100)) { + const obj = getTarget(type, id); + if (obj) { + out.push({ id, obj, type }); + continue; + } + // A character step accepts tokens that represent characters. + if (type === 'character') { + const token = getTarget('graphic', id); + if (token) { + out.push({ id, obj: token, type: 'graphic' }); + } + } + } + return out; +} + +/** + * Returns true when a run acts with GM authority. + * + * @param {object} inst Instance. + * @returns {boolean} True for GM authority. + */ +function hasGmAuthority(inst) { + return inst.authority === 'gmService' || isGm(inst.initiatorPlayerId); +} + +/** + * Returns true when any branch of a run is ready to run. + * + * @param {object} inst Instance. + * @returns {boolean} True when one is running. + */ +function anyRunning(inst) { + return Object.values(inst.branches).some((b) => b.status === 'running'); +} + +// --------------------------------------------------------------------------- +// Errors, branches and joins +// --------------------------------------------------------------------------- + +/** + * Applies a step's error policy. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {object} step Failed step. + * @param {*} err Error. + * @param {boolean} advanced True when the frame already points past the step. + * @returns {'next'|'suspend'|'stop'} What the branch does next. + */ +function handleStepError(inst, branch, step, err, advanced) { + const typed = asTaskError(err); + const def = defOf(inst); + const replay = replayClass(step); + const frame = branch.frames.at(-1); + timeline(inst, 'error', { stepId: step.id, code: typed.code, reason: typed.reason }); + const journal = Object.values(inst.journal) + .filter((e) => e.stepId === step.id && e.branchId === branch.id) + .at(-1); + if ( + typed.code === ERROR_CODES.UNAUTHORIZED && + ['controlLost', 'initiatorGone', 'publicationRevoked'].includes(typed.reason) + ) { + enterReview(inst, branch, typed.reason, { + stepId: step.id, + indexAdvanced: advanced, + vars: typed.vars, + }); + return 'stop'; + } + if (typed.reason === 'adapterUnavailable') { + enterReview(inst, branch, 'adapterUnavailable', { + stepId: step.id, + indexAdvanced: advanced, + vars: typed.vars, + }); + createIncident({ + kind: 'adapter', + cause: 'adapterUnavailable', + runId: inst.id, + definitionId: inst.definitionId, + evidence: typed.vars.adapter, + }); + return 'stop'; + } + if (typed.uncertain && (replay === REPLAY.NON_IDEMPOTENT || replay === REPLAY.ACKNOWLEDGED)) { + enterReview(inst, branch, 'uncertainOutcome', { + stepId: step.id, + indexAdvanced: advanced, + journalKey: journal ? keyOf(inst, journal) : null, + }); + return 'stop'; + } + if (journal && journal.status === 'prepared') { + journal.status = 'failed'; + journal.error = { code: typed.code, reason: typed.reason }; + } + const policy = step.onError || def?.policy?.onError || 'fail'; + const retry = + step.retry || + def?.policy?.retry || + (policy === 'retry' ? { maxAttempts: 3, baseDelayMs: 1000, maxDelayMs: 30000 } : null); + const attempt = branch.attempts[step.id] || 1; + if ( + retry && + (policy === 'retry' || step.retry) && + typed.transient && + attempt < retry.maxAttempts + ) { + const base = retry.baseDelayMs ?? 1000; + const delay = + Math.min(base * 2 ** (attempt - 1), retry.maxDelayMs ?? 30000) + + Math.floor(Math.random() * (retry.jitterMs ?? 0)); + branch.attempts[step.id] = attempt + 1; + if (advanced && frame) { + frame.index -= 1; + } + branch.gen += 1; + branch.wait = { + kind: 'retry', + dueAt: now() + Math.max(delay, 10), + stepId: step.id, + attempt: attempt + 1, + }; + branch.status = 'waiting'; + timeline(inst, 'retry', { stepId: step.id, attempt: attempt + 1 }); + scheduleBranch(inst, branch, branch.wait.dueAt, JOB.RETRY); + touch(inst); + return 'suspend'; + } + delete branch.attempts[step.id]; + if (policy === 'skip') { + timeline(inst, 'skip', { stepId: step.id }); + if (journal && journal.status === 'failed') { + journal.status = 'skipped'; + } + if (advanced) { + return 'next-advanced'; + } + return 'next'; + } + if (policy === 'review') { + enterReview(inst, branch, 'stepError', { + stepId: step.id, + indexAdvanced: advanced, + vars: { code: typed.code, reason: typed.reason }, + }); + return 'stop'; + } + failBranch(inst, branch, errorRecord(typed, step.id)); + return 'stop'; +} + +/** + * Returns the journal key of an entry. + * + * @param {object} inst Instance. + * @param {object} entry Journal entry. + * @returns {string|null} Key. + */ +function keyOf(inst, entry) { + return Object.keys(inst.journal).find((k) => inst.journal[k] === entry) || null; +} + +/** + * Marks a branch failed and propagates the failure. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @param {object} error Error record. + * @returns {void} + */ +function failBranch(inst, branch, error) { + branch.status = 'failed'; + branch.error = error; + branch.wait = null; + branch.gen += 1; + if (branch.id === 'main') { + finalize(inst, STATUS.FAILED, error); + return; + } + onSubBranchTerminal(inst, branch); +} + +/** + * Marks a branch completed and propagates. + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @returns {void} + */ +function completeBranch(inst, branch) { + branch.status = 'completed'; + branch.wait = null; + branch.gen += 1; + if (branch.id === 'main') { + finalize(inst, STATUS.COMPLETED, null); + return; + } + onSubBranchTerminal(inst, branch); +} + +/** + * Re-evaluates a parallel join after one of its branches finished. + * + * @param {object} inst Instance. + * @param {object} branch Finished sub-branch. + * @returns {void} + */ +function onSubBranchTerminal(inst, branch) { + const parent = inst.branches[branch.parentId]; + const wait = parent?.wait; + if ( + !parent || + wait?.kind !== 'join' || + !wait.children.includes(branch.id) || + TERMINAL_STATUSES.has(inst.status) + ) { + return; + } + const children = wait.children.map((id) => inst.branches[id]); + const finished = children.filter((b) => ['completed', 'failed', 'cancelled'].includes(b.status)); + const succeeded = children.filter((b) => b.status === 'completed'); + let decided = null; + if (wait.join === 'all') { + if (finished.length === children.length) { + const failed = children.find((b) => b.status === 'failed'); + decided = failed ? { ok: false, error: failed.error } : { ok: true }; + } + } else if (wait.join === 'anySuccess') { + if (succeeded.length) { + decided = { ok: true, winner: succeeded[0].id }; + } else if (finished.length === children.length) { + decided = { + ok: false, + error: + children.find((b) => b.error)?.error || + errorRecord(taskError(ERROR_CODES.INTERNAL, 'allBranchesFailed', {})), + }; + } + } else if (wait.join === 'race') { + decided = + branch.status === 'completed' + ? { ok: true, winner: branch.id } + : { ok: false, error: branch.error }; + } + if (!decided) { + touch(inst); + return; + } + if (wait.join !== 'all' && wait.cancelLosers) { + for (const child of children) { + if (child.status === 'running' || child.status === 'waiting') { + cancelBranch(inst, child); + } + } + } + const step = getCompiled(inst.definitionId, inst.definitionRevision).steps.get(wait.stepId).step; + const results = {}; + for (const child of children) { + results[child.label] = { + status: child.status, + vars: child.status === 'completed' ? child.variables : null, + }; + } + parent.variables[step.saveAs || step.id] = clone(results); + for (const rule of step.merge || []) { + parent.variables[rule.name] = reduceMerge(rule, results); + } + timeline(inst, 'join', { stepId: step.id, ok: decided.ok, winner: decided.winner }); + parent.wait = null; + parent.gen += 1; + if (!decided.ok) { + parent.status = 'running'; + failBranch( + inst, + parent, + decided.error || errorRecord(taskError(ERROR_CODES.INTERNAL, 'branchFailed', {})) + ); + return; + } + parent.status = 'running'; + inst.status = STATUS.RUNNING; + scheduleBranch(inst, parent, now()); + touch(inst); +} + +/** + * Applies an explicit merge reducer to branch results. + * + * @param {object} rule {name, from?, var?, reducer}. + * @param {object} results Branch results by branch ID. + * @returns {*} Merged value. + */ +function reduceMerge(rule, results) { + const source = rule.var || rule.name; + const values = Object.entries(results) + .filter(([id, r]) => r.status === 'completed' && (!rule.from || rule.from.includes(id))) + .map(([, r]) => r.vars?.[source]) + .filter((v) => v !== undefined && v !== null); + const numbers = values.filter((v) => typeof v === 'number'); + switch (rule.reducer) { + case 'sum': + return numbers.reduce((a, b) => a + b, 0); + case 'min': + return numbers.length ? Math.min(...numbers) : null; + case 'max': + return numbers.length ? Math.max(...numbers) : null; + case 'first': + return values[0] ?? null; + case 'last': + return values.at(-1) ?? null; + case 'count': + return values.length; + case 'all': + return values.length > 0 && values.every(Boolean); + case 'any': + return values.some(Boolean); + default: + return null; + } +} + +/** + * Cancels one branch (a parallel loser). + * + * @param {object} inst Instance. + * @param {object} branch Branch. + * @returns {void} + */ +function cancelBranch(inst, branch) { + if (branch.wait?.kind === 'child') { + cancelRun(branch.wait.childId, null, 'branchCancelled'); + } + for (const child of Object.values(inst.branches)) { + if ( + child.parentId === branch.id && + (child.status === 'running' || child.status === 'waiting') + ) { + cancelBranch(inst, child); + } + } + branch.status = 'cancelled'; + branch.wait = null; + branch.gen += 1; + runtime.scheduler?.cancelKey(`run:${inst.id}:${branch.id}`); + runtime.scheduler?.cancelKey(`await:${inst.id}:${branch.id}`); + runtime.scheduler?.cancelKey(`async:${inst.id}:${branch.id}`); +} + +// --------------------------------------------------------------------------- +// Children +// --------------------------------------------------------------------------- + +/** + * Tells a parent that an owned child finished. + * + * @param {object} child Child instance. + * @returns {void} + */ +function notifyParent(child) { + if (!child.parentId || !child.owned) { + return; + } + const parent = getInstance(child.parentId); + const branch = parent?.branches[child.parentBranchId]; + if ( + !branch || + branch.wait?.kind !== 'child' || + branch.wait.childId !== child.id || + TERMINAL_STATUSES.has(parent.status) + ) { + return; + } + branch.wait.completion = { + status: child.status, + error: child.error, + vars: clone(child.branches.main?.variables || {}), + }; + if (parent.status === STATUS.PAUSED || parent.status === STATUS.REVIEW) { + return; + } + scheduleBranch(parent, branch, now()); +} + +/** + * Applies a child's outcome to the waiting parent branch. + * + * @param {object} inst Parent instance. + * @param {object} branch Parent branch. + * @param {object} wait Child wait. + * @returns {boolean} True when the branch continues. + */ +function finishChildWait(inst, branch, wait) { + const done = wait.completion; + branch.variables[wait.saveAs] = { runId: wait.childId, status: done.status, vars: done.vars }; + timeline(inst, 'childDone', { stepId: wait.stepId, child: wait.childId, status: done.status }); + if (done.status !== STATUS.COMPLETED && wait.onChildFail !== 'continue') { + const step = getCompiled(inst.definitionId, inst.definitionRevision).steps.get( + wait.stepId + ).step; + const err = taskError( + ERROR_CODES.INTERNAL, + 'childFailed', + { child: wait.childId, status: done.status }, + { transient: false } + ); + return handleStepError(inst, branch, step, err, true) === 'next-advanced'; + } + return true; +} + +// --------------------------------------------------------------------------- +// Pause, resume, cancel, review +// --------------------------------------------------------------------------- + +/** + * Freezes a run's waits: relative waits keep their remaining time, event + * waits stop listening. + * + * @param {object} inst Instance. + * @returns {void} + */ +function freeze(inst) { + const at = now(); + for (const branch of Object.values(inst.branches)) { + const wait = branch.wait; + if (!wait) { + continue; + } + if ((wait.kind === 'time' && !wait.absolute) || wait.kind === 'retry') { + wait.remainingMs = Math.max(0, wait.dueAt - at); + } else if (wait.kind === 'event') { + wait.remainingMs = Math.max(0, wait.timeoutAt - at); + } + } + inst.generation += 1; + cancelJobs(inst); + maybeCall('awaitsChanged'); +} + +/** + * Re-creates the jobs of a run from its persisted waits. + * + * @param {object} inst Instance. + * @param {{thaw?: boolean, gapFrom?: number}} [options] Thaw relative waits; note an observation gap. + * @returns {void} + */ +export function rebuildJobs(inst, { thaw = false, gapFrom } = {}) { + const at = now(); + for (const branch of Object.values(inst.branches)) { + const wait = branch.wait; + if (branch.status === 'running' && !wait) { + scheduleBranch(inst, branch, at); + continue; + } + if (!wait) { + continue; + } + switch (wait.kind) { + case 'time': + case 'retry': + if (thaw && wait.remainingMs !== undefined && wait.remainingMs !== null) { + wait.dueAt = at + wait.remainingMs; + } + wait.remainingMs = null; + scheduleBranch(inst, branch, wait.dueAt, wait.kind === 'retry' ? JOB.RETRY : JOB.CONTINUE); + break; + case 'yield': + scheduleBranch(inst, branch, at); + break; + case 'event': + if (thaw && wait.remainingMs !== undefined && wait.remainingMs !== null) { + wait.timeoutAt = at + wait.remainingMs; + wait.remainingMs = null; + } + if (gapFrom) { + timeline(inst, 'observationGap', { stepId: wait.stepId, from: gapFrom, to: at }); + wait.gaps = (wait.gaps || 0) + 1; + } + schedule({ + kind: JOB.AWAIT_TIMEOUT, + dueAt: wait.timeoutAt, + key: `await:${inst.id}:${branch.id}`, + target: { runId: inst.id, branchId: branch.id, gen: inst.generation, bgen: branch.gen }, + }); + break; + case 'async': + if (wait.completion) { + scheduleBranch(inst, branch, at); + } else { + schedule({ + kind: JOB.ASYNC_TIMEOUT, + dueAt: wait.timeoutAt, + key: `async:${inst.id}:${branch.id}`, + target: { + runId: inst.id, + branchId: branch.id, + gen: inst.generation, + bgen: branch.gen, + opId: wait.opId, + }, + }); + } + break; + case 'child': + case 'join': + if (wait.completion) { + scheduleBranch(inst, branch, at); + } + break; + default: + break; + } + } + maybeCall('awaitsChanged'); +} + +/** + * Pauses a run and its owned children. + * + * @param {string} runId Run ID. + * @param {string|null} by Player ID. + * @param {{byParent?: boolean}} [options] Paused by its parent. + * @returns {{ok: boolean, error?: string, vars?: object}} Result. + */ +export function pauseRun(runId, by, { byParent = false } = {}) { + const inst = getInstance(runId); + if (!inst) { + return { ok: false, error: 'runNotFound', vars: { id: runId } }; + } + if (inst.status !== STATUS.RUNNING && inst.status !== STATUS.WAITING) { + return { ok: false, error: 'notPausable', vars: { id: runId, status: inst.status } }; + } + freeze(inst); + inst.pause = { previous: inst.status, at: now(), by: by || null, byParent }; + inst.status = STATUS.PAUSED; + timeline(inst, 'pause', { by }); + audit('run.pause', { run: runId, by }); + bump(inst); + for (const childId of inst.children) { + const child = getInstance(childId); + if (child?.owned && (child.status === STATUS.RUNNING || child.status === STATUS.WAITING)) { + pauseRun(childId, by, { byParent: true }); + } + } + touch(inst); + return { ok: true }; +} + +/** + * Resumes a paused run. Relative waits keep their remaining time; event waits + * listen again and the pause is recorded as an observation gap. + * + * @param {string} runId Run ID. + * @param {string|null} by Player ID. + * @returns {{ok: boolean, error?: string, vars?: object}} Result. + */ +export function resumeRun(runId, by) { + const inst = getInstance(runId); + if (!inst) { + return { ok: false, error: 'runNotFound', vars: { id: runId } }; + } + if (inst.status !== STATUS.PAUSED) { + return { ok: false, error: 'notPaused', vars: { id: runId } }; + } + const gapFrom = inst.pause?.at; + inst.status = anyRunning(inst) ? STATUS.RUNNING : STATUS.WAITING; + inst.pause = null; + inst.generation += 1; + timeline(inst, 'resume', { by }); + audit('run.resume', { run: runId, by }); + bump(inst); + rebuildJobs(inst, { thaw: true, gapFrom }); + for (const childId of inst.children) { + const child = getInstance(childId); + if (child?.status === STATUS.PAUSED && child.pause?.byParent) { + resumeRun(childId, by); + } + } + touch(inst); + return { ok: true }; +} + +/** + * Cancels a run, its jobs, its waits and its owned children. Effects already + * applied stay applied and are listed in the journal. + * + * @param {string} runId Run ID. + * @param {string|null} by Player ID. + * @param {string} [reason='cancelled'] Reason key. + * @returns {{ok: boolean, error?: string, vars?: object}} Result. + */ +export function cancelRun(runId, by, reason = 'cancelled') { + const inst = getInstance(runId); + if (!inst) { + return { ok: false, error: 'runNotFound', vars: { id: runId } }; + } + if (!ACTIVE_STATUSES.has(inst.status)) { + return { ok: false, error: 'notActive', vars: { id: runId, status: inst.status } }; + } + finalize(inst, STATUS.CANCELLED, { + code: 'cancelled', + reason, + vars: { by: by || '' }, + stepId: null, + }); + return { ok: true }; +} + +/** + * Puts a run into review: nothing advances until a GM decides. + * + * @param {object} inst Instance. + * @param {object} branch Branch that needs the decision. + * @param {string} reason Reason key. + * @param {object} detail Step, journal key, index state. + * @returns {void} + */ +function enterReview(inst, branch, reason, detail) { + if (inst.status === STATUS.REVIEW) { + return; + } + freeze(inst); + inst.review = { reason, branchId: branch.id, at: now(), ...detail, previous: inst.status }; + branch.status = 'waiting'; + branch.wait = branch.wait || { kind: 'review', stepId: detail.stepId }; + inst.status = STATUS.REVIEW; + timeline(inst, 'review', { reason, stepId: detail.stepId }); + audit('run.review', { run: inst.id, reason, step: detail.stepId }); + const incident = createIncident({ + kind: 'review', + cause: reason, + runId: inst.id, + definitionId: inst.definitionId, + evidence: detail.stepId, + group: `review:${inst.id}`, + }); + inst.review.incidentId = incident.id; + maybeCall('notifyGm', 'review', { id: inst.id, reason, step: detail.stepId || '' }); + bump(inst); +} + +/** + * Applies a GM decision to a run in review. + * + * @param {string} runId Run ID. + * @param {'retry'|'skip'|'complete'|'skipNext'|'cancel'} decision Decision. + * @param {string} by GM player ID. + * @returns {{ok: boolean, error?: string, vars?: object}} Result. + */ +export function decideReview(runId, decision, by) { + const inst = getInstance(runId); + if (!inst || inst.status !== STATUS.REVIEW || !inst.review) { + return { ok: false, error: 'notInReview', vars: { id: runId } }; + } + const review = inst.review; + if (review.incidentId) { + resolveIncident(review.incidentId, decision, by); + } + audit('run.decide', { run: runId, decision, by }); + if (decision === 'cancel') { + inst.status = review.previous === STATUS.PAUSED ? STATUS.PAUSED : STATUS.RUNNING; + return cancelRun(runId, by, 'reviewCancelled'); + } + const branch = inst.branches[review.branchId]; + const frame = branch?.frames.at(-1); + if (review.journalKey && inst.journal[review.journalKey]) { + inst.journal[review.journalKey].status = + decision === 'complete' ? 'confirmed' : decision === 'skip' ? 'skipped' : 'abandoned'; + inst.journal[review.journalKey].decidedBy = by; + } + if (branch && frame && review.stepId) { + if (decision === 'retry' && review.indexAdvanced) { + frame.index -= 1; + } + if ((decision === 'skip' || decision === 'complete') && !review.indexAdvanced) { + frame.index += 1; + } + if (decision === 'skipNext') { + frame.index += 1; + } + if (decision === 'retry') { + branch.attempts[review.stepId] = (branch.attempts[review.stepId] || 1) + 1; + } + } + if (branch && ['review', 'async', 'time'].includes(branch.wait?.kind) && review.stepId) { + branch.wait = null; + branch.status = 'running'; + } + inst.review = null; + inst.status = STATUS.RUNNING; + inst.generation += 1; + timeline(inst, 'decide', { decision, by }); + rebuildJobs(inst, { thaw: true }); + bump(inst); + return { ok: true }; +} + +/** + * Starts a fresh run linked to an earlier one. + * + * @param {string} runId Earlier run ID (instance or history). + * @param {string} by Player ID. + * @returns {object} Admission result. + */ +export function rerun(runId, by) { + const s = getState(); + const source = getInstance(runId) || s.history.find((h) => h.id === runId); + if (!source) { + return { ok: false, status: 'rejected', reason: 'runNotFound', vars: { id: runId } }; + } + if (source.status && ACTIVE_STATUSES.has(source.status)) { + return { ok: false, status: 'rejected', reason: 'stillActive', vars: { id: runId } }; + } + return admitRun(source.definitionId, { + initiatorId: by, + params: source.params, + bindings: source.bindings, + trigger: { type: 'rerun', id: runId }, + rerunOf: runId, + }); +} + +/** + * Finishes a run: terminalises branches, cancels owned children, records + * history, notifies the parent and frees the queue. + * + * @param {object} inst Instance. + * @param {string} status Terminal status. + * @param {object|null} error Error record. + * @returns {void} + */ +export function finalize(inst, status, error) { + if (TERMINAL_STATUSES.has(inst.status)) { + return; + } + const at = now(); + inst.status = status; + inst.error = error; + inst.outcome = status; + inst.completedAt = at; + inst.generation += 1; + inst.revision += 1; + inst.pause = null; + cancelJobs(inst, true); + for (const branch of Object.values(inst.branches)) { + if (branch.status === 'running' || branch.status === 'waiting') { + branch.status = status === STATUS.COMPLETED ? 'completed' : 'cancelled'; + branch.wait = null; + } + } + for (const childId of inst.children) { + const child = getInstance(childId); + if (child?.owned && ACTIVE_STATUSES.has(child.status)) { + cancelRun(childId, null, 'parentFinished'); + } + } + if (inst.review?.incidentId) { + resolveIncident(inst.review.incidentId, status, null); + } + inst.review = null; + timeline(inst, 'end', { status, code: error?.code, reason: error?.reason }); + count(status === STATUS.COMPLETED ? 'completed' : 'failed'); + recordHistory({ + id: inst.id, + definitionId: inst.definitionId, + revision: inst.definitionRevision, + name: inst.name, + status, + initiator: inst.initiatorPlayerId, + trigger: inst.trigger?.type, + createdAt: inst.createdAt, + completedAt: at, + durationMs: at - inst.startedAt, + latenessMs: inst.latenessMs, + effects: inst.effects, + error, + params: inst.params, + bindings: inst.bindings, + parentId: inst.parentId, + rerunOf: inst.rerunOf, + uncertain: Object.values(inst.journal).filter((e) => + ['prepared', 'uncertain', 'dispatched'].includes(e.status) + ).length, + }); + audit('run.end', { run: inst.id, status, reason: error?.reason }); + emitTaskEvent( + inst, + { + completed: 'task.completed', + failed: 'task.failed', + expired: 'task.failed', + cancelled: 'task.cancelled', + }[status] + ); + maybeCall('awaitsChanged'); + notifyParent(inst); + maybeCall('runTerminal', inst); + drainQueue(inst.definitionId); + pruneTerminal(); + maybeCall('dashboardDirty', 'instances'); +} + +/** + * Keeps only the most recent finished runs in full; history keeps summaries. + * + * @returns {void} + */ +export function pruneTerminal() { + const s = getState(); + const terminal = Object.values(s.instances) + .filter((i) => TERMINAL_STATUSES.has(i.status)) + .sort((a, b) => b.completedAt - a.completedAt); + for (const inst of terminal.slice(RETAIN_TERMINAL)) { + // Keep finished children while their parent still runs. + const parent = inst.parentId ? s.instances[inst.parentId] : null; + if (parent && ACTIVE_STATUSES.has(parent.status)) { + continue; + } + delete s.instances[inst.id]; + } +} + +/** + * Returns the step a run is on, for the dashboard. + * + * @param {object} inst Instance. + * @returns {string} Step ID, or an empty string. + */ +export function currentStep(inst) { + const main = inst.branches.main; + if (!main) { + return ''; + } + if (main.wait?.stepId) { + return main.wait.stepId; + } + const frame = main.frames.at(-1); + if (!frame) { + return ''; + } + const compiled = getCompiled(inst.definitionId, inst.definitionRevision); + return compiled?.blocks.get(frame.block)?.[frame.index]?.id || ''; +} + +// --------------------------------------------------------------------------- +// Restart recovery (plan §8) +// --------------------------------------------------------------------------- + +/** + * Compares an interrupted action's intended result with the live objects. + * + * @param {object} entry Journal entry (prepared or dispatched). + * @param {object} step Step. + * @returns {'confirmed'|'notApplied'|'conflict'|'unknown'} Reconciliation. + */ +function reconcileEntry(entry, step) { + const data = entry.data || {}; + const graphic = (id) => getTarget('graphic', id); + if (data.resolvedSide) { + const results = Object.entries(data.resolvedSide).map(([id, side]) => { + const token = graphic(id); + return token ? Number(token.get('currentSide')) === side : false; + }); + return results.every(Boolean) ? 'confirmed' : 'conflict'; + } + if (step.type === 'attribute.adjust' && data.plan) { + const field = data.field || 'current'; + const states = Object.values(data.plan).map((p) => { + const attr = getTarget('attribute', p.attrId); + const value = Number(attr?.get(field)); + return value === p.new ? 'new' : value === p.old ? 'old' : 'other'; + }); + if (states.every((x) => x === 'new')) { + return 'confirmed'; + } + return 'conflict'; + } + if (step.type === 'token.set' && data.set) { + // Absolute writes: done if every target already holds the values. + return 'unknown'; + } + if (step.type === 'attribute.set' && data.old) { + return 'unknown'; + } + return 'unknown'; +} + +/** + * Reconciles one run after a restart: interrupted actions follow their + * replay class, callbacks from the old boot are written off, finished + * children and joins are re-checked, and jobs are rebuilt. + * + * @param {object} inst Instance. + * @param {{gapFrom: number|null}} context Restart context. + * @returns {{review: boolean, gap: boolean}} What happened. + */ +export function recoverInstance(inst, { gapFrom }) { + const outcome = { review: false, gap: false }; + const def = defOf(inst); + const compiled = getCompiled(inst.definitionId, inst.definitionRevision); + if (!def || !compiled) { + finalize( + inst, + STATUS.FAILED, + errorRecord(taskError(ERROR_CODES.VALIDATION, 'revisionMissing', { id: inst.definitionId })) + ); + return outcome; + } + inst.generation += 1; + if (inst.status === STATUS.PAUSED || inst.status === STATUS.REVIEW) { + return outcome; + } + const restart = def.policy?.restart || 'reconcile'; + if (restart === 'fail') { + finalize( + inst, + STATUS.FAILED, + errorRecord(taskError(ERROR_CODES.UNCERTAIN, 'restartPolicyFail', {})) + ); + return outcome; + } + // Interrupted synchronous actions. + for (const [key, entry] of Object.entries(inst.journal)) { + if (entry.status !== 'prepared') { + continue; + } + const branch = inst.branches[entry.branchId]; + const step = compiled.steps.get(entry.stepId)?.step; + if (!branch || !step) { + entry.status = 'abandoned'; + continue; + } + const replay = entry.replay; + if (replay === REPLAY.EPHEMERAL) { + entry.status = 'skipped'; + continue; + } + const verdict = reconcileEntry(entry, step); + if (verdict === 'confirmed') { + entry.status = 'confirmed'; + entry.reconciled = true; + const frame = branch.frames.at(-1); + if (frame && compiled.blocks.get(frame.block)?.[frame.index]?.id === step.id) { + frame.index += 1; + } + continue; + } + if (replay === REPLAY.IDEMPOTENT && verdict !== 'conflict' && !step.expect) { + // Safe to apply again: the step re-runs with the same absolute values. + entry.status = 'abandoned'; + continue; + } + entry.status = 'uncertain'; + enterReview(inst, branch, verdict === 'conflict' ? 'restartConflict' : 'uncertainOutcome', { + stepId: step.id, + journalKey: key, + indexAdvanced: false, + }); + outcome.review = true; + return outcome; + } + for (const branch of Object.values(inst.branches)) { + const wait = branch.wait; + if (!wait) { + continue; + } + if (wait.kind === 'async' && !wait.completion && wait.bootId !== runtime.bootId) { + const status = maybeCall('adapterStatus', wait.adapter, wait.action, wait.key); + if (status?.done) { + wait.completion = { ok: true, result: compactResult(status.result) }; + } else { + if (inst.journal[wait.key]) { + inst.journal[wait.key].status = 'uncertain'; + } + enterReview(inst, branch, 'uncertainOutcome', { + stepId: wait.stepId, + journalKey: wait.key, + indexAdvanced: true, + }); + outcome.review = true; + return outcome; + } + } + if (wait.kind === 'child' && !wait.completion) { + const child = getInstance(wait.childId); + if (!child || TERMINAL_STATUSES.has(child.status)) { + wait.completion = { + status: child?.status || STATUS.FAILED, + error: child?.error || null, + vars: clone(child?.branches.main?.variables || {}), + }; + } + } + if (wait.kind === 'event') { + outcome.gap = true; + } + } + if (restart === 'review') { + enterReview(inst, inst.branches.main, 'restartPolicyReview', { + stepId: currentStep(inst), + indexAdvanced: false, + }); + outcome.review = true; + return outcome; + } + rebuildJobs(inst, { gapFrom: outcome.gap ? gapFrom : undefined }); + // Joins whose last branch finished just before the restart. + for (const branch of Object.values(inst.branches)) { + if (branch.parentId && ['completed', 'failed', 'cancelled'].includes(branch.status)) { + onSubBranchTerminal(inst, branch); + } + } + return outcome; +} + +/** + * Registers the executor's job handlers. + * + * @returns {void} + */ +export function registerExecutorJobs() { + onJob(JOB.CONTINUE, onContinueJob); + onJob(JOB.RETRY, onContinueJob); + onJob(JOB.AWAIT_TIMEOUT, onAwaitTimeout); + onJob(JOB.ASYNC_TIMEOUT, onAsyncTimeout); + onJob(JOB.QUEUE_EXPIRY, onQueueExpiry); +} diff --git a/TaskAutomation/src/expressions.js b/TaskAutomation/src/expressions.js new file mode 100644 index 0000000000..45eb50a0d4 --- /dev/null +++ b/TaskAutomation/src/expressions.js @@ -0,0 +1,539 @@ +// Safe structured expressions (plan §4.3). Expressions are JSON trees, never +// source code: no eval, no functions, no prototype walking, no regular +// expressions. Missing values stay missing and never turn into zero. +// Pure: references are resolved through a callback supplied by the caller. + +import { DEFAULT_LIMITS } from './constants.js'; +import { isRecord, parseMarkers, parseStrictNumber, parseBoolean } from './utils.js'; + +/** The typed "no value" result. Comparisons with it are false. */ +export const MISSING = Object.freeze({ missing: true }); + +const BINARY = new Set([ + 'eq', + 'ne', + 'lt', + 'lte', + 'gt', + 'gte', + 'add', + 'sub', + 'mul', + 'div', + 'mod', + 'contains', + 'startsWith', + 'endsWith', + 'hasMarker', + 'in', +]); +const UNARY = new Set([ + 'not', + 'neg', + 'abs', + 'round', + 'floor', + 'ceil', + 'exists', + 'number', + 'string', + 'boolean', + 'lower', + 'upper', + 'trim', + 'length', +]); +const NARY = new Set([ + 'all', + 'any', + 'min', + 'max', + 'concat', + 'coalesce', + 'crossBelow', + 'crossAbove', + 'randomInt', +]); +const ARITY = { crossBelow: 3, crossAbove: 3, randomInt: 2 }; + +export const OPERATORS = Object.freeze([...BINARY, ...UNARY, ...NARY, 'if']); + +const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$/; + +/** + * Returns true when a value is the MISSING sentinel. + * + * @param {*} value Value. + * @returns {boolean} True when missing. + */ +export function isMissing(value) { + return value === MISSING; +} + +/** + * Returns whether an evaluated value counts as true. Missing is false. + * + * @param {*} value Value. + * @returns {boolean} Truthiness. + */ +export function truthy(value) { + if (value === MISSING || value === null || value === undefined) { + return false; + } + return Boolean(value); +} + +/** + * Validates an expression tree. + * + * @param {*} node Expression. + * @param {object} options Options. + * @param {Set} options.roots Reference roots allowed here (e.g. param, var, event). + * @param {string} [options.path='$'] Location for error messages. + * @param {object} [options.limits] Depth/node/string limits. + * @returns {{path: string, code: string, vars?: object}[]} Errors (empty when valid). + */ +export function validateExpression(node, { roots, path = '$', limits = DEFAULT_LIMITS }) { + const errors = []; + let count = 0; + const visit = (n, depth, at) => { + count += 1; + if (count > limits.expressionNodes) { + errors.push({ path: at, code: 'exprTooLarge', vars: { max: limits.expressionNodes } }); + return; + } + if (depth > limits.expressionDepth) { + errors.push({ path: at, code: 'exprTooDeep', vars: { max: limits.expressionDepth } }); + return; + } + if (n === null || typeof n === 'boolean') { + return; + } + if (typeof n === 'number') { + if (!Number.isFinite(n)) { + errors.push({ path: at, code: 'notFinite' }); + } + return; + } + if (typeof n === 'string') { + if (n.length > limits.stringLength) { + errors.push({ path: at, code: 'stringTooLong', vars: { max: limits.stringLength } }); + } + return; + } + if (!isRecord(n)) { + errors.push({ path: at, code: 'exprShape' }); + return; + } + if ('value' in n) { + if (Object.keys(n).length !== 1) { + errors.push({ path: at, code: 'unknownField', vars: { field: Object.keys(n).join(', ') } }); + } + return; + } + if ('ref' in n) { + for (const key of Object.keys(n)) { + if (!['ref', 'as', 'default'].includes(key)) { + errors.push({ path: `${at}.${key}`, code: 'unknownField', vars: { field: key } }); + } + } + if (typeof n.ref !== 'string' || !REF_PATTERN.test(n.ref)) { + errors.push({ path: `${at}.ref`, code: 'badRef', vars: { ref: String(n.ref) } }); + return; + } + const root = n.ref.split('.')[0]; + if (!roots.has(root)) { + errors.push({ + path: `${at}.ref`, + code: 'refRoot', + vars: { root, allowed: [...roots].join(', ') }, + }); + } + if (n.as !== undefined && !['number', 'string', 'boolean'].includes(n.as)) { + errors.push({ path: `${at}.as`, code: 'badConversion', vars: { as: String(n.as) } }); + } + if (n.default !== undefined && n.default !== null && typeof n.default === 'object') { + errors.push({ path: `${at}.default`, code: 'defaultScalar' }); + } + return; + } + if (typeof n.op !== 'string' || !OPERATORS.includes(n.op)) { + errors.push({ path: `${at}.op`, code: 'badOperator', vars: { op: String(n.op) } }); + return; + } + const allowed = ['op']; + if (BINARY.has(n.op)) { + allowed.push('left', 'right'); + for (const side of ['left', 'right']) { + if (!(side in n)) { + errors.push({ path: at, code: 'missingOperand', vars: { field: side, op: n.op } }); + } else { + visit(n[side], depth + 1, `${at}.${side}`); + } + } + } else if (UNARY.has(n.op)) { + allowed.push('arg'); + if (!('arg' in n)) { + errors.push({ path: at, code: 'missingOperand', vars: { field: 'arg', op: n.op } }); + } else { + visit(n.arg, depth + 1, `${at}.arg`); + } + } else if (n.op === 'if') { + allowed.push('cond', 'then', 'else'); + for (const field of ['cond', 'then', 'else']) { + if (!(field in n)) { + errors.push({ path: at, code: 'missingOperand', vars: { field, op: n.op } }); + } else { + visit(n[field], depth + 1, `${at}.${field}`); + } + } + } else { + allowed.push('args'); + if (!Array.isArray(n.args) || n.args.length === 0) { + errors.push({ path: at, code: 'missingOperand', vars: { field: 'args', op: n.op } }); + } else { + if (ARITY[n.op] && n.args.length !== ARITY[n.op]) { + errors.push({ + path: `${at}.args`, + code: 'arity', + vars: { op: n.op, count: ARITY[n.op] }, + }); + } + n.args.forEach((arg, i) => visit(arg, depth + 1, `${at}.args.${i}`)); + } + } + for (const key of Object.keys(n)) { + if (!allowed.includes(key)) { + errors.push({ path: `${at}.${key}`, code: 'unknownField', vars: { field: key } }); + } + } + }; + visit(node, 0, path); + return errors; +} + +/** + * Lists the reference paths used by an expression (for dependency checks). + * + * @param {*} node Expression. + * @returns {string[]} Reference paths. + */ +export function collectRefs(node) { + const refs = []; + const visit = (n) => { + if (!isRecord(n)) { + return; + } + if (typeof n.ref === 'string') { + refs.push(n.ref); + return; + } + for (const key of ['left', 'right', 'arg', 'cond', 'then', 'else']) { + if (key in n) { + visit(n[key]); + } + } + if (Array.isArray(n.args)) { + n.args.forEach(visit); + } + }; + visit(node); + return refs; +} + +/** + * Converts a value as `ref.as` asks. Malformed numbers are missing, not zero. + * + * @param {*} value Value. + * @param {string} [as] Target type. + * @returns {*} Converted value or MISSING. + */ +export function convert(value, as) { + if (value === MISSING || value === undefined) { + return MISSING; + } + if (as === 'number') { + const n = parseStrictNumber(value); + return n === null ? MISSING : n; + } + if (as === 'string') { + return value === null ? '' : typeof value === 'object' ? MISSING : String(value); + } + if (as === 'boolean') { + const b = parseBoolean(value); + return b === null ? MISSING : b; + } + return value; +} + +/** + * Compares two values without coercion. Mismatched types and missing values + * are not equal and not ordered. + * + * @param {string} op Comparison operator. + * @param {*} a Left. + * @param {*} b Right. + * @returns {boolean} Result. + */ +function compare(op, a, b) { + if (a === MISSING || b === MISSING) { + return op === 'ne' ? a !== b : false; + } + if (op === 'eq') { + return a === b; + } + if (op === 'ne') { + return a !== b; + } + const bothNumbers = typeof a === 'number' && typeof b === 'number'; + const bothStrings = typeof a === 'string' && typeof b === 'string'; + if (!bothNumbers && !bothStrings) { + return false; + } + switch (op) { + case 'lt': + return a < b; + case 'lte': + return a <= b; + case 'gt': + return a > b; + case 'gte': + return a >= b; + default: + return false; + } +} + +/** + * Returns a finite number or MISSING. + * + * @param {*} value Value. + * @returns {number|object} Number or MISSING. + */ +function finite(value) { + return typeof value === 'number' && Number.isFinite(value) ? value : MISSING; +} + +/** + * Evaluates an expression. + * + * @param {*} node Validated expression. + * @param {object} ctx Context. + * @param {(path: string) => {found: boolean, value?: *}} ctx.resolve Reference resolver. + * @param {() => number} [ctx.random] Random source in [0, 1). + * @param {number} [ctx.maxString] Longest string result. + * @returns {*} Value or MISSING. + */ +export function evaluate(node, ctx) { + const maxString = ctx.maxString ?? DEFAULT_LIMITS.stringLength; + const ev = (n) => evaluate(n, ctx); + if (isLiteral(node)) { + return node; + } + if (!isRecord(node)) { + return MISSING; + } + if ('value' in node) { + return node.value; + } + if ('ref' in node) { + const found = ctx.resolve(node.ref); + let value = found.found ? found.value : MISSING; + if (value !== MISSING && node.as) { + value = convert(value, node.as); + } + if (value === MISSING && node.default !== undefined) { + return node.default; + } + return value; + } + const op = node.op; + switch (op) { + case 'eq': + case 'ne': + case 'lt': + case 'lte': + case 'gt': + case 'gte': + return compare(op, ev(node.left), ev(node.right)); + case 'add': + case 'sub': + case 'mul': + case 'div': + case 'mod': { + const a = finite(ev(node.left)); + const b = finite(ev(node.right)); + if (a === MISSING || b === MISSING) { + return MISSING; + } + const result = { add: a + b, sub: a - b, mul: a * b, div: a / b, mod: a % b }[op]; + return finite(result); + } + case 'neg': + case 'abs': + case 'round': + case 'floor': + case 'ceil': { + const a = finite(ev(node.arg)); + if (a === MISSING) { + return MISSING; + } + return { + neg: -a, + abs: Math.abs(a), + round: Math.round(a), + floor: Math.floor(a), + ceil: Math.ceil(a), + }[op]; + } + case 'min': + case 'max': { + const values = node.args.map((arg) => finite(ev(arg))); + if (values.includes(MISSING)) { + return MISSING; + } + return op === 'min' ? Math.min(...values) : Math.max(...values); + } + case 'all': + return node.args.every((arg) => truthy(ev(arg))); + case 'any': + return node.args.some((arg) => truthy(ev(arg))); + case 'not': { + const a = ev(node.arg); + return a === MISSING ? MISSING : !truthy(a); + } + case 'exists': + return ev(node.arg) !== MISSING; + case 'number': + case 'string': + case 'boolean': + return convert(ev(node.arg), op); + case 'lower': + case 'upper': + case 'trim': { + const a = ev(node.arg); + if (typeof a !== 'string') { + return MISSING; + } + return op === 'lower' ? a.toLowerCase() : op === 'upper' ? a.toUpperCase() : a.trim(); + } + case 'length': { + const a = ev(node.arg); + return typeof a === 'string' || Array.isArray(a) ? a.length : MISSING; + } + case 'concat': { + const parts = node.args.map(ev); + if (parts.includes(MISSING)) { + return MISSING; + } + const text = parts.map((p) => (p === null ? '' : String(p))).join(''); + return text.length > maxString ? MISSING : text; + } + case 'contains': + case 'startsWith': + case 'endsWith': { + const a = ev(node.left); + const b = ev(node.right); + if (typeof a !== 'string' || typeof b !== 'string') { + return Array.isArray(a) && op === 'contains' && b !== MISSING ? a.includes(b) : false; + } + return op === 'contains' + ? a.includes(b) + : op === 'startsWith' + ? a.startsWith(b) + : a.endsWith(b); + } + case 'in': { + const a = ev(node.left); + const list = ev(node.right); + return a !== MISSING && Array.isArray(list) && list.includes(a); + } + case 'hasMarker': { + const markers = ev(node.left); + const tag = ev(node.right); + if (typeof markers !== 'string' || typeof tag !== 'string') { + return false; + } + return parseMarkers(markers).some((m) => m.tag === tag); + } + case 'coalesce': { + for (const arg of node.args) { + const v = ev(arg); + if (v !== MISSING && v !== null) { + return v; + } + } + return MISSING; + } + case 'crossBelow': + case 'crossAbove': { + const [oldV, newV, threshold] = node.args.map((arg) => finite(convert(ev(arg), 'number'))); + if ([oldV, newV, threshold].includes(MISSING)) { + return false; + } + return op === 'crossBelow' + ? oldV > threshold && newV <= threshold + : oldV < threshold && newV >= threshold; + } + case 'randomInt': { + const [lo, hi] = node.args.map((arg) => finite(ev(arg))); + if (lo === MISSING || hi === MISSING || hi < lo) { + return MISSING; + } + const rand = ctx.random ? ctx.random() : Math.random(); + return Math.floor(lo + rand * (Math.floor(hi) - Math.ceil(lo) + 1)); + } + case 'if': + return truthy(ev(node.cond)) ? ev(node.then) : ev(node.else); + default: + return MISSING; + } +} + +/** + * Returns true for JSON scalars, which evaluate to themselves. + * + * @param {*} node Node. + * @returns {boolean} True for literals. + */ +function isLiteral(node) { + return node === null || ['string', 'number', 'boolean'].includes(typeof node); +} + +/** + * Renders a text template. `{{path}}` placeholders are replaced by declared + * scalar values; the caller escapes the result for its context. Values are + * flattened to one line so they cannot add commands. + * + * @param {string} template Template text. + * @param {(path: string) => {found: boolean, value?: *}} resolve Resolver. + * @param {(value: string) => string} sanitise Per-context escaping. + * @returns {{text: string, missing: string[]}} Text and unresolved placeholders. + */ +export function renderTemplate(template, resolve, sanitise) { + const missing = []; + const text = String(template).replaceAll( + /\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}/g, + (_, path) => { + const found = resolve(path); + if (!found.found || found.value === null || typeof found.value === 'object') { + missing.push(path); + return ''; + } + return sanitise(String(found.value)); + } + ); + return { text, missing }; +} + +/** + * Lists the placeholder paths in a template. + * + * @param {string} template Template text. + * @returns {string[]} Paths. + */ +export function templatePaths(template) { + return [...String(template).matchAll(/\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}/g)].map( + (m) => m[1] + ); +} diff --git a/TaskAutomation/src/heap.js b/TaskAutomation/src/heap.js new file mode 100644 index 0000000000..39aaa000a8 --- /dev/null +++ b/TaskAutomation/src/heap.js @@ -0,0 +1,121 @@ +// A binary min-heap of scheduler jobs ordered by (dueAt, priority, sequence). +// Pure: no Roll20 globals. + +/** + * Returns true when job a must run before job b. Time always wins; priority + * only orders equal deadlines; sequence keeps insertion order stable. + * + * @param {{dueAt: number, priority: number, sequence: number}} a Job. + * @param {{dueAt: number, priority: number, sequence: number}} b Job. + * @returns {boolean} True when a comes first. + */ +export function jobBefore(a, b) { + if (a.dueAt !== b.dueAt) { + return a.dueAt < b.dueAt; + } + if (a.priority !== b.priority) { + return a.priority < b.priority; + } + return a.sequence < b.sequence; +} + +/** + * Creates an empty heap. + * + * @returns {{items: object[]}} Heap. + */ +export function createHeap() { + return { items: [] }; +} + +/** + * Inserts a job. O(log n). + * + * @param {{items: object[]}} heap Heap. + * @param {object} job Job. + * @returns {void} + */ +export function heapPush(heap, job) { + const items = heap.items; + items.push(job); + let i = items.length - 1; + while (i > 0) { + const parent = (i - 1) >> 1; + if (!jobBefore(items[i], items[parent])) { + break; + } + [items[i], items[parent]] = [items[parent], items[i]]; + i = parent; + } +} + +/** + * Returns the first job without removing it. O(1). + * + * @param {{items: object[]}} heap Heap. + * @returns {object|undefined} Head job. + */ +export function heapPeek(heap) { + return heap.items[0]; +} + +/** + * Removes and returns the first job. O(log n). + * + * @param {{items: object[]}} heap Heap. + * @returns {object|undefined} Head job. + */ +export function heapPop(heap) { + const items = heap.items; + if (items.length === 0) { + return undefined; + } + const head = items[0]; + const last = items.pop(); + if (items.length > 0) { + items[0] = last; + siftDown(items, 0); + } + return head; +} + +/** + * Rebuilds the heap from the given jobs. O(n). + * + * @param {{items: object[]}} heap Heap. + * @param {object[]} jobs Jobs to keep. + * @returns {void} + */ +export function heapRebuild(heap, jobs) { + heap.items = [...jobs]; + for (let i = (heap.items.length >> 1) - 1; i >= 0; i -= 1) { + siftDown(heap.items, i); + } +} + +/** + * Moves an item down until both children come after it. + * + * @param {object[]} items Heap array. + * @param {number} start Index. + * @returns {void} + */ +function siftDown(items, start) { + let i = start; + for (;;) { + const left = 2 * i + 1; + const right = left + 1; + let first = i; + if (left < items.length && jobBefore(items[left], items[first])) { + first = left; + } + if (right < items.length && jobBefore(items[right], items[first])) { + first = right; + } + if (first === i) { + return; + } + [items[i], items[first]] = [items[first], items[i]]; + i = first; + } +} diff --git a/TaskAutomation/src/host.js b/TaskAutomation/src/host.js new file mode 100644 index 0000000000..63f67ca593 --- /dev/null +++ b/TaskAutomation/src/host.js @@ -0,0 +1,235 @@ +// The thin Roll20 boundary (plan §3). Every Roll20 global the engine uses is +// reached through here, so the rest of the code reads as plain functions and +// tests can run the bundle in a stub sandbox. + +import { SCRIPT_NAME } from './constants.js'; +import { isRecord, toText } from './utils.js'; + +/** + * Returns the current epoch time. Roll20 has no monotonic clock. + * + * @returns {number} Epoch milliseconds. + */ +export function now() { + return Date.now(); +} + +/** + * Sets a timeout. Only the scheduler may call this. + * + * @param {Function} fn Callback. + * @param {number} ms Delay. + * @returns {*} Handle. + */ +export function setTimer(fn, ms) { + return setTimeout(fn, ms); +} + +/** + * Clears a timeout. Only the scheduler may call this. + * + * @param {*} handle Handle. + * @returns {void} + */ +export function clearTimer(handle) { + clearTimeout(handle); +} + +/** + * Returns the persistent state root object. + * + * @returns {object} Roll20 `state`. + */ +export function stateRoot() { + return state; +} + +/** + * Writes a line to the API console. + * + * @param {string} text Text. + * @returns {void} + */ +export function logLine(text) { + log(`${SCRIPT_NAME}: ${text}`); +} + +/** + * Returns a Roll20 object by type and ID, or null. + * + * @param {string} type Object type. + * @param {string} id Object ID. + * @returns {object|null} Object. + */ +export function getObject(type, id) { + if (typeof id !== 'string' || !id) { + return null; + } + return getObj(type, id) || null; +} + +/** + * Finds Roll20 objects. + * + * @param {object} filter findObjs filter. + * @returns {object[]} Objects. + */ +export function findObjects(filter) { + return findObjs(filter) || []; +} + +/** + * Creates a Roll20 object. + * + * @param {string} type Object type. + * @param {object} attrs Attributes. + * @returns {object|null} Object. + */ +export function createObject(type, attrs) { + return createObj(type, attrs) || null; +} + +/** + * Returns the campaign object. + * + * @returns {object} Campaign. + */ +export function campaign() { + return Campaign(); +} + +/** + * Returns true when a sender is a real GM. API-origin messages (`playerid` + * "API") are never GMs, whatever name they claim. + * + * @param {string} playerId Player ID from the message. + * @returns {boolean} True for GMs. + */ +export function isGm(playerId) { + if (typeof playerId !== 'string' || !playerId || playerId === 'API') { + return false; + } + return Boolean(playerIsGM(playerId)); +} + +/** + * Returns the IDs of every GM in the game. + * + * @returns {string[]} GM player IDs. + */ +export function gmPlayerIds() { + return findObjects({ _type: 'player' }) + .filter((player) => isGm(player.id)) + .map((player) => player.id); +} + +/** + * Returns a player's display name. + * + * @param {string} playerId Player ID. + * @returns {string} Name, or the ID. + */ +export function playerName(playerId) { + return toText(getObject('player', playerId)?.get('displayname')) || toText(playerId); +} + +/** + * Sends a chat message. + * + * @param {string} speaker Speaker name. + * @param {string} text Message text. + * @param {object} [options] sendChat options. + * @returns {void} + */ +export function chat(speaker, text, options) { + sendChat(speaker, text, null, options); +} + +/** + * Returns true when a Roll20 function exists and is not a legacy no-op stub. + * + * @param {string} name Global function name. + * @returns {boolean} True when callable. + */ +export function hasFunction(name) { + return typeof globalThis[name] === 'function'; +} + +/** + * Pings a location. + * + * @param {number} left X. + * @param {number} top Y. + * @param {string} pageId Page ID. + * @param {string|null} playerId Who pings (null for the Mod). + * @param {boolean} moveAll Move everyone's view. + * @param {string[]|undefined} visibleTo Player IDs who see it. + * @returns {void} + */ +export function ping(left, top, pageId, playerId, moveAll, visibleTo) { + sendPing(left, top, pageId, playerId, moveAll, visibleTo); +} + +/** + * Spawns a built-in or custom effect. + * + * @param {object} spec {left, top, pageId, fx, to?, definition?}. + * @returns {void} + */ +export function spawnEffect(spec) { + if (spec.definition) { + spawnFxWithDefinition(spec.left, spec.top, spec.definition, spec.pageId); + } else if (spec.to) { + spawnFxBetweenPoints( + { x: spec.left, y: spec.top }, + { x: spec.to.left, y: spec.to.top }, + spec.fx, + spec.pageId + ); + } else { + spawnFx(spec.left, spec.top, spec.fx, spec.pageId); + } +} + +/** + * Returns the Roll20 One-Click useroptions for this Mod. + * + * @param {string} key State key. + * @returns {object|null} Options. + */ +export function userOptions(key) { + if (typeof globalconfig === 'undefined' || !isRecord(globalconfig)) { + return null; + } + const branch = globalconfig[key.toLowerCase()] || globalconfig[key]; + if (!isRecord(branch)) { + return null; + } + return isRecord(branch.useroptions) ? branch.useroptions : branch; +} + +/** + * Registers a Roll20 event handler. + * + * @param {string} event Event name. + * @param {Function} fn Handler. + * @returns {void} + */ +export function onEvent(event, fn) { + on(event, fn); +} + +let jumpgateCache = null; + +/** + * Returns true when the game runs on Jumpgate. + * + * @returns {boolean} True for Jumpgate games. + */ +export function isJumpgate() { + if (jumpgateCache === null) { + const c = campaign(); + jumpgateCache = toText(c.get('_release') || c.get('release')).toLowerCase() === 'jumpgate'; + } + return jumpgateCache; +} diff --git a/TaskAutomation/src/i18n.js b/TaskAutomation/src/i18n.js new file mode 100644 index 0000000000..f03bdcc18e --- /dev/null +++ b/TaskAutomation/src/i18n.js @@ -0,0 +1,123 @@ +import { + DEFAULT_LOCALE, + LOCALE_ALIASES, + LOCALE_DEFINITIONS, + TRANSLATIONS, + VALID_LOCALES, +} from './locales/index.js'; + +export { + DEFAULT_LOCALE, + LOCALE_DEFINITIONS, + LOCALE_LABELS, + SUPPORTED_LOCALE_LIST, + SUPPORTED_LOCALES, + VALID_LOCALES, +} from './locales/index.js'; + +/** + * Returns the canonical locale for a supported locale string or alias. + * Matching is case-insensitive after exact matches are checked. + * + * @param {string} lang Locale string or alias (e.g. 'en', 'fr', 'zh-TW'). + * @returns {string} Canonical locale code, or an empty string when unsupported. + */ +export function normalizeLocale(lang) { + const s = typeof lang === 'string' ? lang.trim() : ''; + if (VALID_LOCALES.has(s)) { + return s; + } + if (LOCALE_ALIASES[s]) { + return LOCALE_ALIASES[s]; + } + const normalized = s.toLowerCase(); + const found = Array.from(VALID_LOCALES).find((locale) => locale.toLowerCase() === normalized); + return ( + found || + Object.entries(LOCALE_ALIASES).find(([alias]) => alias.toLowerCase() === normalized)?.[1] || + '' + ); +} + +/** + * Returns a valid canonical locale, falling back to the default when unsupported. + * + * @param {string} lang Locale string to validate. + * @returns {string} Validated canonical locale. + */ +export function getLocale(lang) { + return normalizeLocale(lang) || DEFAULT_LOCALE; +} + +/** + * Navigates a nested object following dot-separated key path segments. + * + * @param {object} obj Root object. + * @param {string[]} parts Key path segments. + * @returns {*} Value at the path, or undefined. + */ +function getNestedValue(obj, parts) { + let current = obj; + for (const part of parts) { + if (current == null || typeof current !== 'object') { + return undefined; + } + current = current[part]; + } + return current; +} + +/** + * Returns the raw translated value (string, array or object) for a key, + * falling back to en-US when the key is missing in the requested locale. + * + * @param {string} key Dot-separated translation key. + * @param {string} locale Locale code. + * @returns {*} Raw value, or undefined when not found. + */ +export function tRaw(key, locale) { + const lang = getLocale(locale); + const parts = key.split('.'); + const value = getNestedValue(TRANSLATIONS[lang], parts); + return value === undefined && lang !== DEFAULT_LOCALE + ? getNestedValue(TRANSLATIONS[DEFAULT_LOCALE], parts) + : value; +} + +/** + * Returns the text direction CSS for a locale. + * + * @param {string} locale Locale code. + * @returns {string} CSS declarations, empty for left-to-right locales. + */ +export function getDirectionStyle(locale) { + const lang = getLocale(locale); + const def = LOCALE_DEFINITIONS.find((d) => d.code === lang); + return def?.direction === 'rtl' ? 'direction:rtl;text-align:right;' : ''; +} + +/** + * Returns the translated string for a dot-separated key, interpolating {placeholder} vars. + * Falls back to en-US when the key is missing in the requested locale. + * No HTML escaping is performed — callers must pre-escape HTML-unsafe values. + * + * @param {string} key Dot-separated translation key (e.g. 'errors.noTokensSelected'). + * @param {string} locale Locale code. + * @param {object} [vars] Interpolation variables mapped to {placeholder} names. + * @returns {string} Translated and interpolated string. Returns the key when not found. + */ +export function t(key, locale, vars = {}) { + const lang = getLocale(locale); + const parts = key.split('.'); + let value = getNestedValue(TRANSLATIONS[lang], parts); + + if (value === undefined && lang !== DEFAULT_LOCALE) { + value = getNestedValue(TRANSLATIONS[DEFAULT_LOCALE], parts); + } + + if (typeof value !== 'string') { + return key; + } + + return value.replaceAll(/\{(\w+)\}/g, (_, k) => (k in vars ? String(vars[k]) : `{${k}}`)); +} diff --git a/TaskAutomation/src/index.js b/TaskAutomation/src/index.js new file mode 100644 index 0000000000..7558f63826 --- /dev/null +++ b/TaskAutomation/src/index.js @@ -0,0 +1,327 @@ +import { handleInput, showMenu } from './commands.js'; +import { SCRIPT_LAST_UPDATED, SCRIPT_NAME, SCRIPT_VERSION } from './constants.js'; +import { + ensureDashboard, + flushDashboard, + markDirty, + noticeGm, + onHandoutDestroyed, + registerDashboardJobs, +} from './dashboard.js'; +import { getRecord, getSnapshot } from './definitions.js'; +import { admitRun, drainQueue, registerExecutorJobs } from './executor.js'; +import { isJumpgate, logLine, onEvent } from './host.js'; +import { t } from './i18n.js'; +import { + ADAPTER_CONTRACT, + drainRegistrationQueue, + getAdapter, + registerAdapter, + registerReferenceAdapter, +} from './integrations.js'; +import { installHelpHandout } from './journals.js'; +import { + releaseAdmissions, + recoverState, + registerRecoveryJobs, + startBoot, + summaryWorthTelling, +} from './recovery.js'; +import { createRuntimeScheduler, provide, runtime } from './runtime.js'; +import { + onRunTerminal, + registerScheduleJobs, + removeSchedule, + syncDefinitionSchedules, +} from './schedules.js'; +import { applyGlobalConfig, getConfig, getState, initialiseState } from './state.js'; +import { + awaitsChanged, + definitionsChanged, + emitInternal, + noteOwnWrite, + raiseSignal, + registerNativeHandlers, + registerTriggerJobs, +} from './triggers.js'; +import { escapeHtml, formatDuration, isRecord, toText } from './utils.js'; +import { whisper } from './chat.js'; + +/** + * Wraps a handler so an exception is logged instead of stopping the sandbox. + * + * @param {string} label Handler name. + * @param {Function} fn Handler. + * @returns {Function} Wrapped handler. + */ +function guarded(label, fn) { + return (...args) => { + if (!runtime.ready || runtime.disabled) { + return; + } + try { + fn(...args); + } catch (err) { + logLine(`ERROR in ${label}: ${err?.message || err}`); + if (err?.stack) { + log(err.stack); + } + } + }; +} + +/** + * Connects services that call each other through runtime hooks. + * + * @returns {void} + */ +function wireHooks() { + provide('dashboardDirty', () => markDirty()); + provide('emitEvent', (event) => emitInternal(event)); + provide('noteOwnWrite', (id, changes) => noteOwnWrite(id, changes)); + provide('awaitsChanged', () => awaitsChanged()); + provide('definitionChanged', (id) => { + definitionsChanged(); + syncDefinitionSchedules(id); + drainQueue(id); + }); + provide('removeSchedule', (id, by) => removeSchedule(id, by)); + provide('runTerminal', (inst) => onRunTerminal(inst)); + provide('adapterKnown', (name) => Boolean(getAdapter(name))); + provide('adapterStatus', (name, action, key) => { + const fn = getAdapter(name)?.actions?.[action]?.status; + return typeof fn === 'function' ? fn(key) : null; + }); + provide('adapterRegistered', (name) => { + if (!runtime.ready) { + return; + } + const waiting = Object.values(getState().instances).filter( + (i) => i.review?.reason === 'adapterUnavailable' && i.review?.vars?.adapter === name + ); + if (waiting.length) { + noticeGm( + `adapter:${name}`, + t('notices.adapterRegistered', getConfig().language, { name, count: waiting.length }) + ); + } + }); + provide('notifyGm', (kind, vars) => + noticeGm(`${kind}:${vars.id || ''}`, t(`notices.${kind}`, getConfig().language, vars)) + ); +} + +/** + * Publishes the API other Mods can call (plan §11). Registration is + * idempotent; specs queued on globalThis.TaskAutomationQueue before this + * Mod loaded are drained on ready. + * + * @returns {void} + */ +function publishApi() { + globalThis.TaskAutomation = Object.freeze({ + version: SCRIPT_VERSION, + contract: ADAPTER_CONTRACT, + /** + * Registers a trusted adapter (see docs/ADAPTERS.md). + * + * @param {object} spec Adapter spec. + * @returns {{ok: boolean, error?: string}} Result. + */ + registerAdapter(spec) { + return registerAdapter(spec); + }, + /** + * Registers an action provided by an adapter: shorthand for an adapter + * with one action. + * + * @param {string} adapter Adapter name. + * @param {string} action Action name. + * @param {object} impl {invoke, replay, timeoutMs, status}. + * @returns {{ok: boolean, error?: string}} Result. + */ + registerAction(adapter, action, impl) { + const existing = getAdapter(adapter); + return registerAdapter({ + name: adapter, + contract: ADAPTER_CONTRACT, + version: existing?.version || '1', + authority: existing?.authority || 'gm', + actions: { ...(existing?.actions || {}), [action]: impl }, + }); + }, + /** + * Registers an adapter event name so tasks can await or trigger on it. + * Events are raised with `emit`. + * + * @param {string} adapter Adapter name. + * @param {string} event Event name. + * @returns {boolean} True when the adapter exists. + */ + registerTrigger(adapter, event) { + const existing = getAdapter(adapter); + if (!existing) { + return false; + } + registerAdapter({ ...existing, events: [...new Set([...(existing.events || []), event])] }); + return true; + }, + /** + * Raises an adapter event (`integration` events in task triggers). + * + * @param {string} adapter Adapter name. + * @param {string} event Event name. + * @param {*} value Scalar value. + * @returns {boolean} True when routed. + */ + emit(adapter, event, value) { + if (!runtime.ready || !getAdapter(adapter)) { + return false; + } + emitInternal({ + type: 'integration', + adapter, + name: toText(event).slice(0, 48), + value: isRecord(value) ? null : (value ?? null), + source: 'adapter', + }); + return true; + }, + /** + * Raises a named signal from a trusted Mod. Not a person: it never has + * GM rights. + * + * @param {string} name Signal name. + * @param {*} value Scalar value. + * @returns {boolean} True when routed. + */ + signal(name, value) { + if (!runtime.ready || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(toText(name))) { + return false; + } + raiseSignal(name, isRecord(value) ? null : (value ?? null), null, 'adapter'); + return true; + }, + /** + * Starts a GM-service definition. Definitions acting for a person + * cannot be started from other Mods. + * + * @param {string} definitionId Definition ID. + * @param {object} [options] {params, bindings: {name: [ids]}}. + * @returns {string|null} Run ID, or null. + */ + start(definitionId, options = {}) { + const def = runtime.ready ? getSnapshot(definitionId) : null; + if (!def || def.access?.authority !== 'gmService' || !getRecord(definitionId)?.enabled) { + return null; + } + const result = admitRun(definitionId, { + initiatorId: null, + params: isRecord(options.params) ? options.params : {}, + bindings: isRecord(options.bindings) ? options.bindings : {}, + trigger: { type: 'api', id: 'api' }, + }); + return result.instance?.id || null; + }, + }); +} + +/** + * Startup (plan §8): load state, hold admissions, recover, then listen. + * + * @returns {void} + */ +function checkInstall() { + const loaded = initialiseState(Date.now()); + if (!['fresh', 'ok', 'migrated'].includes(loaded.status)) { + // Fail closed: keep the stored data and run nothing. + runtime.disabled = loaded; + logLine( + `ERROR: state not loaded (${loaded.status}${loaded.error ? `: ${loaded.error}` : ''}); no tasks will run.` + ); + whisper( + null, + SCRIPT_NAME, + `
${escapeHtml(t('notices.disabled', 'en-US', { status: loaded.status, version: loaded.from ?? '?' }))}
` + ); + return; + } + applyGlobalConfig(); + wireHooks(); + createRuntimeScheduler(); + registerExecutorJobs(); + registerScheduleJobs(); + registerTriggerJobs(); + registerDashboardJobs(); + registerRecoveryJobs(); + registerReferenceAdapter(); + drainRegistrationQueue(); + publishApi(); + const boot = startBoot(); + runtime.ready = true; + runtime.admissionsHeld = true; + const summary = recoverState(boot); + registerNativeHandlers(guarded); + onEvent('destroy:handout', guarded('destroy:handout', onHandoutDestroyed)); + releaseAdmissions(summary); + ensureDashboard(); + installHelpHandout(); + flushDashboard({ force: true }); + + log( + `-=> ${SCRIPT_NAME} v${SCRIPT_VERSION} [Updated: ${SCRIPT_LAST_UPDATED}] (${isJumpgate() ? 'Jumpgate' : 'Legacy'}) <=-` + ); + const lang = getConfig().language; + const lines = [`
${escapeHtml(t('titles.ready', lang))}
`]; + if (loaded.status === 'migrated') { + lines.push(`
${escapeHtml(t('messages.migrated', lang, { from: loaded.from }))}
`); + } + if (summaryWorthTelling(summary)) { + lines.push( + `
${escapeHtml( + t('messages.recoverySummary', lang, { + runs: summary.runs, + review: summary.review, + missed: summary.missed, + admitted: summary.admitted, + gaps: summary.gaps, + gap: summary.gapMs ? formatDuration(summary.gapMs) : '—', + }) + )}
` + ); + } + lines.push( + `
${escapeHtml(t('messages.readyHint', lang))}
` + ); + whisper(null, `${SCRIPT_NAME} v${SCRIPT_VERSION}`, lines); + if (loaded.status === 'fresh') { + showMenu(null); + } +} + +/** + * Registers the startup and command handlers. Object and campaign handlers + * are registered after ready by the trigger router. + * + * @returns {void} + */ +function registerEventHandlers() { + onEvent('ready', () => { + try { + checkInstall(); + } catch (err) { + runtime.disabled = { status: 'fatal', error: String(err?.message || err) }; + logLine(`ERROR during startup: ${err?.message || err}`); + if (err?.stack) { + log(err.stack); + } + } + }); + onEvent('chat:message', (msg) => { + if (runtime.ready || runtime.disabled) { + handleInput(msg); + } + }); +} + +registerEventHandlers(); diff --git a/TaskAutomation/src/integrations.js b/TaskAutomation/src/integrations.js new file mode 100644 index 0000000000..c92c9aa07b --- /dev/null +++ b/TaskAutomation/src/integrations.js @@ -0,0 +1,158 @@ +// Trusted adapter registry (plan §11). Other Mods register adapters through +// the global TaskAutomation API, or before it exists by pushing specs onto +// globalThis.TaskAutomationQueue. Registration is idempotent; late +// registration lets the GM resume runs that were waiting for the adapter. + +import { JOB, REPLAY, SCRIPT_NAME } from './constants.js'; +import { audit } from './audit.js'; +import { logLine, now } from './host.js'; +import { maybeCall, onJob, schedule } from './runtime.js'; +import { isRecord, toText } from './utils.js'; + +export const ADAPTER_CONTRACT = 1; + +const adapters = new Map(); +const referenceStore = new Map(); + +/** + * Validates an adapter spec. + * + * @param {*} spec Adapter spec. + * @returns {{ok: boolean, error?: string}} Result. + */ +export function checkAdapterSpec(spec) { + if (!isRecord(spec) || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(toText(spec.name))) { + return { ok: false, error: 'adapterName' }; + } + if (spec.contract !== ADAPTER_CONTRACT) { + return { ok: false, error: 'adapterContract' }; + } + if (!isRecord(spec.actions) || Object.keys(spec.actions).length === 0) { + return { ok: false, error: 'adapterActions' }; + } + for (const [name, action] of Object.entries(spec.actions)) { + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(name) || typeof action?.invoke !== 'function') { + return { ok: false, error: 'adapterActions' }; + } + if (action.replay !== undefined && !Object.values(REPLAY).includes(action.replay)) { + return { ok: false, error: 'adapterReplay' }; + } + } + return { ok: true }; +} + +/** + * Registers (or re-registers) an adapter. + * + * Spec: { name, contract: 1, version, description, authority: 'gm'|'any', + * actions: { [name]: { invoke(input, ctx, done), replay, timeoutMs, + * status?(key) -> {done, result}|null, cancel?(opId) } }, events?: [names] } + * + * @param {object} spec Adapter spec. + * @returns {{ok: boolean, error?: string}} Result. + */ +export function registerAdapter(spec) { + const check = checkAdapterSpec(spec); + if (!check.ok) { + logLine(`adapter rejected (${check.error}): ${toText(spec?.name)}`); + return check; + } + const isNew = !adapters.has(spec.name); + adapters.set(spec.name, { ...spec, registeredAt: now() }); + if (isNew) { + audit('adapter.register', { adapter: spec.name, version: toText(spec.version) }); + maybeCall('adapterRegistered', spec.name); + } + maybeCall('dashboardDirty', 'integrations'); + return { ok: true }; +} + +/** + * Drains adapters queued before the Mod loaded. + * + * @returns {number} Adapters registered. + */ +export function drainRegistrationQueue() { + const queue = Array.isArray(globalThis.TaskAutomationQueue) ? globalThis.TaskAutomationQueue : []; + let count = 0; + for (const spec of queue.splice(0)) { + if (registerAdapter(spec).ok) { + count += 1; + } + } + return count; +} + +/** + * Returns an adapter. + * + * @param {string} name Adapter name. + * @returns {object|null} Adapter. + */ +export function getAdapter(name) { + return adapters.get(name) || null; +} + +/** + * Returns all registered adapters. + * + * @returns {object[]} Adapters. + */ +export function listAdapters() { + return [...adapters.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Registers the built-in reference adapter used by the examples and tests. + * It acknowledges calls immediately (`echo`), after a delay on the shared + * scheduler (`delayedEcho`), or never (`silent`, to show timeouts), and + * answers status lookups by idempotency key. + * + * @returns {void} + */ +export function registerReferenceAdapter() { + onJob(JOB.MAINTENANCE + ':referenceAck', (job) => { + const { key, input, done } = job.target; + referenceStore.set(key, { done: true, result: { echo: input } }); + done(null, { echo: input }); + }); + registerAdapter({ + name: 'reference', + contract: ADAPTER_CONTRACT, + version: '1.0.0', + description: `${SCRIPT_NAME} reference adapter`, + authority: 'gm', + actions: { + echo: { + replay: REPLAY.ACKNOWLEDGED, + invoke(input, ctx, done) { + referenceStore.set(ctx.idempotencyKey, { done: true, result: { echo: input } }); + done(null, { echo: input }); + }, + status(key) { + return referenceStore.get(key) || null; + }, + }, + delayedEcho: { + replay: REPLAY.ACKNOWLEDGED, + timeoutMs: 10000, + invoke(input, ctx, done) { + const delay = Math.min(Math.max(Number(input?.delayMs) || 1000, 100), 9000); + schedule({ + kind: `${JOB.MAINTENANCE}:referenceAck`, + dueAt: now() + delay, + target: { key: ctx.idempotencyKey, input, done }, + }); + }, + status(key) { + return referenceStore.get(key) || null; + }, + }, + silent: { + replay: REPLAY.ACKNOWLEDGED, + timeoutMs: 2000, + invoke() {}, + }, + }, + }); +} diff --git a/TaskAutomation/src/journals.js b/TaskAutomation/src/journals.js new file mode 100644 index 0000000000..62fa3747c2 --- /dev/null +++ b/TaskAutomation/src/journals.js @@ -0,0 +1,144 @@ +// The GM-only Help handout. + +import { STEP_SPECS } from './catalogue.js'; +import { + AUTHOR_LINKS, + COLOR_ACCENT, + COLOR_BORDER, + COLOR_CARD_BG_BOTTOM, + COLOR_HEADER_BG, + COLOR_HEADER_TEXT, + COLOR_MUTED, + COLOR_TEXT, + COMMAND, + HANDOUT_HELP_NAME, + HELP_COMMANDS, + SCRIPT_NAME, + SCRIPT_VERSION, +} from './constants.js'; +import { createObject, findObjects, getObject } from './host.js'; +import { getDirectionStyle, getLocale, t, tRaw } from './i18n.js'; +import { getConfig, getState } from './state.js'; +import { escapeHtml, hashText } from './utils.js'; + +const STYLE = { + outer: `background:${COLOR_CARD_BG_BOTTOM};color:${COLOR_TEXT};padding:14px 18px;border:1px solid ${COLOR_BORDER};border-radius:6px;font-family:Arial,sans-serif;line-height:1.5;`, + header: `background:${COLOR_HEADER_BG};padding:12px;border-radius:4px;text-align:center;border-bottom:3px solid ${COLOR_ACCENT};margin-bottom:12px;`, + h1: `color:${COLOR_HEADER_TEXT};margin:0;font-size:1.8em;letter-spacing:0.05em;`, + subtitle: `color:${COLOR_MUTED};margin:4px 0 0 0;font-size:0.9em;`, + h2: `color:${COLOR_ACCENT};border-bottom:1px solid ${COLOR_BORDER};padding-bottom:3px;margin:18px 0 8px 0;font-size:1.25em;`, + p: `color:${COLOR_TEXT};margin:6px 0;`, + li: `color:${COLOR_TEXT};margin:3px 0;`, + table: 'width:100%;border-collapse:collapse;margin:6px 0;font-size:0.92em;', + th: `background:${COLOR_HEADER_BG};color:${COLOR_HEADER_TEXT};padding:5px 8px;text-align:left;border:1px solid ${COLOR_BORDER};`, + td: `padding:5px 8px;border:1px solid ${COLOR_BORDER};vertical-align:top;color:${COLOR_TEXT};`, + code: 'font-family:monospace;color:#ffd58a;', + link: `color:${COLOR_ACCENT};`, + footer: `color:${COLOR_MUTED};font-size:0.85em;text-align:center;margin-top:16px;border-top:1px solid ${COLOR_BORDER};padding-top:8px;`, +}; + +/** + * Builds a table. + * + * @param {string[]} headings Headings (escaped). + * @param {string[][]} rows Cells (trusted HTML). + * @returns {string} HTML. + */ +function table(headings, rows) { + const head = headings.map((h) => `${escapeHtml(h)}`).join(''); + const body = rows + .map((cells) => `${cells.map((c) => `${c}`).join('')}`) + .join(''); + return `${head}${body}
`; +} + +/** + * Builds a list from a translated array. + * + * @param {*} items Items. + * @param {boolean} [ordered=false] Numbered. + * @returns {string} HTML. + */ +function list(items, ordered = false) { + const tag = ordered ? 'ol' : 'ul'; + return `<${tag}>${(Array.isArray(items) ? items : []).map((item) => `
  • ${escapeHtml(item)}
  • `).join('')}`; +} + +/** + * Builds the Help handout HTML. + * + * @param {string} locale Locale. + * @returns {string} HTML. + */ +export function buildHelpHtml(locale) { + const lang = getLocale(locale); + const h = (key, vars) => escapeHtml(t(`handout.${key}`, lang, vars)); + const code = (text) => `${escapeHtml(text)}`; + const section = (key, body) => `

    ${h(`${key}.heading`)}

    ${body}`; + const commands = HELP_COMMANDS.map(([text, key]) => [ + code(`${COMMAND} ${text}`.trim()), + escapeHtml(t(`help.${key}`, lang)), + ]); + const steps = Object.keys(STEP_SPECS).map((type) => [ + code(type), + escapeHtml(t(`stepTypes.${type}`, lang)), + ]); + const triggers = ['manual', 'at', 'interval', 'daily', 'weekly', 'event', 'signal', 'chat'].map( + (type) => [code(type), escapeHtml(t(`triggerTypes.${type}`, lang))] + ); + const overdue = ['skip', 'runOnce', 'catchUpBounded', 'review', 'expire'].map((mode) => [ + code(mode), + escapeHtml(t(`overdueModes.${mode}`, lang)), + ]); + const maintainer = AUTHOR_LINKS.maintainer; + return [ + `
    `, + `

    ${escapeHtml(SCRIPT_NAME)}

    ${h('subtitle', { version: SCRIPT_VERSION })}

    `, + `

    ${h('intro')}

    `, + section('quickStart', list(tRaw('handout.quickStart.items', lang), true)), + section('concepts', list(tRaw('handout.concepts.items', lang))), + section( + 'commands', + table([t('handout.commands.command', lang), t('handout.commands.does', lang)], commands) + ), + section('steps', table([t('handout.steps.type', lang), t('handout.steps.does', lang)], steps)), + section( + 'triggers', + table([t('handout.triggers.type', lang), t('handout.triggers.does', lang)], triggers) + ), + section( + 'overdue', + `

    ${h('overdue.intro')}

    ${table([t('handout.overdue.mode', lang), t('handout.overdue.does', lang)], overdue)}` + ), + section('limits', list(tRaw('handout.limits.items', lang))), + `
    ${h('footer', { name: maintainer.name })} · GitHub
    `, + '
    ', + ].join(''); +} + +/** + * Creates or updates the GM-only Help handout. The handout is only rewritten + * when its content changes. + * + * @returns {object|null} Handout. + */ +export function installHelpHandout() { + const s = getState(); + let handout = s.dashboard.helpHandoutId ? getObject('handout', s.dashboard.helpHandoutId) : null; + if (!handout) { + handout = + findObjects({ _type: 'handout', name: HANDOUT_HELP_NAME })[0] || + createObject('handout', { name: HANDOUT_HELP_NAME, inplayerjournals: '', controlledby: '' }); + } + if (!handout) { + return null; + } + s.dashboard.helpHandoutId = handout.id; + const html = buildHelpHtml(getConfig().language); + const hash = hashText(html); + if (s.dashboard.helpHash !== hash) { + handout.set({ notes: html, inplayerjournals: '', controlledby: '' }); + s.dashboard.helpHash = hash; + } + return handout; +} diff --git a/TaskAutomation/src/locales/index.js b/TaskAutomation/src/locales/index.js new file mode 100644 index 0000000000..da317e5773 --- /dev/null +++ b/TaskAutomation/src/locales/index.js @@ -0,0 +1,15 @@ +import localeEnUS from './locale/en-US.js'; + +export { + DEFAULT_LOCALE, + LOCALE_ALIASES, + LOCALE_DEFINITIONS, + LOCALE_LABELS, + SUPPORTED_LOCALE_LIST, + SUPPORTED_LOCALES, + VALID_LOCALES, +} from './metadata.js'; + +export const TRANSLATIONS = { + 'en-US': localeEnUS, +}; diff --git a/TaskAutomation/src/locales/locale/en-US.js b/TaskAutomation/src/locales/locale/en-US.js new file mode 100644 index 0000000000..870e5cccf8 --- /dev/null +++ b/TaskAutomation/src/locales/locale/en-US.js @@ -0,0 +1,728 @@ +const TRANSLATION = { + titles: { + ready: 'TaskAutomation is ready.', + help: 'TaskAutomation Commands', + definitions: 'Task Definitions', + validation: 'Validation', + confirm: 'Please Confirm', + export: 'Export ({page}/{pages})', + templates: 'Templates', + runs: 'Runs', + dryRun: 'Dry Run', + schedules: 'Schedules', + schedulePreview: 'New Schedule', + offset: 'Change UTC Offset', + incidents: 'Missed Work', + history: 'Finished Runs', + audit: 'Audit Trail', + diagnostics: 'Diagnostics', + recovery: 'Recovery', + config: 'Settings', + adapters: 'Adapters', + }, + menu: { + status: 'Status', + definitions: 'Tasks', + active: 'Active runs', + review: 'Need a decision', + incidents: 'Open incidents', + }, + buttons: { + acknowledge: 'Acknowledge', + add: 'Add', + apply: 'Apply', + archive: 'Archive', + cancel: 'Cancel', + clone: 'Clone', + confirmPurge: 'Delete permanently', + confirmRetry: 'Retry anyway', + confirmSchedule: 'Create schedule', + confirmStop: 'Stop everything', + dashboard: 'Open Dashboard', + definitions: 'Tasks', + disable: 'Disable', + dryRun: 'Dry run', + enable: 'Enable', + export: 'Export', + help: 'Help', + incidents: 'Missed work', + inspect: 'Inspect', + pause: 'Pause', + publish: 'Publish', + purge: 'Delete…', + recoverRun: 'Run once…', + recoverSkip: 'Skip…', + refresh: 'Refresh', + rerun: 'Run again', + resume: 'Resume', + resumeCatchUp: 'Resume and catch up', + resumeSkip: 'Resume (skip paused times)', + runs: 'Runs', + show: 'Show', + start: 'Start', + templates: 'Templates', + validate: 'Validate', + }, + labels: { + id: 'ID', + revision: 'Revision', + draft: 'unpublished changes', + enabled: 'Enabled', + yes: 'yes', + triggers: 'Triggers', + schedules: 'Schedules', + steps: 'Steps', + noSteps: 'No steps yet.', + installed: '(installed)', + definition: 'Task', + status: 'Status', + step: 'Step', + started: 'Started', + initiator: 'Started by', + gmService: 'GM service', + targets: 'Targets', + lateness: 'Late by', + journal: 'Actions', + timeline: 'Timeline', + ended: 'ended', + schedule: 'Schedule', + nextTimes: 'Next times (UTC)', + before: 'Now', + after: 'After', + incident: 'Incident', + cause: 'Cause', + missed: 'Missed', + range: 'Between', + }, + statuses: { + queued: 'queued', + running: 'running', + waiting: 'waiting', + paused: 'paused', + review: 'needs a decision', + completed: 'completed', + failed: 'failed', + cancelled: 'cancelled', + expired: 'expired', + }, + incidentStatus: { + open: 'open', + acknowledged: 'acknowledged', + resolved: 'resolved', + }, + waits: { + time: 'waiting for a time', + event: 'waiting for {event}', + child: 'waiting for {child}', + join: 'waiting for branches', + async: 'waiting for an adapter', + retry: 'retrying soon', + yield: 'about to continue', + review: 'waiting for a decision', + }, + review: { + overdueWait: 'a wait ended long after it was due', + uncertainOutcome: 'an action may or may not have happened', + restartConflict: 'objects changed while the sandbox was off', + restartPolicyReview: 'restarted (this task asks for a check after restarts)', + controlLost: 'the player no longer controls a target', + initiatorGone: 'the player who started it has left', + publicationRevoked: 'the task is no longer published to players', + adapterUnavailable: 'an adapter is not installed', + stepError: 'a step failed', + }, + decisions: { + retry: 'Retry step', + skip: 'Skip step', + complete: 'Mark done and continue', + skipNext: 'Skip the next step', + cancel: 'Cancel run', + }, + journal: { + prepared: 'started', + dispatched: 'sent (not confirmed)', + confirmed: 'done', + failed: 'failed', + skipped: 'skipped', + uncertain: 'outcome unknown', + abandoned: 'abandoned', + }, + timeline: { + start: 'started ({trigger})', + if: '{stepId}: chose {choice}', + late: '{stepId}: ran late ({mode})', + loopCap: '{stepId}: stopped at the loop limit', + call: '{stepId}: started {child}', + parallel: '{stepId}: started {branches} branches', + event: '{stepId}: received {type}', + awaitTimeout: '{stepId}: timed out waiting', + asyncTimeout: '{stepId}: adapter did not answer', + error: '{stepId}: {code} ({reason})', + retry: '{stepId}: retry {attempt}', + skip: '{stepId}: skipped after an error', + join: '{stepId}: branches joined', + childDone: '{stepId}: {child} {status}', + pause: 'paused', + resume: 'resumed', + review: 'needs a decision ({reason})', + decide: 'decision: {decision}', + end: 'finished: {status}', + observationGap: '{stepId}: events between restarts could not be seen', + }, + causes: { + late: 'Ran late', + downtime: 'Sandbox was off', + pause: 'Schedule was paused', + concurrency: 'Already running', + expiredQueue: 'Waited too long in the queue', + queueFull: 'Queue was full', + queueRejected: 'Could not start from the queue', + burstCap: 'Too many events at once', + bindingFailed: 'Targets not found', + circuitBreaker: 'Started too often; task disabled', + adapterUnavailable: 'Adapter not installed', + observationGap: 'Events missed while the sandbox was off', + overflow: 'Other missed work (combined)', + overdueWait: 'A wait ended long after it was due', + uncertainOutcome: 'Action outcome unknown', + restartConflict: 'Objects changed while the sandbox was off', + restartPolicyReview: 'Restarted', + controlLost: 'Player lost control of a target', + initiatorGone: 'Player left', + publicationRevoked: 'Publication withdrawn', + stepError: 'A step failed', + rejected: 'Not started', + tooManyRuns: 'Too many runs', + halted: 'Everything was stopped', + definitionDisabled: 'Task disabled', + }, + dashboard: { + generated: 'Generated {at} · v{version}', + generatedNote: + 'This handout is rebuilt by the Mod. Anything you type here is overwritten. Times are UTC; remaining times are as of when it was generated.', + statusRunning: 'Running', + statusHalted: 'Stopped (stop-all)', + statusDisabled: 'Disabled: state could not be loaded', + refresh: 'Refresh', + stopAll: 'Stop all…', + resumeAll: 'Allow starts again', + templates: 'Templates', + help: 'Help', + views: { + overview: 'Overview', + definitions: 'Tasks', + instances: 'Runs', + queue: 'Queue', + incidents: 'Missed work', + history: 'History', + integrations: 'Integrations', + }, + counts: { + definitions: 'Tasks enabled', + running: 'Running or waiting', + paused: 'Paused', + review: 'Need a decision', + queued: 'Queued', + incidents: 'Open incidents', + nextJob: 'Next scheduled work', + }, + cols: { + item: 'Item', + value: 'Value', + schedule: 'Schedule', + definition: 'Task', + due: 'Due', + state: 'State', + triggers: 'Triggers', + next: 'Next', + run: 'Run', + status: 'Status', + targets: 'Targets', + step: 'Step', + entry: 'Entry', + admitted: 'Queued at', + expires: 'Expires', + incident: 'Incident', + cause: 'Cause', + range: 'When', + count: 'Count', + finished: 'Finished', + duration: 'Took', + effects: 'Actions', + adapter: 'Adapter', + version: 'Version', + actions: 'Actions', + registered: 'Registered', + task: 'Task', + description: 'Description', + }, + diag: { + jobs: 'Scheduled jobs', + stale: 'Cancelled jobs awaiting cleanup', + next: 'Next job', + armed: 'Timer set', + drains: 'Timer wakes', + yields: 'Budget yields', + maxDrainMs: 'Longest wake (ms)', + clockJumps: 'Clock jumps noticed', + stateKb: 'Stored state (KiB)', + boot: 'Started', + admitted: 'Runs started', + suppressed: 'Starts suppressed', + dropped: 'Events dropped', + coalesced: 'Events combined', + }, + needsDecision: 'Needs a decision', + nextTimes: 'Next scheduled times', + warnings: 'Warnings', + warnState: 'Stored state is {kb} KiB of a {max} KiB budget.', + warnCircuit: 'Task {id} was disabled because it started too often.', + warnDrift: + 'The dashboard became visible to players; it is not being updated. Run !taskautomation dashboard repair.', + enabled: 'enabled', + disabled: 'disabled ({reason})', + noDefinitions: 'No tasks yet. Add one from Templates or with definition create.', + noRuns: 'Nothing is running.', + noQueue: 'The queue is empty.', + noIncidents: 'No missed work.', + noHistory: 'No finished runs yet.', + noAdapters: 'No adapters registered.', + noPlayerTasks: 'No tasks are shared with players.', + late: 'late by {late}', + uncertain: '{count} unknown', + page: 'Page {page} of {pages}', + prev: '‹ Previous', + next: 'Next ›', + diagnostics: 'Diagnostics', + playerTitle: 'Tasks You Can Start', + activeRuns: 'Running now', + }, + messages: { + readyHint: 'Type !taskautomation for the menu, or open the TaskAutomation - Dashboard handout.', + migrated: 'Stored data was upgraded from schema {from}.', + recoverySummary: + 'After the restart: {runs} runs carried on, {review} need a decision, {missed} scheduled times were missed and {admitted} were run, {gaps} runs were waiting for events. The sandbox was off for about {gap}.', + dashboardRefresh: 'The dashboard will refresh in a moment.', + dashboardRepaired: 'The dashboard is GM-only again.', + definitionCreated: 'Task {name} created ({id}). Add steps, then publish it.', + valid: '{id} is valid.', + invalid: '{id} has {count} problems:', + published: + '{id} revision {revision} published. New runs use it; running ones keep their revision.', + enabled: '{id} enabled.', + disabled: '{id} disabled. Runs already going carry on.', + cloned: 'Copied as {name} ({id}).', + archived: '{id} archived. It stays while anything refers to it.', + purgePreview: + 'Delete {id} for good? Its {schedules} schedules are removed too. This cannot be undone.', + purged: '{id} deleted.', + exportedHandout: + 'Exported to the GM-only handout "{name}". Anyone you share that handout with can read it.', + exportHint: 'Copy the text above into one line after {command} in any game.', + imported: '{id} imported as revision {revision}.', + importBegun: + 'Send the {parts} parts in order with definition import part --n --json …', + importPart: 'Part {n} of {parts} received.', + importCancelled: 'Import cancelled.', + draftUpdated: 'Draft of {id} updated. Validate and publish to use it.', + templateAdded: 'Added: {ids}.', + templateAddedDisabled: + 'Added: {ids}. It starts by itself, so it is disabled until you enable it.', + started: '{id} started ({definition}).', + queued: '{definition} is busy; the start was queued.', + dryRunOk: '{id} would start: targets, permissions, branches and adapters check out.', + dryRunNote: 'A dry run cannot predict future events, conditions or what other players do.', + paused: '{id} paused.', + resumed: '{id} resumed.', + cancelled: '{id} cancelled. Actions already done ({effects}) are not undone.', + decided: '{id}: {action}.', + retryWarning: '{id} may already have done step {step}. Retrying could do it twice.', + noSchedules: 'No schedules.', + schedulePaused: '{id} paused. Its next time is kept.', + scheduleResumed: '{id} resumed. Times passed while paused: {skipped}.', + scheduleRemoved: '{id} removed.', + nextTimes: '{id} next runs (UTC): {times}', + offsetPreview: 'Move {id} to UTC{offset}?', + offsetApplied: '{id} now uses UTC{offset}.', + offsetNote: + 'Times use a fixed UTC offset. When the clocks change (for example British Summer Time), update it with schedule offset.', + noFutureTimes: 'none', + scheduleAdded: '{id} created. Next run {next} (UTC).', + signalled: 'Signal {name} sent.', + acknowledged: '{id} acknowledged. Nothing was run.', + recoveryRunOnce: + 'One run will start now for the latest missed time. Older missed times are not run.', + recoverySkip: 'The missed work will be marked as handled without running anything.', + recovered: '{id} handled. Run: {run}.', + configSet: '{key} is now {value}.', + stopAllPreview: + 'Stop everything? {runs} runs are cancelled, {queued} queued starts are dropped and nothing new starts until you allow it. Messages and changes already made stay.', + stopped: 'Stopped. Runs cancelled: {count}.', + resumedAll: 'Tasks may start again.', + }, + notices: { + drift: + 'The TaskAutomation dashboard was shared with players. It will not be updated until you run !taskautomation dashboard repair.', + adapterRegistered: + 'Adapter {name} is now installed. {count} runs were waiting for it in review.', + disabled: + 'TaskAutomation could not load its saved data ({status}, schema {version}). No tasks will run and nothing was overwritten. Check the API console.', + circuit: 'Task {id} started {count} times in a minute and was disabled.', + review: 'Run {id} needs a decision: {reason} at {step}.', + missed: + 'Schedule {id} missed {count} times while the sandbox was off. Choose what to do on the dashboard.', + }, + errors: { + internal: 'Something went wrong. The details are in the API console.', + gmOnly: 'Only the GM can do that.', + notAllowed: 'You are not allowed to do that.', + rateLimited: 'Too many commands. Wait a minute and try again.', + tooLarge: 'That is too large (limit {max} bytes).', + modDisabled: 'TaskAutomation is disabled ({reason}). See the API console.', + unterminatedQuote: 'A quote is not closed.', + unknownCommand: 'Unknown command: {command}.', + unknownOption: 'Unknown option {option}.', + missingValue: '{option} needs a value.', + badRev: '--rev must be a positive number.', + badPair: 'Use name=value.', + badView: 'Unknown section. Choose one of: {views}.', + dashboardMissing: 'The dashboard handout could not be created.', + definitionNotFound: 'There is no task {id}.', + definitionExists: 'There is already a task {id}.', + definitionDisabled: '{id} is disabled.', + tooManyDefinitions: 'You already have the most tasks allowed ({max}).', + badId: '"{id}" is not a valid ID (letters, digits, - and _; up to 48).', + badName: 'Give a name of up to {max} characters.', + badField: 'You can set: {fields}.', + stale: + '{id} has changed since that button was made. Refresh and try again (now revision {rev}).', + nothingToPublish: '{id} has no draft to publish.', + notPublished: '{id} has not been published yet.', + invalidDefinition: 'That task is not valid.', + purgeBlocked: 'It is still used: {runs} runs, {queued} queued, called by {callers}.', + emptyJson: 'Add the JSON after --json.', + badJson: 'That is not valid JSON.', + tooDeep: 'That JSON is nested too deeply.', + cycle: 'That JSON refers to itself.', + notPlain: 'Only plain JSON values are allowed.', + notFinite: 'Numbers must be finite.', + unsafeKey: 'The key at {path} is not allowed.', + stringTooLong: 'A text value is too long.', + notObject: 'That must be a JSON object.', + badParts: 'Parts must be between 1 and 64.', + noImport: 'No import is in progress (or it expired).', + importOrder: 'Send part {expected} next.', + duplicateTrigger: 'There is already a trigger {id}.', + triggerNotFound: 'There is no trigger {id}.', + duplicateStep: 'There is already a step {id}.', + stepNotFound: 'There is no step {id}.', + badBlock: 'Cannot place the step there ({block}).', + unknownAction: 'Unknown step type {type}.', + templateNotFound: 'There is no template {id}.', + runNotFound: 'There is no run {id} you can manage.', + notPausable: '{id} cannot be paused while it is {status}.', + notPaused: '{id} is not paused.', + notActive: '{id} has already finished.', + notInReview: '{id} is not waiting for a decision.', + stillActive: '{id} is still running.', + badDecision: 'Choose one of: {actions}.', + alreadyRunning: '{id} is already running.', + halted: 'Everything is stopped. Allow starts again first.', + disabled: 'TaskAutomation is disabled.', + revisionMissing: 'Revision {revision} of {id} is missing.', + ancestryLimit: 'Too many tasks started each other (limit {max}).', + recursionLimit: 'A task cannot call itself.', + descendantLimit: 'This run started too many other runs (limit {max}).', + tooManyRuns: 'Too many runs at once (limit {max}).', + stateFull: 'Stored data is nearly full. Clear finished work first.', + circuitOpen: '{id} started too often and was disabled.', + concurrencyLimit: 'The most runs at once ({max}) are already going.', + queueFull: 'The queue is full ({max}).', + rejected: 'The task could not start.', + apiDenied: 'Commands from other Mods cannot do that.', + notShared: 'That task is not shared with you.', + notController: 'You can only use tokens and characters you control.', + tooManyTargets: 'Too many targets.', + unknownBinding: "{name} is not one of this task's targets.", + unknownParam: '{name} is not a parameter of this task.', + paramRequired: 'The parameter {name} is required.', + badParam: 'The parameter {name} is not valid ({reason}).', + binding_selectionUnavailable: 'Select the targets for {binding} first.', + binding_targetMissing: 'The target {id} for {binding} no longer exists.', + binding_tooManyTargets: 'Too many targets for {binding} (at most {max}).', + binding_noTargets: 'No targets for {binding}. Select some first.', + scheduleNotFound: 'There is no schedule {id}.', + scheduleKind: 'Give --at, --every, --daily or --weekly.', + scheduleNeedsService: + '{id} acts for a person. Set access.authority to gmService to schedule it.', + badInstant: '"{value}" is not a date-time with an offset, e.g. 2026-11-01T19:00:00Z.', + badOffset: 'Give the offset as +hh:mm or -hh:mm, e.g. +01:00.', + offsetRequired: 'Daily and weekly schedules need --offset (the UTC offset, e.g. +00:00).', + badMode: 'Choose one of: {modes}.', + notCalendar: '{id} is not a daily or weekly schedule.', + noFutureTimes: 'That schedule has no future times.', + spec_badWindow: 'The start must be before the end.', + spec_badCount: '--count must be a positive number.', + spec_badTime: 'That time is not valid (use HH:MM).', + spec_intervalTooShort: 'The interval must be at least {min}.', + spec_badMode: 'The mode must be fixedRate or fixedDelay.', + spec_badOffset: 'The offset is not valid.', + spec_badDays: 'Give days as mon,tue,wed,thu,fri,sat,sun.', + spec_badKind: 'Unknown schedule kind.', + signalNotAllowed: 'You cannot send the signal {name}.', + incidentNotFound: 'There is no open incident {id}.', + recoveryRejected: 'The recovery run could not start ({reason}).', + confirmationUnknown: 'That confirmation is not valid. Preview again.', + confirmationExpired: 'That confirmation expired. Preview again.', + confirmationForeign: 'That confirmation was made for someone else.', + notHalted: 'Nothing is stopped.', + badConfig: 'Unknown setting or value for {key}.', + gmOnlyMode: 'Only the GM can do that.', + }, + hints: { + gmOnly: 'Ask your GM to share the task with you.', + unknownCommand: 'Type !taskautomation help for the commands.', + unknownOption: 'Type !taskautomation help for the commands.', + stale: 'Click Refresh on the dashboard, or open the run again.', + binding_selectionUnavailable: + 'Scheduled and event starts need explicit IDs: --bind name=-id1,-id2.', + }, + validation: { + unknownField: '{field} is not a known field.', + schemaVersion: 'schemaVersion must be {version}.', + badId: '"{id}" is not a valid ID.', + badName: 'Give a name of up to {max} characters.', + duplicateId: '"{id}" is used twice.', + notObject: 'must be an object.', + notArray: 'must be a list.', + notString: 'must be text.', + notNumber: 'must be a number.', + notBoolean: 'must be true or false.', + notFinite: 'must be a finite number.', + notIdList: 'must be a list of IDs.', + badEnum: 'must be one of: {values}.', + outOfRange: 'must be between {min} and {max}.', + stringTooLong: 'is longer than {max} characters.', + missingField: '{field} is required.', + noSteps: 'add at least one step.', + tooManySteps: 'more than {max} steps.', + tooDeep: 'nested more than {max} levels.', + unknownAction: '{type} is not a step type.', + unknownParam: 'there is no parameter {name}.', + unknownBinding: 'there is no target binding {name}.', + badTarget: 'use {"binding": name}, {"ids": [...]} or {"ref": path}.', + badTemplate: 'must be text of up to {max} characters.', + badProperty: '{property} cannot be used here.', + badInstant: 'needs a date-time with an offset, e.g. 2026-11-01T19:00:00Z.', + badClock: 'needs a time as HH:MM.', + badOffset: 'needs an offset as +hh:mm.', + badDays: 'needs days such as ["mon","fri"].', + intervalTooShort: 'must be at least {min} ms.', + badCrossing: 'needs property, direction (below or above) and a numeric threshold.', + badMatch: 'needs equals, contains or startsWith.', + badBurst: 'needs count and windowMs.', + badMerge: 'needs name and a reducer (sum, min, max, first, last, count, all, any).', + badCommand: 'must start with a Mod command such as !token-mod and be one line.', + waitKind: 'give either durationMs or until.', + waitTooShort: 'waits must be at least {min} ms.', + foreverNotAllowed: 'set policy.allowForever to true to allow a loop that never ends.', + foreverNoWait: 'a forever loop must wait on every pass.', + branchCount: 'parallel needs 2 to {max} branches.', + gmOnlyAction: '{type} cannot be shared with players.', + notPlayerCapability: '{type} is not allowed in a task shared with players.', + playersNeedInitiator: 'tasks shared with players must act as the player (authority initiator).', + unattendedNeedsService: 'scheduled and event tasks must use authority gmService.', + selectionUnattended: + 'selection only exists when someone starts the task; add an alternate binding.', + callCycle: 'tasks call each other in a circle: {cycle}.', + lateBoundDefinition: 'calls {id}, which does not exist yet.', + adapterUnknown: 'uses adapter {adapter}, which is not installed.', + badRef: '"{ref}" is not a valid reference.', + refRoot: '{root} is not available here (use {allowed}).', + badConversion: '"{as}" is not number, string or boolean.', + defaultScalar: 'the default must be a single value.', + badOperator: '"{op}" is not an operator.', + missingOperand: '{op} needs {field}.', + arity: '{op} needs {count} arguments.', + exprShape: 'is not an expression.', + exprTooDeep: 'the expression is nested more than {max} levels.', + exprTooLarge: 'the expression has more than {max} parts.', + }, + runErrors: { + internal: 'Internal error: {message}', + cancelled: 'Cancelled.', + replaced: 'Replaced by a newer run.', + parentFinished: 'The run that started it finished.', + stopAll: 'Stopped by stop-all.', + cancelledByTask: 'Cancelled by another task.', + branchCancelled: 'Its branch was cancelled.', + reviewCancelled: 'Cancelled after review.', + stopStep: 'Stopped by a stop step: {message}', + runTimeout: 'Took longer than its time limit.', + runExpired: 'Its lifetime ended.', + overdueExpired: 'Too late to be useful (late by {latenessMs} ms).', + runaway: 'Ran {max} steps without waiting; stopped to protect the game.', + revisionMissing: 'Its task revision is missing.', + restartPolicyFail: 'Stopped by a restart (this task fails on restart).', + noTargets: 'No targets left for this step.', + noCharacter: '{id} does not represent a character.', + noRecipients: 'Nobody to send the message to.', + attributeMissing: 'The character has no attribute {attribute}.', + notNumeric: '{attribute} is not a number.', + notNumber: '{field} is not a number.', + badValue: '{field} has a value that cannot be used.', + valueMissing: '{field} has no value.', + expectedValue: '{property} of {id} has changed; nothing was overwritten.', + notMultiSided: '{id} is not a multi-sided token.', + sideOutOfRange: '{id} has only {max} sides.', + sideImageUnsupported: + 'Side {side} of {id} is not an image the API can set (it must be in your library).', + badMarker: '{marker} is not a valid marker name.', + trackMissing: 'There is no jukebox track {track}.', + fxMissing: 'There is no custom effect {fx}.', + customFx: 'Custom effects need the GM.', + badLocation: 'The location is not valid.', + gmOnlyAction: '{type} needs the GM.', + gmOnlyMode: '{mode} needs the GM.', + gmProperty: 'Only the GM can change {property}.', + emptyMessage: 'The message is empty.', + badCommand: 'The command is not valid.', + unsafeArgument: 'A value cannot be put into the command safely.', + rateLimited: 'Too many {what} (limit {max} a minute).', + adapterUnavailable: 'Adapter {adapter} is not installed.', + adapterAction: 'Adapter {adapter} has no action {action}.', + adapterAuthority: 'Adapter {adapter} needs the GM.', + adapterFailed: 'Adapter {adapter} reported a failure: {message}', + workerUnavailable: 'Sheet workers cannot be used in this game.', + definitionMissing: 'Task {id} does not exist.', + childRejected: 'Could not start {id} ({reason}).', + childFailed: '{child} {status}.', + detachNeedsGm: 'Only GM runs may start runs that carry on alone.', + runNotOwned: '{id} does not belong to this run.', + tooManyBranches: 'More than {max} branches.', + allBranchesFailed: 'Every branch failed.', + branchFailed: 'A branch failed.', + badDuration: 'The wait is not a valid duration.', + badInstant: 'The time is not valid.', + badCount: 'The loop count is not a whole number.', + unknownAction: 'Unknown step type {type}.', + controlLost: 'The player no longer controls {id}.', + initiatorGone: 'The player who started it has left.', + publicationRevoked: 'The task is no longer shared with players.', + }, + help: { + menu: 'Show the menu.', + help: 'Show these commands.', + dashboard: 'Open, refresh or page the GM dashboard handout.', + definition: 'List, show, create, validate and publish tasks.', + definitionManage: 'Enable, disable, copy, archive or delete tasks.', + definitionEdit: 'Edit a draft: name, parameters, targets, access, policy, triggers and steps.', + definitionTransfer: 'Copy tasks between games as JSON.', + template: 'List or add the worked examples.', + instanceStart: 'Start a task (selected tokens become its targets), or check it with a dry run.', + instance: 'List, inspect, pause, resume, cancel or rerun runs.', + instanceDecide: 'Decide what a run that needs a decision does next.', + schedule: 'Run tasks at a time, every so often, daily or weekly.', + signal: 'Send a named signal to waiting tasks.', + report: 'Missed work, finished runs, the audit trail and diagnostics.', + recovery: 'Preview and apply recovery for missed work.', + config: 'Show or change settings.', + adapter: 'List Mod adapters.', + stopAll: 'Emergency stop: cancel all runs and hold new ones.', + resumeAll: 'Allow tasks to start again after stop-all.', + }, + stepTypes: { + wait: 'Wait for a duration (durationMs) or until a moment (until).', + awaitEvent: 'Wait for an event or signal, with a timeout.', + if: 'Run then or else depending on a condition.', + loop: 'Repeat steps: count, while, until or forever (GM-approved).', + call: 'Run another task; wait for it unless await is false.', + parallel: 'Run branches side by side; join all, anySuccess or race.', + stop: 'Finish the run now.', + 'variable.set': 'Store a value for later steps.', + 'event.emit': 'Send a named signal to other tasks.', + 'chat.send': 'Say, emote, describe or whisper a message.', + 'chat.command': 'GM only: send a chat command such as /roll.', + 'token.set': 'Set token position, rotation, layer, bars, tint or name.', + 'token.get': 'Read a token property into a variable.', + 'token.advanceSide': 'Move a multi-sided token to its next side.', + 'token.setSide': 'Show a chosen side of a multi-sided token.', + 'attribute.set': 'Set a character attribute.', + 'attribute.adjust': 'Add to or subtract from a numeric attribute.', + 'attribute.get': 'Read an attribute into a variable.', + 'marker.set': 'Add, remove, toggle or count a status marker.', + ping: 'Ping a token or a point.', + 'effect.spawn': 'Show a visual effect.', + 'audio.control': 'Play, stop or change the volume of a jukebox track.', + 'task.start': 'Start another task that carries on by itself.', + 'task.cancel': 'Cancel runs this run started.', + 'task.pause': 'Pause runs this run started.', + 'integration.command': 'GM only: send an approved command to another Mod.', + 'integration.call': 'Call a trusted Mod adapter and wait for its answer.', + }, + triggerTypes: { + manual: 'Started from a command, button or the dashboard.', + at: 'Once, at a date-time with an offset.', + interval: 'Every so often (fixed rate or fixed delay).', + daily: 'Every day at a time, at a fixed UTC offset.', + weekly: 'On chosen days at a time, at a fixed UTC offset.', + event: 'When tokens, attributes, the turn order, the page, players or tasks change.', + signal: 'When someone sends !taskautomation signal .', + chat: 'When ordinary chat matches (GM-approved).', + }, + overdueModes: { + skip: 'Skip what was missed and note it.', + runOnce: 'Run once for the latest missed time.', + catchUpBounded: 'Run a few of the missed times, spaced out.', + review: 'Ask the GM.', + expire: 'Drop it: it is too late to matter.', + }, + handout: { + subtitle: 'Version {version} · GM only', + intro: + 'TaskAutomation runs tasks you describe as data: steps such as messages, token and attribute changes, waits, conditions, loops and parallel branches, started by hand, on a schedule or by events in the game.', + quickStart: { + heading: 'Quick Start', + items: [ + 'Open the TaskAutomation - Dashboard handout, or type !taskautomation.', + 'Click Templates and add one of the worked examples.', + 'Select any tokens it needs and click Start.', + 'Watch it on the dashboard; pause, resume or cancel it there.', + ], + }, + concepts: { + heading: 'How It Works', + items: [ + 'A task (definition) is a program. Publishing it makes a numbered revision; runs keep the revision they started with.', + 'A run is one start of a task, with its own targets, variables and progress.', + 'All waiting uses one timer. Waits are saved, so runs carry on after an API restart.', + "If the sandbox was off, missed times are reported and handled by the task's overdue policy.", + 'Actions that may have happened when the sandbox stopped are never repeated automatically: the run asks you.', + 'Tasks are GM-only unless published to players, who can then only start them on things they control.', + ], + }, + commands: { heading: 'Commands', command: 'Command', does: 'What it does' }, + steps: { heading: 'Step Types', type: 'Type', does: 'What it does' }, + triggers: { heading: 'Triggers', type: 'Type', does: 'Starts the task' }, + overdue: { + heading: 'Missed Work', + intro: + 'A short delay (250 ms by default) is normal. Up to five minutes late, a schedule runs once. Beyond that the overdue mode decides:', + mode: 'Mode', + does: 'What happens', + }, + limits: { + heading: 'Good to Know', + items: [ + 'Times are real-world time, not game time. Schedules use a fixed UTC offset; update it when the clocks change.', + 'Nothing runs while the API sandbox is off, and events that happened then cannot be seen.', + 'Changes to several objects are not all-or-nothing. A cancelled run does not undo what it already did.', + 'A command sent to another Mod is shown as sent, not as done.', + 'The dashboard is a snapshot. Click Refresh to update it; it never ticks by itself.', + ], + }, + footer: 'TaskAutomation by {name}', + }, +}; + +export default TRANSLATION; diff --git a/TaskAutomation/src/locales/metadata.js b/TaskAutomation/src/locales/metadata.js new file mode 100644 index 0000000000..41330951fb --- /dev/null +++ b/TaskAutomation/src/locales/metadata.js @@ -0,0 +1,38 @@ +export const DEFAULT_LOCALE = 'en-US'; + +// Add a locale here, create src/locales/locale/.js (copy en-US.js) and +// import it in index.js, then run `npm run sync-locales` to translate it. +export const LOCALE_DEFINITIONS = Object.freeze([ + { + code: 'en-US', + name: 'English (US)', + direction: 'ltr', + translationFile: 'locale/en-US.js', + aliases: ['en'], + flag: '🇺🇸', + flagLabel: 'Flag of the United States', + }, +]); + +export const SUPPORTED_LOCALES = Object.freeze(LOCALE_DEFINITIONS.map(({ code }) => code)); +export const VALID_LOCALES = new Set(SUPPORTED_LOCALES); +export const LOCALE_ALIASES = Object.freeze( + LOCALE_DEFINITIONS.reduce((aliases, locale) => { + for (const alias of locale.aliases || []) { + aliases[alias] = locale.code; + } + return aliases; + }, {}) +); +export const LOCALE_LABELS = Object.freeze( + LOCALE_DEFINITIONS.reduce((labels, locale) => { + const displayName = locale.nativeName + ? `${locale.code} - ${locale.name} (${locale.nativeName})` + : `${locale.code} - ${locale.name}`; + labels[locale.code] = displayName; + return labels; + }, {}) +); +export const SUPPORTED_LOCALE_LIST = SUPPORTED_LOCALES.map((code) => LOCALE_LABELS[code]).join( + ' / ' +); diff --git a/TaskAutomation/src/objects.js b/TaskAutomation/src/objects.js new file mode 100644 index 0000000000..74dbfb50ab --- /dev/null +++ b/TaskAutomation/src/objects.js @@ -0,0 +1,418 @@ +// Reading Roll20 objects for tasks: binding resolution, targets, allowlisted +// property reads, control checks and multi-sided tokens (plan §4.1). + +import { + ATTRIBUTE_READ_PROPERTIES, + CHARACTER_READ_PROPERTIES, + PAGE_READ_PROPERTIES, + PLAYER_READ_PROPERTIES, + TOKEN_READ_PROPERTIES, +} from './constants.js'; +import { findObjects, getObject, isGm } from './host.js'; +import { isRecord, parseMarkers, toText } from './utils.js'; + +const READABLE = { + graphic: TOKEN_READ_PROPERTIES, + attribute: ATTRIBUTE_READ_PROPERTIES, + character: CHARACTER_READ_PROPERTIES, + player: PLAYER_READ_PROPERTIES, + page: PAGE_READ_PROPERTIES, + jukeboxtrack: ['title', 'playing', 'volume', 'loop'], +}; + +// Roll20 stores a few properties under other names. +const PROPERTY_ALIASES = { + pageid: '_pageid', + represents: 'represents', + online: '_online', + currentSide: 'currentSide', +}; + +/** + * Returns a Roll20 object of a type, treating graphics that are not tokens + * (cards, map images) as missing. + * + * @param {string} type Object type. + * @param {string} id Object ID. + * @returns {object|null} Object. + */ +export function getTarget(type, id) { + const obj = getObject(type, id); + if (obj && type === 'graphic' && obj.get('_subtype') !== 'token') { + return null; + } + return obj; +} + +/** + * Finds the type of an object ID among the types tasks use. + * + * @param {string} id Object ID. + * @returns {{type: string, obj: object}|null} Type and object. + */ +export function findAnyTarget(id) { + for (const type of ['graphic', 'character', 'attribute', 'player', 'page', 'jukeboxtrack']) { + const obj = getTarget(type, id); + if (obj) { + return { type, obj }; + } + } + return null; +} + +/** + * Reads an allowlisted property, returning plain data only. + * + * @param {object} obj Roll20 object. + * @param {string} type Object type. + * @param {string} prop Property. + * @returns {{found: boolean, value?: *}} Result. + */ +export function readProperty(obj, type, prop) { + if (prop === 'id') { + return { found: true, value: obj.id }; + } + if (!(READABLE[type] || []).includes(prop)) { + return { found: false }; + } + let value = obj.get(PROPERTY_ALIASES[prop] || prop); + if (value === undefined && PROPERTY_ALIASES[prop]) { + value = obj.get(prop); + } + if (value === undefined || value === null) { + return { found: false }; + } + if (typeof value === 'object') { + return { found: false }; + } + return { found: true, value }; +} + +/** + * Takes a compact snapshot of a token's allowlisted properties. + * + * @param {object} obj Roll20 object or a plain attribute map. + * @param {string} type Object type. + * @returns {object} Snapshot. + */ +export function snapshot(obj, type) { + const out = {}; + const get = typeof obj?.get === 'function' ? (k) => obj.get(k) : (k) => obj?.[k]; + for (const prop of READABLE[type] || []) { + let value = get(PROPERTY_ALIASES[prop] || prop); + if (value === undefined && PROPERTY_ALIASES[prop]) { + value = get(prop); + } + if (value !== undefined && value !== null && typeof value !== 'object') { + out[prop] = typeof value === 'string' && value.length > 500 ? value.slice(0, 500) : value; + } + } + return out; +} + +/** + * Returns the player IDs that control an object. `all` means everyone. + * Journal visibility is not control. + * + * @param {object} obj Roll20 graphic or character. + * @param {string} type Object type. + * @returns {string[]} Controller IDs (may include 'all'). + */ +export function controllersOf(obj, type) { + const split = (raw) => + toText(raw) + .split(',') + .map((x) => x.trim()) + .filter(Boolean); + if (type === 'character') { + return split(obj.get('controlledby')); + } + if (type === 'graphic') { + const own = split(obj.get('controlledby')); + const charId = toText(obj.get('represents')); + const character = charId ? getObject('character', charId) : null; + return [...new Set([...own, ...(character ? split(character.get('controlledby')) : [])])]; + } + if (type === 'attribute') { + const character = getObject('character', toText(obj.get('_characterid'))); + return character ? split(character.get('controlledby')) : []; + } + return []; +} + +/** + * Returns true when a player may act on an object. GMs control everything. + * + * @param {string} playerId Player ID. + * @param {object} obj Roll20 object. + * @param {string} type Object type. + * @returns {boolean} True when controlled. + */ +export function playerControls(playerId, obj, type) { + if (isGm(playerId)) { + return true; + } + const controllers = controllersOf(obj, type); + return controllers.includes('all') || controllers.includes(playerId); +} + +/** + * Returns the character an object stands for: the character itself, or the + * character a token represents. + * + * @param {object} obj Roll20 object. + * @param {string} type Object type. + * @returns {object|null} Character. + */ +export function characterOf(obj, type) { + if (type === 'character') { + return obj; + } + if (type === 'graphic') { + const id = toText(obj.get('represents')); + return id ? getObject('character', id) : null; + } + return null; +} + +/** + * Finds a character's attribute by exact name. + * + * @param {string} characterId Character ID. + * @param {string} name Attribute name. + * @returns {object|null} Attribute. + */ +export function findAttribute(characterId, name) { + return findObjects({ _type: 'attribute', _characterid: characterId, name })[0] || null; +} + +/** + * Runs a bounded binding query against the game. + * + * @param {object} query Query spec {name, pageId, represents, layer, marker, max}. + * @param {string} objectType Object type. + * @returns {string[]} Matching IDs (at most query.max). + */ +export function runQuery(query, objectType) { + const filter = { _type: objectType }; + if (objectType === 'graphic') { + filter._subtype = 'token'; + } + if (query.pageId) { + filter._pageid = query.pageId; + } + if (query.name) { + filter.name = query.name; + } + if (query.represents) { + filter.represents = query.represents; + } + if (query.layer) { + filter.layer = query.layer; + } + let found = findObjects(filter); + if (query.marker) { + found = found.filter((o) => + parseMarkers(o.get('statusmarkers')).some((m) => m.tag === query.marker) + ); + } + return found.slice(0, query.max).map((o) => o.id); +} + +/** + * Resolves the bindings of a definition for a new run. + * + * @param {object} def Definition snapshot. + * @param {object} sources Where values come from. + * @param {string[]} [sources.selected] Selected IDs from the start command. + * @param {string|null} [sources.eventObjectId] Object of the triggering event. + * @param {object} [sources.explicit] Explicit IDs per binding (`--bind name=id,id`). + * @param {object} [sources.params] Resolved parameters. + * @param {boolean} [sources.unattended] True for schedule/event starts. + * @returns {{ok: boolean, bindings?: object, errors?: object[]}} Result. + */ +export function resolveBindings(def, sources) { + const bindings = {}; + const errors = []; + for (const [name, spec] of Object.entries(def.bindings || {})) { + let from = spec.from; + let ids = []; + const explicit = sources.explicit?.[name]; + if (explicit !== undefined && explicit !== 'selected') { + ids = explicit; + from = 'explicit'; + } else if (from === 'selected' && (sources.unattended || !sources.selected)) { + if (!spec.alternate) { + errors.push({ binding: name, code: 'selectionUnavailable' }); + continue; + } + from = spec.alternate.from; + if (from === 'ids') { + ids = spec.alternate.ids || []; + } + } + switch (from) { + case 'selected': + ids = sources.selected || []; + break; + case 'ids': + ids = ids.length ? ids : spec.ids || []; + break; + case 'event': + ids = sources.eventObjectId ? [sources.eventObjectId] : []; + break; + case 'param': { + const value = sources.params?.[spec.param]; + ids = typeof value === 'string' && value ? [value] : []; + break; + } + case 'query': + // Snapshot at start unless the definition asks for per-step evaluation. + ids = spec.query.evaluate === 'step' ? [] : runQuery(spec.query, spec.objectType); + break; + default: + break; + } + const unique = [...new Set(ids.filter((id) => typeof id === 'string' && id))]; + const valid = []; + for (const id of unique) { + const obj = getTarget(spec.objectType, id); + if (!obj && spec.objectType === 'character' && getTarget('graphic', id)) { + // A selected token stands for the character it represents. + const character = characterOf(getTarget('graphic', id), 'graphic'); + if (character) { + valid.push(character.id); + continue; + } + } + if (!obj) { + errors.push({ binding: name, code: 'targetMissing', id }); + continue; + } + valid.push(id); + } + const max = spec.max ?? 20; + if (valid.length > max) { + errors.push({ binding: name, code: 'tooManyTargets', max }); + continue; + } + const min = + spec.min ?? + (from === 'none' || spec.from === 'none' || spec.query?.evaluate === 'step' ? 0 : 1); + if (valid.length < min) { + errors.push({ binding: name, code: 'noTargets' }); + continue; + } + bindings[name] = [...new Set(valid)]; + } + return errors.length ? { ok: false, errors } : { ok: true, bindings }; +} + +/** + * Parses a token's `sides` into image URLs. + * + * @param {object} token Roll20 graphic. + * @returns {string[]} Side image URLs. + */ +export function tokenSides(token) { + return toText(token.get('sides')) + .split('|') + .filter(Boolean) + .map((side) => { + try { + return decodeURIComponent(side); + } catch { + return side; + } + }); +} + +/** + * Converts a library image URL into the form the API may write to `imgsrc` + * (the `thumb` size). Marketplace and external images cannot be set by the + * API, so they return null. + * + * @param {string} url Image URL. + * @returns {string|null} Writable URL, or null. + */ +export function writableImage(url) { + const match = + /^(https:\/\/s3\.amazonaws\.com\/files\.d20\.io\/images\/(?:.*?)\/)(?:thumb|med|original|max)([^?]*)(\?[^?]+)?$/.exec( + toText(url) + ); + if (!match) { + const alt = + /^(https:\/\/files\.d20\.io\/images\/(?:.*?)\/)(?:thumb|med|original|max)([^?]*)(\?[^?]+)?$/.exec( + toText(url) + ); + if (!alt) { + return null; + } + return `${alt[1]}thumb${alt[2]}${alt[3] || `?${Date.now()}`}`; + } + return `${match[1]}thumb${match[2]}${match[3] || `?${Date.now()}`}`; +} + +/** + * Returns a token's current side as a 0-based index. + * + * @param {object} token Roll20 graphic. + * @returns {number} Index. + */ +export function currentSideIndex(token) { + const raw = Number(token.get('currentSide')); + return Number.isInteger(raw) && raw >= 0 ? raw : 0; +} + +/** + * Returns a compact label for an object. + * + * @param {string} id Object ID. + * @returns {string} Name, or the ID. + */ +export function targetLabel(id) { + const found = findAnyTarget(id); + if (!found) { + return id; + } + const name = toText( + found.obj.get( + found.type === 'player' ? 'displayname' : found.type === 'jukeboxtrack' ? 'title' : 'name' + ) + ); + return name || id; +} + +/** + * Normalises a recipients spec into whisper names and player IDs. + * + * @param {object} to Recipients spec. + * @param {object} inst Instance (for bindings and initiator). + * @returns {{gm: boolean, players: string[]}} Recipients. + */ +export function resolveRecipients(to, inst) { + const result = { gm: Boolean(to?.gm), players: [] }; + if (!isRecord(to)) { + return result; + } + if (Array.isArray(to.players)) { + result.players.push(...to.players.filter((p) => getObject('player', p))); + } + if (to.initiator && inst.initiatorPlayerId) { + result.players.push(inst.initiatorPlayerId); + } + for (const key of ['binding', 'controllers']) { + if (to[key]) { + for (const id of inst.bindings?.[to[key]] || []) { + const found = findAnyTarget(id); + if (found?.type === 'player') { + result.players.push(id); + } else if (found) { + result.players.push(...controllersOf(found.obj, found.type).filter((p) => p !== 'all')); + } + } + } + } + result.players = [...new Set(result.players)]; + return result; +} diff --git a/TaskAutomation/src/permissions.js b/TaskAutomation/src/permissions.js new file mode 100644 index 0000000000..0ce8b19990 --- /dev/null +++ b/TaskAutomation/src/permissions.js @@ -0,0 +1,185 @@ +// Permissions (plan §10): GM checks from the real sender ID, narrowly +// published player tasks, effect-time control checks, single-use +// confirmation tokens for destructive operations and per-player rate limits. + +import { TIMING } from './constants.js'; +import { getObject, isGm, now } from './host.js'; +import { findAnyTarget, playerControls } from './objects.js'; +import { getLimits, getState, nextTokenCounter } from './state.js'; +import { randomToken } from './utils.js'; + +const commandWindows = new Map(); + +export { isGm }; + +/** + * Returns true when a player may start a definition with the given targets. + * + * @param {string} playerId Real sender ID. + * @param {object} def Definition snapshot. + * @param {object} [bindings] Resolved bindings. + * @returns {{ok: boolean, reason?: string}} Decision. + */ +export function canStart(playerId, def, bindings = {}) { + if (isGm(playerId)) { + return { ok: true }; + } + if (!playerId || playerId === 'API') { + return { ok: false, reason: 'apiDenied' }; + } + const access = def.access || {}; + if (access.mode !== 'players') { + return { ok: false, reason: 'gmOnly' }; + } + const listed = + Array.isArray(access.players) && + (access.players.includes(playerId) || access.players.includes('all')); + const ids = Object.values(bindings).flat(); + const controlsAll = + ids.length > 0 && + ids.every((id) => { + const found = findAnyTarget(id); + return found && playerControls(playerId, found.obj, found.type); + }); + if (!listed && !(access.controllers && controlsAll)) { + return { ok: false, reason: 'notShared' }; + } + // Players may only aim the task at things they control. + if (ids.length && !controlsAll) { + return { ok: false, reason: 'notController' }; + } + const maxTargets = access.capabilities?.maxTargets; + if (Number.isInteger(maxTargets) && ids.length > maxTargets) { + return { ok: false, reason: 'tooManyTargets' }; + } + return { ok: true }; +} + +/** + * Returns true when a player may inspect, pause, resume or cancel a run. + * + * @param {string} playerId Real sender ID. + * @param {object} inst Instance. + * @returns {boolean} True when allowed. + */ +export function canManageRun(playerId, inst) { + if (isGm(playerId)) { + return true; + } + return ( + Boolean(playerId) && + playerId !== 'API' && + inst.initiatorPlayerId === playerId && + inst.authority === 'initiator' + ); +} + +/** + * Checks, at effect time, that a run may still act on its targets. Player + * runs lose authority when the player loses control; GM-service runs need + * the definition to be enabled and published by a GM. + * + * @param {object} inst Instance. + * @param {string[]} targetIds Object IDs the step will change. + * @param {object} def Definition snapshot. + * @returns {{ok: boolean, reason?: string, id?: string}} Decision. + */ +export function checkEffectAuthority(inst, targetIds, def) { + if (inst.authority === 'gmService') { + return { ok: true }; + } + const playerId = inst.initiatorPlayerId; + if (isGm(playerId)) { + return { ok: true }; + } + if (!playerId || !getObject('player', playerId)) { + return { ok: false, reason: 'initiatorGone' }; + } + const access = def.access || {}; + if (access.mode !== 'players') { + return { ok: false, reason: 'publicationRevoked' }; + } + for (const id of targetIds) { + const found = findAnyTarget(id); + if (found && !playerControls(playerId, found.obj, found.type)) { + return { ok: false, reason: 'controlLost', id }; + } + } + return { ok: true }; +} + +/** + * Applies the per-player command rate limit. + * + * @param {string} playerId Player ID. + * @returns {boolean} True when the command may run. + */ +export function allowCommand(playerId) { + if (isGm(playerId)) { + return true; + } + const at = now(); + const window = commandWindows.get(playerId) || { start: at, count: 0 }; + if (at - window.start > 60000) { + window.start = at; + window.count = 0; + } + window.count += 1; + commandWindows.set(playerId, window); + return window.count <= getLimits().playerCommandsPerMinute; +} + +/** + * Issues a single-use confirmation token bound to the caller, the operation, + * the IDs and their revisions. + * + * @param {string} playerId Caller. + * @param {string} op Operation. + * @param {object} payload Operation details (IDs, revisions, options). + * @returns {string} Token. + */ +export function issueConfirmation(playerId, op, payload) { + const s = getState(); + pruneConfirmations(); + const token = randomToken(nextTokenCounter()); + s.confirmations[token] = { playerId, op, payload, expiresAt: now() + TIMING.confirmationTtlMs }; + return token; +} + +/** + * Consumes a confirmation token. Expired, reused or foreign tokens fail. + * + * @param {string} token Token. + * @param {string} playerId Caller. + * @returns {{ok: boolean, op?: string, payload?: object, reason?: string}} Result. + */ +export function consumeConfirmation(token, playerId) { + const s = getState(); + const entry = s.confirmations[token]; + if (!entry) { + return { ok: false, reason: 'confirmationUnknown' }; + } + delete s.confirmations[token]; + if (entry.expiresAt < now()) { + return { ok: false, reason: 'confirmationExpired' }; + } + if (entry.playerId !== playerId) { + return { ok: false, reason: 'confirmationForeign' }; + } + return { ok: true, op: entry.op, payload: entry.payload }; +} + +/** + * Drops expired confirmations. + * + * @returns {void} + */ +export function pruneConfirmations() { + const s = getState(); + const at = now(); + for (const [token, entry] of Object.entries(s.confirmations)) { + if (entry.expiresAt < at) { + delete s.confirmations[token]; + } + } +} diff --git a/TaskAutomation/src/recovery.js b/TaskAutomation/src/recovery.js new file mode 100644 index 0000000000..d98c8f5487 --- /dev/null +++ b/TaskAutomation/src/recovery.js @@ -0,0 +1,189 @@ +// Startup recovery (plan §8): admissions are held while persisted work is +// reconciled, overdue schedules apply their policies once, observation gaps +// are recorded, and the GM gets at most one concise summary. + +import { audit, createIncident } from './audit.js'; +import { ACTIVE_STATUSES, JOB, TIMING } from './constants.js'; +import { pruneRevisions, listRecords } from './definitions.js'; +import { listInstances, recoverInstance } from './executor.js'; +import { now } from './host.js'; +import { onJob, runtime, schedule } from './runtime.js'; +import { recoverSchedules } from './schedules.js'; +import { getConfig, getState } from './state.js'; +import { pruneConfirmations } from './permissions.js'; +import { rebuildTriggerJobs, reconcileBaselines } from './triggers.js'; + +/** + * Starts a new boot: records the previous last-observed time, which only + * estimates when the sandbox stopped. + * + * @returns {{bootId: string, gapFrom: number|null, gapMs: number}} Boot info. + */ +export function startBoot() { + const s = getState(); + const at = now(); + const previous = s.boot || {}; + const bootId = `boot-${(previous.count || 0) + 1}-${at.toString(36)}`; + s.boot = { + id: bootId, + at, + count: (previous.count || 0) + 1, + previousId: previous.id || null, + previousObservedAt: previous.lastObservedAt || null, + lastObservedAt: at, + }; + runtime.bootId = bootId; + runtime.bootAt = at; + const gapFrom = previous.lastObservedAt || null; + return { bootId, gapFrom, gapMs: gapFrom ? at - gapFrom : 0 }; +} + +/** + * Runs the recovery protocol over persisted runs, queues and schedules. + * + * @param {{gapFrom: number|null, gapMs: number}} boot Boot info. + * @returns {object} Summary for the GM. + */ +export function recoverState(boot) { + const s = getState(); + const summary = { + runs: 0, + review: 0, + gaps: 0, + schedules: 0, + missed: 0, + admitted: 0, + reconciled: 0, + gapMs: boot.gapMs, + }; + pruneConfirmations(); + for (const inst of listInstances((i) => ACTIVE_STATUSES.has(i.status))) { + summary.runs += 1; + const result = recoverInstance(inst, { gapFrom: boot.gapFrom }); + if (result.review) { + summary.review += 1; + } + if (result.gap) { + summary.gaps += 1; + } + } + if (summary.gaps && boot.gapFrom) { + // Events while the sandbox was off cannot be replayed; record the gap. + createIncident({ + kind: 'gap', + cause: 'observationGap', + count: summary.gaps, + range: { from: boot.gapFrom, to: now() }, + group: `gap:${runtime.bootId}`, + }); + } + for (const queue of Object.values(s.queues)) { + for (const entry of queue) { + if (entry.expiresAt) { + schedule({ + kind: JOB.QUEUE_EXPIRY, + dueAt: entry.expiresAt, + key: `queue:${entry.id}`, + target: { defId: entry.definitionId, entryId: entry.id }, + }); + } + } + } + for (const record of listRecords({ archived: true })) { + pruneRevisions(record.id); + } + const schedules = recoverSchedules(); + summary.schedules = schedules.schedules; + summary.missed = schedules.missed; + summary.admitted = schedules.admitted; + rebuildTriggerJobs(); + return summary; +} + +/** + * Finishes recovery: releases admissions, then routes reconciled events. + * + * @param {object} summary Recovery summary (updated). + * @returns {void} + */ +export function releaseAdmissions(summary) { + runtime.admissionsHeld = false; + summary.reconciled = reconcileBaselines(); + audit('boot', { + boot: runtime.bootId, + runs: summary.runs, + review: summary.review, + missed: summary.missed, + }); + armMaintenance(); +} + +/** + * Schedules the periodic maintenance job (pruning, size estimate) and the + * optional health checkpoint. Both use the shared scheduler. + * + * @returns {void} + */ +export function armMaintenance() { + schedule({ kind: JOB.MAINTENANCE, dueAt: now() + TIMING.maintenanceMs, key: 'maintenance' }); + const every = getConfig().checkpointMs; + if (every > 0) { + schedule({ kind: JOB.CHECKPOINT, dueAt: now() + every, key: 'checkpoint' }); + } +} + +/** + * Maintenance: prunes confirmations and old revisions. Re-arms itself only + * while there is persisted work, so an idle game has no timer. + * + * @returns {void} + */ +function onMaintenance() { + pruneConfirmations(); + for (const record of listRecords({ archived: true })) { + pruneRevisions(record.id); + } + const s = getState(); + const busy = + Object.values(s.instances).some((i) => ACTIVE_STATUSES.has(i.status)) || + Object.keys(s.confirmations).length > 0; + if (busy) { + schedule({ kind: JOB.MAINTENANCE, dueAt: now() + TIMING.maintenanceMs, key: 'maintenance' }); + } +} + +/** + * Optional coarse checkpoint: refreshes the last-observed time to sharpen + * outage estimates. It cannot keep the sandbox alive. + * + * @returns {void} + */ +function onCheckpoint() { + getState().boot.lastObservedAt = now(); + const every = getConfig().checkpointMs; + if (every > 0) { + schedule({ kind: JOB.CHECKPOINT, dueAt: now() + every, key: 'checkpoint' }); + } +} + +/** + * Registers recovery job handlers. + * + * @returns {void} + */ +export function registerRecoveryJobs() { + onJob(JOB.MAINTENANCE, onMaintenance); + onJob(JOB.CHECKPOINT, onCheckpoint); +} + +/** + * Builds the one-line facts of a recovery summary for the GM. + * + * @param {object} summary Summary. + * @returns {boolean} True when there is anything worth saying. + */ +export function summaryWorthTelling(summary) { + return Boolean( + summary.runs || summary.review || summary.missed || summary.gaps || summary.reconciled + ); +} diff --git a/TaskAutomation/src/recurrence.js b/TaskAutomation/src/recurrence.js new file mode 100644 index 0000000000..e90e29f38e --- /dev/null +++ b/TaskAutomation/src/recurrence.js @@ -0,0 +1,317 @@ +// Time schedule arithmetic (plan §5): one-off, fixed-rate and fixed-delay +// intervals, and daily/weekly calendar times at a fixed UTC offset. Missed +// occurrences are counted with arithmetic, never by walking years of times. +// Pure: no Roll20 globals and no host timezone. + +import { WEEKDAYS } from './constants.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; +// 1970-01-01 was a Thursday. +const EPOCH_WEEKDAY = 4; + +/** + * Parses `HH:MM` (24-hour) into milliseconds after midnight. + * + * @param {*} raw Raw input. + * @returns {number|null} Milliseconds, or null when invalid. + */ +export function parseClockTime(raw) { + const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(String(raw ?? '').trim()); + return match ? (Number(match[1]) * 60 + Number(match[2])) * 60000 : null; +} + +/** + * Formats milliseconds after midnight as `HH:MM`. + * + * @param {number} ms Milliseconds. + * @returns {string} Clock time. + */ +export function formatClockTime(ms) { + const minutes = Math.floor(ms / 60000); + return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`; +} + +/** + * Parses weekday names (`mon,wed` or `mon wed`) into sorted day numbers. + * + * @param {*} raw Raw input (string or array). + * @returns {number[]|null} Day numbers (0 = Sunday), or null when invalid. + */ +export function parseWeekdays(raw) { + const list = Array.isArray(raw) ? raw : String(raw ?? '').split(/[\s,]+/); + const days = new Set(); + for (const item of list.filter((x) => x !== '')) { + const index = + typeof item === 'number' ? item : WEEKDAYS.indexOf(String(item).slice(0, 3).toLowerCase()); + if (!Number.isInteger(index) || index < 0 || index > 6) { + return null; + } + days.add(index); + } + return days.size ? [...days].sort((a, b) => a - b) : null; +} + +/** + * Returns the local day number (days since 1970-01-01 at the offset) of an instant. + * + * @param {number} t Epoch milliseconds. + * @param {number} offsetMinutes Offset east of UTC. + * @returns {number} Day number. + */ +function localDay(t, offsetMinutes) { + return Math.floor((t + offsetMinutes * 60000) / DAY_MS); +} + +/** + * Returns the instant of a calendar occurrence on a local day. + * + * @param {number} day Local day number. + * @param {object} spec Calendar spec. + * @returns {number} Epoch milliseconds. + */ +function occurrenceOnDay(day, spec) { + return day * DAY_MS + spec.timeMs - spec.offsetMinutes * 60000; +} + +/** + * Returns true when a calendar spec fires on a local day. + * + * @param {number} day Local day number. + * @param {object} spec Calendar spec. + * @returns {boolean} True when it fires. + */ +function firesOnDay(day, spec) { + if (spec.kind === 'daily') { + return true; + } + const weekday = (((day + EPOCH_WEEKDAY) % 7) + 7) % 7; + return spec.days.includes(weekday); +} + +/** + * Returns the first occurrence at or after an instant, ignoring windows. + * + * @param {object} spec Normalised schedule spec. + * @param {number} t Epoch milliseconds. + * @returns {number|null} Epoch milliseconds, or null when there is none. + */ +function rawAtOrAfter(spec, t) { + switch (spec.kind) { + case 'once': + return spec.at >= t ? spec.at : null; + case 'interval': { + if (t <= spec.anchor) { + return spec.anchor; + } + return spec.anchor + Math.ceil((t - spec.anchor) / spec.everyMs) * spec.everyMs; + } + case 'daily': + case 'weekly': { + let day = localDay(t, spec.offsetMinutes); + for (let i = 0; i < 9; i += 1, day += 1) { + const at = occurrenceOnDay(day, spec); + if (at >= t && firesOnDay(day, spec)) { + return at; + } + } + return null; + } + default: + return null; + } +} + +/** + * Returns the last occurrence at or before an instant, ignoring windows. + * + * @param {object} spec Normalised schedule spec. + * @param {number} t Epoch milliseconds. + * @returns {number|null} Epoch milliseconds, or null when there is none. + */ +function rawAtOrBefore(spec, t) { + switch (spec.kind) { + case 'once': + return spec.at <= t ? spec.at : null; + case 'interval': + if (t < spec.anchor) { + return null; + } + return spec.anchor + Math.floor((t - spec.anchor) / spec.everyMs) * spec.everyMs; + case 'daily': + case 'weekly': { + let day = localDay(t, spec.offsetMinutes); + for (let i = 0; i < 9; i += 1, day -= 1) { + const at = occurrenceOnDay(day, spec); + if (at <= t && firesOnDay(day, spec)) { + return at; + } + } + return null; + } + default: + return null; + } +} + +/** + * Returns the first occurrence at or after an instant, inside the spec's + * start/end window. + * + * @param {object} spec Normalised schedule spec. + * @param {number} t Epoch milliseconds. + * @returns {number|null} Epoch milliseconds, or null when the schedule has ended. + */ +export function nextAtOrAfter(spec, t) { + const from = Number.isFinite(spec.startAt) ? Math.max(t, spec.startAt) : t; + const at = rawAtOrAfter(spec, from); + if (at === null || (Number.isFinite(spec.endAt) && at > spec.endAt)) { + return null; + } + return at; +} + +/** + * Returns the first occurrence strictly after an instant. + * + * @param {object} spec Normalised schedule spec. + * @param {number} t Epoch milliseconds. + * @returns {number|null} Epoch milliseconds, or null. + */ +export function nextAfter(spec, t) { + return nextAtOrAfter(spec, t + 1); +} + +/** + * Counts occurrences in the closed range [from, to] arithmetically. + * + * @param {object} spec Normalised schedule spec. + * @param {number} from Epoch milliseconds. + * @param {number} to Epoch milliseconds. + * @returns {number} Count. + */ +export function countBetween(spec, from, to) { + const a = Number.isFinite(spec.startAt) ? Math.max(from, spec.startAt) : from; + const b = Number.isFinite(spec.endAt) ? Math.min(to, spec.endAt) : to; + if (b < a) { + return 0; + } + const first = rawAtOrAfter(spec, a); + const last = rawAtOrBefore(spec, b); + if (first === null || last === null || last < first) { + return 0; + } + switch (spec.kind) { + case 'once': + return 1; + case 'interval': + return Math.round((last - first) / spec.everyMs) + 1; + case 'daily': + return localDay(last, spec.offsetMinutes) - localDay(first, spec.offsetMinutes) + 1; + case 'weekly': { + const d1 = localDay(first, spec.offsetMinutes); + const d2 = localDay(last, spec.offsetMinutes); + const days = d2 - d1 + 1; + const fullWeeks = Math.floor(days / 7); + let count = fullWeeks * spec.days.length; + for (let d = d1 + fullWeeks * 7; d <= d2; d += 1) { + if (firesOnDay(d, spec)) { + count += 1; + } + } + return count; + } + default: + return 0; + } +} + +/** + * Lists up to `limit` occurrences at or after an instant. + * + * @param {object} spec Normalised schedule spec. + * @param {number} from Epoch milliseconds. + * @param {number} limit Maximum occurrences. + * @param {number} [to=Infinity] Last instant to include. + * @returns {number[]} Occurrences. + */ +export function listFrom(spec, from, limit, to = Infinity) { + const out = []; + let at = nextAtOrAfter(spec, from); + while (at !== null && at <= to && out.length < limit) { + out.push(at); + at = spec.kind === 'once' ? null : nextAfter(spec, at); + } + return out; +} + +/** + * Validates and normalises a schedule spec. + * + * @param {object} raw Raw spec: {kind, at | everyMs+anchor+mode | time+offset(+days), startAt, endAt, maxCount}. + * @param {{minIntervalMs: number}} limits Limits. + * @returns {{ok: boolean, spec?: object, error?: string}} Result. + */ +export function normaliseSpec(raw, { minIntervalMs }) { + const base = {}; + for (const key of ['startAt', 'endAt']) { + if (raw[key] !== undefined && raw[key] !== null) { + if (!Number.isFinite(raw[key])) { + return { ok: false, error: 'badWindow' }; + } + base[key] = raw[key]; + } + } + if (base.startAt !== undefined && base.endAt !== undefined && base.endAt < base.startAt) { + return { ok: false, error: 'badWindow' }; + } + if (raw.maxCount !== undefined && raw.maxCount !== null) { + if (!Number.isInteger(raw.maxCount) || raw.maxCount < 1) { + return { ok: false, error: 'badCount' }; + } + base.maxCount = raw.maxCount; + } + switch (raw.kind) { + case 'once': + if (!Number.isFinite(raw.at)) { + return { ok: false, error: 'badTime' }; + } + return { ok: true, spec: { kind: 'once', at: raw.at, ...base } }; + case 'interval': { + if (!Number.isFinite(raw.everyMs) || raw.everyMs < minIntervalMs) { + return { ok: false, error: 'intervalTooShort' }; + } + if (!Number.isFinite(raw.anchor)) { + return { ok: false, error: 'badTime' }; + } + const mode = raw.mode || 'fixedRate'; + if (!['fixedRate', 'fixedDelay'].includes(mode)) { + return { ok: false, error: 'badMode' }; + } + return { + ok: true, + spec: { kind: 'interval', everyMs: raw.everyMs, anchor: raw.anchor, mode, ...base }, + }; + } + case 'daily': + case 'weekly': { + const timeMs = typeof raw.timeMs === 'number' ? raw.timeMs : parseClockTime(raw.time); + if (timeMs === null || timeMs < 0 || timeMs >= DAY_MS) { + return { ok: false, error: 'badTime' }; + } + if (!Number.isInteger(raw.offsetMinutes) || Math.abs(raw.offsetMinutes) > 14 * 60) { + return { ok: false, error: 'badOffset' }; + } + const spec = { kind: raw.kind, timeMs, offsetMinutes: raw.offsetMinutes, ...base }; + if (raw.kind === 'weekly') { + const days = parseWeekdays(raw.days); + if (!days) { + return { ok: false, error: 'badDays' }; + } + spec.days = days; + } + return { ok: true, spec }; + } + default: + return { ok: false, error: 'badKind' }; + } +} diff --git a/TaskAutomation/src/runtime.js b/TaskAutomation/src/runtime.js new file mode 100644 index 0000000000..4066b48c08 --- /dev/null +++ b/TaskAutomation/src/runtime.js @@ -0,0 +1,135 @@ +// In-memory runtime shared by the services: the one scheduler, job handlers, +// cross-service hooks and the admission gate. Nothing here is persisted; the +// scheduler is rebuilt from persistent records on every start (plan §3.1). + +import { SCRIPT_NAME } from './constants.js'; +import { clearTimer, logLine, now, setTimer } from './host.js'; +import { createScheduler } from './scheduler.js'; +import { getState } from './state.js'; + +const handlers = new Map(); +const hooks = new Map(); + +export const runtime = { + ready: false, + // True while startup recovery runs; triggers and starts wait. + admissionsHeld: true, + // Set when state could not be loaded; nothing runs. + disabled: null, + bootId: null, + bootAt: null, + scheduler: null, +}; + +/** + * Creates the scheduler. Each job kind is dispatched to the handler + * registered with `onJob`. + * + * @returns {object} Scheduler. + */ +export function createRuntimeScheduler() { + runtime.scheduler = createScheduler({ + now, + setTimer, + clearTimer, + dispatch(job) { + const handler = handlers.get(job.kind); + if (handler) { + handler(job); + } + const s = getState(); + if (s) { + s.boot.lastObservedAt = now(); + } + }, + onError(job, err) { + logLine(`ERROR in ${job.kind} job: ${err?.message || err}`); + if (err?.stack) { + log(err.stack); + } + }, + }); + return runtime.scheduler; +} + +/** + * Registers the handler for a job kind. + * + * @param {string} kind Job kind. + * @param {(job: object) => void} fn Handler. + * @returns {void} + */ +export function onJob(kind, fn) { + handlers.set(kind, fn); +} + +/** + * Schedules a job on the shared scheduler. + * + * @param {object} spec Job spec (kind, dueAt, key, target). + * @returns {number|null} Job ID, or null when the scheduler is not running. + */ +export function schedule(spec) { + return runtime.scheduler ? runtime.scheduler.add(spec) : null; +} + +/** + * Cancels a keyed job. + * + * @param {string} key Job key. + * @returns {void} + */ +export function unschedule(key) { + runtime.scheduler?.cancelKey(key); +} + +/** + * Registers a hook implementation. Services call each other through hooks + * so modules do not import each other in circles. + * + * @param {string} name Hook name. + * @param {Function} fn Implementation. + * @returns {void} + */ +export function provide(name, fn) { + hooks.set(name, fn); +} + +/** + * Calls a hook. Missing hooks are a programming error. + * + * @param {string} name Hook name. + * @param {...*} args Arguments. + * @returns {*} Result. + */ +export function call(name, ...args) { + const fn = hooks.get(name); + if (!fn) { + throw new Error(`${SCRIPT_NAME}: hook ${name} is not registered`); + } + return fn(...args); +} + +/** + * Calls a hook when it is registered. + * + * @param {string} name Hook name. + * @param {...*} args Arguments. + * @returns {*} Result, or undefined. + */ +export function maybeCall(name, ...args) { + const fn = hooks.get(name); + return fn ? fn(...args) : undefined; +} + +/** + * Writes a diagnostic line when diagnostic logging is on. + * + * @param {string} text Text. + * @returns {void} + */ +export function diag(text) { + if (getState()?.config?.diagnostics) { + logLine(`[diag] ${text}`); + } +} diff --git a/TaskAutomation/src/scheduler.js b/TaskAutomation/src/scheduler.js new file mode 100644 index 0000000000..cf491bb3b8 --- /dev/null +++ b/TaskAutomation/src/scheduler.js @@ -0,0 +1,351 @@ +// The only owner of a timer in TaskAutomation (plan §3.1). One priority queue, +// one re-armed timeout, generation guards and bounded draining. Pure: the +// clock, timer functions and dispatcher are injected so tests can drive it. + +import { JOB_PRIORITY, SCHEDULER_DEFAULTS } from './constants.js'; +import { createHeap, heapPeek, heapPop, heapPush, heapRebuild } from './heap.js'; + +/** + * Creates a scheduler. + * + * @param {object} options Options. + * @param {() => number} options.now Epoch milliseconds. + * @param {(fn: Function, ms: number) => *} options.setTimer setTimeout. + * @param {(handle: *) => void} options.clearTimer clearTimeout. + * @param {(job: object) => void} options.dispatch Runs one due job. + * @param {(job: object, err: Error) => void} [options.onError] Called when a job throws. + * @param {object} [options.limits] Budgets, see SCHEDULER_DEFAULTS. + * @returns {object} Scheduler. + */ +export function createScheduler({ now, setTimer, clearTimer, dispatch, onError, limits = {} }) { + const cfg = { ...SCHEDULER_DEFAULTS, ...limits }; + const heap = createHeap(); + const jobs = new Map(); + const keys = new Map(); + let sequence = 0; + let nextJobId = 1; + let handle = null; + let armedDueAt = null; + let armGeneration = 0; + let draining = false; + let rearmRequested = false; + let lastWakeAt = null; + let expectedWakeAt = null; + const stats = { + drains: 0, + dispatched: 0, + yields: 0, + errors: 0, + maxDrainMs: 0, + clockJumps: 0, + lastJumpMs: 0, + compactions: 0, + timersSet: 0, + }; + + /** + * Returns the first job that has not been cancelled, discarding + * cancelled jobs from the top of the heap. + * + * @returns {object|undefined} Head job. + */ + function validHead() { + let head = heapPeek(heap); + while (head && jobs.get(head.id) !== head) { + heapPop(heap); + head = heapPeek(heap); + } + return head; + } + + /** + * Rebuilds the heap when cancelled jobs outnumber the configured ratio. + * + * @returns {void} + */ + function maybeCompact() { + const stale = heap.items.length - jobs.size; + if (stale > cfg.compactMinimum && stale > heap.items.length * cfg.compactRatio) { + heapRebuild(heap, [...jobs.values()]); + stats.compactions += 1; + } + } + + /** + * Arms the single timeout for the earliest job. Does nothing while + * draining (the drain re-arms once at the end) or when the timer already + * targets the same deadline. + * + * @param {number} [minDelay=0] Minimum delay, used to yield after a budget. + * @returns {void} + */ + function arm(minDelay = 0) { + if (draining) { + rearmRequested = true; + return; + } + const head = validHead(); + if (!head) { + disarm(); + return; + } + const current = now(); + const target = Math.max(head.dueAt, current + minDelay); + if (handle !== null && armedDueAt === target) { + return; + } + disarm(); + armGeneration += 1; + const generation = armGeneration; + const delay = Math.min(Math.max(target - current, 0), cfg.maxHostDelayMs); + armedDueAt = target; + expectedWakeAt = current + delay; + stats.timersSet += 1; + handle = setTimer(() => wake(generation), delay); + } + + /** + * Clears the owned timeout, if any. + * + * @returns {void} + */ + function disarm() { + if (handle !== null) { + clearTimer(handle); + } + handle = null; + armedDueAt = null; + expectedWakeAt = null; + } + + /** + * Notes clock jumps: a wake far from when it was expected means the clock + * moved or the sandbox was suspended. The two cannot be told apart. + * + * @param {number} current Current time. + * @returns {void} + */ + function noteClock(current) { + if (expectedWakeAt !== null) { + const drift = current - expectedWakeAt; + if (Math.abs(drift) > 5000) { + stats.clockJumps += 1; + stats.lastJumpMs = drift; + } + } + lastWakeAt = current; + } + + /** + * Timer callback: runs due jobs within the job-count and time budgets. + * Stale generations (callbacks queued before a clear) do nothing. + * + * @param {number} generation Arm generation captured when the timer was set. + * @returns {void} + */ + function wake(generation) { + if (generation !== armGeneration || draining) { + return; + } + handle = null; + armedDueAt = null; + const started = now(); + noteClock(started); + expectedWakeAt = null; + draining = true; + rearmRequested = false; + let count = 0; + let yieldNeeded = false; + stats.drains += 1; + try { + for (;;) { + const head = validHead(); + // A clamped long wait wakes early: never run future work. + if (!head || head.dueAt > now()) { + break; + } + if (count >= cfg.maxJobsPerDrain || now() - started >= cfg.maxDrainMs) { + yieldNeeded = true; + stats.yields += 1; + break; + } + heapPop(heap); + jobs.delete(head.id); + if (head.key && keys.get(head.key) === head.id) { + keys.delete(head.key); + } + count += 1; + stats.dispatched += 1; + try { + dispatch(head); + } catch (err) { + stats.errors += 1; + onError?.(head, err); + } + } + } finally { + draining = false; + stats.maxDrainMs = Math.max(stats.maxDrainMs, now() - started); + maybeCompact(); + arm(yieldNeeded ? cfg.yieldMs : 0); + } + } + + return { + /** + * Adds a job. A job with the same key replaces the earlier one. + * + * @param {{kind: string, dueAt: number, priority?: number, key?: string, target?: object}} spec Job. + * @returns {number} Job ID. + */ + add(spec) { + if (!Number.isFinite(spec.dueAt)) { + throw new Error(`Job ${spec.kind} has no finite deadline`); + } + if (spec.key) { + this.cancelKey(spec.key); + } + const job = { + id: nextJobId++, + kind: spec.kind, + dueAt: spec.dueAt, + priority: spec.priority ?? JOB_PRIORITY[spec.kind] ?? 50, + sequence: sequence++, + key: spec.key || null, + target: spec.target || {}, + }; + jobs.set(job.id, job); + if (job.key) { + keys.set(job.key, job.id); + } + heapPush(heap, job); + const head = validHead(); + if (head === job || handle === null) { + arm(); + } + return job.id; + }, + /** + * Cancels a job by ID. The heap entry is discarded lazily. + * + * @param {number} jobId Job ID. + * @returns {boolean} True when a job was cancelled. + */ + cancel(jobId) { + const job = jobs.get(jobId); + if (!job) { + return false; + } + const wasHead = heapPeek(heap) === job; + jobs.delete(jobId); + if (job.key && keys.get(job.key) === jobId) { + keys.delete(job.key); + } + if (wasHead) { + arm(); + } + maybeCompact(); + return true; + }, + /** + * Cancels the job registered under a key. + * + * @param {string} key Key. + * @returns {boolean} True when a job was cancelled. + */ + cancelKey(key) { + const id = keys.get(key); + return id === undefined ? false : this.cancel(id); + }, + /** + * Cancels every job matching a predicate. + * + * @param {(job: object) => boolean} predicate Test. + * @returns {number} Jobs cancelled. + */ + cancelWhere(predicate) { + let count = 0; + for (const job of [...jobs.values()]) { + if (predicate(job)) { + this.cancel(job.id); + count += 1; + } + } + return count; + }, + /** + * Returns the job registered under a key. + * + * @param {string} key Key. + * @returns {object|undefined} Job. + */ + getKey(key) { + const id = keys.get(key); + return id === undefined ? undefined : jobs.get(id); + }, + /** + * Returns the earliest deadline, or null when idle. + * + * @returns {number|null} Epoch milliseconds. + */ + nextDueAt() { + return validHead()?.dueAt ?? null; + }, + /** + * Returns the number of live jobs. + * + * @returns {number} Count. + */ + size() { + return jobs.size; + }, + /** + * Returns live jobs, earliest first (for diagnostics). + * + * @param {number} [limit=20] Maximum jobs. + * @returns {object[]} Jobs. + */ + peekJobs(limit = 20) { + return [...jobs.values()] + .sort((a, b) => a.dueAt - b.dueAt || a.priority - b.priority || a.sequence - b.sequence) + .slice(0, limit); + }, + /** + * Re-arms the timer, e.g. after the clock is known to have moved. + * + * @returns {void} + */ + rearm() { + disarm(); + arm(); + }, + /** + * Drops every job and the timer (used when the Mod halts). + * + * @returns {void} + */ + clear() { + jobs.clear(); + keys.clear(); + heap.items = []; + disarm(); + }, + /** + * Returns counters for diagnostics. + * + * @returns {object} Stats. + */ + stats() { + return { + ...stats, + jobs: jobs.size, + heap: heap.items.length, + stale: heap.items.length - jobs.size, + nextDueAt: validHead()?.dueAt ?? null, + armed: handle !== null, + armedDueAt, + lastWakeAt, + }; + }, + }; +} diff --git a/TaskAutomation/src/schedules.js b/TaskAutomation/src/schedules.js new file mode 100644 index 0000000000..61f9cf08cf --- /dev/null +++ b/TaskAutomation/src/schedules.js @@ -0,0 +1,858 @@ +// Time schedules (plan §5, §7): one record per time trigger or command-made +// schedule, a persisted cursor and revision, duplicate-safe occurrence keys, +// fixed-rate and fixed-delay intervals, and the overdue policies used after +// downtime or a paused schedule. + +import { audit, createIncident, resolveIncident } from './audit.js'; +import { JOB } from './constants.js'; +import { TIME_TRIGGER_TYPES } from './catalogue.js'; +import { getRecord, getSnapshot } from './definitions.js'; +import { admitRun } from './executor.js'; +import { now } from './host.js'; +import { resolveBindings } from './objects.js'; +import { + countBetween, + listFrom, + nextAfter, + nextAtOrAfter, + normaliseSpec, + parseClockTime, +} from './recurrence.js'; +import { maybeCall, onJob, runtime, schedule } from './runtime.js'; +import { durationValue } from './schema.js'; +import { getConfig, getLimits, getState, nextId } from './state.js'; +import { clone, parseIsoInstant, parseUtcOffset } from './utils.js'; + +/** + * Builds a normalised recurrence spec from a definition trigger. + * + * @param {object} trigger Time trigger. + * @param {number} at Current time (default anchor). + * @returns {{ok: boolean, spec?: object, error?: string}} Result. + */ +export function specFromTrigger(trigger, at) { + const raw = {}; + if (trigger.start) { + raw.startAt = parseIsoInstant(trigger.start); + } + if (trigger.end) { + raw.endAt = parseIsoInstant(trigger.end); + } + if (trigger.maxCount) { + raw.maxCount = trigger.maxCount; + } + switch (trigger.type) { + case 'at': + return normaliseSpec({ kind: 'once', at: parseIsoInstant(trigger.at) }, getLimits()); + case 'interval': + return normaliseSpec( + { + ...raw, + kind: 'interval', + everyMs: durationValue(trigger.every), + anchor: trigger.anchor ? parseIsoInstant(trigger.anchor) : at, + mode: trigger.mode || 'fixedRate', + }, + getLimits() + ); + case 'daily': + case 'weekly': + return normaliseSpec( + { + ...raw, + kind: trigger.type, + timeMs: parseClockTime(trigger.time), + offsetMinutes: parseUtcOffset(trigger.offset), + days: trigger.days, + }, + getLimits() + ); + default: + return { ok: false, error: 'badKind' }; + } +} + +/** + * Returns the default overdue policy for a schedule (plan §7.2): one-off + * times ask the GM when missed by more than the late window; recurring + * schedules skip and summarise. + * + * @param {object} spec Recurrence spec. + * @returns {object} Overdue policy. + */ +function defaultOverdue(spec) { + return { mode: spec.kind === 'once' ? 'review' : 'skip' }; +} + +/** + * Returns the effective overdue policy of a schedule. + * + * @param {object} sched Schedule. + * @returns {{mode: string, graceMs: number, windowMs: number, catchUp: object}} Policy. + */ +function overdueOf(sched) { + const def = getSnapshot(sched.definitionId); + const policy = { + ...defaultOverdue(sched.spec), + ...(def?.policy?.overdue || {}), + ...(sched.overdue || {}), + }; + return { + mode: policy.mode, + graceMs: policy.graceMs ?? getConfig().graceMs, + maxAgeMs: policy.maxAgeMs ?? null, + windowMs: policy.windowMs ?? getConfig().lateWindowMs, + catchUp: { + max: getLimits().catchUpPerGroup, + maxAgeMs: 24 * 3600 * 1000, + spacingMs: 1000, + ...(policy.catchUp || {}), + }, + }; +} + +/** + * Creates a schedule record and arms its first occurrence. + * + * @param {object} options Schedule options. + * @param {string} options.definitionId Definition ID. + * @param {object} options.spec Normalised recurrence spec. + * @param {string|null} [options.triggerId] Definition trigger it comes from. + * @param {object} [options.overdue] Overdue policy override. + * @param {object} [options.bindings] Static target IDs per binding. + * @param {object} [options.params] Parameters. + * @param {string|null} [options.by] Player ID. + * @returns {object} Schedule. + */ +export function addSchedule({ + definitionId, + spec, + triggerId = null, + overdue = null, + bindings = {}, + params = {}, + by = null, +}) { + const s = getState(); + const at = now(); + const sched = { + id: nextId('trigger'), + definitionId, + triggerId, + source: triggerId ? 'definition' : 'command', + spec, + revision: 1, + rev: 1, + paused: false, + pausedAt: null, + ended: false, + overdue, + bindings: clone(bindings), + params: clone(params), + cursor: { nextDueAt: null, lastDueAt: null, count: 0, waitingRun: null }, + createdAt: at, + createdBy: by, + }; + sched.cursor.nextDueAt = nextAtOrAfter(spec, at); + if (sched.cursor.nextDueAt === null) { + sched.ended = true; + } + s.schedules[sched.id] = sched; + armSchedule(sched); + audit('schedule.add', { schedule: sched.id, definition: definitionId, by }); + maybeCall('dashboardDirty', 'definitions'); + return sched; +} + +/** + * Arms the scheduler job for a schedule's next occurrence. + * + * @param {object} sched Schedule. + * @returns {void} + */ +export function armSchedule(sched) { + runtime.scheduler?.cancelKey(`sched:${sched.id}`); + if (sched.paused || sched.ended || sched.cursor.nextDueAt === null || sched.cursor.waitingRun) { + return; + } + schedule({ + kind: JOB.OCCURRENCE, + dueAt: sched.cursor.nextDueAt, + key: `sched:${sched.id}`, + target: { scheduleId: sched.id, revision: sched.revision, dueAt: sched.cursor.nextDueAt }, + }); +} + +/** + * Keeps definition time triggers and schedule records in step after a + * publish, enable or disable. Changed specs get a new revision, which + * invalidates occurrences queued for the old one. + * + * @param {string} defId Definition ID. + * @returns {void} + */ +export function syncDefinitionSchedules(defId) { + const s = getState(); + const record = getRecord(defId); + const def = record ? getSnapshot(defId) : null; + const wanted = new Map(); + if (def && record.enabled) { + for (const trigger of def.triggers || []) { + if (TIME_TRIGGER_TYPES.has(trigger.type) && trigger.enabled !== false) { + wanted.set(trigger.id, trigger); + } + } + } + const existing = Object.values(s.schedules).filter( + (sc) => sc.definitionId === defId && sc.triggerId + ); + for (const sched of existing) { + const trigger = wanted.get(sched.triggerId); + if (!trigger) { + removeSchedule(sched.id, null); + continue; + } + wanted.delete(sched.triggerId); + const built = specFromTrigger(trigger, sched.spec.anchor ?? now()); + if (built.ok && JSON.stringify(built.spec) !== JSON.stringify(sched.spec)) { + sched.spec = built.spec; + sched.revision += 1; + sched.rev += 1; + sched.cursor.nextDueAt = nextAtOrAfter(sched.spec, now()); + sched.ended = sched.cursor.nextDueAt === null; + armSchedule(sched); + } + sched.overdue = trigger.overdue || null; + } + for (const trigger of wanted.values()) { + const built = specFromTrigger(trigger, now()); + if (built.ok) { + addSchedule({ + definitionId: defId, + spec: built.spec, + triggerId: trigger.id, + overdue: trigger.overdue || null, + bindings: staticBindings(def, trigger), + }); + } + } + for (const sched of Object.values(s.schedules).filter( + (sc) => sc.definitionId === defId && !sc.triggerId + )) { + if (!record?.enabled) { + runtime.scheduler?.cancelKey(`sched:${sched.id}`); + } else { + armSchedule(sched); + } + } +} + +/** + * Returns explicit IDs a time trigger binds. + * + * @param {object} def Definition. + * @param {object} trigger Trigger. + * @returns {object} Bindings. + */ +function staticBindings(def, trigger) { + if (trigger.bind?.binding && Array.isArray(trigger.bind.ids)) { + return { [trigger.bind.binding]: trigger.bind.ids }; + } + return {}; +} + +/** + * Removes a schedule. + * + * @param {string} id Schedule ID. + * @param {string|null} by Player ID. + * @returns {boolean} True when removed. + */ +export function removeSchedule(id, by) { + const s = getState(); + if (!s.schedules[id]) { + return false; + } + runtime.scheduler?.cancelKey(`sched:${id}`); + delete s.schedules[id]; + audit('schedule.remove', { schedule: id, by }); + maybeCall('dashboardDirty', 'definitions'); + return true; +} + +/** + * Handles a schedule occurrence job. + * + * @param {object} job Job. + * @returns {void} + */ +function onOccurrence(job) { + const s = getState(); + const sched = s.schedules[job.target.scheduleId]; + if (!sched || sched.revision !== job.target.revision || sched.paused || sched.ended) { + return; + } + if (job.target.catchUp !== undefined) { + admitOccurrence(sched, job.target.catchUp, { missedCount: 0, catchUp: true }); + return; + } + const due = sched.cursor.nextDueAt; + if (due === null || due !== job.target.dueAt) { + return; + } + const at = now(); + if (due > at) { + armSchedule(sched); + return; + } + const policy = overdueOf(sched); + if (at - due <= policy.graceMs) { + advanceCursor(sched, due); + admitOccurrence(sched, due, { missedCount: 0 }); + armSchedule(sched); + return; + } + handleOverdue(sched, 'late'); +} + +/** + * Moves a schedule's cursor past an occurrence. + * + * @param {object} sched Schedule. + * @param {number} due Occurrence handled. + * @param {number} [counted=1] Occurrences it accounts for. + * @returns {void} + */ +function advanceCursor(sched, due, counted = 1) { + sched.cursor.lastDueAt = due; + sched.cursor.count += counted; + if (sched.spec.maxCount && sched.cursor.count >= sched.spec.maxCount) { + sched.ended = true; + sched.cursor.nextDueAt = null; + return; + } + if (sched.spec.kind === 'interval' && sched.spec.mode === 'fixedDelay') { + // The next time is set when this run finishes. + sched.cursor.nextDueAt = null; + return; + } + sched.cursor.nextDueAt = sched.spec.kind === 'once' ? null : nextAfter(sched.spec, due); + if (sched.cursor.nextDueAt === null) { + sched.ended = true; + } +} + +/** + * Admits the run for one occurrence. The key (schedule, revision, intended + * time) prevents the same occurrence being admitted twice. + * + * @param {object} sched Schedule. + * @param {number} due Intended time. + * @param {{missedCount: number, catchUp?: boolean, recovery?: string}} info Occurrence details. + * @returns {object} Admission result. + */ +export function admitOccurrence(sched, due, info) { + const key = `${sched.id}:${sched.revision}:${due}`; + const ledger = sched.cursor.admitted || (sched.cursor.admitted = []); + if (ledger.includes(key)) { + return { ok: false, status: 'duplicate' }; + } + ledger.push(key); + if (ledger.length > 20) { + ledger.splice(0, ledger.length - 20); + } + const def = getSnapshot(sched.definitionId); + if (!def) { + return { ok: false, status: 'rejected', reason: 'definitionNotFound' }; + } + const resolved = resolveBindings(def, { + explicit: sched.bindings, + params: sched.params, + unattended: true, + }); + if (!resolved.ok) { + createIncident({ + kind: 'missed', + cause: 'bindingFailed', + scheduleId: sched.id, + definitionId: sched.definitionId, + count: 1, + range: { from: due, to: due }, + evidence: resolved.errors[0]?.code, + }); + return { ok: false, status: 'rejected', reason: 'bindingFailed' }; + } + const result = admitRun(sched.definitionId, { + initiatorId: null, + params: sched.params, + bindings: resolved.bindings, + trigger: { type: 'schedule', id: sched.triggerId || sched.id, scheduleId: sched.id }, + occurrence: { + key, + intendedDueAt: due, + missedCount: info.missedCount, + recovery: info.recovery || null, + }, + }); + if ( + result.ok && + result.instance && + sched.spec.kind === 'interval' && + sched.spec.mode === 'fixedDelay' + ) { + sched.cursor.waitingRun = result.instance.id; + } + if (!result.ok) { + // Concurrency skips are a different reason from downtime (plan §7.2). + createIncident({ + kind: 'missed', + cause: result.status === 'skipped' ? 'concurrency' : result.reason || 'rejected', + scheduleId: sched.id, + definitionId: sched.definitionId, + count: 1, + range: { from: due, to: due }, + status: result.status === 'skipped' ? 'resolved' : 'open', + }); + } + return result; +} + +/** + * Applies the overdue policy to a schedule whose next occurrence has + * passed by more than the grace period. Missed occurrences are counted + * arithmetically; only a bounded number are ever admitted. + * + * @param {object} sched Schedule. + * @param {string} cause late | downtime | pause. + * @returns {{mode: string, missed: number, admitted: number, incident?: object}} Summary. + */ +export function handleOverdue(sched, cause) { + const at = now(); + const due = sched.cursor.nextDueAt; + if (due === null || due > at) { + armSchedule(sched); + return { mode: 'none', missed: 0, admitted: 0 }; + } + const policy = overdueOf(sched); + let missed = countBetween(sched.spec, due, at); + if (sched.spec.maxCount) { + missed = Math.min(missed, sched.spec.maxCount - sched.cursor.count); + } + const latest = sched.spec.kind === 'once' ? due : listLatest(sched.spec, due, at); + const summary = { mode: policy.mode, missed, admitted: 0 }; + const range = { from: due, to: at }; + const lateness = at - latest; + // Within the late window: one representative run, older occurrences summarised. + if (lateness <= policy.windowMs) { + finishOverdue(sched, latest, missed); + admitOccurrence(sched, latest, { missedCount: missed - 1 }); + summary.admitted = 1; + if (missed > 1) { + summary.incident = createIncident({ + kind: 'missed', + cause, + scheduleId: sched.id, + definitionId: sched.definitionId, + count: missed - 1, + range, + policy: 'coalesce', + status: 'resolved', + }); + } + armSchedule(sched); + return summary; + } + switch (policy.mode) { + case 'runOnce': + if (policy.maxAgeMs !== null && lateness > policy.maxAgeMs) { + // Too old to be useful: summarise instead of running. + finishOverdue(sched, latest, missed); + summary.mode = 'skip'; + summary.incident = createIncident({ + kind: 'missed', + cause, + scheduleId: sched.id, + definitionId: sched.definitionId, + count: missed, + range, + policy: 'runOnce:tooOld', + proposal: { mode: 'runOnce', occurrence: latest }, + }); + break; + } + finishOverdue(sched, latest, missed); + admitOccurrence(sched, latest, { missedCount: missed, recovery: 'runOnce' }); + summary.admitted = 1; + summary.incident = createIncident({ + kind: 'missed', + cause, + scheduleId: sched.id, + definitionId: sched.definitionId, + count: missed, + range, + policy: 'runOnce', + status: 'resolved', + }); + break; + case 'catchUpBounded': { + const eligible = listFrom( + sched.spec, + Math.max(due, at - policy.catchUp.maxAgeMs), + Math.min(policy.catchUp.max, getLimits().catchUpPerGroup), + at + ); + finishOverdue(sched, latest, missed); + eligible.forEach((occurrence, i) => { + schedule({ + kind: JOB.OCCURRENCE, + dueAt: at + i * policy.catchUp.spacingMs, + key: `catchup:${sched.id}:${occurrence}`, + target: { scheduleId: sched.id, revision: sched.revision, catchUp: occurrence }, + }); + }); + summary.admitted = eligible.length; + summary.incident = createIncident({ + kind: 'missed', + cause, + scheduleId: sched.id, + definitionId: sched.definitionId, + count: missed, + range, + policy: 'catchUpBounded', + proposal: { caughtUp: eligible.length, skipped: missed - eligible.length }, + status: 'resolved', + }); + break; + } + case 'review': + finishOverdue(sched, latest, missed); + summary.incident = createIncident({ + kind: 'missed', + cause, + scheduleId: sched.id, + definitionId: sched.definitionId, + count: missed, + range, + policy: 'review', + proposal: { mode: 'runOnce', occurrence: latest }, + }); + maybeCall('notifyGm', 'missed', { id: sched.id, count: missed }); + break; + case 'expire': + finishOverdue(sched, latest, missed); + summary.incident = createIncident({ + kind: 'missed', + cause, + scheduleId: sched.id, + definitionId: sched.definitionId, + count: missed, + range, + policy: 'expire', + status: 'resolved', + }); + break; + default: + finishOverdue(sched, latest, missed); + summary.incident = createIncident({ + kind: 'missed', + cause, + scheduleId: sched.id, + definitionId: sched.definitionId, + count: missed, + range, + policy: 'skip', + proposal: { mode: 'runOnce', occurrence: latest }, + }); + break; + } + armSchedule(sched); + return summary; +} + +/** + * Returns the latest occurrence in a range (falls back to the first). + * + * @param {object} spec Recurrence spec. + * @param {number} from Start. + * @param {number} to End. + * @returns {number} Occurrence. + */ +function listLatest(spec, from, to) { + if (spec.kind === 'interval') { + return spec.anchor + Math.floor((to - spec.anchor) / spec.everyMs) * spec.everyMs; + } + let at = nextAtOrAfter(spec, Math.max(from, to - 8 * 24 * 3600 * 1000)); + let latest = from; + while (at !== null && at <= to) { + latest = at; + at = nextAfter(spec, at); + } + return latest; +} + +/** + * Moves the cursor past every missed occurrence to the next future one. + * + * @param {object} sched Schedule. + * @param {number} latest Latest missed occurrence. + * @param {number} missed Missed count. + * @returns {void} + */ +function finishOverdue(sched, latest, missed) { + advanceCursor(sched, latest, Math.max(1, missed)); + if (sched.spec.kind === 'interval' && sched.spec.mode === 'fixedDelay' && !sched.ended) { + sched.cursor.nextDueAt = null; + } else if (!sched.ended && sched.cursor.nextDueAt !== null && sched.cursor.nextDueAt <= now()) { + sched.cursor.nextDueAt = nextAfter(sched.spec, now()); + sched.ended = sched.cursor.nextDueAt === null; + } +} + +/** + * Pauses a schedule: no admissions; the cursor is kept. + * + * @param {string} id Schedule ID. + * @param {string} by Player ID. + * @param {number} [expectedRev] Stale-link guard. + * @returns {{ok: boolean, error?: string, vars?: object}} Result. + */ +export function pauseSchedule(id, by, expectedRev) { + const sched = getState().schedules[id]; + if (!sched) { + return { ok: false, error: 'scheduleNotFound', vars: { id } }; + } + if (expectedRev !== undefined && expectedRev !== null && sched.rev !== expectedRev) { + return { ok: false, error: 'stale', vars: { id, rev: sched.rev } }; + } + sched.paused = true; + sched.pausedAt = now(); + sched.rev += 1; + runtime.scheduler?.cancelKey(`sched:${id}`); + audit('schedule.pause', { schedule: id, by }); + maybeCall('dashboardDirty', 'definitions'); + return { ok: true }; +} + +/** + * Resumes a schedule. `skipPausedRange` (default) jumps to the next future + * time; `catchUp` applies the schedule's overdue policy to the paused range. + * + * @param {string} id Schedule ID. + * @param {string} by Player ID. + * @param {'skipPausedRange'|'catchUp'} mode Resume mode. + * @param {number} [expectedRev] Stale-link guard. + * @returns {{ok: boolean, skipped?: number, summary?: object, error?: string, vars?: object}} Result. + */ +export function resumeSchedule(id, by, mode = 'skipPausedRange', expectedRev) { + const sched = getState().schedules[id]; + if (!sched) { + return { ok: false, error: 'scheduleNotFound', vars: { id } }; + } + if (expectedRev !== undefined && expectedRev !== null && sched.rev !== expectedRev) { + return { ok: false, error: 'stale', vars: { id, rev: sched.rev } }; + } + if (!sched.paused) { + return { ok: false, error: 'notPaused', vars: { id } }; + } + sched.paused = false; + sched.rev += 1; + const at = now(); + audit('schedule.resume', { schedule: id, by, mode }); + if (mode === 'catchUp') { + const summary = handleOverdue(sched, 'pause'); + return { ok: true, summary }; + } + let skipped = 0; + if (sched.cursor.nextDueAt !== null && sched.cursor.nextDueAt < at) { + // Times strictly before now were passed while paused; a time due now still runs. + skipped = countBetween(sched.spec, sched.cursor.nextDueAt, at - 1); + sched.cursor.count += skipped; + sched.cursor.nextDueAt = nextAtOrAfter(sched.spec, at); + sched.ended = + sched.cursor.nextDueAt === null || + Boolean(sched.spec.maxCount && sched.cursor.count >= sched.spec.maxCount); + if (skipped) { + createIncident({ + kind: 'missed', + cause: 'pause', + scheduleId: id, + definitionId: sched.definitionId, + count: skipped, + range: { from: sched.pausedAt, to: at }, + policy: 'skipPausedRange', + status: 'resolved', + }); + } + } + sched.pausedAt = null; + armSchedule(sched); + maybeCall('dashboardDirty', 'definitions'); + return { ok: true, skipped }; +} + +/** + * Previews a new UTC offset for a daily or weekly schedule (for daylight + * saving changes): the next occurrences before and after. + * + * @param {string} id Schedule ID. + * @param {number} offsetMinutes New offset. + * @returns {{ok: boolean, before?: number[], after?: number[], error?: string, vars?: object}} Preview. + */ +export function previewOffset(id, offsetMinutes) { + const sched = getState().schedules[id]; + if (!sched) { + return { ok: false, error: 'scheduleNotFound', vars: { id } }; + } + if (sched.spec.kind !== 'daily' && sched.spec.kind !== 'weekly') { + return { ok: false, error: 'notCalendar', vars: { id } }; + } + const at = now(); + return { + ok: true, + before: listFrom(sched.spec, at, 3), + after: listFrom({ ...sched.spec, offsetMinutes }, at, 3), + }; +} + +/** + * Applies a new UTC offset to a calendar schedule. + * + * @param {string} id Schedule ID. + * @param {number} offsetMinutes New offset. + * @param {string} by Player ID. + * @returns {{ok: boolean, error?: string, vars?: object}} Result. + */ +export function applyOffset(id, offsetMinutes, by) { + const preview = previewOffset(id, offsetMinutes); + if (!preview.ok) { + return preview; + } + const sched = getState().schedules[id]; + sched.spec = { ...sched.spec, offsetMinutes }; + sched.revision += 1; + sched.rev += 1; + sched.cursor.nextDueAt = nextAtOrAfter(sched.spec, now()); + armSchedule(sched); + audit('schedule.offset', { schedule: id, by, offset: offsetMinutes }); + maybeCall('dashboardDirty', 'definitions'); + return { ok: true }; +} + +/** + * Applies a reviewed recovery to a missed-work incident. + * + * @param {object} incident Incident. + * @param {'runOnce'|'skip'} mode Recovery. + * @param {string} by GM player ID. + * @returns {{ok: boolean, run?: string, error?: string, vars?: object}} Result. + */ +export function applyRecovery(incident, mode, by) { + if (mode === 'skip') { + resolveIncident(incident.id, 'skip', by); + return { ok: true }; + } + const sched = getState().schedules[incident.scheduleId]; + const defId = incident.definitionId; + const occurrence = incident.proposal?.occurrence ?? incident.range?.to ?? now(); + let result; + if (sched) { + result = admitOccurrence(sched, occurrence, { + missedCount: incident.count, + recovery: 'review', + }); + } else { + result = admitRun(defId, { + initiatorId: null, + trigger: { type: 'recovery', id: incident.id }, + occurrence: { + key: `recovery:${incident.id}`, + intendedDueAt: occurrence, + missedCount: incident.count, + }, + }); + } + if (!result.ok) { + return { + ok: false, + error: 'recoveryRejected', + vars: { reason: result.reason || result.status }, + }; + } + resolveIncident(incident.id, 'runOnce', by); + return { ok: true, run: result.instance?.id }; +} + +/** + * Schedules the next fixed-delay occurrence when its run finishes. + * + * @param {object} inst Finished instance. + * @returns {void} + */ +export function onRunTerminal(inst) { + const schedId = inst.trigger?.scheduleId; + const sched = schedId ? getState().schedules[schedId] : null; + if (!sched || sched.cursor.waitingRun !== inst.id) { + return; + } + sched.cursor.waitingRun = null; + if (!sched.ended) { + sched.cursor.nextDueAt = Math.max(now(), inst.completedAt) + sched.spec.everyMs; + if (Number.isFinite(sched.spec.endAt) && sched.cursor.nextDueAt > sched.spec.endAt) { + sched.ended = true; + sched.cursor.nextDueAt = null; + } + } + armSchedule(sched); +} + +/** + * Recovers every schedule after a restart: overdue ones apply their policy + * once; the rest are re-armed. + * + * @returns {{schedules: number, missed: number, admitted: number}} Summary. + */ +export function recoverSchedules() { + const s = getState(); + const totals = { schedules: 0, missed: 0, admitted: 0 }; + const at = now(); + for (const sched of Object.values(s.schedules)) { + totals.schedules += 1; + const record = getRecord(sched.definitionId); + if (!record?.enabled || sched.paused || sched.ended) { + continue; + } + if (sched.cursor.waitingRun && !s.instances[sched.cursor.waitingRun]) { + sched.cursor.waitingRun = null; + sched.cursor.nextDueAt = sched.cursor.nextDueAt ?? at; + } + if (sched.cursor.nextDueAt !== null && sched.cursor.nextDueAt + overdueOf(sched).graceMs < at) { + const summary = handleOverdue(sched, 'downtime'); + totals.missed += summary.missed; + totals.admitted += summary.admitted; + } else { + armSchedule(sched); + } + } + return totals; +} + +/** + * Lists schedules, optionally for one definition. + * + * @param {string} [defId] Definition ID. + * @returns {object[]} Schedules. + */ +export function listSchedules(defId) { + return Object.values(getState().schedules) + .filter((sc) => !defId || sc.definitionId === defId) + .sort((a, b) => (a.cursor.nextDueAt ?? Infinity) - (b.cursor.nextDueAt ?? Infinity)); +} + +/** + * Registers the schedule job handler. + * + * @returns {void} + */ +export function registerScheduleJobs() { + onJob(JOB.OCCURRENCE, onOccurrence); +} diff --git a/TaskAutomation/src/schema.js b/TaskAutomation/src/schema.js new file mode 100644 index 0000000000..c648bd0c14 --- /dev/null +++ b/TaskAutomation/src/schema.js @@ -0,0 +1,1216 @@ +// Runtime validator and compiler for task definitions (plan §12.1). It +// enforces the same rules as schema/task-definition.schema.json plus the +// checks JSON Schema cannot express: unique step IDs, reference roots, loop +// safety, parallel limits and call cycles. Pure: no Roll20 globals. + +import { + DEFAULT_LIMITS, + DEFINITION_SCHEMA_VERSION, + TOKEN_READ_PROPERTIES, + TOKEN_WRITE_PROPERTIES, +} from './constants.js'; +import { + EVENT_TYPES, + STEP_SPECS, + SUSPENDING, + TIME_TRIGGER_TYPES, + TRIGGER_TYPES, + stepFields, +} from './catalogue.js'; +import { templatePaths, validateExpression } from './expressions.js'; +import { parseClockTime, parseWeekdays } from './recurrence.js'; +import { isRecord, parseDuration, parseIsoInstant, parseUtcOffset } from './utils.js'; + +export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/; + +const TOP_FIELDS = [ + 'schemaVersion', + 'id', + 'revision', + 'name', + 'enabled', + 'description', + 'parameters', + 'bindings', + 'access', + 'triggers', + 'policy', + 'steps', + 'tags', +]; +const STEP_ROOTS = new Set(['param', 'var', 'event', 'target', 'run', 'bindings', 'now']); +const EVENT_ROOTS = new Set(['event', 'param', 'now']); +const AWAIT_ROOTS = new Set(['event', 'param', 'var', 'run', 'now']); +const OBJECT_TYPES = ['graphic', 'character', 'player', 'jukeboxtrack', 'page']; +const PARAM_TYPES = ['number', 'string', 'boolean', 'id']; +const CONCURRENCY = ['allow', 'skipIfRunning', 'queue', 'replace']; +const OVERDUE_MODES = ['skip', 'runOnce', 'catchUpBounded', 'review', 'expire']; +const FILTER_FIELDS = [ + 'objectIds', + 'pageIds', + 'characterIds', + 'names', + 'properties', + 'attribute', + 'changedTo', + 'changedFrom', + 'crossing', + 'signal', + 'definitionId', + 'binding', + 'condition', + 'adapter', +]; + +/** + * Creates an error collector. + * + * @returns {{errors: object[], add: Function}} Collector. + */ +function collector() { + const errors = []; + return { + errors, + add(path, code, vars = {}) { + if (errors.length < 50) { + errors.push({ path, code, vars }); + } + }, + }; +} + +/** + * Reports keys that are not in the allowed list. + * + * @param {object} obj Object. + * @param {string[]} allowed Allowed keys. + * @param {string} path Location. + * @param {object} out Collector. + * @returns {void} + */ +function checkKeys(obj, allowed, path, out) { + for (const key of Object.keys(obj)) { + if (!allowed.includes(key)) { + out.add(`${path}.${key}`, 'unknownField', { field: key }); + } + } +} + +/** + * Parses a duration given as milliseconds or text such as `5m`. + * + * @param {*} raw Value. + * @returns {number|null} Milliseconds. + */ +export function durationValue(raw) { + if (typeof raw === 'number') { + return Number.isFinite(raw) && raw >= 0 ? raw : null; + } + return parseDuration(raw); +} + +/** + * Validates parameters. + * + * @param {*} params Parameter declarations. + * @param {object} out Collector. + * @returns {void} + */ +function validateParameters(params, out) { + if (params === undefined) { + return; + } + if (!isRecord(params)) { + out.add('parameters', 'notObject'); + return; + } + for (const [name, spec] of Object.entries(params)) { + const at = `parameters.${name}`; + if (!ID_PATTERN.test(name)) { + out.add(at, 'badId', { id: name }); + } + if (!isRecord(spec)) { + out.add(at, 'notObject'); + continue; + } + checkKeys( + spec, + ['type', 'default', 'min', 'max', 'maxLength', 'enum', 'required', 'description'], + at, + out + ); + if (!PARAM_TYPES.includes(spec.type)) { + out.add(`${at}.type`, 'badEnum', { values: PARAM_TYPES.join(', ') }); + continue; + } + if (spec.default !== undefined) { + const checked = checkParamValue(spec, spec.default); + if (!checked.ok) { + out.add(`${at}.default`, checked.error, checked.vars); + } + } + } +} + +/** + * Checks a parameter value against its declaration. + * + * @param {object} spec Parameter declaration. + * @param {*} value Value. + * @returns {{ok: boolean, value?: *, error?: string, vars?: object}} Result. + */ +export function checkParamValue(spec, value) { + switch (spec.type) { + case 'number': { + const n = typeof value === 'number' ? value : Number.NaN; + if (!Number.isFinite(n)) { + return { ok: false, error: 'notNumber' }; + } + if ((spec.min !== undefined && n < spec.min) || (spec.max !== undefined && n > spec.max)) { + return { + ok: false, + error: 'outOfRange', + vars: { min: spec.min ?? '−∞', max: spec.max ?? '∞' }, + }; + } + return { ok: true, value: n }; + } + case 'boolean': + return typeof value === 'boolean' ? { ok: true, value } : { ok: false, error: 'notBoolean' }; + case 'id': + return typeof value === 'string' && /^-?[A-Za-z0-9_-]{1,64}$/.test(value) + ? { ok: true, value } + : { ok: false, error: 'badId', vars: { id: String(value) } }; + case 'string': { + if (typeof value !== 'string') { + return { ok: false, error: 'notString' }; + } + const max = spec.maxLength ?? 200; + if (value.length > max) { + return { ok: false, error: 'stringTooLong', vars: { max } }; + } + if (Array.isArray(spec.enum) && !spec.enum.includes(value)) { + return { ok: false, error: 'badEnum', vars: { values: spec.enum.join(', ') } }; + } + return { ok: true, value }; + } + default: + return { ok: false, error: 'badEnum', vars: { values: PARAM_TYPES.join(', ') } }; + } +} + +/** + * Validates binding declarations. + * + * @param {*} bindings Bindings. + * @param {object} params Parameters. + * @param {object} out Collector. + * @param {object} limits Limits. + * @returns {void} + */ +function validateBindings(bindings, params, out, limits) { + if (bindings === undefined) { + return; + } + if (!isRecord(bindings)) { + out.add('bindings', 'notObject'); + return; + } + for (const [name, spec] of Object.entries(bindings)) { + const at = `bindings.${name}`; + if (!ID_PATTERN.test(name)) { + out.add(at, 'badId', { id: name }); + } + if (!isRecord(spec)) { + out.add(at, 'notObject'); + continue; + } + checkKeys( + spec, + ['from', 'objectType', 'ids', 'max', 'min', 'param', 'query', 'alternate', 'description'], + at, + out + ); + if (!['selected', 'ids', 'event', 'query', 'param', 'none'].includes(spec.from)) { + out.add(`${at}.from`, 'badEnum', { values: 'selected, ids, event, query, param, none' }); + } + if (!OBJECT_TYPES.includes(spec.objectType)) { + out.add(`${at}.objectType`, 'badEnum', { values: OBJECT_TYPES.join(', ') }); + } + if ( + spec.max !== undefined && + (!Number.isInteger(spec.max) || spec.max < 1 || spec.max > limits.targetsPerChunk * 5) + ) { + out.add(`${at}.max`, 'outOfRange', { min: 1, max: limits.targetsPerChunk * 5 }); + } + if ( + spec.from === 'ids' && + (!Array.isArray(spec.ids) || spec.ids.some((id) => typeof id !== 'string')) + ) { + out.add(`${at}.ids`, 'notIdList'); + } + if (spec.from === 'param' && !(isRecord(params) && params[spec.param])) { + out.add(`${at}.param`, 'unknownParam', { name: String(spec.param) }); + } + if (spec.from === 'query') { + if (!isRecord(spec.query)) { + out.add(`${at}.query`, 'notObject'); + } else { + checkKeys( + spec.query, + ['name', 'pageId', 'represents', 'layer', 'max', 'evaluate', 'marker'], + `${at}.query`, + out + ); + const max = spec.query.max; + if (!Number.isInteger(max) || max < 1 || max > limits.queryResults) { + out.add(`${at}.query.max`, 'outOfRange', { min: 1, max: limits.queryResults }); + } + if (spec.query.evaluate !== undefined && !['start', 'step'].includes(spec.query.evaluate)) { + out.add(`${at}.query.evaluate`, 'badEnum', { values: 'start, step' }); + } + } + } + if (spec.alternate !== undefined) { + if (!isRecord(spec.alternate) || !['ids', 'event', 'none'].includes(spec.alternate.from)) { + out.add(`${at}.alternate`, 'badEnum', { values: 'ids, event, none' }); + } + } + } +} + +/** + * Validates the access policy. + * + * @param {*} access Access. + * @param {object} out Collector. + * @returns {void} + */ +function validateAccess(access, out) { + if (access === undefined) { + return; + } + if (!isRecord(access)) { + out.add('access', 'notObject'); + return; + } + checkKeys( + access, + [ + 'mode', + 'players', + 'controllers', + 'authority', + 'capabilities', + 'selfTrigger', + 'detachChildren', + ], + 'access', + out + ); + if (access.mode !== undefined && !['gmOnly', 'players'].includes(access.mode)) { + out.add('access.mode', 'badEnum', { values: 'gmOnly, players' }); + } + if (access.authority !== undefined && !['initiator', 'gmService'].includes(access.authority)) { + out.add('access.authority', 'badEnum', { values: 'initiator, gmService' }); + } + // A player's run acts with that player's rights, never the GM's. + if (access.mode === 'players' && access.authority === 'gmService') { + out.add('access.authority', 'playersNeedInitiator'); + } + if ( + access.players !== undefined && + (!Array.isArray(access.players) || access.players.some((p) => typeof p !== 'string')) + ) { + out.add('access.players', 'notIdList'); + } + if (access.capabilities !== undefined) { + if (!isRecord(access.capabilities)) { + out.add('access.capabilities', 'notObject'); + } else { + checkKeys( + access.capabilities, + ['actions', 'maxTargets', 'signals'], + 'access.capabilities', + out + ); + const actions = access.capabilities.actions; + if ( + actions !== undefined && + (!Array.isArray(actions) || actions.some((a) => !STEP_SPECS[a])) + ) { + out.add('access.capabilities.actions', 'unknownAction'); + } + } + } +} + +/** + * Validates the run policy. + * + * @param {*} policy Policy. + * @param {object} out Collector. + * @returns {void} + */ +function validatePolicy(policy, out) { + if (policy === undefined) { + return; + } + if (!isRecord(policy)) { + out.add('policy', 'notObject'); + return; + } + checkKeys( + policy, + [ + 'concurrency', + 'maxConcurrent', + 'maxQueue', + 'queueExpiryMs', + 'onError', + 'retry', + 'overdue', + 'restart', + 'timeoutMs', + 'lifetimeMs', + 'allowForever', + 'recursion', + 'circuit', + 'quiet', + ], + 'policy', + out + ); + if (policy.concurrency !== undefined && !CONCURRENCY.includes(policy.concurrency)) { + out.add('policy.concurrency', 'badEnum', { values: CONCURRENCY.join(', ') }); + } + if ( + policy.onError !== undefined && + !['fail', 'skip', 'retry', 'review'].includes(policy.onError) + ) { + out.add('policy.onError', 'badEnum', { values: 'fail, skip, retry, review' }); + } + if (policy.restart !== undefined && !['reconcile', 'review', 'fail'].includes(policy.restart)) { + out.add('policy.restart', 'badEnum', { values: 'reconcile, review, fail' }); + } + for (const key of ['maxConcurrent', 'maxQueue']) { + if (policy[key] !== undefined && (!Number.isInteger(policy[key]) || policy[key] < 1)) { + out.add(`policy.${key}`, 'outOfRange', { min: 1, max: '∞' }); + } + } + if (policy.retry !== undefined) { + validateRetry(policy.retry, 'policy.retry', out); + } + if (policy.overdue !== undefined) { + validateOverdue(policy.overdue, 'policy.overdue', out); + } + if (policy.recursion !== undefined) { + if (!isRecord(policy.recursion)) { + out.add('policy.recursion', 'notObject'); + } else { + checkKeys(policy.recursion, ['allow', 'maxDepth'], 'policy.recursion', out); + } + } +} + +/** + * Validates a retry policy. + * + * @param {*} retry Retry policy. + * @param {string} at Location. + * @param {object} out Collector. + * @returns {void} + */ +function validateRetry(retry, at, out) { + if (!isRecord(retry)) { + out.add(at, 'notObject'); + return; + } + checkKeys(retry, ['maxAttempts', 'baseDelayMs', 'maxDelayMs', 'jitterMs'], at, out); + if (!Number.isInteger(retry.maxAttempts) || retry.maxAttempts < 1 || retry.maxAttempts > 10) { + out.add(`${at}.maxAttempts`, 'outOfRange', { min: 1, max: 10 }); + } + for (const key of ['baseDelayMs', 'maxDelayMs', 'jitterMs']) { + if (retry[key] !== undefined && (!Number.isFinite(retry[key]) || retry[key] < 0)) { + out.add(`${at}.${key}`, 'outOfRange', { min: 0, max: '∞' }); + } + } +} + +/** + * Validates an overdue policy. + * + * @param {*} overdue Overdue policy. + * @param {string} at Location. + * @param {object} out Collector. + * @returns {void} + */ +function validateOverdue(overdue, at, out) { + if (!isRecord(overdue)) { + out.add(at, 'notObject'); + return; + } + checkKeys(overdue, ['mode', 'graceMs', 'windowMs', 'maxAgeMs', 'catchUp'], at, out); + if (overdue.mode !== undefined && !OVERDUE_MODES.includes(overdue.mode)) { + out.add(`${at}.mode`, 'badEnum', { values: OVERDUE_MODES.join(', ') }); + } + if (overdue.catchUp !== undefined) { + if (!isRecord(overdue.catchUp)) { + out.add(`${at}.catchUp`, 'notObject'); + } else { + checkKeys(overdue.catchUp, ['max', 'maxAgeMs', 'spacingMs'], `${at}.catchUp`, out); + } + } +} + +/** + * Validates an event filter. + * + * @param {*} filter Filter. + * @param {string} at Location. + * @param {Set} roots Allowed expression roots for `condition`. + * @param {object} out Collector. + * @param {object} limits Limits. + * @returns {void} + */ +function validateFilter(filter, at, roots, out, limits) { + if (filter === undefined) { + return; + } + if (!isRecord(filter)) { + out.add(at, 'notObject'); + return; + } + checkKeys(filter, FILTER_FIELDS, at, out); + for (const key of ['objectIds', 'pageIds', 'characterIds', 'names', 'properties']) { + if ( + filter[key] !== undefined && + (!Array.isArray(filter[key]) || filter[key].some((v) => typeof v !== 'string')) + ) { + out.add(`${at}.${key}`, 'notIdList'); + } + } + if (Array.isArray(filter.properties)) { + const allowed = [...TOKEN_READ_PROPERTIES, 'current', 'max']; + for (const prop of filter.properties) { + if (!allowed.includes(prop)) { + out.add(`${at}.properties`, 'badProperty', { property: prop }); + } + } + } + if (filter.crossing !== undefined) { + const c = filter.crossing; + if ( + !isRecord(c) || + typeof c.property !== 'string' || + !['below', 'above'].includes(c.direction) || + !Number.isFinite(c.threshold) + ) { + out.add(`${at}.crossing`, 'badCrossing'); + } + } + for (const key of ['changedTo', 'changedFrom']) { + if (filter[key] !== undefined && !isRecord(filter[key])) { + out.add(`${at}.${key}`, 'notObject'); + } + } + if (filter.condition !== undefined) { + for (const e of validateExpression(filter.condition, { + roots, + path: `${at}.condition`, + limits, + })) { + out.add(e.path, e.code, e.vars); + } + } +} + +/** + * Validates triggers. + * + * @param {*} triggers Triggers. + * @param {object} out Collector. + * @param {object} limits Limits. + * @returns {void} + */ +function validateTriggers(triggers, out, limits) { + if (triggers === undefined) { + return; + } + if (!Array.isArray(triggers)) { + out.add('triggers', 'notArray'); + return; + } + const ids = new Set(); + triggers.forEach((trigger, i) => { + const at = `triggers.${isRecord(trigger) && trigger.id ? trigger.id : i}`; + if (!isRecord(trigger)) { + out.add(at, 'notObject'); + return; + } + if (!ID_PATTERN.test(String(trigger.id ?? ''))) { + out.add(`${at}.id`, 'badId', { id: String(trigger.id) }); + } else if (ids.has(trigger.id)) { + out.add(`${at}.id`, 'duplicateId', { id: trigger.id }); + } + ids.add(trigger.id); + if (!TRIGGER_TYPES.includes(trigger.type)) { + out.add(`${at}.type`, 'badEnum', { values: TRIGGER_TYPES.join(', ') }); + return; + } + const common = ['id', 'type', 'enabled', 'bind', 'description']; + const time = ['start', 'end', 'maxCount', 'overdue']; + const rate = ['cooldownMs', 'debounceMs', 'leading', 'burst', 'coalesce', 'selfTrigger']; + switch (trigger.type) { + case 'manual': + checkKeys(trigger, common, at, out); + break; + case 'at': + checkKeys(trigger, [...common, 'at', 'overdue'], at, out); + if (parseIsoInstant(trigger.at) === null) { + out.add(`${at}.at`, 'badInstant'); + } + break; + case 'interval': { + checkKeys(trigger, [...common, ...time, 'every', 'anchor', 'mode'], at, out); + const every = durationValue(trigger.every); + if (every === null || every < limits.minIntervalMs) { + out.add(`${at}.every`, 'intervalTooShort', { min: limits.minIntervalMs }); + } + if (trigger.anchor !== undefined && parseIsoInstant(trigger.anchor) === null) { + out.add(`${at}.anchor`, 'badInstant'); + } + if (trigger.mode !== undefined && !['fixedRate', 'fixedDelay'].includes(trigger.mode)) { + out.add(`${at}.mode`, 'badEnum', { values: 'fixedRate, fixedDelay' }); + } + break; + } + case 'daily': + case 'weekly': + checkKeys(trigger, [...common, ...time, 'time', 'offset', 'days'], at, out); + if (parseClockTime(trigger.time) === null) { + out.add(`${at}.time`, 'badClock'); + } + if (parseUtcOffset(trigger.offset) === null) { + out.add(`${at}.offset`, 'badOffset'); + } + if (trigger.type === 'weekly' && !parseWeekdays(trigger.days)) { + out.add(`${at}.days`, 'badDays'); + } + break; + case 'event': + checkKeys(trigger, [...common, ...rate, 'event', 'filter', 'reconcile'], at, out); + if (!EVENT_TYPES.includes(trigger.event)) { + out.add(`${at}.event`, 'badEnum', { values: EVENT_TYPES.join(', ') }); + } + validateFilter(trigger.filter, `${at}.filter`, EVENT_ROOTS, out, limits); + break; + case 'signal': + checkKeys(trigger, [...common, ...rate, 'name', 'players'], at, out); + if (!ID_PATTERN.test(String(trigger.name ?? ''))) { + out.add(`${at}.name`, 'badId', { id: String(trigger.name) }); + } + break; + case 'chat': + checkKeys(trigger, [...common, ...rate, 'match', 'channels', 'private'], at, out); + if ( + !isRecord(trigger.match) || + !['equals', 'contains', 'startsWith'].some((k) => typeof trigger.match[k] === 'string') + ) { + out.add(`${at}.match`, 'badMatch'); + } + break; + default: + break; + } + if (TIME_TRIGGER_TYPES.has(trigger.type)) { + for (const key of ['start', 'end']) { + if (trigger[key] !== undefined && parseIsoInstant(trigger[key]) === null) { + out.add(`${at}.${key}`, 'badInstant'); + } + } + if (trigger.overdue !== undefined) { + validateOverdue(trigger.overdue, `${at}.overdue`, out); + } + } + for (const key of ['cooldownMs', 'debounceMs']) { + if (trigger[key] !== undefined && (!Number.isFinite(trigger[key]) || trigger[key] < 0)) { + out.add(`${at}.${key}`, 'outOfRange', { min: 0, max: '∞' }); + } + } + if ( + trigger.burst !== undefined && + !( + isRecord(trigger.burst) && + Number.isInteger(trigger.burst.count) && + Number.isFinite(trigger.burst.windowMs) + ) + ) { + out.add(`${at}.burst`, 'badBurst'); + } + if (trigger.bind !== undefined && !isRecord(trigger.bind)) { + out.add(`${at}.bind`, 'notObject'); + } + }); +} + +/** + * Validates one field of a step. + * + * @param {object} spec Field spec. + * @param {*} value Value. + * @param {string} at Location. + * @param {object} ctx Validation context. + * @returns {void} + */ +function validateField(spec, value, at, ctx) { + const { out, limits } = ctx; + switch (spec.kind) { + case 'expr': + for (const e of validateExpression(value, { roots: STEP_ROOTS, path: at, limits })) { + out.add(e.path, e.code, e.vars); + } + break; + case 'template': + if (typeof value !== 'string' || value.length > limits.stringLength) { + out.add(at, 'badTemplate', { max: limits.stringLength }); + break; + } + for (const path of templatePaths(value)) { + const root = path.split('.')[0]; + if (!STEP_ROOTS.has(root) && !(root === 'args' && ctx.stepType === 'integration.command')) { + out.add(at, 'refRoot', { root: path.split('.')[0], allowed: [...STEP_ROOTS].join(', ') }); + } + } + break; + case 'string': + if (typeof value !== 'string' || value.length > limits.stringLength) { + out.add(at, 'notString'); + } + break; + case 'id': + if (typeof value !== 'string' || !ID_PATTERN.test(value)) { + out.add(at, 'badId', { id: String(value) }); + } + break; + case 'number': + if (!Number.isFinite(value)) { + out.add(at, 'notNumber'); + } + break; + case 'integer': + if (!Number.isInteger(value) || (spec.min !== undefined && value < spec.min)) { + out.add(at, 'outOfRange', { min: spec.min ?? '−∞', max: '∞' }); + } + break; + case 'boolean': + if (typeof value !== 'boolean') { + out.add(at, 'notBoolean'); + } + break; + case 'enum': + if (!spec.values.includes(value)) { + out.add(at, 'badEnum', { values: spec.values.join(', ') }); + } + break; + case 'target': + validateTarget(value, at, ctx); + break; + case 'recipients': + if (!isRecord(value)) { + out.add(at, 'notObject'); + } else { + checkKeys(value, ['gm', 'players', 'binding', 'controllers', 'initiator'], at, out); + if (value.binding !== undefined && !ctx.bindings[value.binding]) { + out.add(at, 'unknownBinding', { name: String(value.binding) }); + } + if (value.controllers !== undefined && !ctx.bindings[value.controllers]) { + out.add(at, 'unknownBinding', { name: String(value.controllers) }); + } + } + break; + case 'point': + if (!isRecord(value)) { + out.add(at, 'notObject'); + } else { + checkKeys(value, ['left', 'top', 'page'], at, out); + for (const key of ['left', 'top', 'page']) { + if (value[key] === undefined) { + out.add(at, 'missingField', { field: key }); + } else { + validateField({ kind: 'expr' }, value[key], `${at}.${key}`, ctx); + } + } + } + break; + case 'exprMap': + if (!isRecord(value)) { + out.add(at, 'notObject'); + } else { + for (const [key, expr] of Object.entries(value)) { + validateField({ kind: 'expr' }, expr, `${at}.${key}`, ctx); + } + } + break; + case 'steps': + validateBlock(value, at, ctx); + break; + default: + break; + } +} + +/** + * Validates a step target. + * + * @param {*} target Target. + * @param {string} at Location. + * @param {object} ctx Context. + * @returns {void} + */ +function validateTarget(target, at, ctx) { + if (!isRecord(target)) { + ctx.out.add(at, 'notObject'); + return; + } + const keys = Object.keys(target); + if (keys.length !== 1 || !['binding', 'ids', 'ref'].includes(keys[0])) { + ctx.out.add(at, 'badTarget'); + return; + } + if ('binding' in target && !ctx.bindings[target.binding]) { + ctx.out.add(at, 'unknownBinding', { name: String(target.binding) }); + } + if ( + 'ids' in target && + (!Array.isArray(target.ids) || target.ids.some((id) => typeof id !== 'string')) + ) { + ctx.out.add(at, 'notIdList'); + } + if ('ref' in target) { + validateField({ kind: 'expr' }, { ref: target.ref }, at, ctx); + } +} + +/** + * Validates a block of steps. + * + * @param {*} steps Steps. + * @param {string} at Location. + * @param {object} ctx Context. + * @returns {void} + */ +function validateBlock(steps, at, ctx) { + if (!Array.isArray(steps)) { + ctx.out.add(at, 'notArray'); + return; + } + ctx.depth += 1; + if (ctx.depth > ctx.limits.nestingDepth) { + ctx.out.add(at, 'tooDeep', { max: ctx.limits.nestingDepth }); + } + for (const step of steps) { + validateStep(step, ctx); + } + ctx.depth -= 1; +} + +/** + * Validates one step and its nested blocks. + * + * @param {*} step Step. + * @param {object} ctx Context. + * @returns {void} + */ +function validateStep(step, ctx) { + const { out, limits } = ctx; + ctx.count += 1; + if (ctx.count > limits.stepsPerDefinition) { + if (ctx.count === limits.stepsPerDefinition + 1) { + out.add('steps', 'tooManySteps', { max: limits.stepsPerDefinition }); + } + return; + } + if (!isRecord(step)) { + out.add('steps', 'notObject'); + return; + } + const id = String(step.id ?? ''); + const at = `step:${id || '?'}`; + if (!ID_PATTERN.test(id)) { + out.add(at, 'badId', { id }); + } else if (ctx.stepIds.has(id)) { + out.add(at, 'duplicateId', { id }); + } + ctx.stepIds.add(id); + ctx.stepType = step.type; + const fields = stepFields(step.type); + if (!fields) { + out.add(`${at}.type`, 'unknownAction', { type: String(step.type) }); + return; + } + for (const [key, value] of Object.entries(step)) { + if (!fields[key]) { + out.add(`${at}.${key}`, 'unknownField', { field: key }); + continue; + } + if (key !== 'type' && key !== 'id') { + validateField(fields[key], value, `${at}.${key}`, ctx); + } + } + for (const [key, spec] of Object.entries(fields)) { + if (spec.required && step[key] === undefined) { + out.add(at, 'missingField', { field: key }); + } + } + if (step.retry !== undefined) { + validateRetry(step.retry, `${at}.retry`, out); + } + validateStepRules(step, at, ctx); +} + +/** + * Checks rules specific to step types that field specs cannot express. + * + * @param {object} step Step. + * @param {string} at Location. + * @param {object} ctx Context. + * @returns {void} + */ +function validateStepRules(step, at, ctx) { + const { out, limits, def } = ctx; + switch (step.type) { + case 'wait': { + const hasDuration = step.durationMs !== undefined; + if (hasDuration === (step.until !== undefined)) { + out.add(at, 'waitKind'); + } + if (typeof step.durationMs === 'number' && step.durationMs < limits.minWaitMs) { + out.add(`${at}.durationMs`, 'waitTooShort', { min: limits.minWaitMs }); + } + break; + } + case 'awaitEvent': + if (!EVENT_TYPES.includes(step.event)) { + out.add(`${at}.event`, 'badEnum', { values: EVENT_TYPES.join(', ') }); + } + validateFilter(step.filter, `${at}.filter`, AWAIT_ROOTS, out, limits); + if (step.timeoutMs > limits.awaitTimeoutMaxMs) { + out.add(`${at}.timeoutMs`, 'outOfRange', { min: 100, max: limits.awaitTimeoutMaxMs }); + } + if (step.filter?.binding !== undefined && !ctx.bindings[step.filter.binding]) { + out.add(`${at}.filter.binding`, 'unknownBinding', { name: String(step.filter.binding) }); + } + break; + case 'loop': + if (step.mode === 'count' && step.count === undefined) { + out.add(at, 'missingField', { field: 'count' }); + } + if ((step.mode === 'while' || step.mode === 'until') && step.condition === undefined) { + out.add(at, 'missingField', { field: 'condition' }); + } + if ( + step.maxIterations !== undefined && + step.maxIterations > limits.loopIterations && + step.mode !== 'forever' + ) { + out.add(`${at}.maxIterations`, 'outOfRange', { min: 1, max: limits.loopIterations }); + } + if ( + typeof step.count === 'number' && + (!Number.isInteger(step.count) || step.count < 0 || step.count > limits.loopIterations) + ) { + out.add(`${at}.count`, 'outOfRange', { min: 0, max: limits.loopIterations }); + } + if (step.mode === 'forever') { + if (def.policy?.allowForever !== true) { + out.add(at, 'foreverNotAllowed'); + } + if (Array.isArray(step.steps) && !blockAlwaysSuspends(step.steps)) { + out.add(at, 'foreverNoWait'); + } + } + break; + case 'parallel': { + if (!Array.isArray(step.branches) || step.branches.length < 2) { + out.add(`${at}.branches`, 'branchCount', { max: limits.branchesPerRun }); + break; + } + if (step.branches.length > limits.branchesPerRun) { + out.add(`${at}.branches`, 'branchCount', { max: limits.branchesPerRun }); + } + const ids = new Set(); + step.branches.forEach((branch, i) => { + const bat = `${at}.branches.${i}`; + if (!isRecord(branch)) { + out.add(bat, 'notObject'); + return; + } + checkKeys(branch, ['id', 'steps'], bat, out); + if (!ID_PATTERN.test(String(branch.id ?? '')) || ids.has(branch.id)) { + out.add(`${bat}.id`, 'badId', { id: String(branch.id) }); + } + ids.add(branch.id); + validateBlock(branch.steps, `${bat}.steps`, ctx); + }); + if (step.merge !== undefined) { + if (!Array.isArray(step.merge)) { + out.add(`${at}.merge`, 'notArray'); + } else { + for (const [i, rule] of step.merge.entries()) { + if ( + !isRecord(rule) || + !ID_PATTERN.test(String(rule.name ?? '')) || + !['sum', 'min', 'max', 'first', 'last', 'count', 'all', 'any'].includes(rule.reducer) + ) { + out.add(`${at}.merge.${i}`, 'badMerge'); + } + } + } + } + break; + } + case 'call': + case 'task.start': + ctx.calls.add(step.definition); + if (step.bindings !== undefined && !isRecord(step.bindings)) { + out.add(`${at}.bindings`, 'notObject'); + } + break; + case 'token.set': + if (isRecord(step.set)) { + for (const key of Object.keys(step.set)) { + if (!(key in TOKEN_WRITE_PROPERTIES)) { + out.add(`${at}.set.${key}`, 'badProperty', { property: key }); + } + } + } + break; + case 'token.get': + if (!TOKEN_READ_PROPERTIES.includes(step.property)) { + out.add(`${at}.property`, 'badProperty', { property: String(step.property) }); + } + break; + case 'integration.command': + if (typeof step.command === 'string' && !/^![A-Za-z0-9_-]+/.test(step.command)) { + out.add(`${at}.command`, 'badCommand'); + } + if (typeof step.command === 'string' && /[\r\n]/.test(step.command)) { + out.add(`${at}.command`, 'badCommand'); + } + break; + default: + break; + } + if (STEP_SPECS[step.type]?.gmOnly && def.access?.mode === 'players') { + out.add(at, 'gmOnlyAction', { type: step.type }); + } + if (def.access?.mode === 'players') { + const allowed = def.access.capabilities?.actions; + const spec = STEP_SPECS[step.type]; + if ( + !spec.flow && + (!spec.playerSafe || (Array.isArray(allowed) && !allowed.includes(step.type))) + ) { + out.add(at, 'notPlayerCapability', { type: step.type }); + } + } +} + +/** + * Returns true when every pass through a block is certain to suspend + * (a positive wait, an awaited event or an awaited call). Conservative: + * anything uncertain counts as not suspending. + * + * @param {object[]} steps Steps. + * @returns {boolean} True when the block always suspends. + */ +export function blockAlwaysSuspends(steps) { + return steps.some((step) => stepAlwaysSuspends(step)); +} + +/** + * Returns true when a step always suspends its branch. + * + * @param {object} step Step. + * @returns {boolean} True when it always suspends. + */ +function stepAlwaysSuspends(step) { + if (!isRecord(step)) { + return false; + } + if (SUSPENDING.has(step.type)) { + if (step.type === 'wait' && step.durationMs !== undefined) { + return typeof step.durationMs === 'number' && step.durationMs > 0; + } + return true; + } + if (step.type === 'call') { + return step.await !== false; + } + if (step.type === 'if') { + return blockAlwaysSuspends(step.then || []) && blockAlwaysSuspends(step.else || []); + } + if (step.type === 'parallel' && Array.isArray(step.branches)) { + return step.join === 'all' + ? step.branches.some((b) => blockAlwaysSuspends(b.steps || [])) + : step.branches.every((b) => blockAlwaysSuspends(b.steps || [])); + } + return false; +} + +/** + * Validates a definition. + * + * @param {*} def Definition. + * @param {object} [options] Options. + * @param {object} [options.limits] Limits. + * @returns {{ok: boolean, errors: object[], calls: string[]}} Result with called definition IDs. + */ +export function validateDefinition(def, { limits = DEFAULT_LIMITS } = {}) { + const out = collector(); + if (!isRecord(def)) { + out.add('$', 'notObject'); + return { ok: false, errors: out.errors, calls: [] }; + } + checkKeys(def, TOP_FIELDS, '$', out); + if (def.schemaVersion !== DEFINITION_SCHEMA_VERSION) { + out.add('schemaVersion', 'schemaVersion', { version: DEFINITION_SCHEMA_VERSION }); + } + if (!ID_PATTERN.test(String(def.id ?? ''))) { + out.add('id', 'badId', { id: String(def.id) }); + } + if (typeof def.name !== 'string' || !def.name.trim() || def.name.length > limits.nameLength) { + out.add('name', 'badName', { max: limits.nameLength }); + } + if ( + def.description !== undefined && + (typeof def.description !== 'string' || def.description.length > limits.stringLength) + ) { + out.add('description', 'notString'); + } + if (def.revision !== undefined && (!Number.isInteger(def.revision) || def.revision < 1)) { + out.add('revision', 'outOfRange', { min: 1, max: '∞' }); + } + if (def.enabled !== undefined && typeof def.enabled !== 'boolean') { + out.add('enabled', 'notBoolean'); + } + validateParameters(def.parameters, out); + validateBindings(def.bindings, def.parameters, out, limits); + validateAccess(def.access, out); + validateTriggers(def.triggers, out, limits); + validatePolicy(def.policy, out); + const ctx = { + out, + limits, + def, + bindings: isRecord(def.bindings) ? def.bindings : {}, + stepIds: new Set(), + calls: new Set(), + depth: 0, + count: 0, + }; + if (!Array.isArray(def.steps) || def.steps.length === 0) { + out.add('steps', 'noSteps'); + } else { + validateBlock(def.steps, 'steps', ctx); + } + // Unattended starts act for the GM, never as a former initiator (plan §10). + const unattendedTypes = new Set([...TIME_TRIGGER_TYPES, 'event', 'chat']); + if ( + (def.triggers || []).some((tr) => isRecord(tr) && unattendedTypes.has(tr.type)) && + def.access?.authority !== 'gmService' + ) { + out.add('access.authority', 'unattendedNeedsService'); + } + // Selection is only available on a person's command: other starts need an alternate. + const unattended = (def.triggers || []).some( + (tr) => isRecord(tr) && tr.type !== 'manual' && tr.type !== 'signal' + ); + for (const [name, spec] of Object.entries(ctx.bindings)) { + if (isRecord(spec) && spec.from === 'selected' && unattended && !spec.alternate) { + out.add(`bindings.${name}`, 'selectionUnattended'); + } + } + return { ok: out.errors.length === 0, errors: out.errors, calls: [...ctx.calls] }; +} + +/** + * Compiles a validated definition into lookup tables for the executor. + * Block keys: `root`, `/steps`, `/then`, `/else`, + * `/branch/`. + * + * @param {object} def Validated definition. + * @returns {{blocks: Map, steps: Map}} Compiled program. + */ +export function compileDefinition(def) { + const blocks = new Map(); + const steps = new Map(); + const walk = (list, key) => { + blocks.set(key, list); + list.forEach((step, index) => { + steps.set(step.id, { step, block: key, index }); + if (step.type === 'loop') { + walk(step.steps, `${step.id}/steps`); + } else if (step.type === 'if') { + walk(step.then || [], `${step.id}/then`); + walk(step.else || [], `${step.id}/else`); + } else if (step.type === 'parallel') { + for (const branch of step.branches) { + walk(branch.steps, `${step.id}/branch/${branch.id}`); + } + } + }); + }; + walk(def.steps, 'root'); + return { blocks, steps }; +} + +/** + * Finds a call cycle starting at a definition. + * + * @param {string} startId Definition ID. + * @param {(id: string) => string[]} callsOf Returns the IDs a definition calls. + * @returns {string[]|null} Cycle path, or null. + */ +export function findCallCycle(startId, callsOf) { + const stack = []; + const visiting = new Set(); + const done = new Set(); + const visit = (id) => { + if (visiting.has(id)) { + return [...stack.slice(stack.indexOf(id)), id]; + } + if (done.has(id)) { + return null; + } + visiting.add(id); + stack.push(id); + for (const next of callsOf(id)) { + const cycle = visit(next); + if (cycle) { + return cycle; + } + } + stack.pop(); + visiting.delete(id); + done.add(id); + return null; + }; + return visit(startId); +} + +/** + * Formats a validation error path for people: `step:advance.delta` becomes + * `advance: delta`. + * + * @param {string} path Error path. + * @returns {string} Readable location. + */ +export function describePath(path) { + const match = /^step:([^.]+)(?:\.(.+))?$/.exec(path); + if (match) { + return match[2] ? `${match[1]}: ${match[2]}` : match[1]; + } + return path; +} diff --git a/TaskAutomation/src/state.js b/TaskAutomation/src/state.js new file mode 100644 index 0000000000..39aed42f3a --- /dev/null +++ b/TaskAutomation/src/state.js @@ -0,0 +1,386 @@ +// Persistent state (plan §3.2): namespaced JSON under state.TaskAutomation, +// sequential migrations with a bounded backup, ID counters, configuration +// and size estimates. Live objects, timers and indexes never go in here. + +import { + DASHBOARD_ROW_OPTIONS, + DEFAULT_CONFIG, + DEFAULT_LIMITS, + SCHEMA_VERSION, + SCRIPT_VERSION, + STATE_KEY, + USEROPTION_KEYS, +} from './constants.js'; +import { normalizeLocale } from './i18n.js'; +import { stateRoot, userOptions } from './host.js'; +import { + clone, + estimateSize, + isRecord, + parseBoolean, + parseDuration, + parseStrictInt, +} from './utils.js'; + +const BACKUP_KEY = `${STATE_KEY}Backup`; +const MAX_BACKUP_BYTES = 256 * 1024; + +// Map branches every valid state must have, with their empty values. +const BRANCHES = { + definitions: () => ({}), + schedules: () => ({}), + triggerState: () => ({}), + instances: () => ({}), + queues: () => ({}), + incidents: () => ({}), + history: () => [], + audit: () => [], + confirmations: () => ({}), + imports: () => ({}), + circuit: () => ({}), + baselines: () => ({}), + notices: () => ({}), +}; + +// Sequential migrations: MIGRATIONS[n] turns schema n into schema n + 1. +// Schema 1 is the first release, so there are none yet; the loop and its +// backup/rollback are exercised by tests with a synthetic migration. +export const MIGRATIONS = {}; + +/** + * Returns a fresh state. + * + * @param {number} at Creation time. + * @returns {object} State. + */ +export function createState(at) { + const fresh = { + schemaVersion: SCHEMA_VERSION, + releaseVersion: SCRIPT_VERSION, + createdAt: at, + boot: { id: null, at: null, count: 0, lastObservedAt: null }, + config: { ...DEFAULT_CONFIG }, + counters: { id: 1, token: 1 }, + halted: null, + dashboard: { + handoutId: null, + playerHandoutId: null, + helpHandoutId: null, + view: 'overview', + page: 1, + lastHash: null, + playerHash: null, + recreatedAt: 0, + drift: null, + }, + stats: { + admitted: 0, + rejected: 0, + suppressed: 0, + dropped: 0, + coalesced: 0, + completed: 0, + failed: 0, + }, + }; + for (const [key, make] of Object.entries(BRANCHES)) { + fresh[key] = make(); + } + return fresh; +} + +/** + * Checks the shape of stored state without changing it. + * + * @param {*} raw Stored value. + * @returns {{ok: boolean, error?: string}} Result. + */ +export function checkStateShape(raw) { + if (!isRecord(raw)) { + return { ok: false, error: 'notObject' }; + } + if (!Number.isInteger(raw.schemaVersion) || raw.schemaVersion < 1) { + return { ok: false, error: 'badSchemaVersion' }; + } + for (const [key, make] of Object.entries(BRANCHES)) { + if (raw[key] !== undefined && Array.isArray(make()) !== Array.isArray(raw[key])) { + return { ok: false, error: `bad:${key}` }; + } + if (raw[key] !== undefined && !Array.isArray(raw[key]) && !isRecord(raw[key])) { + return { ok: false, error: `bad:${key}` }; + } + } + return { ok: true }; +} + +/** + * Fills in branches added since the state was written, keeping data. + * + * @param {object} s State. + * @param {number} at Current time. + * @returns {object} The same state. + */ +function fillDefaults(s, at) { + const fresh = createState(at); + for (const key of Object.keys(fresh)) { + if (s[key] === undefined) { + s[key] = fresh[key]; + } + } + s.config = { ...DEFAULT_CONFIG, ...(isRecord(s.config) ? s.config : {}) }; + s.dashboard = { ...fresh.dashboard, ...(isRecord(s.dashboard) ? s.dashboard : {}) }; + s.stats = { ...fresh.stats, ...(isRecord(s.stats) ? s.stats : {}) }; + s.counters = { ...fresh.counters, ...(isRecord(s.counters) ? s.counters : {}) }; + return s; +} + +/** + * Migrates stored state in place to the current schema. Never overwrites + * state from a newer version or state that is not recognisable: the Mod + * stops instead and tells the GM. + * + * @param {*} raw Stored value (undefined on a new game). + * @param {number} at Current time. + * @param {object} [migrations] Migration table (tests pass their own). + * @param {number} [target] Schema to migrate to (tests pass their own). + * @returns {{status: 'fresh'|'ok'|'migrated'|'newer'|'corrupt'|'migrationFailed', state?: object, from?: number, error?: string}} + * Outcome. + */ +export function migrateState(raw, at, migrations = MIGRATIONS, target = SCHEMA_VERSION) { + if (raw === undefined || raw === null) { + return { status: 'fresh', state: createState(at) }; + } + const shape = checkStateShape(raw); + if (!shape.ok) { + return { status: 'corrupt', error: shape.error }; + } + if (raw.schemaVersion > target) { + return { status: 'newer', from: raw.schemaVersion }; + } + if (raw.schemaVersion === target) { + return { status: 'ok', state: fillDefaults(raw, at) }; + } + const from = raw.schemaVersion; + let working = clone(raw); + try { + while (working.schemaVersion < target) { + const step = migrations[working.schemaVersion]; + if (typeof step !== 'function') { + return { status: 'migrationFailed', from, error: `noMigration:${working.schemaVersion}` }; + } + working = step(working); + const check = checkStateShape(working); + if (!check.ok) { + return { status: 'migrationFailed', from, error: check.error }; + } + } + } catch (err) { + return { status: 'migrationFailed', from, error: String(err?.message || err) }; + } + return { status: 'migrated', from, state: fillDefaults(working, at) }; +} + +let disabled = null; + +/** + * Loads, validates and migrates the persistent state. On failure the + * original data is left untouched and the Mod runs no tasks. + * + * @param {number} at Current time. + * @returns {{status: string, from?: number, error?: string}} Outcome. + */ +export function initialiseState(at) { + const root = stateRoot(); + const raw = root[STATE_KEY]; + let backupMade = false; + if (isRecord(raw) && Number.isInteger(raw.schemaVersion) && raw.schemaVersion < SCHEMA_VERSION) { + if (estimateSize(raw) <= MAX_BACKUP_BYTES) { + root[BACKUP_KEY] = clone(raw); + backupMade = true; + } + } + const result = migrateState(raw, at); + if (!result.state) { + disabled = result; + return result; + } + result.state.releaseVersion = SCRIPT_VERSION; + root[STATE_KEY] = result.state; + if (backupMade) { + delete root[BACKUP_KEY]; + } + disabled = null; + return result; +} + +/** + * Returns the reason the Mod is disabled, or null. + * + * @returns {object|null} Failed migration outcome. + */ +export function disabledReason() { + return disabled; +} + +/** + * Returns the persistent state. + * + * @returns {object} State branch. + */ +export function getState() { + return stateRoot()[STATE_KEY]; +} + +/** + * Returns the global configuration. + * + * @returns {object} Config. + */ +export function getConfig() { + return getState().config; +} + +/** + * Returns the effective limits. Limits are product defaults; they live in + * code so a crafted import cannot raise them. + * + * @returns {object} Limits. + */ +export function getLimits() { + return DEFAULT_LIMITS; +} + +/** + * Allocates a new ID with a prefix, e.g. `run-42`. + * + * @param {string} prefix Prefix. + * @returns {string} ID. + */ +export function nextId(prefix) { + const s = getState(); + const id = `${prefix}-${s.counters.id}`; + s.counters.id += 1; + return id; +} + +/** + * Returns the next value of the confirmation-token counter. + * + * @returns {number} Counter. + */ +export function nextTokenCounter() { + const s = getState(); + s.counters.token += 1; + return s.counters.token; +} + +/** + * Validates a configuration value. + * + * @param {string} key Config key. + * @param {*} raw Raw value. + * @returns {{valid: boolean, value?: *}} Result. + */ +export function validateConfigValue(key, raw) { + switch (key) { + case 'language': { + const value = normalizeLocale(String(raw ?? '')); + return value ? { valid: true, value } : { valid: false }; + } + case 'dashboardRows': { + const value = parseStrictInt(raw); + return DASHBOARD_ROW_OPTIONS.includes(value) ? { valid: true, value } : { valid: false }; + } + case 'playerView': + case 'diagnostics': + case 'privateChatTriggers': { + const value = parseBoolean(raw); + return value === null ? { valid: false } : { valid: true, value }; + } + case 'roundAnchor': { + const value = String(raw ?? '').trim(); + return value && value.length <= 40 ? { valid: true, value } : { valid: false }; + } + case 'checkpointMs': { + if (String(raw) === '0' || String(raw).toLowerCase() === 'off') { + return { valid: true, value: 0 }; + } + const value = typeof raw === 'number' ? raw : parseDuration(raw); + return value !== null && value >= 60000 ? { valid: true, value } : { valid: false }; + } + case 'graceMs': + case 'lateWindowMs': { + const value = typeof raw === 'number' ? raw : parseDuration(raw); + const min = key === 'graceMs' ? 0 : 1000; + return value !== null && value >= min ? { valid: true, value } : { valid: false }; + } + default: + return { valid: false }; + } +} + +/** + * Sets one configuration value. + * + * @param {string} key Config key. + * @param {*} raw Raw value. + * @returns {{valid: boolean, value?: *}} Result. + */ +export function setConfigValue(key, raw) { + if (!(key in DEFAULT_CONFIG)) { + return { valid: false }; + } + const result = validateConfigValue(key, raw); + if (result.valid) { + getConfig()[key] = result.value; + } + return result; +} + +/** + * Imports One-Click useroptions. Options are only re-applied when the GM + * changes them on the Mod page, so chat changes survive restarts. + * + * @returns {void} + */ +export function applyGlobalConfig() { + const options = userOptions(STATE_KEY); + if (!options) { + return; + } + const s = getState(); + const applied = isRecord(s.notices.useroptions) ? s.notices.useroptions : {}; + for (const key of USEROPTION_KEYS) { + if (key in options && options[key] !== applied[key]) { + setConfigValue(key, options[key]); + applied[key] = options[key]; + } + } + s.notices.useroptions = applied; +} + +/** + * Estimates the stored size of the state. + * + * @returns {number} Approximate bytes. + */ +export function stateSize() { + return estimateSize(getState()); +} + +let sizeCache = { at: -Infinity, bytes: 0 }; + +/** + * Returns true when the state is close to its size budget; new admissions + * are refused while history and incidents keep their headroom. The size is + * re-measured at most every ten seconds. + * + * @param {number} at Current time. + * @returns {boolean} True when nearly full. + */ +export function stateNearlyFull(at) { + if (at - sizeCache.at > 10000) { + sizeCache = { at, bytes: stateSize() }; + } + return sizeCache.bytes > getLimits().stateBytes * 0.9; +} diff --git a/TaskAutomation/src/templates.js b/TaskAutomation/src/templates.js new file mode 100644 index 0000000000..0ebf1e42b6 --- /dev/null +++ b/TaskAutomation/src/templates.js @@ -0,0 +1,360 @@ +// Ready-made task definitions: the plan's montage fixture and the seven +// worked examples (plan §12.2, §12.4). `npm run examples` writes each one to +// examples/.json so they can be imported into any game. + +const base = (id, name, description) => ({ + schemaVersion: 1, + id, + revision: 1, + name, + description, + enabled: true, + parameters: {}, +}); + +export const TEMPLATES = [ + { + ...base( + 'montage', + 'Adventure montage', + 'Cycle selected multi-sided tokens ten times, eight seconds apart.' + ), + bindings: { tokens: { from: 'selected', objectType: 'graphic', max: 5 } }, + access: { mode: 'gmOnly', authority: 'initiator' }, + triggers: [{ id: 'manual', type: 'manual' }], + policy: { + concurrency: 'skipIfRunning', + maxConcurrent: 1, + onError: 'fail', + overdue: { mode: 'skip', graceMs: 250 }, + restart: 'reconcile', + }, + steps: [ + { + id: 'frames', + type: 'loop', + mode: 'count', + count: 10, + steps: [ + { + id: 'advance', + type: 'token.advanceSide', + target: { binding: 'tokens' }, + delta: 1, + wrap: true, + }, + { id: 'hold', type: 'wait', durationMs: 8000 }, + ], + }, + ], + }, + { + ...base( + 'torch-reminder', + 'Torch reminder', + 'Warn the torchbearer after 50 real-world minutes and put the torch out 10 minutes later. Real minutes, not game time.' + ), + bindings: { torchbearer: { from: 'selected', objectType: 'graphic', max: 1 } }, + access: { mode: 'gmOnly', authority: 'initiator' }, + triggers: [{ id: 'manual', type: 'manual' }], + policy: { concurrency: 'allow', maxConcurrent: 10, onError: 'fail', restart: 'reconcile' }, + steps: [ + { id: 'burn', type: 'wait', durationMs: 50 * 60 * 1000, overdue: 'review' }, + { + id: 'warn', + type: 'chat.send', + mode: 'whisper', + to: { gm: true, controllers: 'torchbearer' }, + text: "{{target.torchbearer.name}}'s torch is guttering: about 10 minutes of light left.", + }, + { id: 'last-light', type: 'wait', durationMs: 10 * 60 * 1000, overdue: 'review' }, + { + id: 'out', + type: 'chat.send', + mode: 'say', + text: "{{target.torchbearer.name}}'s torch burns out.", + }, + ], + }, + { + ...base( + 'hp-zero', + 'HP threshold', + "When a token's bar 1 drops from above 0 to 0 or less, mark it dead and tell the GM." + ), + enabled: false, + bindings: { token: { from: 'event', objectType: 'graphic', max: 1 } }, + access: { mode: 'gmOnly', authority: 'gmService' }, + triggers: [ + { + id: 'bar-crossing', + type: 'event', + event: 'token.change', + filter: { + properties: ['bar1_value'], + crossing: { property: 'bar1_value', direction: 'below', threshold: 0 }, + }, + bind: { binding: 'token' }, + cooldownMs: 1000, + }, + ], + policy: { concurrency: 'allow', maxConcurrent: 10, onError: 'skip' }, + steps: [ + { id: 'mark', type: 'marker.set', target: { binding: 'token' }, marker: 'dead', op: 'add' }, + { + id: 'tell', + type: 'chat.send', + mode: 'whisper', + to: { gm: true }, + text: '{{target.token.name}} has dropped to {{event.new.bar1_value}} HP.', + }, + ], + }, + { + ...base( + 'turn-response', + 'Turn response', + 'When the current turn changes to a token, wait briefly for an optional ready signal, then ping it for the GM.' + ), + enabled: false, + bindings: { token: { from: 'event', objectType: 'graphic', max: 1, min: 0 } }, + access: { mode: 'gmOnly', authority: 'gmService' }, + triggers: [{ id: 'turn', type: 'event', event: 'turn.change', bind: { binding: 'token' } }], + policy: { concurrency: 'replace', maxConcurrent: 1, onError: 'skip' }, + steps: [ + { + id: 'has-token', + type: 'if', + condition: { op: 'gt', left: { ref: 'target.token.count' }, right: 0 }, + then: [ + { + id: 'ready', + type: 'awaitEvent', + event: 'signal', + filter: { signal: 'turn-ready' }, + timeoutMs: 15000, + }, + { id: 'ping', type: 'ping', target: { binding: 'token' }, to: { gm: true } }, + { + id: 'notify', + type: 'chat.send', + mode: 'whisper', + to: { gm: true }, + text: "It is {{target.token.name}}'s turn.", + }, + ], + else: [ + { + id: 'notify-custom', + type: 'chat.send', + mode: 'whisper', + to: { gm: true }, + text: 'Turn: {{event.name}}', + }, + ], + }, + ], + }, + { + ...base( + 'scene-cleanup', + 'Scene cleanup', + 'Stop a jukebox track and announce the scene is over. Called by Parallel scene.' + ), + parameters: { track: { type: 'string', required: true, maxLength: 100 } }, + bindings: {}, + access: { mode: 'gmOnly', authority: 'initiator' }, + triggers: [{ id: 'manual', type: 'manual' }], + policy: { concurrency: 'allow', maxConcurrent: 5, onError: 'skip' }, + steps: [ + { id: 'silence', type: 'audio.control', track: '{{param.track}}', op: 'stop' }, + { id: 'done', type: 'chat.send', mode: 'desc', text: 'The scene falls quiet.' }, + ], + }, + { + ...base( + 'parallel-scene', + 'Parallel scene', + 'Fade the "Ambience" jukebox track in steps while a door token changes after a wait; when both finish, chain Scene cleanup.' + ), + parameters: { track: { type: 'string', default: 'Ambience', maxLength: 100 } }, + bindings: { door: { from: 'selected', objectType: 'graphic', max: 1 } }, + access: { mode: 'gmOnly', authority: 'initiator' }, + triggers: [{ id: 'manual', type: 'manual' }], + policy: { + concurrency: 'skipIfRunning', + maxConcurrent: 1, + onError: 'fail', + restart: 'reconcile', + }, + steps: [ + { + id: 'scene', + type: 'parallel', + join: 'all', + branches: [ + { + id: 'music', + steps: [ + { + id: 'fade', + type: 'loop', + mode: 'count', + count: 5, + steps: [ + { + id: 'volume', + type: 'audio.control', + track: '{{param.track}}', + op: 'volume', + volume: { + op: 'sub', + left: 60, + right: { op: 'mul', left: { ref: 'run.iteration' }, right: 10 }, + }, + onError: 'skip', + }, + { id: 'fade-step', type: 'wait', durationMs: 1000 }, + ], + }, + ], + }, + { + id: 'door', + steps: [ + { id: 'door-wait', type: 'wait', durationMs: 3000 }, + { + id: 'door-open', + type: 'token.advanceSide', + target: { binding: 'door' }, + delta: 1, + wrap: true, + }, + ], + }, + ], + }, + { + id: 'cleanup', + type: 'call', + definition: 'scene-cleanup', + await: true, + params: { track: { ref: 'param.track' } }, + }, + ], + }, + { + ...base( + 'session-reminder', + 'Session reminder', + 'Every Friday at 19:00 (UTC+00:00) remind the table that the session starts soon. Update the offset when the clocks change.' + ), + enabled: false, + bindings: {}, + access: { mode: 'gmOnly', authority: 'gmService' }, + triggers: [ + { + id: 'weekly', + type: 'weekly', + days: ['fri'], + time: '19:00', + offset: '+00:00', + overdue: { mode: 'runOnce', windowMs: 15 * 60 * 1000, maxAgeMs: 2 * 60 * 60 * 1000 }, + }, + ], + policy: { + concurrency: 'skipIfRunning', + maxConcurrent: 1, + onError: 'skip', + lifetimeMs: 60 * 60 * 1000, + }, + steps: [ + { + id: 'remind', + type: 'chat.send', + mode: 'say', + text: 'Session starts in 15 minutes. Grab your dice!', + }, + ], + }, + { + ...base( + 'token-mod-command', + 'Other-Mod command', + 'Send an approved TokenMod command for the selected token, by ID. Shown as dispatched, not confirmed.' + ), + bindings: { token: { from: 'selected', objectType: 'graphic', max: 1 } }, + access: { mode: 'gmOnly', authority: 'initiator' }, + triggers: [{ id: 'manual', type: 'manual' }], + policy: { concurrency: 'allow', maxConcurrent: 5, onError: 'fail' }, + steps: [ + { + id: 'mark-blue', + type: 'integration.command', + command: '!token-mod --ids {{args.token}} --set statusmarkers|+blue', + args: { token: { ref: 'target.token.id' } }, + }, + ], + }, + { + ...base( + 'door-sequence', + 'Door sequence', + 'Announce a door, wait up to a minute for the door-open signal, then open it or report that it stayed shut.' + ), + bindings: { door: { from: 'selected', objectType: 'graphic', max: 1 } }, + access: { mode: 'gmOnly', authority: 'initiator' }, + triggers: [{ id: 'manual', type: 'manual' }], + policy: { + concurrency: 'skipIfRunning', + maxConcurrent: 1, + onError: 'fail', + restart: 'reconcile', + }, + steps: [ + { + id: 'announce', + type: 'chat.send', + mode: 'say', + text: 'The heavy door groans. Someone must pull the lever...', + }, + { + id: 'lever', + type: 'awaitEvent', + event: 'signal', + filter: { signal: 'door-open' }, + timeoutMs: 60000, + saveAs: 'lever', + }, + { + id: 'opened', + type: 'if', + condition: { ref: 'var.lever.matched' }, + then: [ + { + id: 'swing', + type: 'token.advanceSide', + target: { binding: 'door' }, + delta: 1, + wrap: false, + }, + { id: 'open-msg', type: 'chat.send', mode: 'say', text: 'The door swings open.' }, + ], + else: [{ id: 'shut-msg', type: 'chat.send', mode: 'say', text: 'The door stays shut.' }], + }, + ], + }, +]; + +// Templates that need another template installed first. +export const TEMPLATE_DEPENDENCIES = { 'parallel-scene': ['scene-cleanup'] }; + +/** + * Returns a template by ID. + * + * @param {string} id Template ID. + * @returns {object|null} Template definition. + */ +export function getTemplate(id) { + return TEMPLATES.find((tpl) => tpl.id === id) || null; +} diff --git a/TaskAutomation/src/triggers.js b/TaskAutomation/src/triggers.js new file mode 100644 index 0000000000..5c11d9336e --- /dev/null +++ b/TaskAutomation/src/triggers.js @@ -0,0 +1,1119 @@ +// Trigger router (plan §6): one set of native subscriptions registered after +// ready, normalised event envelopes, indexed filters, cooldown/debounce/burst +// controls, awaited events, signals, self-echo suppression and opt-in +// offline reconciliation. + +import { count, createIncident } from './audit.js'; +import { JOB } from './constants.js'; +import { getRecord, getSnapshot, listRecords } from './definitions.js'; +import { compactEvent, admitRun, deliverEvent, eventWaits } from './executor.js'; +import { MISSING, evaluate, truthy } from './expressions.js'; +import { campaign, getObject, isGm, logLine, now, onEvent } from './host.js'; +import { resolveBindings, snapshot } from './objects.js'; +import { diag, onJob, runtime, schedule } from './runtime.js'; +import { getConfig, getLimits, getState } from './state.js'; +import { clone, isRecord, parseStrictNumber, readPath, toText, truncate } from './utils.js'; + +const SELF_SENSITIVE = new Set([ + 'token', + 'attribute', + 'turn', + 'turnorder', + 'tracker', + 'page', + 'player', + 'chat', +]); + +let triggerIndex = null; +let awaitIndex = null; +let eventCounter = 0; +const internalQueue = []; +const recentWrites = new Map(); + +/** + * Marks the trigger index stale (definitions changed). + * + * @returns {void} + */ +export function definitionsChanged() { + triggerIndex = null; +} + +/** + * Marks the awaited-event index stale (a run started or stopped waiting). + * + * @returns {void} + */ +export function awaitsChanged() { + awaitIndex = null; +} + +/** + * Builds the index of event, signal and chat triggers by event type. + * + * @returns {Map} Index. + */ +function buildTriggerIndex() { + const index = new Map(); + for (const record of listRecords()) { + if (!record.enabled || !record.current) { + continue; + } + const def = getSnapshot(record.id); + for (const trigger of def?.triggers || []) { + if (trigger.enabled === false) { + continue; + } + const type = + trigger.type === 'event' + ? trigger.event + : trigger.type === 'signal' + ? 'signal' + : trigger.type === 'chat' + ? 'chat' + : null; + if (!type) { + continue; + } + if (!index.has(type)) { + index.set(type, []); + } + index.get(type).push({ defId: record.id, trigger, def }); + } + } + return index; +} + +/** + * Builds the index of awaited events by type. + * + * @returns {Map} Index. + */ +function buildAwaitIndex() { + const index = new Map(); + for (const entry of eventWaits()) { + const type = entry.wait.event; + if (!index.has(type)) { + index.set(type, []); + } + index.get(type).push(entry); + } + return index; +} + +/** + * Records a write made by this Mod so the matching native event (if Roll20 + * sends one) is not mistaken for a person's change. + * + * @param {string} objectId Object ID. + * @param {object} changes Properties written. + * @returns {void} + */ +export function noteOwnWrite(objectId, changes) { + recentWrites.set(objectId, { changes: clone(changes), until: now() + getLimits().echoWindowMs }); +} + +/** + * Returns true when a native change only repeats a recent write by this Mod. + * + * @param {object} event Normalised native event. + * @returns {boolean} True for an echo. + */ +function isEcho(event) { + const entry = recentWrites.get(event.objectId); + if (!entry) { + return false; + } + if (entry.until < now()) { + recentWrites.delete(event.objectId); + return false; + } + const changed = event.changed || []; + return ( + changed.length > 0 && + changed.every((prop) => prop in entry.changes && entry.changes[prop] === event.new?.[prop]) + ); +} + +/** + * Creates a normalised event envelope. + * + * @param {object} fields Event fields. + * @returns {object} Event. + */ +function envelope(fields) { + eventCounter += 1; + return { + id: `ev-${eventCounter}`, + observedAt: now(), + source: 'native', + actor: null, + correlation: null, + objectType: null, + objectId: null, + old: null, + new: null, + ...fields, + }; +} + +/** + * Lists the properties that differ between two snapshots. + * + * @param {object} oldSnap Old snapshot. + * @param {object} newSnap New snapshot. + * @returns {string[]} Changed properties. + */ +function changedProps(oldSnap, newSnap) { + const keys = new Set([...Object.keys(oldSnap || {}), ...Object.keys(newSnap || {})]); + return [...keys].filter((k) => oldSnap?.[k] !== newSnap?.[k]); +} + +// --------------------------------------------------------------------------- +// Native handlers +// --------------------------------------------------------------------------- + +/** + * Normalises and routes a token change. + * + * @param {object} obj Roll20 graphic. + * @param {object} prev Previous values. + * @returns {void} + */ +function onGraphicChange(obj, prev) { + if (obj.get('_subtype') !== 'token') { + return; + } + const oldSnap = snapshot(prev, 'graphic'); + const newSnap = snapshot(obj, 'graphic'); + const changed = changedProps(oldSnap, newSnap); + if (!changed.length) { + return; + } + routeEvent( + envelope({ + type: 'token.change', + objectType: 'graphic', + objectId: obj.id, + pageId: obj.get('_pageid'), + characterId: toText(obj.get('represents')) || null, + name: toText(obj.get('name')), + old: oldSnap, + new: newSnap, + changed, + }) + ); +} + +/** + * Routes token creation and removal. + * + * @param {string} type token.add or token.destroy. + * @param {object} obj Roll20 graphic. + * @returns {void} + */ +function onGraphicLifecycle(type, obj) { + if (obj.get('_subtype') !== 'token') { + return; + } + const snap = snapshot(obj, 'graphic'); + routeEvent( + envelope({ + type, + objectType: 'graphic', + objectId: obj.id, + pageId: obj.get('_pageid'), + characterId: toText(obj.get('represents')) || null, + name: toText(obj.get('name')), + old: type === 'token.destroy' ? snap : null, + new: type === 'token.add' ? snap : null, + }) + ); +} + +/** + * Routes attribute events. + * + * @param {string} type attribute.add / change / destroy. + * @param {object} obj Roll20 attribute. + * @param {object} [prev] Previous values. + * @returns {void} + */ +function onAttribute(type, obj, prev) { + const pick = (src) => + src ? { current: src.current ?? src.get?.('current'), max: src.max ?? src.get?.('max') } : null; + const newSnap = + type === 'attribute.destroy' ? null : { current: obj.get('current'), max: obj.get('max') }; + const oldSnap = + type === 'attribute.change' + ? pick(prev) + : type === 'attribute.destroy' + ? { current: obj.get('current'), max: obj.get('max') } + : null; + const changed = type === 'attribute.change' ? changedProps(oldSnap, newSnap) : []; + if (type === 'attribute.change' && !changed.length) { + return; + } + routeEvent( + envelope({ + type, + objectType: 'attribute', + objectId: obj.id, + characterId: obj.get('_characterid'), + name: toText(obj.get('name')), + old: oldSnap, + new: newSnap, + changed, + }) + ); +} + +/** + * Parses turn order text defensively. + * + * @param {*} raw Turn order JSON. + * @returns {object[]} Entries. + */ +export function parseTurnOrder(raw) { + if (Array.isArray(raw)) { + return raw; + } + try { + const parsed = JSON.parse(toText(raw) || '[]'); + return Array.isArray(parsed) ? parsed.filter(isRecord) : []; + } catch { + return []; + } +} + +/** + * Describes a turn order entry. + * + * @param {object|undefined} entry Entry. + * @returns {{tokenId: string|null, custom: string, pr: *}|null} Description. + */ +function turnEntry(entry) { + if (!entry) { + return null; + } + const id = toText(entry.id); + return { + tokenId: id && id !== '-1' ? id : null, + custom: toText(entry.custom), + pr: entry.pr ?? null, + }; +} + +/** + * Classifies a turn order change: the current entry changed (a new turn), + * a new round anchor came up, or the list was only edited. + * + * @param {object} c Campaign. + * @param {object} prev Previous values. + * @returns {void} + */ +function onTurnOrder(c, prev) { + const before = parseTurnOrder(prev?.turnorder); + const after = parseTurnOrder(c.get('turnorder')); + const oldTurn = turnEntry(before[0]); + const newTurn = turnEntry(after[0]); + const key = (t) => (t ? `${t.tokenId}|${t.custom}` : ''); + if (key(oldTurn) !== key(newTurn) && newTurn) { + const token = newTurn.tokenId ? getObject('graphic', newTurn.tokenId) : null; + routeEvent( + envelope({ + type: 'turn.change', + objectType: newTurn.tokenId ? 'graphic' : null, + objectId: newTurn.tokenId, + pageId: token?.get('_pageid') || null, + characterId: toText(token?.get('represents')) || null, + name: token ? toText(token.get('name')) : newTurn.custom, + old: oldTurn, + new: newTurn, + value: newTurn.pr, + }) + ); + const anchor = toText(getConfig().roundAnchor || 'Round').toLowerCase(); + if (!newTurn.tokenId && newTurn.custom.toLowerCase().startsWith(anchor)) { + routeEvent( + envelope({ + type: 'turn.round', + name: newTurn.custom, + value: newTurn.pr, + old: oldTurn, + new: newTurn, + }) + ); + } + } else if (JSON.stringify(before) !== JSON.stringify(after)) { + routeEvent( + envelope({ + type: 'turnorder.edit', + value: after.length, + old: { count: before.length }, + new: { count: after.length }, + }) + ); + } +} + +/** + * Routes the turn tracker opening and closing. + * + * @param {object} c Campaign. + * @param {object} prev Previous values. + * @returns {void} + */ +function onInitiativePage(c, prev) { + const wasOpen = Boolean(prev?.initiativepage); + const isOpen = Boolean(c.get('initiativepage')); + if (wasOpen !== isOpen) { + routeEvent( + envelope({ + type: isOpen ? 'tracker.open' : 'tracker.close', + pageId: isOpen ? toText(c.get('initiativepage')) : null, + }) + ); + } +} + +/** + * Routes a change of the player ribbon page. + * + * @param {object} c Campaign. + * @param {object} prev Previous values. + * @returns {void} + */ +function onPlayerPage(c, prev) { + const from = toText(prev?.playerpageid); + const to = toText(c.get('playerpageid')); + if (from !== to) { + routeEvent( + envelope({ + type: 'page.change', + objectType: 'page', + objectId: to, + pageId: to, + old: { pageid: from }, + new: { pageid: to }, + }) + ); + } +} + +/** + * Routes players coming online or going offline. This is observed presence, + * not proof that a GM is active. + * + * @param {object} player Roll20 player. + * @returns {void} + */ +function onPlayerOnline(player) { + const online = Boolean(player.get('_online')); + routeEvent( + envelope({ + type: online ? 'player.online' : 'player.offline', + objectType: 'player', + objectId: player.id, + name: toText(player.get('displayname')), + actor: null, + }) + ); +} + +/** + * Routes ordinary chat to chat triggers. Whispers and rolls are ignored + * unless the GM enabled private chat triggers; text is kept only when a + * trigger matches. + * + * @param {object} msg Roll20 chat message. + * @returns {void} + */ +function onChat(msg) { + if (msg.type === 'api' || msg.playerid === 'API') { + return; + } + const entries = index().get('chat'); + if (!entries?.length) { + return; + } + const isPrivate = + msg.type === 'whisper' || + msg.type === 'gmrollresult' || + msg.type === 'rollresult' || + msg.target !== undefined; + for (const entry of entries) { + const trigger = entry.trigger; + const channels = trigger.channels || ['general', 'emote', 'desc']; + if (!channels.includes(msg.type) && !(isPrivate && trigger.private)) { + continue; + } + if (isPrivate && !(trigger.private && getConfig().privateChatTriggers)) { + continue; + } + const text = truncate(toText(msg.content), 500); + const lower = text.toLowerCase(); + const m = trigger.match; + const hit = + (typeof m.equals === 'string' && lower === m.equals.toLowerCase()) || + (typeof m.contains === 'string' && lower.includes(m.contains.toLowerCase())) || + (typeof m.startsWith === 'string' && lower.startsWith(m.startsWith.toLowerCase())); + if (hit) { + handleTrigger( + entry, + envelope({ type: 'chat', name: msg.type, value: text, actor: msg.playerid || null }) + ); + } + } +} + +/** + * Registers the shared native subscriptions once, after ready, so the add + * events Roll20 replays at startup are not mistaken for new objects. + * + * @param {(label: string, fn: Function) => Function} guarded Error wrapper. + * @returns {void} + */ +export function registerNativeHandlers(guarded) { + onEvent('change:graphic', guarded('change:graphic', onGraphicChange)); + onEvent( + 'add:graphic', + guarded('add:graphic', (obj) => onGraphicLifecycle('token.add', obj)) + ); + onEvent( + 'destroy:graphic', + guarded('destroy:graphic', (obj) => onGraphicLifecycle('token.destroy', obj)) + ); + onEvent( + 'change:attribute', + guarded('change:attribute', (obj, prev) => onAttribute('attribute.change', obj, prev)) + ); + onEvent( + 'add:attribute', + guarded('add:attribute', (obj) => onAttribute('attribute.add', obj)) + ); + onEvent( + 'destroy:attribute', + guarded('destroy:attribute', (obj) => onAttribute('attribute.destroy', obj)) + ); + onEvent('change:campaign:turnorder', guarded('change:campaign:turnorder', onTurnOrder)); + onEvent( + 'change:campaign:initiativepage', + guarded('change:campaign:initiativepage', onInitiativePage) + ); + onEvent('change:campaign:playerpageid', guarded('change:campaign:playerpageid', onPlayerPage)); + onEvent('change:player:_online', guarded('change:player:_online', onPlayerOnline)); + onEvent('chat:message', guarded('chat:message', onChat)); +} + +// --------------------------------------------------------------------------- +// Routing +// --------------------------------------------------------------------------- + +/** + * Returns the trigger index, rebuilding it when stale. + * + * @returns {Map} Index. + */ +function index() { + if (!triggerIndex) { + triggerIndex = buildTriggerIndex(); + } + return triggerIndex; +} + +/** + * Queues an internal event (a signal from a task, a write by this Mod, a + * task lifecycle event). Internal events are routed by a scheduler job so a + * task never re-enters the router while it runs. + * + * @param {object} fields Event fields. + * @returns {void} + */ +export function emitInternal(fields) { + const depth = fields.correlation?.depth ?? 0; + if (depth > getLimits().ancestryDepth) { + count('dropped'); + diag(`internal event dropped (ancestry ${depth}): ${fields.type}`); + return; + } + internalQueue.push(envelope({ ...fields, source: fields.source || 'internal' })); + if (internalQueue.length > 500) { + internalQueue.splice(0, internalQueue.length - 500); + count('dropped'); + } + schedule({ kind: 'internalEvents', dueAt: now(), key: 'internalEvents' }); +} + +/** + * Routes queued internal events in a bounded batch. + * + * @returns {void} + */ +function drainInternal() { + for (const event of internalQueue.splice(0, 50)) { + routeEvent(event); + } + if (internalQueue.length) { + schedule({ kind: 'internalEvents', dueAt: now() + 10, key: 'internalEvents' }); + } +} + +/** + * Routes one event: first to runs awaiting it, then to triggers. + * + * @param {object} event Normalised event. + * @returns {void} + */ +export function routeEvent(event) { + if (runtime.disabled || !runtime.ready) { + return; + } + if (event.source === 'native' && isEcho(event)) { + count('suppressed'); + return; + } + updateBaselines(event); + if (runtime.admissionsHeld) { + return; + } + deliverToAwaits(event); + for (const entry of index().get(event.type) || []) { + handleTrigger(entry, event); + } +} + +/** + * Delivers an event to the first matching awaiting branch of each run. + * + * @param {object} event Event. + * @returns {void} + */ +function deliverToAwaits(event) { + if (!awaitIndex) { + awaitIndex = buildAwaitIndex(); + } + const waits = awaitIndex.get(event.type); + if (!waits?.length) { + return; + } + for (const entry of [...waits]) { + if (entry.branch.wait?.kind !== 'event' || entry.branch.wait.event !== event.type) { + continue; + } + if (!allowSource(event, entry.inst.definitionId)) { + continue; + } + if (matchFilter(entry.wait.filter, event, { inst: entry.inst, branch: entry.branch })) { + deliverEvent(entry.runId, entry.branchId, event); + } + } +} + +/** + * Returns true when an event's source may drive a definition. Changes made + * by tasks themselves only trigger definitions that opt in. + * + * @param {object} event Event. + * @param {string} defId Definition ID. + * @param {object} [trigger] Trigger. + * @returns {boolean} True when allowed. + */ +function allowSource(event, defId, trigger) { + if (event.source !== 'internal') { + return true; + } + if (!SELF_SENSITIVE.has(event.type.split('.')[0])) { + return true; + } + const def = getSnapshot(defId); + return Boolean(def?.access?.selfTrigger && (trigger ? trigger.selfTrigger : true)); +} + +/** + * Tests an event against a filter. Cheap list checks run before the + * condition expression. + * + * @param {object} filter Filter. + * @param {object} event Event. + * @param {{inst?: object, branch?: object}} [scope] Run scope for awaits. + * @returns {boolean} True on a match. + */ +export function matchFilter(filter, event, scope = {}) { + if (!filter) { + return true; + } + const inList = (list, value) => !Array.isArray(list) || list.includes(value); + if (!inList(filter.objectIds, event.objectId) || !inList(filter.pageIds, event.pageId)) { + return false; + } + if (!inList(filter.characterIds, event.characterId) || !inList(filter.names, event.name)) { + return false; + } + if (filter.attribute !== undefined && event.name !== filter.attribute) { + return false; + } + if (filter.signal !== undefined && !(event.type === 'signal' && event.name === filter.signal)) { + return false; + } + if (filter.definitionId !== undefined && event.name !== filter.definitionId) { + return false; + } + if (filter.adapter !== undefined && event.adapter !== filter.adapter) { + return false; + } + if ( + Array.isArray(filter.properties) && + !(event.changed || []).some((p) => filter.properties.includes(p)) + ) { + return false; + } + for (const [prop, value] of Object.entries(filter.changedTo || {})) { + if (event.new?.[prop] !== value || event.old?.[prop] === value) { + return false; + } + } + for (const [prop, value] of Object.entries(filter.changedFrom || {})) { + if (event.old?.[prop] !== value || event.new?.[prop] === value) { + return false; + } + } + if (filter.crossing) { + const { property, direction, threshold } = filter.crossing; + const before = parseStrictNumber(event.old?.[property]); + const after = parseStrictNumber(event.new?.[property]); + if (before === null || after === null) { + return false; + } + // Crossing only: staying below (or above) the threshold does not fire again. + const crossed = + direction === 'below' + ? before > threshold && after <= threshold + : before < threshold && after >= threshold; + if (!crossed) { + return false; + } + } + if (filter.binding !== undefined) { + const ids = scope.inst?.bindings?.[filter.binding] || []; + if (!ids.includes(event.objectId) && !ids.includes(event.characterId)) { + return false; + } + } + if (filter.condition !== undefined) { + const value = evaluate(filter.condition, { + resolve: (path) => { + const parts = path.split('.'); + const root = parts.shift(); + if (root === 'event') { + return readPath(event, parts); + } + if (root === 'now') { + return { found: true, value: now() }; + } + if (root === 'param' && scope.inst) { + return readPath(scope.inst.params, parts); + } + if (root === 'var' && scope.branch) { + return readPath(scope.branch.variables, parts); + } + if (root === 'run' && scope.inst && parts[0] === 'id') { + return { found: true, value: scope.inst.id }; + } + return { found: false }; + }, + }); + if (value === MISSING || !truthy(value)) { + return false; + } + } + return true; +} + +/** + * Applies a trigger's filters and rate controls, then admits a run. + * + * @param {{defId: string, trigger: object, def: object}} entry Index entry. + * @param {object} event Event. + * @returns {void} + */ +function handleTrigger(entry, event) { + const { defId, trigger } = entry; + const record = getRecord(defId); + if (!record?.enabled) { + return; + } + if (!allowSource(event, defId, trigger)) { + return; + } + if (trigger.type === 'signal' && event.name !== trigger.name) { + return; + } + if ( + trigger.type === 'signal' && + event.source === 'command' && + !isGm(event.actor) && + !trigger.players + ) { + return; + } + if (trigger.type === 'event' && !matchFilter(trigger.filter, event)) { + return; + } + const s = getState(); + const coalesce = trigger.coalesce === 'objectId' && event.objectId ? `:${event.objectId}` : ''; + const key = `${defId}/${trigger.id}${coalesce}`; + const ts = + s.triggerState[key] || + (s.triggerState[key] = { + cooldownUntil: 0, + burst: null, + debounce: null, + suppressed: 0, + dropped: 0, + fired: 0, + }); + const at = now(); + // Burst cap: aggregate drops instead of logging each one. + const burst = trigger.burst || { count: getLimits().burstPerTrigger, windowMs: 10000 }; + if (!ts.burst || at - ts.burst.start > burst.windowMs) { + ts.burst = { start: at, count: 0 }; + } + ts.burst.count += 1; + if (ts.burst.count > burst.count) { + ts.dropped += 1; + count('dropped'); + if (ts.burst.count === burst.count + 1) { + createIncident({ + kind: 'missed', + cause: 'burstCap', + definitionId: defId, + count: 1, + group: `burst:${key}`, + range: { from: ts.burst.start, to: at }, + }); + } + return; + } + if (trigger.debounceMs) { + debounce(key, ts, entry, event, trigger); + return; + } + if (ts.cooldownUntil > at) { + ts.suppressed += 1; + count('suppressed'); + return; + } + fire(entry, event, ts); +} + +/** + * Applies trailing (or leading-edge) debounce. Only the latest event and a + * count are kept, persisted with the deadline. + * + * @param {string} key Trigger state key. + * @param {object} ts Trigger state. + * @param {object} entry Index entry. + * @param {object} event Event. + * @param {object} trigger Trigger. + * @returns {void} + */ +function debounce(key, ts, entry, event, trigger) { + const at = now(); + const active = ts.debounce && ts.debounce.dueAt > at; + if (trigger.leading && !active) { + ts.debounce = { + dueAt: at + trigger.debounceMs, + event: null, + count: 0, + leading: true, + defId: entry.defId, + triggerId: trigger.id, + }; + fire(entry, event, ts); + } else { + const previous = active ? ts.debounce.count : 0; + ts.debounce = { + dueAt: at + trigger.debounceMs, + event: trigger.leading ? null : compactEvent(event), + count: previous + 1, + leading: Boolean(trigger.leading), + defId: entry.defId, + triggerId: trigger.id, + }; + count('coalesced'); + } + schedule({ + kind: JOB.DEBOUNCE, + dueAt: ts.debounce.dueAt, + key: `debounce:${key}`, + target: { key }, + }); +} + +/** + * Fires a debounced trigger when its quiet period ends. + * + * @param {object} job Job. + * @returns {void} + */ +function onDebounce(job) { + const s = getState(); + const ts = s.triggerState[job.target.key]; + const pending = ts?.debounce; + if (!pending) { + return; + } + if (pending.dueAt > now()) { + schedule({ + kind: JOB.DEBOUNCE, + dueAt: pending.dueAt, + key: `debounce:${job.target.key}`, + target: job.target, + }); + return; + } + ts.debounce = null; + if (pending.leading || !pending.event) { + return; + } + const def = getSnapshot(pending.defId); + const trigger = def?.triggers?.find((t) => t.id === pending.triggerId); + if (!trigger) { + return; + } + fire({ defId: pending.defId, trigger, def }, { ...pending.event, count: pending.count }, ts); +} + +/** + * Admits a run for a trigger. + * + * @param {{defId: string, trigger: object, def: object}} entry Index entry. + * @param {object} event Event. + * @param {object} ts Trigger state. + * @returns {void} + */ +function fire(entry, event, ts) { + const { defId, trigger, def } = entry; + const human = + event.actor && event.actor !== 'API' && getObject('player', event.actor) ? event.actor : null; + const bindingIds = + trigger.bind?.from === 'event.characterId' ? event.characterId : event.objectId; + const explicit = {}; + if (trigger.bind?.binding && bindingIds) { + explicit[trigger.bind.binding] = [bindingIds]; + } + const resolved = resolveBindings(def, { + eventObjectId: bindingIds, + explicit, + params: {}, + unattended: !human, + }); + if (!resolved.ok) { + ts.suppressed += 1; + diag(`trigger ${defId}/${trigger.id} bindings failed: ${JSON.stringify(resolved.errors)}`); + createIncident({ + kind: 'missed', + cause: 'bindingFailed', + definitionId: defId, + count: 1, + group: `binding:${defId}/${trigger.id}`, + evidence: resolved.errors[0]?.code, + }); + return; + } + const result = admitRun(defId, { + initiatorId: def.access?.authority === 'gmService' ? null : human, + params: {}, + bindings: resolved.bindings, + event: compactEvent(event), + trigger: { type: trigger.type, id: trigger.id }, + correlation: { depth: event.correlation?.depth ?? 0 }, + }); + if (result.ok) { + ts.fired += 1; + if (trigger.cooldownMs) { + ts.cooldownUntil = now() + trigger.cooldownMs; + } + } else { + ts.suppressed += 1; + } +} + +/** + * Raises a signal from an authenticated command or a trusted adapter. + * + * @param {string} name Signal name. + * @param {*} value Scalar value. + * @param {string|null} actor Real player ID, or null. + * @param {string} [source='command'] command | adapter | internal. + * @returns {void} + */ +export function raiseSignal(name, value, actor, source = 'command') { + const event = envelope({ type: 'signal', name, value: value ?? null, actor, source }); + routeEvent(event); +} + +/** + * Lists the signal names players may raise. + * + * @returns {Set} Names. + */ +export function playerSignals() { + const names = new Set(); + for (const entry of index().get('signal') || []) { + if (entry.trigger.players) { + names.add(entry.trigger.name); + } + } + for (const { wait, inst } of eventWaits()) { + if ( + wait.event === 'signal' && + wait.filter?.signal && + getSnapshot(inst.definitionId)?.access?.capabilities?.signals + ) { + names.add(wait.filter.signal); + } + } + return names; +} + +// --------------------------------------------------------------------------- +// Offline reconciliation (opt-in) +// --------------------------------------------------------------------------- + +/** + * Returns the triggers that keep baselines. + * + * @returns {object[]} Index entries. + */ +function reconcilingTriggers() { + const out = []; + for (const entries of index().values()) { + for (const entry of entries) { + if (entry.trigger.reconcile && Array.isArray(entry.trigger.filter?.objectIds)) { + out.push(entry); + } + } + } + return out; +} + +/** + * Reads the watched values of an object for a baseline. + * + * @param {object} trigger Trigger. + * @param {string} id Object ID. + * @returns {object|null} Values. + */ +function baselineValues(trigger, id) { + const type = trigger.event.startsWith('attribute') ? 'attribute' : 'graphic'; + const obj = getObject(type, id); + if (!obj) { + return null; + } + const snap = + type === 'attribute' + ? { current: obj.get('current'), max: obj.get('max') } + : snapshot(obj, 'graphic'); + const props = trigger.filter.properties || Object.keys(snap); + return Object.fromEntries(props.filter((p) => p in snap).map((p) => [p, snap[p]])); +} + +/** + * Keeps baselines current as events are observed. + * + * @param {object} event Event. + * @returns {void} + */ +function updateBaselines(event) { + if (!event.objectId || event.source === 'reconciled') { + return; + } + const s = getState(); + for (const entry of reconcilingTriggers()) { + if (entry.trigger.filter.objectIds.includes(event.objectId)) { + const key = `${entry.defId}/${entry.trigger.id}`; + s.baselines[key] = s.baselines[key] || {}; + s.baselines[key][event.objectId] = baselineValues(entry.trigger, event.objectId); + } + } +} + +/** + * After a restart, compares watched objects with their saved baselines and + * routes a `reconciled` event for each difference. Intermediate values and + * crossings while the sandbox was off cannot be known. + * + * @returns {number} Reconciled events routed. + */ +export function reconcileBaselines() { + const s = getState(); + let routed = 0; + for (const entry of reconcilingTriggers()) { + const key = `${entry.defId}/${entry.trigger.id}`; + const saved = s.baselines[key] || {}; + for (const id of entry.trigger.filter.objectIds) { + const current = baselineValues(entry.trigger, id); + if (saved[id] && current && JSON.stringify(saved[id]) !== JSON.stringify(current)) { + const event = envelope({ + type: entry.trigger.event, + source: 'reconciled', + objectId: id, + old: saved[id], + new: current, + changed: changedProps(saved[id], current), + }); + handleTrigger(entry, event); + routed += 1; + } + s.baselines[key] = s.baselines[key] || {}; + s.baselines[key][id] = current; + } + } + return routed; +} + +/** + * Remembers the current turn order so the first change after a restart is + * compared with what was there before. + * + * @returns {void} + */ +export function snapshotCampaign() { + try { + getState().notices.turnorder = toText(campaign().get('turnorder')); + } catch (err) { + logLine(`could not read the turn order: ${err?.message || err}`); + } +} + +/** + * Registers the router's job handlers. + * + * @returns {void} + */ +export function registerTriggerJobs() { + onJob(JOB.DEBOUNCE, onDebounce); + onJob('internalEvents', drainInternal); +} + +/** + * Re-creates debounce jobs from persisted trigger state after a restart. + * + * @returns {void} + */ +export function rebuildTriggerJobs() { + const s = getState(); + for (const [key, ts] of Object.entries(s.triggerState)) { + if (ts.debounce) { + schedule({ + kind: JOB.DEBOUNCE, + dueAt: ts.debounce.dueAt, + key: `debounce:${key}`, + target: { key }, + }); + } + } +} diff --git a/TaskAutomation/src/utils.js b/TaskAutomation/src/utils.js new file mode 100644 index 0000000000..d942ff46df --- /dev/null +++ b/TaskAutomation/src/utils.js @@ -0,0 +1,562 @@ +// Small helpers with no Roll20 globals, so they can be unit tested directly. + +import { DURATION_UNITS } from './constants.js'; + +const UNSAFE_KEYS = new Set(['__proto__', 'prototype', 'constructor']); + +/** + * Returns true when a value is a plain non-null, non-array object. + * + * @param {*} value Value to test. + * @returns {boolean} True when the value is a record. + */ +export function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Converts a value to trimmed text, treating null and undefined as empty. + * + * @param {*} value Value to convert. + * @returns {string} Trimmed string. + */ +export function toText(value) { + return value === null || value === undefined ? '' : String(value).trim(); +} + +/** + * Escapes HTML special characters for text and attribute contexts. + * + * @param {*} text Text to escape. + * @returns {string} Escaped text. + */ +export function escapeHtml(text) { + return String(text ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +/** + * Removes characters that could change what a chat line does: line breaks + * (which start a new command), and leading slashes or `!` (which make the + * text a chat command or an API command). + * + * @param {*} text Text from a task parameter or object. + * @returns {string} Single-line text that cannot start a command. + */ +export function neutraliseChatText(text) { + return String(text ?? '') + .replaceAll(/[\r\n\u2028\u2029]+/g, ' ') + .replace(/^[\s/!]+/, ''); +} + +/** + * Parses a whole number, rejecting anything that is not entirely digits + * (with an optional leading minus sign). + * + * @param {*} raw Raw input. + * @returns {number|null} Parsed integer, or null when invalid. + */ +export function parseStrictInt(raw) { + if (typeof raw === 'number') { + return Number.isInteger(raw) ? raw : null; + } + const text = toText(raw); + return /^-?\d+$/.test(text) ? Number(text) : null; +} + +/** + * Parses a finite decimal number, rejecting partial matches such as "12abc". + * Empty text is not a number (it never becomes zero). + * + * @param {*} raw Raw input. + * @returns {number|null} Parsed number, or null when invalid. + */ +export function parseStrictNumber(raw) { + if (typeof raw === 'number') { + return Number.isFinite(raw) ? raw : null; + } + const text = toText(raw); + if (!/^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/.test(text)) { + return null; + } + const value = Number(text); + return Number.isFinite(value) ? value : null; +} + +/** + * Parses a yes/no style value. + * + * @param {*} raw Raw input. + * @returns {boolean|null} Parsed boolean, or null when not recognised. + */ +export function parseBoolean(raw) { + if (typeof raw === 'boolean') { + return raw; + } + const text = toText(raw).toLowerCase(); + if (['on', 'true', 'yes', '1'].includes(text)) { + return true; + } + if (['off', 'false', 'no', '0'].includes(text)) { + return false; + } + return null; +} + +/** + * Clamps a number into a range. + * + * @param {number} value Value. + * @param {number} min Minimum. + * @param {number} max Maximum. + * @returns {number} Clamped value. + */ +export function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +/** + * Parses a duration such as `500ms`, `10s`, `5m`, `2h` or `1d`. A bare + * number is rejected because its unit would be ambiguous. + * + * @param {*} raw Raw input. + * @returns {number|null} Milliseconds, or null when invalid. + */ +export function parseDuration(raw) { + const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(toText(raw).toLowerCase()); + if (!match) { + return null; + } + const value = Math.round(Number(match[1]) * DURATION_UNITS[match[2]]); + return Number.isFinite(value) ? value : null; +} + +/** + * Formats milliseconds as a short duration, e.g. `1h 5m`, `12s`, `250ms`. + * + * @param {number} ms Milliseconds. + * @returns {string} Duration text. + */ +export function formatDuration(ms) { + if (!Number.isFinite(ms)) { + return '—'; + } + const sign = ms < 0 ? '-' : ''; + let rest = Math.abs(Math.round(ms)); + if (rest < 1000) { + return `${sign}${rest}ms`; + } + const parts = []; + for (const [unit, size] of [ + ['d', DURATION_UNITS.d], + ['h', DURATION_UNITS.h], + ['m', DURATION_UNITS.m], + ['s', DURATION_UNITS.s], + ]) { + if (rest >= size) { + parts.push(`${Math.floor(rest / size)}${unit}`); + rest %= size; + } + if (parts.length === 2) { + break; + } + } + return sign + parts.join(' '); +} + +/** + * Parses an ISO 8601 date-time that states its offset (`Z` or `±hh:mm`). + * Local times without an offset are rejected: the sandbox has no reliable + * timezone, and guessing one would move schedules by hours. + * + * @param {*} raw Raw input. + * @returns {number|null} Epoch milliseconds, or null when invalid. + */ +export function parseIsoInstant(raw) { + const text = toText(raw); + const match = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|[+-]\d{2}:\d{2})$/.exec( + text + ); + if (!match) { + return null; + } + const [, y, mo, d, h, mi, s = '0', frac = '0', zone] = match; + const parts = [y, mo, d, h, mi, s].map(Number); + if (parts[1] < 1 || parts[1] > 12 || parts[3] > 23 || parts[4] > 59 || parts[5] > 59) { + return null; + } + const utc = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4], parts[5]); + // Reject dates such as 2026-02-30 that Date.UTC silently rolls over. + if (new Date(utc).getUTCDate() !== parts[2]) { + return null; + } + const ms = Number(frac.padEnd(3, '0')); + const offset = zone === 'Z' ? 0 : parseUtcOffset(zone); + return offset === null ? null : utc + ms - offset * 60000; +} + +/** + * Parses a fixed UTC offset such as `+01:00`, `-05:30` or `Z` into minutes. + * + * @param {*} raw Raw input. + * @returns {number|null} Offset in minutes east of UTC, or null when invalid. + */ +export function parseUtcOffset(raw) { + const text = toText(raw); + if (text === 'Z' || text === '+00:00' || text === '-00:00') { + return 0; + } + const match = /^([+-])(\d{2}):(\d{2})$/.exec(text); + if (!match) { + return null; + } + const hours = Number(match[2]); + const minutes = Number(match[3]); + if (hours > 14 || minutes > 59) { + return null; + } + return (match[1] === '-' ? -1 : 1) * (hours * 60 + minutes); +} + +/** + * Formats offset minutes as `+hh:mm`. + * + * @param {number} minutes Offset in minutes east of UTC. + * @returns {string} Offset text. + */ +export function formatUtcOffset(minutes) { + const sign = minutes < 0 ? '-' : '+'; + const abs = Math.abs(minutes); + const pad = (n) => String(n).padStart(2, '0'); + return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`; +} + +/** + * Formats epoch milliseconds as an ISO UTC string without milliseconds. + * + * @param {number} ms Epoch milliseconds. + * @returns {string} e.g. `2026-11-01T19:00:00Z`. + */ +export function formatUtc(ms) { + if (!Number.isFinite(ms)) { + return '—'; + } + return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +/** + * Splits command text into words. Double quotes group words, `\"` inside a + * quoted phrase is a literal quote, and `--json` takes the rest of the line + * as one raw value so JSON is never re-split. + * + * @param {string} text Command text. + * @returns {{words: {text: string, quoted: boolean}[], json: string|null, error: string|null}} + * Words, the raw JSON payload and an error code for unterminated quotes. + */ +export function tokenize(text) { + const source = String(text ?? ''); + const words = []; + let json = null; + let i = 0; + while (i < source.length) { + if (/\s/.test(source[i])) { + i += 1; + continue; + } + if (source.startsWith('--json', i) && (i + 6 === source.length || /\s/.test(source[i + 6]))) { + json = source.slice(i + 6).trim(); + break; + } + if (source[i] === '"') { + let value = ''; + let j = i + 1; + let closed = false; + while (j < source.length) { + if (source[j] === '\\' && (source[j + 1] === '"' || source[j + 1] === '\\')) { + value += source[j + 1]; + j += 2; + continue; + } + if (source[j] === '"') { + closed = true; + break; + } + value += source[j]; + j += 1; + } + if (!closed) { + return { words, json, error: 'unterminatedQuote' }; + } + words.push({ text: value, quoted: true }); + i = j + 1; + continue; + } + let j = i; + while (j < source.length && !/\s/.test(source[j])) { + j += 1; + } + words.push({ text: source.slice(i, j), quoted: false }); + i = j; + } + return { words, json, error: null }; +} + +/** + * Quotes a value for a command link so it survives the tokenizer. + * + * @param {*} value Value. + * @returns {string} Quoted text, or the bare value when it needs no quotes. + */ +export function quoteArg(value) { + const text = String(value ?? ''); + if (/^[A-Za-z0-9_.:@+-]+$/.test(text)) { + return text; + } + return `"${text.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`; +} + +/** + * Returns true when a key may be used in persisted or imported data. + * + * @param {string} key Key. + * @returns {boolean} True when safe. + */ +export function isSafeKey(key) { + return typeof key === 'string' && !UNSAFE_KEYS.has(key) && key.length <= 200; +} + +/** + * Checks that a value is plain JSON data: finite numbers, strings, booleans, + * null, arrays and plain objects with safe keys, without cycles, within depth + * and size limits. + * + * @param {*} value Value to check. + * @param {{maxDepth?: number, maxNodes?: number, maxString?: number}} [limits] Limits. + * @returns {{ok: boolean, error?: string, path?: string}} Result. + */ +export function checkPlainData(value, { maxDepth = 32, maxNodes = 20000, maxString = 20000 } = {}) { + const seen = new Set(); + let nodes = 0; + const visit = (node, depth, path) => { + nodes += 1; + if (nodes > maxNodes) { + return { ok: false, error: 'tooLarge', path }; + } + if (depth > maxDepth) { + return { ok: false, error: 'tooDeep', path }; + } + if (node === null || typeof node === 'boolean') { + return { ok: true }; + } + if (typeof node === 'number') { + return Number.isFinite(node) ? { ok: true } : { ok: false, error: 'notFinite', path }; + } + if (typeof node === 'string') { + return node.length <= maxString ? { ok: true } : { ok: false, error: 'stringTooLong', path }; + } + if (typeof node !== 'object') { + return { ok: false, error: 'notPlain', path }; + } + if (seen.has(node)) { + return { ok: false, error: 'cycle', path }; + } + // Plain objects only (any realm): no class instances, dates or maps. + const proto = Object.getPrototypeOf(node); + const plain = + proto === null || + (Object.getPrototypeOf(proto) === null && + Object.prototype.toString.call(node) === '[object Object]'); + if (!Array.isArray(node) && !plain) { + return { ok: false, error: 'notPlain', path }; + } + seen.add(node); + const entries = Array.isArray(node) ? node.map((v, k) => [k, v]) : Object.entries(node); + for (const [key, child] of entries) { + if (!Array.isArray(node) && !isSafeKey(key)) { + return { ok: false, error: 'unsafeKey', path: `${path}.${key}` }; + } + const result = visit(child, depth + 1, `${path}.${key}`); + if (!result.ok) { + return result; + } + } + seen.delete(node); + return { ok: true }; + }; + return visit(value, 0, '$'); +} + +/** + * Parses JSON text and checks it is plain data. Keys such as `__proto__` are + * rejected at any depth. + * + * @param {string} text JSON text. + * @param {number} maxBytes Maximum length. + * @returns {{ok: boolean, value?: *, error?: string, path?: string}} Result. + */ +export function parseJsonSafe(text, maxBytes) { + const raw = toText(text); + if (!raw) { + return { ok: false, error: 'emptyJson' }; + } + if (byteLength(raw) > maxBytes) { + return { ok: false, error: 'tooLarge' }; + } + let value; + try { + value = JSON.parse(raw); + } catch { + return { ok: false, error: 'badJson' }; + } + const check = checkPlainData(value); + return check.ok ? { ok: true, value } : check; +} + +/** + * Deep-copies plain JSON data. + * + * @template T + * @param {T} value Value. + * @returns {T} Copy. + */ +export function clone(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); +} + +/** + * Returns the UTF-8 byte length of text. + * + * @param {string} text Text. + * @returns {number} Bytes. + */ +export function byteLength(text) { + let bytes = 0; + for (const char of String(text)) { + const code = char.codePointAt(0); + bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4; + } + return bytes; +} + +/** + * Estimates the serialised size of a value. + * + * @param {*} value Value. + * @returns {number} Approximate bytes. + */ +export function estimateSize(value) { + try { + return byteLength(JSON.stringify(value) ?? ''); + } catch { + return Infinity; + } +} + +/** + * Hashes text with 32-bit FNV-1a. Used to skip identical handout writes. + * + * @param {string} text Text. + * @returns {string} Hex hash. + */ +export function hashText(text) { + let hash = 0x811c9dc5; + const source = String(text); + for (let i = 0; i < source.length; i += 1) { + hash ^= source.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, '0'); +} + +/** + * Returns an opaque random token for confirmations. Not a secret against + * other Mods in the sandbox; it binds a click to one previewed operation. + * + * @param {number} counter Monotonic counter mixed in so tokens never repeat. + * @returns {string} Token. + */ +export function randomToken(counter) { + const part = () => Math.floor(Math.random() * 0x100000000).toString(36); + return `${part()}${part()}${counter.toString(36)}`; +} + +/** + * Turns a name into a definition ID: lower case letters, digits and dashes. + * + * @param {string} name Name. + * @returns {string} Slug, possibly empty. + */ +export function slugify(name) { + return toText(name) + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '-') + .replaceAll(/^-+|-+$/g, '') + .slice(0, 48); +} + +/** + * Splits a token's `statusmarkers` text into entries. + * + * @param {*} raw statusmarkers value. + * @returns {{tag: string, count: number|null}[]} Markers. + */ +export function parseMarkers(raw) { + return toText(raw) + .split(',') + .filter(Boolean) + .map((entry) => { + const at = entry.lastIndexOf('@'); + if (at > 0 && /^\d+$/.test(entry.slice(at + 1))) { + return { tag: entry.slice(0, at), count: Number(entry.slice(at + 1)) }; + } + return { tag: entry, count: null }; + }); +} + +/** + * Joins marker entries back into `statusmarkers` text. + * + * @param {{tag: string, count: number|null}[]} markers Markers. + * @returns {string} statusmarkers value. + */ +export function formatMarkers(markers) { + return markers.map((m) => (m.count === null ? m.tag : `${m.tag}@${m.count}`)).join(','); +} + +/** + * Truncates text for logs and the dashboard. + * + * @param {*} text Text. + * @param {number} [max=200] Maximum length. + * @returns {string} Truncated text. + */ +export function truncate(text, max = 200) { + const value = String(text ?? ''); + return value.length > max ? `${value.slice(0, max - 1)}…` : value; +} + +/** + * Reads a dotted path from plain data without walking prototypes. + * + * @param {*} root Root value. + * @param {string[]} parts Path segments. + * @returns {{found: boolean, value?: *}} Result. + */ +export function readPath(root, parts) { + let current = root; + for (const part of parts) { + if (!isSafeKey(part) || current === null || typeof current !== 'object') { + return { found: false }; + } + if (!Object.prototype.hasOwnProperty.call(current, part)) { + return { found: false }; + } + current = current[part]; + } + return current === undefined ? { found: false } : { found: true, value: current }; +} From 3964c7e04cc624fe1f628349fc731c5e82e629bb Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Thu, 29 Oct 2026 08:05:50 +0000 Subject: [PATCH 03/13] test(TaskAutomation): Add unit, end-to-end, recovery and security tests Covers the scheduler and heap, expressions, recurrence, the validator and state migrations directly, and runs the built bundle in a Roll20 stub with fake timers for the worked examples, engine behaviour, restart fault injection, permissions, injection and the dashboard. Every end-to-end test asserts that the Mod never held more than one timer. Signed-off-by: Steve Roberts --- TaskAutomation/tests/core.test.mjs | 396 ++++++++++++++++ TaskAutomation/tests/engine.test.mjs | 577 ++++++++++++++++++++++++ TaskAutomation/tests/examples.test.mjs | 308 +++++++++++++ TaskAutomation/tests/load.test.mjs | 84 ++++ TaskAutomation/tests/recovery.test.mjs | 327 ++++++++++++++ TaskAutomation/tests/roll20-stub.mjs | 371 +++++++++++++++ TaskAutomation/tests/scheduler.test.mjs | 247 ++++++++++ TaskAutomation/tests/security.test.mjs | 260 +++++++++++ 8 files changed, 2570 insertions(+) create mode 100644 TaskAutomation/tests/core.test.mjs create mode 100644 TaskAutomation/tests/engine.test.mjs create mode 100644 TaskAutomation/tests/examples.test.mjs create mode 100644 TaskAutomation/tests/load.test.mjs create mode 100644 TaskAutomation/tests/recovery.test.mjs create mode 100644 TaskAutomation/tests/roll20-stub.mjs create mode 100644 TaskAutomation/tests/scheduler.test.mjs create mode 100644 TaskAutomation/tests/security.test.mjs diff --git a/TaskAutomation/tests/core.test.mjs b/TaskAutomation/tests/core.test.mjs new file mode 100644 index 0000000000..033bc3f935 --- /dev/null +++ b/TaskAutomation/tests/core.test.mjs @@ -0,0 +1,396 @@ +// Pure core: utilities, expressions, recurrence, the validator and state +// migrations (plan §4.3, §5, §12.1, §15). + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { MISSING, evaluate, renderTemplate, validateExpression } from '../src/expressions.js'; +import { + countBetween, + listFrom, + nextAtOrAfter, + nextAfter, + normaliseSpec, +} from '../src/recurrence.js'; +import { + blockAlwaysSuspends, + compileDefinition, + findCallCycle, + validateDefinition, +} from '../src/schema.js'; +import { TEMPLATES } from '../src/templates.js'; +import { + checkPlainData, + neutraliseChatText, + parseDuration, + parseIsoInstant, + parseJsonSafe, + parseStrictNumber, + parseUtcOffset, + quoteArg, + tokenize, +} from '../src/utils.js'; + +const DAY = 24 * 3600 * 1000; +const limits = { minIntervalMs: 1000 }; +const ctx = (values) => ({ + resolve: (path) => (path in values ? { found: true, value: values[path] } : { found: false }), +}); + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +test('tokenizer keeps quoted phrases, escaped quotes and a raw --json payload', () => { + const { words, json } = tokenize( + ' definition create --name "Say \\"hi\\" now" --json {"a": "--b c"}' + ); + assert.deepEqual( + words.map((w) => w.text), + ['definition', 'create', '--name', 'Say "hi" now'] + ); + assert.equal(json, '{"a": "--b c"}'); + assert.equal(tokenize('say "open').error, 'unterminatedQuote'); + assert.equal(quoteArg('run-42'), 'run-42'); + assert.equal(tokenize(`x ${quoteArg('a "b" c')}`).words[1].text, 'a "b" c'); +}); + +test('durations need a unit and instants need an offset', () => { + assert.equal(parseDuration('500ms'), 500); + assert.equal(parseDuration('10s'), 10000); + assert.equal(parseDuration('5m'), 300000); + assert.equal(parseDuration('2h'), 7200000); + assert.equal(parseDuration('1d'), DAY); + assert.equal(parseDuration('10'), null); + assert.equal(parseDuration('1w'), null); + assert.equal(parseIsoInstant('2026-11-01T19:00:00Z'), Date.UTC(2026, 10, 1, 19)); + assert.equal(parseIsoInstant('2026-11-01T19:00:00+01:00'), Date.UTC(2026, 10, 1, 18)); + assert.equal(parseIsoInstant('2026-11-01T19:00:00'), null); + assert.equal(parseIsoInstant('2026-02-30T10:00Z'), null); + assert.equal(parseUtcOffset('-05:30'), -330); + assert.equal(parseUtcOffset('+15:00'), null); +}); + +test('malformed numbers are not zero', () => { + assert.equal(parseStrictNumber('12'), 12); + assert.equal(parseStrictNumber('-1.5'), -1.5); + assert.equal(parseStrictNumber(''), null); + assert.equal(parseStrictNumber('12abc'), null); + assert.equal(parseStrictNumber('Infinity'), null); +}); + +test('imported JSON rejects prototype keys, cycles and oversize input', () => { + assert.equal(parseJsonSafe('{"__proto__": {"x": 1}}', 1000).error, 'unsafeKey'); + assert.equal(parseJsonSafe('{"a": {"constructor": 1}}', 1000).error, 'unsafeKey'); + assert.equal(parseJsonSafe('{"a": 1}', 3).error, 'tooLarge'); + assert.equal(parseJsonSafe('{bad', 100).error, 'badJson'); + const loop = {}; + loop.self = loop; + assert.equal(checkPlainData(loop).error, 'cycle'); + assert.equal(checkPlainData({ d: new Date() }).error, 'notPlain'); + let deep = {}; + for (let i = 0; i < 40; i += 1) { + deep = { deep }; + } + assert.equal(checkPlainData(deep).error, 'tooDeep'); +}); + +test('chat values cannot start commands or add lines', () => { + assert.equal(neutraliseChatText('/w gm secret'), 'w gm secret'); + assert.equal(neutraliseChatText('!token-mod --set'), 'token-mod --set'); + assert.equal(neutraliseChatText('hi\n/em dances'), 'hi /em dances'); +}); + +// --------------------------------------------------------------------------- +// Expressions +// --------------------------------------------------------------------------- + +test('comparisons do not coerce and missing values stay missing', () => { + assert.equal( + evaluate( + { op: 'lte', left: { ref: 'event.new.bar1_value', as: 'number' }, right: 0 }, + ctx({ 'event.new.bar1_value': '0' }) + ), + true + ); + assert.equal(evaluate({ op: 'eq', left: { ref: 'x' }, right: 0 }, ctx({ x: '0' })), false); + assert.equal(evaluate({ op: 'lt', left: { ref: 'missing' }, right: 1 }, ctx({})), false); + assert.equal(evaluate({ op: 'add', left: { ref: 'missing' }, right: 1 }, ctx({})), MISSING); + assert.equal(evaluate({ ref: 'x', as: 'number' }, ctx({ x: 'abc' })), MISSING); + assert.equal(evaluate({ ref: 'x', as: 'number', default: 5 }, ctx({ x: 'abc' })), 5); + assert.equal(evaluate({ op: 'div', left: 1, right: 0 }, ctx({})), MISSING); + assert.equal(evaluate({ op: 'exists', arg: { ref: 'x' } }, ctx({})), false); + assert.equal(evaluate({ op: 'not', arg: { ref: 'x' } }, ctx({})), MISSING); +}); + +test('logic, strings, markers and crossings', () => { + const c = ctx({ a: 3, s: 'Hello', m: 'red,dead@2', old: '5', new: '0' }); + assert.equal( + evaluate( + { + op: 'all', + args: [ + { op: 'gt', left: { ref: 'a' }, right: 1 }, + { op: 'startsWith', left: { ref: 's' }, right: 'He' }, + ], + }, + c + ), + true + ); + assert.equal(evaluate({ op: 'concat', args: [{ ref: 's' }, ' ', { ref: 'a' }] }, c), 'Hello 3'); + assert.equal(evaluate({ op: 'hasMarker', left: { ref: 'm' }, right: 'dead' }, c), true); + assert.equal(evaluate({ op: 'crossBelow', args: [{ ref: 'old' }, { ref: 'new' }, 0] }, c), true); + assert.equal(evaluate({ op: 'crossBelow', args: [{ ref: 'new' }, { ref: 'new' }, 0] }, c), false); + assert.equal(evaluate({ op: 'if', cond: false, then: 1, else: 2 }, c), 2); + assert.equal(evaluate({ op: 'randomInt', args: [1, 6] }, { ...c, random: () => 0.999 }), 6); +}); + +test('expression validation rejects unknown roots, operators, fields and huge trees', () => { + const roots = new Set(['event', 'var']); + assert.deepEqual( + validateExpression({ op: 'eq', left: { ref: 'event.x' }, right: 1 }, { roots }), + [] + ); + assert.equal(validateExpression({ ref: 'globalThis.state' }, { roots })[0].code, 'refRoot'); + assert.equal( + validateExpression({ ref: 'event.__proto__.x' }, { roots }).length, + 0, + 'path is checked at read time' + ); + assert.equal(validateExpression({ op: 'eval', args: [1] }, { roots })[0].code, 'badOperator'); + assert.equal( + validateExpression({ op: 'eq', left: 1, right: 2, extra: 1 }, { roots })[0].code, + 'unknownField' + ); + let deep = 1; + for (let i = 0; i < 30; i += 1) { + deep = { op: 'neg', arg: deep }; + } + assert.ok(validateExpression(deep, { roots }).some((e) => e.code === 'exprTooDeep')); +}); + +test('templates escape values and report missing ones', () => { + const out = renderTemplate( + 'Hi {{param.name}} {{param.none}}', + (p) => (p === 'param.name' ? { found: true, value: '' } : { found: false }), + (v) => v.replace('<', '<') + ); + assert.equal(out.text, 'Hi <b> '); + assert.deepEqual(out.missing, ['param.none']); +}); + +// --------------------------------------------------------------------------- +// Recurrence +// --------------------------------------------------------------------------- + +test('fixed-rate intervals are computed from the anchor, without drift', () => { + const anchor = Date.UTC(2026, 10, 1, 19); + const spec = normaliseSpec({ kind: 'interval', everyMs: 600000, anchor }, limits).spec; + assert.equal(nextAtOrAfter(spec, anchor - 5), anchor); + assert.equal(nextAfter(spec, anchor + 1), anchor + 600000); + assert.equal(countBetween(spec, anchor, anchor + 3600000), 7); +}); + +test('years of missed occurrences are counted arithmetically', () => { + const spec = normaliseSpec({ kind: 'interval', everyMs: 1000, anchor: 0 }, limits).spec; + const started = performance.now(); + const count = countBetween(spec, 0, 10 * 365 * DAY); + assert.equal(count, 10 * 365 * 86400 + 1); + const weekly = normaliseSpec( + { kind: 'weekly', time: '19:00', offsetMinutes: 0, days: 'mon,fri' }, + limits + ).spec; + assert.equal(countBetween(weekly, Date.UTC(2026, 0, 1), Date.UTC(2036, 0, 1)), 1044); + assert.ok(performance.now() - started < 50); +}); + +test('daily and weekly schedules use the fixed offset, across month, year and leap days', () => { + const daily = normaliseSpec({ kind: 'daily', time: '00:30', offsetMinutes: 60 }, limits).spec; + // 00:30 at +01:00 is 23:30 UTC the day before. + assert.equal(nextAtOrAfter(daily, Date.UTC(2028, 1, 28, 12)), Date.UTC(2028, 1, 28, 23, 30)); + assert.equal(nextAfter(daily, Date.UTC(2028, 1, 28, 23, 30)), Date.UTC(2028, 1, 29, 23, 30)); + assert.equal(nextAfter(daily, Date.UTC(2026, 11, 31, 23, 30)), Date.UTC(2027, 0, 1, 23, 30)); + const weekly = normaliseSpec( + { kind: 'weekly', time: '19:00', offsetMinutes: 0, days: ['fri'] }, + limits + ).spec; + // 2026-10-30 is a Friday. + assert.equal(nextAtOrAfter(weekly, Date.UTC(2026, 9, 29)), Date.UTC(2026, 9, 30, 19)); + // Changing the offset for summer time moves the UTC time an hour earlier. + const summer = { ...weekly, offsetMinutes: 60 }; + assert.equal(nextAtOrAfter(summer, Date.UTC(2026, 9, 29)), Date.UTC(2026, 9, 30, 18)); +}); + +test('windows and invalid specs', () => { + const anchor = Date.UTC(2026, 0, 1); + const spec = normaliseSpec( + { kind: 'interval', everyMs: DAY, anchor, startAt: anchor + 2 * DAY, endAt: anchor + 4 * DAY }, + limits + ).spec; + assert.deepEqual(listFrom(spec, anchor, 10), [ + anchor + 2 * DAY, + anchor + 3 * DAY, + anchor + 4 * DAY, + ]); + assert.equal(countBetween(spec, anchor, anchor + 30 * DAY), 3); + assert.equal( + normaliseSpec({ kind: 'interval', everyMs: 10, anchor }, limits).error, + 'intervalTooShort' + ); + assert.equal( + normaliseSpec({ kind: 'daily', time: '25:00', offsetMinutes: 0 }, limits).error, + 'badTime' + ); + assert.equal( + normaliseSpec({ kind: 'weekly', time: '10:00', offsetMinutes: 0, days: 'funday' }, limits) + .error, + 'badDays' + ); +}); + +// --------------------------------------------------------------------------- +// Validator +// --------------------------------------------------------------------------- + +test('every template is a valid definition', () => { + for (const tpl of TEMPLATES) { + const result = validateDefinition(tpl); + assert.deepEqual(result.errors, [], tpl.id); + const compiled = compileDefinition(tpl); + assert.ok(compiled.blocks.has('root')); + } +}); + +const minimal = (extra = {}) => ({ + schemaVersion: 1, + id: 'test', + name: 'Test', + triggers: [{ id: 'manual', type: 'manual' }], + steps: [{ id: 'say', type: 'chat.send', text: 'hi' }], + ...extra, +}); + +test('the validator rejects unknown fields, duplicates and bad references', () => { + const codes = (def) => validateDefinition(def).errors.map((e) => e.code); + assert.ok(codes(minimal({ extra: 1 })).includes('unknownField')); + assert.ok( + codes( + minimal({ + steps: [ + { id: 'a', type: 'wait', durationMs: 1000 }, + { id: 'a', type: 'wait', durationMs: 1000 }, + ], + }) + ).includes('duplicateId') + ); + assert.ok(codes(minimal({ steps: [{ id: 'a', type: 'nope' }] })).includes('unknownAction')); + assert.ok( + codes( + minimal({ + steps: [{ id: 'a', type: 'token.set', target: { binding: 'x' }, set: { left: 1 } }], + }) + ).includes('unknownBinding') + ); + assert.ok( + codes( + minimal({ + steps: [{ id: 'a', type: 'token.set', target: { ids: ['-a'] }, set: { imgsrc: 'x' } }], + }) + ).includes('badProperty') + ); + assert.ok( + codes(minimal({ steps: [{ id: 'a', type: 'wait', durationMs: 5 }] })).includes('waitTooShort') + ); + assert.ok(codes(minimal({ schemaVersion: 2 })).includes('schemaVersion')); + assert.ok( + codes(minimal({ steps: [{ id: 'a', type: 'chat.send', text: '{{globalThis.x}}' }] })).includes( + 'refRoot' + ) + ); +}); + +test('forever loops need permission and a wait on every pass', () => { + const loop = (steps, allow = true) => + minimal({ + policy: { allowForever: allow }, + steps: [{ id: 'l', type: 'loop', mode: 'forever', steps }], + }); + const codes = (def) => validateDefinition(def).errors.map((e) => e.code); + assert.deepEqual(codes(loop([{ id: 'w', type: 'wait', durationMs: 1000 }])), []); + assert.ok( + codes(loop([{ id: 'w', type: 'wait', durationMs: 1000 }], false)).includes('foreverNotAllowed') + ); + assert.ok(codes(loop([{ id: 's', type: 'chat.send', text: 'x' }])).includes('foreverNoWait')); + assert.ok( + codes( + loop([ + { + id: 'i', + type: 'if', + condition: true, + then: [{ id: 'w', type: 'wait', durationMs: 1000 }], + }, + ]) + ).includes('foreverNoWait') + ); + assert.equal( + blockAlwaysSuspends([{ id: 'w', type: 'wait', durationMs: { ref: 'param.x' } }]), + false + ); +}); + +test('unattended starts act as the GM service and cannot use selection', () => { + const def = minimal({ + bindings: { t: { from: 'selected', objectType: 'graphic' } }, + triggers: [{ id: 'e', type: 'event', event: 'token.change' }], + }); + const codes = validateDefinition(def).errors.map((e) => e.code); + assert.ok(codes.includes('unattendedNeedsService')); + assert.ok(codes.includes('selectionUnattended')); +}); + +test('player definitions cannot use GM-only actions or act as the GM', () => { + const def = minimal({ + access: { mode: 'players', players: ['p1'], authority: 'gmService' }, + steps: [ + { id: 'cmd', type: 'integration.command', command: '!token-mod --flip' }, + { id: 'a', type: 'attribute.set', target: { ids: ['-c'] }, attribute: 'hp', value: 1 }, + ], + }); + const codes = validateDefinition(def).errors.map((e) => e.code); + assert.ok(codes.includes('playersNeedInitiator')); + assert.ok(codes.includes('gmOnlyAction')); + assert.ok(codes.includes('notPlayerCapability')); +}); + +test('call cycles are found', () => { + const graph = { a: ['b'], b: ['c'], c: ['a'], d: [] }; + assert.deepEqual( + findCallCycle('a', (id) => graph[id] || []), + ['a', 'b', 'c', 'a'] + ); + assert.equal( + findCallCycle('d', (id) => graph[id] || []), + null + ); +}); + +test('limits on steps, depth and branches', () => { + let nested = [{ id: 'leaf', type: 'wait', durationMs: 1000 }]; + for (let i = 0; i < 10; i += 1) { + nested = [{ id: `l${i}`, type: 'loop', mode: 'count', count: 1, steps: nested }]; + } + assert.ok( + validateDefinition(minimal({ steps: nested })).errors.some((e) => e.code === 'tooDeep') + ); + const branches = Array.from({ length: 12 }, (_, i) => ({ + id: `b${i}`, + steps: [{ id: `w${i}`, type: 'wait', durationMs: 1000 }], + })); + assert.ok( + validateDefinition( + minimal({ steps: [{ id: 'p', type: 'parallel', join: 'all', branches }] }) + ).errors.some((e) => e.code === 'branchCount') + ); +}); diff --git a/TaskAutomation/tests/engine.test.mjs b/TaskAutomation/tests/engine.test.mjs new file mode 100644 index 0000000000..1af4ef3043 --- /dev/null +++ b/TaskAutomation/tests/engine.test.mjs @@ -0,0 +1,577 @@ +// End-to-end engine behaviour: flow steps, concurrency policies, pause and +// resume, cancellation, triggers, schedules and adapters (plan §4–§7, §11). + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { createSandbox, restart } from './roll20-stub.mjs'; + +/** + * Creates a ready game. + * + * @param {object} [options] Sandbox options. + * @returns {object} Sandbox API. + */ +function game(options) { + const api = createSandbox(options); + api.ready(); + return api; +} + +/** + * Fails when the Mod logged an error or held more than one timer. + * + * @param {object} api Sandbox API. + * @returns {void} + */ +function assertHealthy(api) { + assert.deepEqual( + api.logs.filter((line) => /ERROR/.test(line)), + [] + ); + assert.ok(api.maxLiveTimers() <= 1, `live timers peaked at ${api.maxLiveTimers()}`); +} + +/** + * Builds a definition. + * + * @param {string} id ID. + * @param {object[]} steps Steps. + * @param {object} [extra] Other fields. + * @returns {object} Definition. + */ +function def(id, steps, extra = {}) { + return { + schemaVersion: 1, + id, + name: id, + triggers: [{ id: 'manual', type: 'manual' }], + access: { mode: 'gmOnly', authority: 'initiator' }, + policy: { concurrency: 'allow', maxConcurrent: 5 }, + steps, + ...extra, + }; +} + +const say = (id, text) => ({ id, type: 'chat.send', text }); +const wait = (id, durationMs) => ({ id, type: 'wait', durationMs }); + +test('sequences keep their order across waits and loops count iterations', () => { + const api = game(); + api.importDef( + def('seq', [ + say('a', 'one'), + wait('w1', 1000), + { + id: 'l', + type: 'loop', + mode: 'count', + count: 3, + steps: [say('in', 'loop {{run.iteration}}'), wait('w2', 500)], + }, + { id: 'v', type: 'variable.set', name: 'total', value: { op: 'add', left: 40, right: 2 } }, + say('z', 'total {{var.total}}'), + ]) + ); + api.command('!taskautomation instance start --definition seq'); + api.clearChat(); + api.advance(5000); + const lines = api.chat.map((c) => c.text); + assert.deepEqual(lines, ['one', 'loop 1', 'loop 2', 'loop 3', 'total 42'].slice(1)); + assertHealthy(api); +}); + +test('conditions choose once and while/until loops are bounded', () => { + const api = game(); + api.importDef( + def('cond', [ + { id: 'set', type: 'variable.set', name: 'n', value: 0 }, + { + id: 'w', + type: 'loop', + mode: 'while', + condition: { op: 'lt', left: { ref: 'var.n' }, right: 3 }, + steps: [ + { + id: 'inc', + type: 'variable.set', + name: 'n', + value: { op: 'add', left: { ref: 'var.n' }, right: 1 }, + }, + ], + }, + { + id: 'u', + type: 'loop', + mode: 'until', + maxIterations: 5, + condition: false, + steps: [{ id: 'inc2', type: 'variable.set', name: 'm', value: { ref: 'run.iteration' } }], + }, + { + id: 'pick', + type: 'if', + condition: { op: 'eq', left: { ref: 'var.n' }, right: 3 }, + then: [say('yes', 'n is {{var.n}}, m is {{var.m}}')], + else: [say('no', 'wrong')], + }, + { + id: 'missing', + type: 'if', + condition: { op: 'gt', left: { ref: 'param.none' }, right: 0 }, + then: [say('bad', 'bad')], + else: [say('ok', 'missing is false')], + }, + ]) + ); + api.command('!taskautomation instance start --definition cond'); + api.advance(10); + assert.match(api.allText(), /n is 3, m is 5/); + assert.match(api.allText(), /missing is false/); + assert.equal(api.lastRun().status, 'completed'); + assertHealthy(api); +}); + +test('a forever loop without suspension is stopped by the runaway breaker', () => { + const api = game(); + // Built directly so the conservative validator's check is bypassed: the runtime limit is the second defence. + api.importDef( + def( + 'spin', + [ + { + id: 'l', + type: 'loop', + mode: 'forever', + steps: [{ id: 'w', type: 'wait', durationMs: 1000 }], + }, + ], + { policy: { allowForever: true } } + ) + ); + const record = api.taState().definitions.spin; + record.revisions[1].steps[0].steps = [{ id: 'v', type: 'variable.set', name: 'x', value: 1 }]; + api.command('!taskautomation instance start --definition spin'); + api.advance(1000); + const run = api.lastRun(); + assert.equal(run.status, 'failed'); + assert.equal(run.error.reason, 'runaway'); + assertHealthy(api); +}); + +test('concurrency: skipIfRunning, queue (pinned targets) and replace', () => { + const api = game(); + api.importDef(def('skip', [wait('w', 5000)], { policy: { concurrency: 'skipIfRunning' } })); + api.command('!taskautomation instance start --definition skip'); + api.command('!taskautomation instance start --definition skip'); + assert.match(api.allText(), /already running/); + assert.equal( + Object.values(api.taState().instances).filter((i) => i.definitionId === 'skip').length, + 1 + ); + + const t1 = api.token({ name: 'One' }); + const t2 = api.token({ name: 'Two' }); + api.importDef( + def( + 'q', + [wait('w', 1000), { id: 'm', type: 'marker.set', target: { binding: 't' }, marker: 'blue' }], + { + bindings: { t: { from: 'selected', objectType: 'graphic' } }, + policy: { concurrency: 'queue', maxConcurrent: 1, maxQueue: 2 }, + } + ) + ); + api.command('!taskautomation instance start --definition q', [t1]); + api.command('!taskautomation instance start --definition q', [t2]); + assert.match(api.allText(), /queued/); + api.advance(1000); + assert.equal(t1.attrs.statusmarkers, 'blue'); + assert.equal(t2.attrs.statusmarkers, ''); + api.advance(1000); + assert.equal(t2.attrs.statusmarkers, 'blue'); + + api.importDef( + def('rep', [wait('w', 5000)], { policy: { concurrency: 'replace', maxConcurrent: 1 } }) + ); + api.command('!taskautomation instance start --definition rep'); + const first = api.lastRun().id; + api.command('!taskautomation instance start --definition rep'); + assert.equal(api.run(first).status, 'cancelled'); + assert.equal(api.run(first).error.reason, 'replaced'); + assertHealthy(api); +}); + +test('pause keeps the remaining wait; resume continues from it', () => { + const api = game(); + api.importDef(def('p', [wait('w', 10000), say('done', 'done')])); + api.command('!taskautomation instance start --definition p'); + const id = api.lastRun().id; + api.advance(4000); + api.command(`!taskautomation instance pause --id ${id}`); + assert.equal(api.run(id).status, 'paused'); + api.advance(60000); + assert.doesNotMatch(api.allText(), /^done$/m); + api.command(`!taskautomation instance resume --id ${id}`); + api.advance(5999); + assert.equal(api.run(id).status, 'waiting'); + api.advance(1); + assert.equal(api.run(id).status, 'completed'); + assertHealthy(api); +}); + +test('stale revision links are refused', () => { + const api = game(); + api.importDef(def('p', [wait('w', 10000)])); + api.command('!taskautomation instance start --definition p'); + const run = api.lastRun(); + const rev = run.revision; + api.command(`!taskautomation instance pause --id ${run.id} --rev ${rev}`); + api.command(`!taskautomation instance pause --id ${run.id} --rev ${rev}`); + assert.match(api.allText(), /has changed since that button was made/); + assertHealthy(api); +}); + +test('cancelling a parent cancels owned children but not detached ones', () => { + const api = game(); + api.importDef(def('child', [wait('w', 60000)])); + api.importDef( + def('parent', [ + { id: 'detach', type: 'task.start', definition: 'child', saveAs: 'loose' }, + { id: 'owned', type: 'call', definition: 'child' }, + ]) + ); + api.command('!taskautomation instance start --definition parent'); + api.advance(10); + const parent = Object.values(api.taState().instances).find((i) => i.definitionId === 'parent'); + const children = Object.values(api.taState().instances).filter((i) => i.definitionId === 'child'); + assert.equal(children.length, 2); + api.command(`!taskautomation instance cancel --id ${parent.id}`); + const owned = children.find((c) => c.owned); + const loose = children.find((c) => !c.owned); + assert.equal(api.run(owned.id).status, 'cancelled'); + assert.equal(api.run(loose.id).status, 'waiting'); + assertHealthy(api); +}); + +test('parallel anySuccess cancels losers; race adopts the first outcome', () => { + const api = game(); + api.importDef( + def('any', [ + { + id: 'p', + type: 'parallel', + join: 'anySuccess', + branches: [ + { id: 'slow', steps: [wait('ws', 5000), say('s', 'slow finished')] }, + { + id: 'fast', + steps: [ + wait('wf', 1000), + { id: 'x', type: 'variable.set', name: 'won', value: 'fast' }, + ], + }, + ], + merge: [{ name: 'won', reducer: 'first' }], + }, + say('after', 'winner {{var.won}}'), + ]) + ); + api.command('!taskautomation instance start --definition any'); + api.advance(6000); + assert.match(api.allText(), /winner fast/); + assert.doesNotMatch(api.allText(), /slow finished/); + + api.importDef( + def('race', [ + { + id: 'p', + type: 'parallel', + join: 'race', + branches: [ + { + id: 'boom', + steps: [ + wait('wb', 500), + { id: 'bad', type: 'token.set', target: { ids: ['-missing'] }, set: { left: 1 } }, + ], + }, + { id: 'ok', steps: [wait('wo', 5000)] }, + ], + }, + ]) + ); + api.command('!taskautomation instance start --definition race'); + api.advance(1000); + assert.equal(api.lastRun().status, 'failed'); + assertHealthy(api); +}); + +test('call cycles are rejected at publish', () => { + const api = game(); + api.importDef(def('a', [{ id: 'c', type: 'call', definition: 'a' }])); + assert.match(api.allText(), /call each other in a circle/); + assert.equal(api.taState().definitions.a, undefined); +}); + +test('retries back off for transient errors; skip continues; review waits for the GM', () => { + const api = game(); + api.importDef( + def('retry', [ + { id: 'adapter', type: 'integration.call', adapter: 'nowhere', action: 'x', onError: 'skip' }, + { + id: 'missing', + type: 'token.set', + target: { ids: ['-gone'] }, + set: { left: 1 }, + onError: 'skip', + }, + say('end', 'reached the end'), + ]) + ); + api.command('!taskautomation instance start --definition retry'); + api.advance(10); + // An unknown adapter is never silently skipped: the run waits for the GM. + const run = api.lastRun(); + assert.equal(run.status, 'review'); + assert.equal(run.review.reason, 'adapterUnavailable'); + api.command(`!taskautomation instance decide --id ${run.id} --action skip`); + api.advance(10); + assert.match(api.allText(), /reached the end/); + assertHealthy(api); +}); + +test('the reference adapter acknowledges; a silent adapter puts the run in review', () => { + const api = game(); + api.importDef( + def('adapt', [ + { + id: 'echo', + type: 'integration.call', + adapter: 'reference', + action: 'delayedEcho', + input: { delayMs: 2000, word: 'hello' }, + saveAs: 'reply', + }, + say('said', 'adapter said {{var.reply.echo.word}}'), + { id: 'quiet', type: 'integration.call', adapter: 'reference', action: 'silent' }, + ]) + ); + api.command('!taskautomation instance start --definition adapt'); + api.advance(1999); + assert.doesNotMatch(api.allText(), /adapter said/); + api.advance(1); + assert.match(api.allText(), /adapter said hello/); + api.advance(2000); + const run = api.lastRun(); + assert.equal(run.status, 'review'); + assert.equal(run.review.reason, 'uncertainOutcome'); + // Retrying could duplicate the effect, so it needs a confirmation. + api.command(`!taskautomation instance decide --id ${run.id} --action retry`); + assert.match(api.allText(), /Retrying could do it twice/); + assertHealthy(api); +}); + +test('event triggers: debounce coalesces bursts, cooldown suppresses, self-changes are ignored', () => { + const api = game(); + const token = api.token({ name: 'Bard' }); + api.importDef( + def( + 'moved', + [ + { + id: 'm', + type: 'marker.set', + target: { binding: 't' }, + marker: 'yellow', + op: 'adjust', + count: 1, + }, + ], + { + bindings: { t: { from: 'event', objectType: 'graphic' } }, + access: { mode: 'gmOnly', authority: 'gmService' }, + triggers: [ + { + id: 'move', + type: 'event', + event: 'token.change', + filter: { properties: ['left'] }, + debounceMs: 1000, + bind: { binding: 't' }, + }, + ], + } + ) + ); + for (let i = 1; i <= 5; i += 1) { + api.change(token, { left: 105 + i * 70 }); + api.advance(200); + } + api.advance(1000); + assert.equal(token.attrs.statusmarkers, 'yellow@1'); + // The marker change made by the task is not a "move" and is internal anyway. + api.advance(5000); + assert.equal(token.attrs.statusmarkers, 'yellow@1'); + assert.ok(api.taState().stats.coalesced >= 4); + assertHealthy(api); +}); + +test('interval schedules: fixed rate, pause skipping the paused range, and downtime summaries', () => { + const api0 = game(); + api0.importDef( + def('tick', [say('t', 'tick')], { + access: { mode: 'gmOnly', authority: 'gmService' }, + policy: { concurrency: 'allow', maxConcurrent: 5 }, + }) + ); + api0.command('!taskautomation schedule add --definition tick --every 10s'); + assert.match(api0.allText(), /New Schedule|Create schedule/); + api0.clearChat(); + // Anchored now: the first tick is immediate. + api0.command('!taskautomation schedule add --definition tick --every 10s --yes'); + const sched = Object.values(api0.taState().schedules)[0]; + api0.advance(30000); + assert.equal(api0.chat.filter((c) => c.text === 'tick').length, 4); + api0.command(`!taskautomation schedule pause --id ${sched.id}`); + api0.advance(60000); + api0.clearChat(); + api0.command(`!taskautomation schedule resume --id ${sched.id}`); + assert.match(api0.allText(), /Times passed while paused: 5/); + api0.advance(9000); + assert.equal(api0.chat.filter((c) => c.text === 'tick').length, 1); + // Off for a day: the latest time is recent, so it runs once; the other + // 8,639 missed ticks are counted, not replayed. + api0.clearChat(); + const api = restart(api0, 24 * 3600 * 1000 + 5000); + api.advance(10); + assert.equal(api.chat.filter((c) => c.text === 'tick').length, 1); + const incident = Object.values(api.taState().incidents).find((i) => i.cause === 'downtime'); + assert.equal(incident.policy, 'coalesce'); + assert.ok(incident.count >= 8639); + assertHealthy(api); +}); + +test('one-off times missed by a lot are offered for review and recovered with a confirmation', () => { + let api = game(); + api.importDef( + def('once', [say('s', 'it is time')], { access: { mode: 'gmOnly', authority: 'gmService' } }) + ); + const at = new Date(api.now + 60000).toISOString().replace(/\.\d+Z$/, 'Z'); + api.command(`!taskautomation schedule add --definition once --at ${at} --yes`); + api = restart(api, 3600 * 1000); + api.advance(10); + assert.doesNotMatch(api.allText(), /it is time/); + const incident = Object.values(api.taState().incidents).find((i) => i.policy === 'review'); + assert.ok(incident); + api.command(`!taskautomation recovery preview --incident ${incident.id} --mode runOnce`); + const token = /recovery apply --token (\S+?)"/.exec(api.allChat())[1]; + api.command(`!taskautomation recovery apply --token ${token}`); + api.advance(10); + assert.match(api.allText(), /it is time/); + // The same token cannot be used twice. + api.command(`!taskautomation recovery apply --token ${token}`); + assert.match(api.allText(), /not valid. Preview again/); + assertHealthy(api); +}); + +test('catch-up admits a bounded number of missed occurrences', () => { + let api = game(); + api.importDef( + def('catch', [say('s', 'catch {{run.intendedDueAt}}')], { + access: { mode: 'gmOnly', authority: 'gmService' }, + policy: { + concurrency: 'allow', + maxConcurrent: 20, + overdue: { + mode: 'catchUpBounded', + windowMs: 1000, + catchUp: { max: 3, spacingMs: 500, maxAgeMs: 3600000 }, + }, + }, + }) + ); + api.command('!taskautomation schedule add --definition catch --every 1m --yes'); + // Back half-way between two times, so the latest one is already late. + api = restart(api, 30 * 60 * 1000 + 30000); + api.advance(2000); + assert.equal(api.chat.filter((c) => c.text.startsWith('catch')).length, 3); + assertHealthy(api); +}); + +test('fixed-delay intervals wait for the run to finish', () => { + const api = game(); + api.importDef( + def('slow', [wait('w', 5000), say('s', 'slow done')], { + access: { mode: 'gmOnly', authority: 'gmService' }, + policy: { concurrency: 'allow', maxConcurrent: 5 }, + }) + ); + api.command('!taskautomation schedule add --definition slow --every 10s --mode fixedDelay --yes'); + api.clearChat(); + api.advance(40000); + // Starts at 0, done at 5, next at 15, done at 20, next at 30, done at 35. + assert.equal(api.chat.filter((c) => c.text === 'slow done').length, 3); + assertHealthy(api); +}); + +test('the circuit breaker disables a definition started too often', () => { + const api = game(); + api.importDef( + def('busy', [say('s', 'x')], { + policy: { concurrency: 'allow', maxConcurrent: 100, circuit: { admissionsPerMinute: 3 } }, + }) + ); + for (let i = 0; i < 5; i += 1) { + api.command('!taskautomation instance start --definition busy'); + } + assert.equal(api.taState().definitions.busy.enabled, false); + assert.equal(api.taState().definitions.busy.disabledReason, 'circuit'); + assert.ok(Object.values(api.taState().incidents).some((i) => i.kind === 'circuit')); + assertHealthy(api); +}); + +test('stop-all needs a confirmation, cancels runs and holds new starts', () => { + const api = game(); + api.importDef(def('long', [wait('w', 60000)])); + api.command('!taskautomation instance start --definition long'); + api.command('!taskautomation stop-all --preview'); + const token = /stop-all --confirm (\S+?)"/.exec(api.allChat())[1]; + api.command(`!taskautomation stop-all --confirm ${token}`); + assert.equal(api.lastRun().status, 'cancelled'); + api.command('!taskautomation instance start --definition long'); + assert.match(api.allText(), /Everything is stopped/); + api.command('!taskautomation resume-all'); + api.command('!taskautomation instance start --definition long'); + assert.equal(api.lastRun().status, 'waiting'); + assertHealthy(api); +}); + +test('drafts: editing steps does not change running runs until republished', () => { + const api = game(); + api.command('!taskautomation definition create --name "Built here" --id built'); + api.command( + '!taskautomation definition policy --id built --json {"concurrency":"allow","maxConcurrent":3}' + ); + api.command( + '!taskautomation definition step add --id built --json {"id":"w","type":"wait","durationMs":2000}' + ); + api.command( + '!taskautomation definition step add --id built --after w --json {"id":"s","type":"chat.send","text":"version one"}' + ); + api.command('!taskautomation definition publish --id built'); + api.command('!taskautomation instance start --definition built'); + const first = api.lastRun().id; + api.command( + '!taskautomation definition step edit --id built --step s --json {"text":"version two"}' + ); + api.command('!taskautomation definition publish --id built'); + api.command('!taskautomation instance start --definition built'); + api.advance(3000); + assert.match(api.allText(), /version one/); + assert.match(api.allText(), /version two/); + assert.equal(api.run(first).definitionRevision, 1); + api.command('!taskautomation definition export --id built'); + assert.match(api.allText(), /"revision":2/); + assertHealthy(api); +}); diff --git a/TaskAutomation/tests/examples.test.mjs b/TaskAutomation/tests/examples.test.mjs new file mode 100644 index 0000000000..e694661833 --- /dev/null +++ b/TaskAutomation/tests/examples.test.mjs @@ -0,0 +1,308 @@ +// End-to-end: the seven worked examples of the user guide and the montage +// fixture, run in the stub sandbox (plan §12.4). Each test also checks the +// single-timer invariant and that nothing was logged as an error. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { createSandbox, restart, START } from './roll20-stub.mjs'; + +const MIN = 60 * 1000; + +/** + * Creates a ready game. + * + * @param {object} [options] Sandbox options. + * @returns {object} Sandbox API. + */ +export function game(options) { + const api = createSandbox(options); + api.ready(); + return api; +} + +/** + * Fails when the Mod logged an error or ever held more than one timer. + * + * @param {object} api Sandbox API. + * @returns {void} + */ +export function assertHealthy(api) { + assert.deepEqual( + api.logs.filter((line) => /ERROR/.test(line)), + [] + ); + assert.ok(api.maxLiveTimers() <= 1, `live timers peaked at ${api.maxLiveTimers()}`); +} + +test('montage: cycles a multi-sided token ten times, eight seconds apart', () => { + const api = game(); + api.command('!taskautomation template add montage'); + const token = api.sidedToken(); + api.command('!taskautomation instance start --definition montage', [token]); + const run = api.lastRun(); + assert.equal(run.bindings.tokens[0], token.id); + api.advance(1); + assert.equal(token.attrs.currentSide, 1); + assert.match(token.attrs.imgsrc, /images\/10002\/abc\/thumb\.png/); + api.advance(7998); + assert.equal(token.attrs.currentSide, 1, 'no early effect'); + api.advance(2); + assert.equal(token.attrs.currentSide, 2); + api.advance(80000); + assert.equal(api.run(run.id).status, 'completed'); + assert.equal(token.attrs.currentSide, 10 % 3); + assertHealthy(api); +}); + +test('torch reminder: warns after 50 minutes and goes out 10 minutes later', () => { + const api = game(); + api.command('!taskautomation template add torch-reminder'); + const torch = api.token({ name: 'Lyra', controlledby: 'p1' }); + api.command('!taskautomation instance start --definition torch-reminder', [torch]); + api.clearChat(); + api.advance(49 * MIN); + assert.doesNotMatch(api.allText(), /guttering/); + api.advance(1 * MIN); + assert.match(api.allText(), /Lyra's torch is guttering/); + assert.ok(api.chat.some((c) => c.text.startsWith('/w gm'))); + assert.ok(api.chat.some((c) => c.text.startsWith('/w "Player One"'))); + api.advance(10 * MIN); + assert.match(api.allText(), /Lyra's torch burns out/); + assert.equal(api.lastRun().status, 'completed'); + assertHealthy(api); +}); + +test('torch reminder: a restart after expiry gives one reviewable consequence, not a burst', () => { + let api = game(); + api.command('!taskautomation template add torch-reminder'); + const torch = api.token({ name: 'Lyra' }); + api.command('!taskautomation instance start --definition torch-reminder', [torch]); + const runId = api.lastRun().id; + api.advance(5 * MIN); + api = restart(api, 90 * MIN); + const run = api.run(runId); + assert.equal(run.status, 'review'); + assert.equal(run.review.reason, 'overdueWait'); + assert.doesNotMatch(api.allText(), /guttering|burns out/); + assert.match(api.allText(), /After the restart/); + // The GM chooses to continue: the warning is sent once, the final wait starts again. + api.command( + `!taskautomation instance decide --id ${runId} --action complete --rev ${run.revision}` + ); + assert.match(api.allText(), /guttering/); + assert.doesNotMatch(api.allText(), /burns out/); + api.advance(10 * MIN); + assert.match(api.allText(), /burns out/); + assertHealthy(api); +}); + +test('HP threshold: crossing to zero marks the token once; staying at zero does not refire', () => { + const api = game(); + api.command('!taskautomation template add hp-zero'); + api.command('!taskautomation definition enable --id hp-zero'); + const orc = api.token({ name: 'Orc', bar1_value: '5', statusmarkers: 'red' }); + api.change(orc, { bar1_value: '3' }); + assert.equal(api.lastRun(), undefined); + api.change(orc, { bar1_value: '0' }); + api.advance(10); + assert.equal(orc.attrs.statusmarkers, 'red,dead'); + assert.match(api.allText(), /Orc has dropped to 0 HP/); + const runs = Object.keys(api.taState().instances).length; + api.advance(2000); + api.change(orc, { bar1_value: '-2' }); + api.advance(10); + assert.equal(Object.keys(api.taState().instances).length, runs); + // Text that is not a number never counts as zero. + const ghost = api.token({ name: 'Ghost', bar1_value: '4' }); + api.change(ghost, { bar1_value: '' }); + assert.equal(Object.keys(api.taState().instances).length, runs); + assertHealthy(api); +}); + +test('turn response: a new current turn pings; editing initiative does not', () => { + const api = game(); + api.command('!taskautomation template add turn-response'); + api.command('!taskautomation definition enable --id turn-response'); + const a = api.token({ name: 'Aria' }); + const b = api.token({ name: 'Brom' }); + api.setTurnOrder([ + { id: a.id, pr: '15' }, + { id: b.id, pr: '10' }, + ]); + const first = api.lastRun(); + assert.equal(first.bindings.token[0], a.id); + api.command('!taskautomation signal turn-ready'); + api.advance(10); + assert.equal(api.pings.length, 1); + assert.match(api.allText(), /It is Aria's turn/); + const count = Object.keys(api.taState().instances).length; + // Editing initiative numbers without changing the current entry. + api.setTurnOrder([ + { id: a.id, pr: '16' }, + { id: b.id, pr: '10' }, + ]); + assert.equal(Object.keys(api.taState().instances).length, count); + // Without a signal the run waits 15 seconds, then pings anyway. + api.setTurnOrder([ + { id: b.id, pr: '10' }, + { id: a.id, pr: '16' }, + ]); + api.advance(14000); + assert.equal(api.pings.length, 1); + api.advance(1000); + assert.equal(api.pings.length, 2); + // Custom entries have no token. + api.setTurnOrder([ + { id: '-1', custom: 'Round 2', pr: '0' }, + { id: b.id, pr: '10' }, + ]); + api.advance(10); + assert.match(api.allText(), /Turn: Round 2/); + assertHealthy(api); +}); + +test('parallel scene: both branches run, join, then the cleanup task is chained', () => { + const api = game(); + const track = api.add('jukeboxtrack', { title: 'Ambience', playing: true, volume: 60 }); + const door = api.sidedToken({ name: 'Door' }); + api.command('!taskautomation template add parallel-scene'); + api.command('!taskautomation instance start --definition parallel-scene', [door]); + const run = api.lastRun(); + api.advance(10); + assert.equal(track.attrs.volume, 50); + api.advance(3000); + assert.equal(door.attrs.currentSide, 1); + assert.equal(track.attrs.volume, 20); + api.advance(2000); + assert.equal(track.attrs.volume, 10); + const child = Object.values(api.taState().instances).find((i) => i.parentId === run.id); + assert.ok(child, 'cleanup started'); + api.advance(100); + assert.equal(track.attrs.playing, false); + assert.equal(api.run(run.id).status, 'completed'); + assert.deepEqual(Object.keys(api.run(run.id).branches.main.variables.scene).sort(), [ + 'door', + 'music', + ]); + assertHealthy(api); +}); + +test('parallel scene: a failing branch fails the join after the other finishes', () => { + const api = game(); + api.add('jukeboxtrack', { title: 'Ambience', playing: true, volume: 60 }); + const plain = api.token({ name: 'Not a door' }); + api.command('!taskautomation template add parallel-scene'); + api.command('!taskautomation instance start --definition parallel-scene', [plain]); + const run = api.lastRun(); + api.advance(6000); + const finished = api.run(run.id); + assert.equal(finished.status, 'failed'); + assert.equal(finished.error.reason, 'notMultiSided'); + assertHealthy(api); +}); + +test('session reminder: weekly at a fixed offset, with a previewed offset change', () => { + // Thursday 2026-10-29 12:00 UTC. + const api = game({ now: Date.UTC(2026, 9, 29, 12) }); + api.command('!taskautomation template add session-reminder'); + api.command('!taskautomation definition enable --id session-reminder'); + const sched = Object.values(api.taState().schedules)[0]; + assert.equal(sched.cursor.nextDueAt, Date.UTC(2026, 9, 30, 19)); + api.command(`!taskautomation schedule offset --id ${sched.id} --offset +01:00`); + assert.match(api.allText(), /2026-10-30T19:00:00Z/); + assert.match(api.allText(), /2026-10-30T18:00:00Z/); + assert.equal(api.taState().schedules[sched.id].spec.offsetMinutes, 0, 'preview changes nothing'); + api.advance(31 * 3600 * 1000); + assert.match(api.allText(), /Session starts in 15 minutes/); + assertHealthy(api); +}); + +test('session reminder: missed while off runs once if recent, summarised if too old', () => { + let api = game({ now: Date.UTC(2026, 9, 30, 18) }); + api.command('!taskautomation template add session-reminder'); + api.command('!taskautomation definition enable --id session-reminder'); + // Off from 18:00 to 19:30: half an hour late, inside the two-hour age limit. + api = restart(api, 90 * MIN); + api.advance(10); + assert.match(api.allText(), /Session starts in 15 minutes/); + const run = Object.values(api.taState().instances).find( + (i) => i.definitionId === 'session-reminder' + ); + assert.equal(run.intendedDueAt, Date.UTC(2026, 9, 30, 19)); + // Off for three weeks and a day: the latest miss is a day old, too old to run. + api.clearChat(); + api = restart(api, 22 * 24 * 3600 * 1000); + api.advance(10); + assert.doesNotMatch(api.allText(), /Session starts/); + const incident = Object.values(api.taState().incidents).find( + (i) => i.policy === 'runOnce:tooOld' + ); + assert.equal(incident.count, 3); + assertHealthy(api); +}); + +test('other-Mod command: dispatched with the escaped token ID, shown as unconfirmed', () => { + const api = game(); + const token = api.token(); + api.command('!taskautomation template add token-mod-command'); + api.command('!taskautomation instance start --definition token-mod-command', [token]); + api.advance(10); + const sent = api.chat.find((c) => c.text.startsWith('!token-mod')); + assert.equal(sent.text, `!token-mod --ids ${token.id} --set statusmarkers|+blue`); + const run = api.lastRun(); + assert.equal(Object.values(run.journal)[0].status, 'dispatched'); + assertHealthy(api); +}); + +test('door sequence: opens on the signal, stays shut on timeout', () => { + const api = game(); + const door = api.sidedToken({ name: 'Door' }); + api.command('!taskautomation template add door-sequence'); + api.command('!taskautomation instance start --definition door-sequence', [door]); + api.advance(10); + api.command('!taskautomation signal door-open'); + api.advance(10); + assert.equal(door.attrs.currentSide, 1); + assert.match(api.allText(), /The door swings open/); + api.command('!taskautomation instance start --definition door-sequence', [door]); + api.advance(60000); + assert.match(api.allText(), /The door stays shut/); + assertHealthy(api); +}); + +test('door sequence: a restart records the observation gap and keeps the timeout', () => { + let api = game(); + const door = api.sidedToken({ name: 'Door' }); + api.command('!taskautomation template add door-sequence'); + api.command('!taskautomation instance start --definition door-sequence', [door]); + const runId = api.lastRun().id; + api.advance(10000); + api = restart(api, 20000); + const run = api.run(runId); + assert.ok(run.timeline.some((e) => e.kind === 'observationGap')); + assert.ok(Object.values(api.taState().incidents).some((i) => i.cause === 'observationGap')); + api.command('!taskautomation signal door-open'); + api.advance(10); + assert.equal(door.attrs.currentSide, 1); + assertHealthy(api); +}); + +test('the example JSON files match the templates', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const { TEMPLATES } = await import('../src/templates.js'); + const dir = path.join( + path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), + '..', + 'examples' + ); + for (const tpl of TEMPLATES) { + const file = JSON.parse(fs.readFileSync(path.join(dir, `${tpl.id}.json`), 'utf8')); + assert.deepEqual(file, tpl, tpl.id); + } +}); + +test('the sandbox starts at the expected time', () => { + assert.equal(new Date(START).toISOString(), '2026-11-01T12:00:00.000Z'); +}); diff --git a/TaskAutomation/tests/load.test.mjs b/TaskAutomation/tests/load.test.mjs new file mode 100644 index 0000000000..cd0dfb191a --- /dev/null +++ b/TaskAutomation/tests/load.test.mjs @@ -0,0 +1,84 @@ +// Load figures (plan §15.5): many definitions and runs, and a burst of due +// work, on one timer with bounded draining. These are stub measurements, +// not Roll20 timings; TESTING.md has the live checklist. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { createSandbox } from './roll20-stub.mjs'; + +test('100 definitions, 100 active runs and a 1,000-job burst stay on one timer', () => { + const api = createSandbox(); + api.ready(); + for (let i = 0; i < 100; i += 1) { + api.importDef({ + schemaVersion: 1, + id: `d${i}`, + name: `d${i}`, + triggers: [{ id: 'manual', type: 'manual' }], + access: { mode: 'gmOnly', authority: 'initiator' }, + policy: { concurrency: 'allow', maxConcurrent: 10, circuit: { admissionsPerMinute: 1000 } }, + steps: [ + { + id: 'l', + type: 'loop', + mode: 'count', + count: 10, + steps: [ + { id: 'v', type: 'variable.set', name: 'n', value: { ref: 'run.iteration' } }, + { id: 'w', type: 'wait', durationMs: 1000 }, + ], + }, + ], + }); + } + const started = performance.now(); + for (let i = 0; i < 100; i += 1) { + api.command(`!taskautomation instance start --definition d${i}`); + } + // Every run waits until the same second: 100 jobs due together, ten times. + api.advance(11000); + const elapsed = performance.now() - started; + const runs = Object.values(api.taState().instances); + assert.equal(runs.filter((r) => r.status === 'completed').length, runs.length); + assert.ok(api.maxLiveTimers() <= 1); + const size = JSON.stringify(api.taState()).length; + assert.ok(size < 1024 * 1024, `state ${size} bytes`); + console.log( + `load: 100 runs × 10 waits in ${Math.round(elapsed)} ms (stub), state ${Math.round(size / 1024)} KiB` + ); +}); + +test('a burst of 1,000 due jobs drains in bounded slices', async () => { + const { createScheduler } = await import('../src/scheduler.js'); + let clock = 0; + const timers = []; + let ran = 0; + const sched = createScheduler({ + now: () => clock, + setTimer: (fn, ms) => { + timers.push({ fn, at: clock + ms }); + return timers.length; + }, + clearTimer: (id) => { + timers[id - 1] = null; + }, + dispatch: () => { + ran += 1; + }, + }); + for (let i = 0; i < 1000; i += 1) { + sched.add({ kind: 'continue', dueAt: 0 }); + } + let wakes = 0; + while (ran < 1000 && wakes < 1000) { + const next = timers.findLast((t) => t); + timers[timers.indexOf(next)] = null; + clock = Math.max(clock, next.at); + next.fn(); + wakes += 1; + } + assert.equal(ran, 1000); + assert.equal(wakes, 40); + assert.equal(sched.stats().yields, 39); +}); diff --git a/TaskAutomation/tests/recovery.test.mjs b/TaskAutomation/tests/recovery.test.mjs new file mode 100644 index 0000000000..6973140823 --- /dev/null +++ b/TaskAutomation/tests/recovery.test.mjs @@ -0,0 +1,327 @@ +// Restart persistence and fault injection (plan §8, §15.2): restarts at +// action boundaries, during waits, joins and adapter calls, while paused, +// and with unreadable or newer state. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { SCHEMA_VERSION } from '../src/constants.js'; +import { createState, migrateState } from '../src/state.js'; +import { createSandbox, restart } from './roll20-stub.mjs'; + +/** + * Creates a ready game. + * + * @param {object} [options] Sandbox options. + * @returns {object} Sandbox API. + */ +function game(options) { + const api = createSandbox(options); + api.ready(); + return api; +} + +const base = (id, steps, extra = {}) => ({ + schemaVersion: 1, + id, + name: id, + triggers: [{ id: 'manual', type: 'manual' }], + access: { mode: 'gmOnly', authority: 'initiator' }, + policy: { concurrency: 'allow', maxConcurrent: 5 }, + steps, + ...extra, +}); + +/** + * Rewinds a run to "the action was prepared but not confirmed", as if the + * sandbox stopped in the middle of it. + * + * @param {object} run Instance (in persisted state). + * @param {string} stepId Action step. + * @param {number} [index=0] Position of the step in its block. + * @returns {void} + */ +function crashDuring(run, stepId, index = 0) { + const main = run.branches.main; + const entry = Object.values(run.journal).find((e) => e.stepId === stepId); + entry.status = 'prepared'; + main.wait = null; + main.status = 'running'; + main.frames.at(-1).index = index; + run.status = 'running'; +} + +test('a restart during a wait continues at the original deadline', () => { + let api = game(); + api.importDef( + base('w', [ + { id: 'w', type: 'wait', durationMs: 60000 }, + { id: 's', type: 'chat.send', text: 'after wait' }, + ]) + ); + api.command('!taskautomation instance start --definition w'); + api.advance(20000); + api = restart(api, 10000); + api.advance(29999); + assert.doesNotMatch(api.allText(), /after wait/); + api.advance(1); + assert.match(api.allText(), /after wait/); +}); + +test('a restart while paused stays paused; resume keeps the remaining time', () => { + let api = game(); + api.importDef( + base('w', [ + { id: 'w', type: 'wait', durationMs: 60000 }, + { id: 's', type: 'chat.send', text: 'after wait' }, + ]) + ); + api.command('!taskautomation instance start --definition w'); + const id = api.lastRun().id; + api.advance(20000); + api.command(`!taskautomation instance pause --id ${id}`); + api = restart(api, 3600 * 1000); + assert.equal(api.run(id).status, 'paused'); + api.command(`!taskautomation instance resume --id ${id}`); + api.advance(39999); + assert.doesNotMatch(api.allText(), /after wait/); + api.advance(1); + assert.match(api.allText(), /after wait/); +}); + +test('an interrupted side change that did happen is confirmed, not repeated', () => { + let api = game(); + api.command('!taskautomation template add montage'); + const token = api.sidedToken(); + api.command('!taskautomation instance start --definition montage', [token]); + api.advance(10); + assert.equal(token.attrs.currentSide, 1); + const run = api.lastRun(); + crashDuring(run, 'advance'); + api = restart(api, 1000); + assert.equal(token.attrs.currentSide, 1, 'not advanced twice'); + const entry = Object.values(api.run(run.id).journal).find((e) => e.stepId === 'advance'); + assert.equal(entry.status, 'confirmed'); + assert.equal(entry.reconciled, true); + assert.equal(api.run(run.id).status, 'waiting'); +}); + +test('an interrupted side change that cannot be confirmed goes to review', () => { + let api = game(); + api.command('!taskautomation template add montage'); + const token = api.sidedToken(); + api.command('!taskautomation instance start --definition montage', [token]); + api.advance(10); + const run = api.lastRun(); + crashDuring(run, 'advance'); + // Someone moved the token to another side meanwhile. + token.attrs.currentSide = 2; + api = restart(api, 1000); + const after = api.run(run.id); + assert.equal(after.status, 'review'); + assert.equal(after.review.reason, 'restartConflict'); + assert.equal(token.attrs.currentSide, 2); + assert.ok( + Object.values(api.taState().incidents).some((i) => i.kind === 'review' && i.runId === run.id) + ); +}); + +test('an interrupted absolute write is re-applied safely', () => { + let api = game(); + const token = api.token(); + api.importDef( + base( + 'move', + [ + { id: 'set', type: 'token.set', target: { binding: 't' }, set: { left: 455 } }, + { id: 'w', type: 'wait', durationMs: 5000 }, + ], + { + bindings: { t: { from: 'selected', objectType: 'graphic' } }, + } + ) + ); + api.command('!taskautomation instance start --definition move', [token]); + api.advance(10); + const run = api.lastRun(); + crashDuring(run, 'set'); + token.attrs.left = 105; + api = restart(api, 1000); + api.advance(10); + assert.equal(token.attrs.left, 455); + assert.equal(api.run(run.id).status, 'waiting'); +}); + +test('an interrupted chat message is never resent automatically', () => { + let api = game(); + api.importDef( + base('say', [ + { id: 'say', type: 'chat.send', text: 'only once' }, + { id: 'w', type: 'wait', durationMs: 5000 }, + ]) + ); + api.command('!taskautomation instance start --definition say'); + api.advance(10); + const run = api.lastRun(); + crashDuring(run, 'say'); + api.clearChat(); + api = restart(api, 1000); + api.advance(100); + assert.doesNotMatch(api.allText(), /^only once$/m); + assert.equal(api.run(run.id).status, 'review'); + assert.equal(api.run(run.id).review.reason, 'uncertainOutcome'); + api.command(`!taskautomation instance decide --id ${run.id} --action complete`); + api.advance(5000); + assert.equal(api.run(run.id).status, 'completed'); +}); + +test('an adapter call in flight at restart is checked, then reviewed', () => { + let api = game(); + api.importDef( + base('slow', [ + { + id: 'call', + type: 'integration.call', + adapter: 'reference', + action: 'delayedEcho', + input: { delayMs: 5000 }, + }, + ]) + ); + api.command('!taskautomation instance start --definition slow'); + api.advance(1000); + const id = api.lastRun().id; + api = restart(api, 1000); + assert.equal(api.run(id).status, 'review'); + assert.equal(api.run(id).review.reason, 'uncertainOutcome'); + // The old boot's callback is ignored even if it arrives. + api.advance(10000); + assert.equal(api.run(id).status, 'review'); +}); + +test('a child that finished just before a restart releases its parent', () => { + let api = game(); + api.importDef(base('kid', [{ id: 'w', type: 'wait', durationMs: 1000 }])); + api.importDef( + base('mum', [ + { id: 'c', type: 'call', definition: 'kid' }, + { id: 's', type: 'chat.send', text: 'child done' }, + ]) + ); + api.command('!taskautomation instance start --definition mum'); + api.advance(10); + const parent = Object.values(api.taState().instances).find((i) => i.definitionId === 'mum'); + const child = Object.values(api.taState().instances).find((i) => i.definitionId === 'kid'); + // The child finished but the parent was never told. + child.status = 'completed'; + child.completedAt = api.now; + api = restart(api, 1000); + api.advance(10); + assert.match(api.allText(), /child done/); + assert.equal(api.run(parent.id).status, 'completed'); +}); + +test('parallel branches restore after a restart and still join', () => { + let api = game(); + api.importDef( + base('par', [ + { + id: 'p', + type: 'parallel', + join: 'all', + branches: [ + { id: 'a', steps: [{ id: 'wa', type: 'wait', durationMs: 3000 }] }, + { id: 'b', steps: [{ id: 'wb', type: 'wait', durationMs: 6000 }] }, + ], + }, + { id: 's', type: 'chat.send', text: 'joined' }, + ]) + ); + api.command('!taskautomation instance start --definition par'); + api.advance(4000); + api = restart(api, 500); + api.advance(1499); + assert.doesNotMatch(api.allText(), /joined/); + api.advance(10); + assert.match(api.allText(), /joined/); +}); + +test('a loop counter and an if choice survive a restart', () => { + let api = game(); + api.importDef( + base('lp', [ + { + id: 'l', + type: 'loop', + mode: 'count', + count: 4, + steps: [ + { + id: 'i', + type: 'if', + condition: { op: 'gt', left: { ref: 'run.iteration' }, right: 0 }, + then: [ + { id: 'w', type: 'wait', durationMs: 1000 }, + { id: 's', type: 'chat.send', text: 'pass {{run.iteration}}' }, + ], + }, + ], + }, + ]) + ); + api.command('!taskautomation instance start --definition lp'); + api.advance(2500); + api = restart(api, 100); + api.advance(5000); + const passes = api.chat.filter((c) => /^pass \d$/.test(c.text)).map((c) => c.text); + assert.deepEqual(passes, ['pass 3', 'pass 4']); +}); + +test('state from a newer version or unreadable state is kept and nothing runs', () => { + const newer = { + TaskAutomation: { ...createState(0), schemaVersion: SCHEMA_VERSION + 1, secret: 'keep me' }, + }; + const api = game({ state: newer }); + assert.equal(api.sandbox.state.TaskAutomation.secret, 'keep me'); + assert.equal(api.sandbox.state.TaskAutomation.schemaVersion, SCHEMA_VERSION + 1); + assert.match(api.allText(), /could not load its saved data/); + api.clearChat(); + api.command('!taskautomation template add montage'); + assert.match(api.allText(), /disabled/); + assert.equal(api.handout('TaskAutomation - Dashboard'), undefined); + + const broken = game({ state: { TaskAutomation: 'nonsense' } }); + assert.equal(broken.sandbox.state.TaskAutomation, 'nonsense'); +}); + +test('migrations run in order, and a failing migration keeps the original', () => { + const old = { schemaVersion: 1, definitions: {}, legacy: true }; + const ok = migrateState(old, 1, { 1: (s) => ({ ...s, schemaVersion: 2, migrated: true }) }, 2); + assert.equal(ok.status, 'migrated'); + assert.equal(ok.state.migrated, true); + assert.equal(old.migrated, undefined, 'original untouched'); + const failed = migrateState( + old, + 1, + { + 1: () => { + throw new Error('nope'); + }, + }, + 2 + ); + assert.equal(failed.status, 'migrationFailed'); + assert.equal(failed.state, undefined); + assert.equal(migrateState({ schemaVersion: 1 }, 1, {}, 2).status, 'migrationFailed'); + assert.equal(migrateState({ schemaVersion: 3 }, 1, {}, 2).status, 'newer'); + assert.equal(migrateState(undefined, 1).status, 'fresh'); + assert.equal(migrateState({ schemaVersion: 1, instances: [] }, 1).status, 'corrupt'); +}); + +test('restarting an idle game leaves no timers and sends one summary', () => { + let api = game(); + api = restart(api, 3600 * 1000); + api.advance(20 * 60 * 1000); + assert.equal(api.liveTimers(), 0); + assert.equal(api.chat.filter((c) => /is ready/.test(c.text)).length, 1); +}); diff --git a/TaskAutomation/tests/roll20-stub.mjs b/TaskAutomation/tests/roll20-stub.mjs new file mode 100644 index 0000000000..f00f3629b8 --- /dev/null +++ b/TaskAutomation/tests/roll20-stub.mjs @@ -0,0 +1,371 @@ +// A small stand-in for the Roll20 API sandbox, enough to load the built +// TaskAutomation.js and drive it from tests. Each call to createSandbox() +// gives an isolated game with its own fake clock and timers. The stub +// counts live timeouts so tests can assert the single-timer invariant. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import vm from 'node:vm'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BUNDLE = fs.readFileSync(path.join(__dirname, '..', 'TaskAutomation.js'), 'utf8'); + +export const START = Date.UTC(2026, 10, 1, 12, 0, 0); +export const IMG = (n) => `https://s3.amazonaws.com/files.d20.io/images/1000${n}/abc/thumb.png?1`; + +/** + * Decodes the HTML entities the Mod's escaping produces. + * + * @param {string} text HTML text. + * @returns {string} Decoded text. + */ +export function decode(text) { + return String(text) + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); +} + +/** + * Creates an isolated Roll20-like sandbox with the bundle loaded. + * + * @param {object} [options] Options. + * @param {object} [options.state] Initial persistent state (shared across restarts). + * @param {number} [options.now] Start time. + * @param {object} [options.globalconfig] One-Click useroptions. + * @param {Function} [options.setup] Called with the API before the bundle loads. + * @param {Map} [options.objects] Objects to reuse (a restart of the same game). + * @returns {object} Sandbox controls. + */ +export function createSandbox({ + state = {}, + now = START, + globalconfig = {}, + setup, + objects: reuse, +} = {}) { + let nextId = 1; + let clock = now; + let timerSeq = 1; + const objects = reuse || new Map(); + const handlers = new Map(); + const chat = []; + const logs = []; + const timers = new Map(); + const pings = []; + const effects = []; + let maxLiveTimers = 0; + + const fire = (event, ...args) => { + for (const fn of handlers.get(event) || []) { + fn(...args); + } + }; + + class Obj { + constructor(type, attrs) { + this.attrs = { ...attrs, _type: type }; + if (!this.attrs._id) { + this.attrs._id = `-id${nextId++}`; + } + } + get id() { + return this.attrs._id; + } + get(key) { + if (key === 'id') { + return this.attrs._id; + } + if (key in this.attrs) { + return this.attrs[key]; + } + return this.attrs[`_${key}`]; + } + set(key, value) { + const updates = typeof key === 'object' ? key : { [key]: value }; + // Roll20 does not raise change events for API changes. + Object.assign(this.attrs, updates); + } + setWithWorker(updates) { + Object.assign(this.attrs, updates); + this.workerCalls = (this.workerCalls || 0) + 1; + } + remove() { + if (objects.delete(this.attrs._id)) { + fire(`destroy:${this.attrs._type}`, this); + } + } + } + + class FakeDate extends Date { + constructor(...args) { + super(...(args.length ? args : [clock])); + } + static now() { + return clock; + } + } + + const campaign = + reuse?.get('root') || + new Obj('campaign', { + _id: 'root', + _release: 'jumpgate', + playerpageid: 'page1', + initiativepage: false, + turnorder: '', + }); + + const sandbox = { + state, + globalconfig, + log: (msg) => logs.push(String(msg)), + on: (event, fn) => { + if (!handlers.has(event)) { + handlers.set(event, []); + } + handlers.get(event).push(fn); + }, + getObj: (type, id) => { + const obj = objects.get(id); + return obj && obj.attrs._type === type ? obj : undefined; + }, + findObjs: (filter) => + [...objects.values()].filter((obj) => + Object.entries(filter).every( + ([k, v]) => obj.get(k.replace(/^_/, '')) === v || obj.attrs[k] === v + ) + ), + createObj: (type, attrs) => { + const obj = new Obj(type, attrs); + objects.set(obj.id, obj); + return obj; + }, + sendChat: (who, text) => { + chat.push({ who, text }); + // Outgoing API commands reach chat:message as API-origin messages. + if (String(text).startsWith('!')) { + fire('chat:message', { type: 'api', content: text, playerid: 'API', who }); + } + }, + sendPing: (left, top, pageid, playerid, moveAll, visibleTo) => + pings.push({ left, top, pageid, moveAll, visibleTo }), + spawnFx: (left, top, fx, pageid) => effects.push({ left, top, fx, pageid }), + spawnFxBetweenPoints: (a, b, fx, pageid) => effects.push({ a, b, fx, pageid }), + spawnFxWithDefinition: (left, top, definition, pageid) => + effects.push({ left, top, definition, pageid }), + playerIsGM: (id) => objects.get(id)?.attrs.gm === true, + Campaign: () => campaign, + setTimeout: (fn, ms) => { + const id = timerSeq++; + timers.set(id, { fn, at: clock + Math.max(0, Number(ms) || 0), seq: id }); + maxLiveTimers = Math.max(maxLiveTimers, timers.size); + return id; + }, + clearTimeout: (id) => { + timers.delete(id); + }, + setInterval: () => { + throw new Error('setInterval must not be used'); + }, + Date: FakeDate, + console, + }; + sandbox.globalThis = sandbox; + + const api = { + sandbox, + objects, + chat, + logs, + pings, + effects, + campaign, + get now() { + return clock; + }, + liveTimers: () => timers.size, + maxLiveTimers: () => maxLiveTimers, + add(type, attrs) { + const obj = new Obj(type, attrs); + objects.set(obj.id, obj); + return obj; + }, + /** Runs every timer that is due now (and those they set for now). */ + flush() { + for (let guard = 0; guard < 10000; guard += 1) { + const due = [...timers.entries()] + .filter(([, t]) => t.at <= clock) + .sort((a, b) => a[1].at - b[1].at || a[1].seq - b[1].seq)[0]; + if (!due) { + return; + } + timers.delete(due[0]); + due[1].fn(); + } + throw new Error('timer loop did not settle'); + }, + ready() { + fire('ready'); + api.flush(); + }, + /** + * Moves the clock forward, firing timers in order at their due times. + * + * @param {number} ms Milliseconds. + * @returns {void} + */ + advance(ms) { + const end = clock + ms; + for (let guard = 0; guard < 100000; guard += 1) { + const next = [...timers.values()].reduce((min, t) => Math.min(min, t.at), Infinity); + if (next > end) { + break; + } + clock = Math.max(clock, next); + api.flush(); + } + clock = end; + api.flush(); + }, + /** Jumps the clock without running timers (the sandbox was off). */ + jump(ms) { + clock += ms; + }, + command(content, selected = [], playerid = 'gm1') { + fire('chat:message', { + type: 'api', + content, + playerid, + who: playerid === 'API' ? 'Some Mod' : 'Someone', + selected: selected.map((o) => ({ _id: o.id, _type: o.attrs._type })), + }); + api.flush(); + }, + say(content, playerid = 'p1', type = 'general') { + fire('chat:message', { type, content, playerid, who: 'Player' }); + api.flush(); + }, + /** A person changes an object in the Roll20 UI (raises the change event). */ + change(obj, updates) { + const prev = { ...obj.attrs }; + Object.assign(obj.attrs, updates); + fire(`change:${obj.attrs._type}`, obj, prev); + if (obj.attrs._type === 'player' && '_online' in updates) { + fire('change:player:_online', obj, prev); + } + api.flush(); + }, + setTurnOrder(entries, open = true) { + const prev = { ...campaign.attrs }; + campaign.attrs.turnorder = JSON.stringify(entries); + fire('change:campaign:turnorder', campaign, prev); + if (Boolean(prev.initiativepage) !== open) { + campaign.attrs.initiativepage = open ? 'page1' : false; + fire('change:campaign:initiativepage', campaign, { ...prev }); + } + api.flush(); + }, + token(attrs = {}) { + return api.add('graphic', { + _subtype: 'token', + _pageid: 'page1', + layer: 'objects', + left: 105, + top: 105, + width: 70, + height: 70, + rotation: 0, + name: 'Guard', + statusmarkers: '', + bar1_value: '10', + bar1_max: '10', + controlledby: '', + represents: '', + ...attrs, + }); + }, + sidedToken(attrs = {}) { + return api.token({ + sides: [IMG(1), IMG(2), IMG(3)].map(encodeURIComponent).join('|'), + currentSide: 0, + imgsrc: IMG(1), + ...attrs, + }); + }, + lastChat() { + return decode(chat.at(-1)?.text || ''); + }, + allChat() { + return decode(chat.map((c) => c.text).join('\n')); + }, + /** All chat as plain text (tags removed, entities decoded). */ + allText() { + return decode(chat.map((c) => c.text.replaceAll(/<[^>]*>/g, ' ')).join('\n')).replaceAll( + /[ \t]+/g, + ' ' + ); + }, + clearChat() { + chat.length = 0; + }, + taState() { + return sandbox.state.TaskAutomation; + }, + TA() { + return sandbox.TaskAutomation; + }, + run(id) { + return sandbox.state.TaskAutomation.instances[id]; + }, + lastRun() { + const runs = Object.values(sandbox.state.TaskAutomation.instances); + return runs.sort( + (a, b) => + b.createdAt - a.createdAt || Number(b.id.split('-')[1]) - Number(a.id.split('-')[1]) + )[0]; + }, + handout(name) { + return [...objects.values()].find( + (o) => o.attrs._type === 'handout' && o.attrs.name === name + ); + }, + importDef(def) { + api.command(`!taskautomation definition import --json ${JSON.stringify(def)}`); + }, + }; + + if (!reuse) { + api.add('player', { _id: 'gm1', displayname: 'The GM', gm: true, _online: true }); + api.add('player', { _id: 'p1', displayname: 'Player One', _online: true }); + api.add('player', { _id: 'p2', displayname: 'Player Two', _online: false }); + api.add('page', { _id: 'page1', name: 'Map', width: 20, height: 15 }); + objects.set('root', campaign); + } + + setup?.(api); + vm.createContext(sandbox); + vm.runInContext(BUNDLE, sandbox); + return api; +} + +/** + * Restarts the sandbox: a new bundle instance over the same state and + * objects, after the clock moved on while it was off. + * + * @param {object} api Previous sandbox. + * @param {number} [offMs=0] How long the sandbox was off. + * @returns {object} New sandbox (already ready). + */ +export function restart(api, offMs = 0) { + const next = createSandbox({ + state: api.sandbox.state, + now: api.now + offMs, + objects: api.objects, + }); + next.ready(); + return next; +} diff --git a/TaskAutomation/tests/scheduler.test.mjs b/TaskAutomation/tests/scheduler.test.mjs new file mode 100644 index 0000000000..3e38387145 --- /dev/null +++ b/TaskAutomation/tests/scheduler.test.mjs @@ -0,0 +1,247 @@ +// Heap and single-timer scheduler (plan §3.1, §15.1), with fake timers. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { createHeap, heapPeek, heapPop, heapPush, heapRebuild } from '../src/heap.js'; +import { createScheduler } from '../src/scheduler.js'; + +/** + * Creates fake timers and a scheduler over them. + * + * @param {object} [limits] Scheduler limits. + * @returns {object} Harness. + */ +function harness(limits = {}) { + let clock = 1_000_000; + const timers = new Map(); + let seq = 1; + let maxLive = 0; + const ran = []; + const sched = createScheduler({ + now: () => clock, + setTimer: (fn, ms) => { + const id = seq++; + timers.set(id, { fn, at: clock + ms, ms }); + maxLive = Math.max(maxLive, timers.size); + return id; + }, + clearTimer: (id) => timers.delete(id), + dispatch: (job) => { + ran.push({ kind: job.kind, at: clock, dueAt: job.dueAt, id: job.target.id }); + job.target.effect?.(); + }, + onError: () => {}, + limits, + }); + const fireDue = () => { + for (;;) { + const due = [...timers.entries()] + .filter(([, t]) => t.at <= clock) + .sort((a, b) => a[1].at - b[1].at)[0]; + if (!due) { + return; + } + timers.delete(due[0]); + due[1].fn(); + } + }; + return { + sched, + ran, + timers, + get clock() { + return clock; + }, + maxLive: () => maxLive, + advance(ms) { + const end = clock + ms; + for (;;) { + const next = Math.min(...[...timers.values()].map((t) => t.at), Infinity); + if (next > end) { + break; + } + clock = Math.max(clock, next); + fireDue(); + } + clock = end; + fireDue(); + }, + setClock(value) { + clock = value; + }, + fireStale(id) { + // A callback that was queued before clearTimeout ran. + const entry = timers.get(id); + return entry; + }, + }; +} + +test('heap orders by deadline, then priority, then insertion', () => { + const heap = createHeap(); + const jobs = [ + { id: 'c', dueAt: 20, priority: 0, sequence: 3 }, + { id: 'a', dueAt: 10, priority: 5, sequence: 1 }, + { id: 'b', dueAt: 10, priority: 1, sequence: 2 }, + { id: 'd', dueAt: 10, priority: 1, sequence: 0 }, + ]; + jobs.forEach((j) => heapPush(heap, j)); + assert.equal(heapPeek(heap).id, 'd'); + assert.deepEqual( + [heapPop(heap), heapPop(heap), heapPop(heap), heapPop(heap)].map((j) => j.id), + ['d', 'b', 'a', 'c'] + ); + assert.equal(heapPop(heap), undefined); + heapRebuild(heap, jobs); + assert.equal(heapPeek(heap).id, 'd'); +}); + +test('heap stays ordered under random insertions', () => { + const heap = createHeap(); + for (let i = 0; i < 500; i += 1) { + heapPush(heap, { dueAt: Math.floor(Math.random() * 1000), priority: 0, sequence: i }); + } + let last = -1; + while (heap.items.length) { + const job = heapPop(heap); + assert.ok(job.dueAt >= last); + last = job.dueAt; + } +}); + +test('one timer at most, and none when there is no work', () => { + const h = harness(); + assert.equal(h.timers.size, 0); + for (let i = 0; i < 50; i += 1) { + h.sched.add({ kind: 'continue', dueAt: h.clock + 1000 + i * 10, target: { id: i } }); + } + assert.equal(h.timers.size, 1); + h.advance(5000); + assert.equal(h.ran.length, 50); + assert.equal(h.timers.size, 0); + assert.equal(h.maxLive(), 1); +}); + +test('an earlier job re-arms the timer; a later one does not', () => { + const h = harness(); + h.sched.add({ kind: 'continue', dueAt: h.clock + 5000, target: { id: 'late' } }); + const before = h.sched.stats().timersSet; + h.sched.add({ kind: 'continue', dueAt: h.clock + 9000, target: { id: 'later' } }); + assert.equal(h.sched.stats().timersSet, before); + h.sched.add({ kind: 'continue', dueAt: h.clock + 1000, target: { id: 'early' } }); + assert.equal(h.sched.stats().timersSet, before + 1); + h.advance(10000); + assert.deepEqual( + h.ran.map((r) => r.id), + ['early', 'late', 'later'] + ); +}); + +test('cancelling the head re-arms for the next job; keyed jobs replace each other', () => { + const h = harness(); + const first = h.sched.add({ kind: 'continue', dueAt: h.clock + 1000, target: { id: 1 } }); + h.sched.add({ kind: 'continue', dueAt: h.clock + 3000, target: { id: 2 } }); + h.sched.cancel(first); + assert.equal(h.sched.nextDueAt(), h.clock + 3000); + h.sched.add({ kind: 'dashboard', dueAt: h.clock + 500, key: 'dash', target: { id: 'd1' } }); + h.sched.add({ kind: 'dashboard', dueAt: h.clock + 700, key: 'dash', target: { id: 'd2' } }); + h.advance(4000); + assert.deepEqual( + h.ran.map((r) => r.id), + ['d2', 2] + ); +}); + +test('a stale callback queued before clearTimeout does nothing', () => { + const h = harness(); + h.sched.add({ kind: 'continue', dueAt: h.clock + 1000, target: { id: 'x' } }); + const [, stale] = [...h.timers.entries()][0]; + h.sched.add({ kind: 'continue', dueAt: h.clock + 100, target: { id: 'y' } }); + h.setClock(h.clock + 2000); + stale.fn(); + assert.equal(h.ran.length, 0); + h.advance(0); + assert.deepEqual( + h.ran.map((r) => r.id), + ['y', 'x'] + ); +}); + +test('long waits are clamped and re-checked: no early effects', () => { + const h = harness({ maxHostDelayMs: 10_000 }); + const due = h.clock + 35_000; + h.sched.add({ kind: 'continue', dueAt: due, target: { id: 'far' } }); + assert.equal([...h.timers.values()][0].ms, 10_000); + h.advance(30_000); + assert.equal(h.ran.length, 0); + h.advance(5_000); + assert.equal(h.ran.length, 1); + assert.ok(h.ran[0].at >= due); +}); + +test('draining yields after its job budget and every due job still runs', () => { + const h = harness({ maxJobsPerDrain: 5, yieldMs: 10 }); + for (let i = 0; i < 23; i += 1) { + h.sched.add({ kind: 'continue', dueAt: h.clock, target: { id: i } }); + } + h.advance(0); + assert.equal(h.ran.length, 5); + assert.equal(h.sched.stats().yields, 1); + h.advance(100); + assert.equal(h.ran.length, 23); + assert.deepEqual( + h.ran.map((r) => r.id), + [...Array(23).keys()] + ); +}); + +test('a backward clock jump delays work until its real time', () => { + const h = harness(); + const due = h.clock + 1000; + h.sched.add({ kind: 'continue', dueAt: due, target: { id: 'a' } }); + h.advance(1000); + assert.equal(h.ran.length, 1); + h.sched.add({ kind: 'continue', dueAt: h.clock + 1000, target: { id: 'b' } }); + // Wall clock goes back 10 seconds; the timer fires but the job is not due. + const [timerId, timer] = [...h.timers.entries()][0]; + h.timers.delete(timerId); + h.setClock(h.clock - 10_000 + 1000); + timer.fn(); + assert.equal(h.ran.length, 1); + assert.equal(h.timers.size, 1); +}); + +test('an exception in one job is contained and the timer is re-armed', () => { + const h = harness(); + h.sched.add({ + kind: 'continue', + dueAt: h.clock + 10, + target: { + id: 'boom', + effect: () => { + throw new Error('boom'); + }, + }, + }); + h.sched.add({ kind: 'continue', dueAt: h.clock + 20, target: { id: 'after' } }); + h.advance(100); + assert.deepEqual( + h.ran.map((r) => r.id), + ['boom', 'after'] + ); + assert.equal(h.sched.stats().errors, 1); +}); + +test('cancelled jobs are compacted out of the heap', () => { + const h = harness({ compactMinimum: 10, compactRatio: 0.5 }); + const ids = []; + for (let i = 0; i < 100; i += 1) { + ids.push(h.sched.add({ kind: 'continue', dueAt: h.clock + 10_000 + i, target: { id: i } })); + } + for (const id of ids.slice(0, 90)) { + h.sched.cancel(id); + } + assert.ok(h.sched.stats().heap < 100); + assert.equal(h.sched.size(), 10); +}); diff --git a/TaskAutomation/tests/security.test.mjs b/TaskAutomation/tests/security.test.mjs new file mode 100644 index 0000000000..b89089d8d2 --- /dev/null +++ b/TaskAutomation/tests/security.test.mjs @@ -0,0 +1,260 @@ +// Permissions, injection and dashboard safety (plan §9, §10, §15.4). + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { createSandbox } from './roll20-stub.mjs'; + +/** + * Creates a ready game. + * + * @param {object} [options] Sandbox options. + * @returns {object} Sandbox API. + */ +function game(options) { + const api = createSandbox(options); + api.ready(); + return api; +} + +const base = (id, steps, extra = {}) => ({ + schemaVersion: 1, + id, + name: id, + triggers: [{ id: 'manual', type: 'manual' }], + access: { mode: 'gmOnly', authority: 'initiator' }, + policy: { concurrency: 'allow', maxConcurrent: 5 }, + steps, + ...extra, +}); + +test('only the exact command is handled; players and other Mods cannot use GM commands', () => { + const api = game(); + api.clearChat(); + api.command('!taskautomationx help'); + api.command('!task help'); + assert.equal(api.chat.length, 0); + api.command('!taskautomation definition list', [], 'p1'); + assert.match(api.allText(), /Only the GM can do that/); + api.clearChat(); + api.command('!taskautomation stop-all --preview', [], 'API'); + assert.equal(api.chat.length, 0, 'API-origin messages are ignored'); +}); + +test('a task cannot drive TaskAutomation itself through an outgoing command', () => { + const api = game(); + api.importDef( + base('sneaky', [ + { id: 'c', type: 'integration.command', command: '!taskautomation stop-all --preview' }, + ]) + ); + api.command('!taskautomation instance start --definition sneaky'); + api.advance(10); + assert.equal(api.taState().halted, null); + assert.equal(Object.keys(api.taState().confirmations).length, 0); +}); + +test('command values cannot add flags or commands', () => { + const api = game(); + const token = api.token({ name: 'x --set layer|gmlayer' }); + api.importDef( + base( + 'cmd', + [ + { + id: 'c', + type: 'integration.command', + command: '!token-mod --ids {{args.n}}', + args: { n: { ref: 'target.t.name' } }, + }, + ], + { + bindings: { t: { from: 'selected', objectType: 'graphic' } }, + } + ) + ); + api.command('!taskautomation instance start --definition cmd', [token]); + api.advance(10); + assert.ok(!api.chat.some((c) => c.text.startsWith('!token-mod'))); + assert.equal(api.lastRun().error.reason, 'unsafeArgument'); +}); + +test('chat values are escaped and cannot become slash commands', () => { + const api = game(); + const token = api.token({ name: '/w gm ' }); + api.importDef( + base('hi', [{ id: 's', type: 'chat.send', text: 'Hello {{target.t.name}}' }], { + bindings: { t: { from: 'selected', objectType: 'graphic' } }, + }) + ); + api.command('!taskautomation instance start --definition hi', [token]); + api.advance(10); + const sent = api.chat.find((c) => c.text.startsWith('Hello')); + assert.equal(sent.text, 'Hello w gm <img src=x onerror=alert(1)>'); +}); + +test('imports reject prototype pollution and oversized commands', () => { + const api = game(); + api.command( + '!taskautomation definition import --json {"schemaVersion":1,"id":"p","name":"p","steps":[],"__proto__":{"polluted":true}}' + ); + assert.match(api.allText(), /is not allowed/); + assert.equal({}.polluted, undefined); + api.command(`!taskautomation definition import --json ${'x'.repeat(40000)}`); + assert.match(api.allText(), /too large/); +}); + +test('published player tasks: listed players, controlled targets only, and revocation', () => { + const api = game(); + const mine = api.token({ name: 'Mine', controlledby: 'p1' }); + const theirs = api.token({ name: 'Theirs', controlledby: 'p2' }); + api.importDef( + base( + 'wave', + [ + { id: 'w', type: 'wait', durationMs: 5000 }, + { id: 'm', type: 'marker.set', target: { binding: 't' }, marker: 'green' }, + ], + { + bindings: { t: { from: 'selected', objectType: 'graphic', max: 1 } }, + access: { + mode: 'players', + players: ['p1'], + authority: 'initiator', + capabilities: { actions: ['marker.set'] }, + }, + } + ) + ); + api.command('!taskautomation instance start --definition wave', [theirs], 'p1'); + assert.match(api.allText(), /only use tokens and characters you control/); + api.command('!taskautomation instance start --definition wave', [mine], 'p2'); + assert.match(api.allText(), /not shared with you/); + api.command('!taskautomation instance start --definition wave --bind t=' + theirs.id, [], 'p1'); + assert.match(api.allText(), /only use tokens and characters you control/); + api.command('!taskautomation instance start --definition wave', [mine], 'p1'); + const run = api.lastRun(); + assert.equal(run.initiatorPlayerId, 'p1'); + // The player loses control while the run waits: it stops for review instead of acting. + api.change(mine, { controlledby: '' }); + api.advance(5000); + assert.equal(api.run(run.id).status, 'review'); + assert.equal(api.run(run.id).review.reason, 'controlLost'); + assert.equal(mine.attrs.statusmarkers, ''); + // Players can inspect and cancel only their own runs. + api.command(`!taskautomation instance cancel --id ${run.id}`, [], 'p2'); + assert.match(api.allText(), /no run run-\d+ you can manage/); +}); + +test('players may only send signals that a task shares with them', () => { + const api = game(); + api.command('!taskautomation signal secret', [], 'p1'); + assert.match(api.allText(), /cannot send the signal secret/); + api.importDef( + base('bell', [{ id: 's', type: 'chat.send', text: 'ding' }], { + triggers: [{ id: 'b', type: 'signal', name: 'bell', players: true }], + }) + ); + api.command('!taskautomation signal bell', [], 'p1'); + api.advance(10); + assert.ok(api.chat.some((c) => c.text === 'ding')); +}); + +test('confirmation tokens are single-use and bound to the caller', () => { + const api = game(); + api.add('player', { _id: 'gm2', displayname: 'Co-GM', gm: true }); + api.command('!taskautomation stop-all --preview'); + const token = /stop-all --confirm (\S+?)"/.exec(api.allChat())[1]; + api.command(`!taskautomation stop-all --confirm ${token}`, [], 'gm2'); + assert.match(api.allText(), /made for someone else/); + assert.equal(api.taState().halted, null); + api.command(`!taskautomation stop-all --confirm ${token}`); + assert.match(api.allText(), /not valid. Preview again/); +}); + +test('the dashboard is GM-only, escaped, throttled, and stops writing when shared', () => { + const api = game(); + const dash = api.handout('TaskAutomation - Dashboard'); + const help = api.handout('TaskAutomation - Help'); + assert.equal(dash.attrs.inplayerjournals, ''); + assert.equal(help.attrs.inplayerjournals, ''); + api.importDef( + base('bold', [{ id: 'w', type: 'wait', durationMs: 60000 }], { + id: 'evil', + name: '', + }) + ); + let writes = 0; + const set = dash.set.bind(dash); + dash.set = (key, value) => { + if (key === 'notes') { + writes += 1; + } + set(key, value); + }; + api.command('!taskautomation dashboard view definitions'); + for (let i = 0; i < 30; i += 1) { + api.command('!taskautomation instance start --definition evil'); + api.advance(100); + } + api.advance(6000); + assert.ok(writes >= 1 && writes <= 4, `notes written ${writes} times`); + assert.doesNotMatch(dash.attrs.notes, /