chore: bootstrap lean sysadmin-chronicles repo

Import the runnable game code, content, docs, scripts, and repo guidance while leaving local agent state, dependency installs, build output, and backup copies out of the published tree.
This commit is contained in:
2026-05-02 11:49:07 -04:00
commit 0265afa054
252 changed files with 37574 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
PORT=3000
HOST_BRIDGE_IP=192.168.100.1
SSH_KEY_PATH=~/.ssh/sc_host_key
SAVE_DIR=~/.local/share/sysadmin-chronicles
CONTENT_DIR=../content
LIBVIRT_URI=qemu:///system
VM_PREFIX=sc-
+877
View File
@@ -0,0 +1,877 @@
{
"name": "sysadmin-chronicles-server",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sysadmin-chronicles-server",
"version": "0.1.0",
"dependencies": {
"dotenv": "^16.0.0",
"express": "^4.18.0",
"ws": "^8.0.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/body-parser/node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "sysadmin-chronicles-server",
"version": "0.1.0",
"type": "module",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"test": "node --test --test-concurrency=1 src/*.test.js src/services/*.test.js"
},
"dependencies": {
"dotenv": "^16.0.0",
"express": "^4.18.0",
"ws": "^8.0.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+211
View File
@@ -0,0 +1,211 @@
import 'dotenv/config';
import http from 'http';
import path from 'path';
import { existsSync } from 'fs';
import express from 'express';
import { WebSocket, WebSocketServer } from 'ws';
import { fileURLToPath } from 'url';
import { eventBus } from './lib/eventBus.js';
import { requireSession } from './lib/session.js';
import sessionRouter from './routes/session.js';
import stateRouter from './routes/state.js';
import ticketsRouter from './routes/tickets.js';
import mailRouter from './routes/mail.js';
import docsRouter from './routes/docs.js';
import vmsRouter from './routes/vms.js';
import sageRouter from './routes/sage.js';
import profileRouter from './routes/profile.js';
import debugRouter from './routes/debug.js';
import { contentLoader } from './services/ContentLoader.js';
import { saveState } from './services/SaveState.js';
import { progressionSystem } from './services/ProgressionSystem.js';
import { trustSystem } from './services/TrustSystem.js';
import { questEngine } from './services/QuestEngine.js';
import { ticketService } from './services/TicketService.js';
import { emailService } from './services/EmailService.js';
import { vmManager } from './services/VMManager.js';
import { shiftTimer } from './services/ShiftTimer.js';
import { incidentScheduler } from './services/IncidentScheduler.js';
import { shiftReviewService } from './services/ShiftReviewService.js';
import { certificationService } from './services/CertificationService.js';
import { behaviorTracker } from './services/BehaviorTracker.js';
import { narrativePhaseTracker } from './services/NarrativePhaseTracker.js';
import { hiddenHookTracker } from './services/HiddenHookTracker.js';
import { endingEvaluator } from './services/EndingEvaluator.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export async function initializeServices() {
try {
await contentLoader.load();
} catch (error) {
throw new Error(`Failed to load content: ${error.message}`);
}
try {
await saveState.load();
} catch (error) {
throw new Error(`Failed to load save state: ${error.message}`);
}
const state = saveState.get();
progressionSystem.initialize(state);
trustSystem.initialize(state);
behaviorTracker.initialize(state);
questEngine.initialize(state);
narrativePhaseTracker.initialize(state);
hiddenHookTracker.initialize(state);
ticketService.initialize(state);
emailService.initialize(state);
shiftReviewService.initialize(state);
certificationService.initialize(state);
shiftTimer.start(state);
incidentScheduler.start();
eventBus.on('quest:completed', () => { endingEvaluator.checkTrigger(); });
try {
const workstationStatus = await vmManager.ensureWorkstationLive();
if (!workstationStatus.ok) {
console.warn('Workstation VM not ready at bootstrap:', workstationStatus.reason ?? workstationStatus);
}
} catch (error) {
console.warn('Failed to ensure workstation VM is live:', error.message);
}
}
export function createApp() {
const app = express();
app.use(express.json());
// Sage KB — static site + article API, no session required
const sageStatic = path.resolve(__dirname, '../../sage');
const sageArticles = path.resolve(__dirname, '../../content/sage-articles');
const sendSageIndex = (_req, res) => res.sendFile(path.join(sageStatic, 'index.html'));
app.use('/sage/api', express.static(sageArticles, { index: false }));
app.use('/sage', express.static(sageStatic));
app.get(['/sage', '/sage/'], sendSageIndex);
// Company website — publicly accessible, no session required
const companyWebsite = path.resolve(__dirname, '../../company-website');
app.use('/company', express.static(companyWebsite));
app.get(['/company', '/company/'], (_req, res) => res.sendFile(path.join(companyWebsite, 'index.html')));
// Public assets — portraits, wallpaper
const publicDir = path.resolve(__dirname, '../public');
app.use('/public', express.static(publicDir));
app.use('/api/session', sessionRouter);
app.use('/api', requireSession);
app.use('/api/state', stateRouter);
app.use('/api/tickets', ticketsRouter);
app.use('/api/mail', mailRouter);
app.use('/api/docs', docsRouter);
app.use('/api/vms', vmsRouter);
app.use('/api/sage', sageRouter);
app.use('/api/profile', profileRouter);
if (process.env.SC_DEBUG === '1') {
app.use('/api/debug', debugRouter);
}
const frontendDist = path.resolve(__dirname, '../../frontend/dist');
const hasFrontendDist = existsSync(frontendDist);
if (hasFrontendDist) {
app.use(express.static(frontendDist));
} else {
app.get('/', (_req, res) => {
res.status(200).json({ status: 'game server running', version: '0.1.0' });
});
}
return app;
}
export function attachWebSocket(server) {
const wss = new WebSocketServer({ server });
wss.on('connection', (socket) => {
socket.send(
JSON.stringify({
type: 'connected',
payload: { trust: trustSystem.getScore() }
})
);
});
const forwardEvent = (eventName) => {
eventBus.on(eventName, (payload) => {
const message = JSON.stringify({ type: eventName, payload });
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
});
};
for (const eventName of [
'trust:changed',
'mail:new',
'progression:changed',
'quest:activated',
'quest:completed',
'ticket:activated',
'ticket:completed',
'shift:tick',
'shift:ended',
'incident:alert',
'certification:awarded'
]) {
forwardEvent(eventName);
}
return { server, wss };
}
export async function startServer({ port = Number(process.env.PORT ?? 3000), host = '0.0.0.0' } = {}) {
await initializeServices();
const app = createApp();
let server;
const tlsCert = process.env.SC_TLS_CERT;
const tlsKey = process.env.SC_TLS_KEY;
if (tlsCert && tlsKey && existsSync(tlsCert) && existsSync(tlsKey)) {
const { createServer: createHttpsServer } = await import('https');
const { readFileSync } = await import('fs');
server = createHttpsServer(
{ cert: readFileSync(tlsCert), key: readFileSync(tlsKey) },
app
);
} else {
server = http.createServer(app);
}
const { wss } = attachWebSocket(server);
await new Promise((resolve) => {
server.listen(port, host, resolve);
});
const proto = (tlsCert && tlsKey && existsSync(tlsCert) && existsSync(tlsKey)) ? 'https' : 'http';
return { app, server, wss, proto };
}
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
startServer()
.then(({ server }) => {
const address = server.address();
const actualPort = typeof address === 'object' && address ? address.port : process.env.PORT ?? 3000;
console.log(`Game server running on port ${actualPort}`);
})
.catch((error) => {
console.error(error.message);
process.exit(1);
});
}
+172
View File
@@ -0,0 +1,172 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'os';
import path from 'path';
import { rm } from 'fs/promises';
import { startServer } from './index.js';
import { saveState } from './services/SaveState.js';
import { shiftTimer } from './services/ShiftTimer.js';
import { incidentScheduler } from './services/IncidentScheduler.js';
import { vmManager } from './services/VMManager.js';
async function bootServer(testId) {
process.env.CONTENT_DIR = path.resolve(process.cwd(), '../content');
process.env.SAVE_DIR = path.join(os.tmpdir(), `sc-server-route-test-${testId}-${Date.now()}`);
await saveState._writeQueue.catch(() => {});
saveState._savePath = null;
saveState._state = null;
saveState._writeQueue = Promise.resolve();
await rm(process.env.SAVE_DIR, { recursive: true, force: true });
const originalEnsureWorkstationLive = vmManager.ensureWorkstationLive.bind(vmManager);
const originalGetState = vmManager.getState.bind(vmManager);
vmManager.ensureWorkstationLive = async () => ({ ok: true, started: false });
vmManager.getState = async (vmId) => (vmId === 'workstation' ? 'running' : 'shut off');
const { server, wss } = await startServer({ port: 0, host: '127.0.0.1' });
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
const baseUrl = `http://127.0.0.1:${port}`;
async function sessionToken() {
const response = await fetch(`${baseUrl}/api/session`);
const payload = await response.json();
return payload.token;
}
async function authedFetch(pathname, init = {}) {
const token = await sessionToken();
const headers = new Headers(init.headers ?? {});
headers.set('Authorization', `Bearer ${token}`);
if (init.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
return fetch(`${baseUrl}${pathname}`, {
...init,
headers
});
}
async function close() {
shiftTimer.stop();
incidentScheduler.stop();
await Promise.all([
new Promise((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
}),
new Promise((resolve) => wss.close(resolve))
]);
vmManager.ensureWorkstationLive = originalEnsureWorkstationLive;
vmManager.getState = originalGetState;
}
return { baseUrl, authedFetch, sessionToken, close };
}
test('session endpoint issues a token and protected state route requires auth', async () => {
const ctx = await bootServer('session-auth');
try {
const sessionResponse = await fetch(`${ctx.baseUrl}/api/session`);
assert.equal(sessionResponse.status, 200);
const sessionPayload = await sessionResponse.json();
assert.ok(typeof sessionPayload.token === 'string');
const unauthResponse = await fetch(`${ctx.baseUrl}/api/state`);
assert.equal(unauthResponse.status, 401);
const authResponse = await ctx.authedFetch('/api/state');
assert.equal(authResponse.status, 200);
const state = await authResponse.json();
assert.equal(state.trust, 50);
assert.ok(state.shift);
assert.ok(Array.isArray(state.certifications));
assert.ok(Array.isArray(state.shiftHistory));
} finally {
await ctx.close();
}
});
test('docs route enforces id validation and unlock gating', async () => {
const ctx = await bootServer('docs');
try {
const invalidResponse = await ctx.authedFetch('/api/docs/bad$id');
assert.equal(invalidResponse.status, 400);
const unlockedResponse = await ctx.authedFetch('/api/docs/onboarding');
assert.equal(unlockedResponse.status, 200);
const unlockedDoc = await unlockedResponse.json();
assert.equal(unlockedDoc.id, 'onboarding');
const lockedResponse = await ctx.authedFetch('/api/docs/server-admin-guide');
assert.equal(lockedResponse.status, 403);
} finally {
await ctx.close();
}
});
test('sage route validates input and returns authored quest help', async () => {
const ctx = await bootServer('sage');
try {
const badResponse = await ctx.authedFetch('/api/sage/message', {
method: 'POST',
body: JSON.stringify({ message: '' })
});
assert.equal(badResponse.status, 400);
const goodResponse = await ctx.authedFetch('/api/sage/message', {
method: 'POST',
body: JSON.stringify({ message: 'give me a hint' })
});
assert.equal(goodResponse.status, 200);
const payload = await goodResponse.json();
assert.match(payload.response, /\.ssh/i);
assert.ok(Array.isArray(payload.followUps));
} finally {
await ctx.close();
}
});
test('vms route returns current VM states under auth', async () => {
const ctx = await bootServer('vms');
try {
const response = await ctx.authedFetch('/api/vms');
assert.equal(response.status, 200);
const vms = await response.json();
assert.equal(vms.length, 3);
assert.equal(vms.find((vm) => vm.id === 'workstation')?.state, 'running');
} finally {
await ctx.close();
}
});
test('ticket and mail routes validate request payloads', async () => {
const ctx = await bootServer('payload-validation');
try {
const ticketResponse = await ctx.authedFetch('/api/tickets/T001/complete', {
method: 'POST',
body: JSON.stringify({ branchId: 123 })
});
assert.equal(ticketResponse.status, 400);
const mailResponse = await ctx.authedFetch('/api/mail/mail-T001-initial/reply', {
method: 'POST',
body: JSON.stringify({ choice: '0' })
});
assert.equal(mailResponse.status, 400);
} finally {
await ctx.close();
}
});
+72
View File
@@ -0,0 +1,72 @@
import { spawn } from 'child_process';
export async function runCommand(binary, args = [], options = {}) {
const {
cwd = process.cwd(),
env = {},
timeoutMs = 15000
} = options;
return await new Promise((resolve) => {
let stdout = '';
let stderr = '';
let timedOut = false;
let settled = false;
const child = spawn(binary, args, {
cwd,
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe']
});
const finalize = (result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
resolve({
...result,
stdout: result.stdout ?? stdout,
stderr: result.stderr ?? stderr,
command: [binary, ...args].join(' ')
});
};
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGKILL');
}, timeoutMs);
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
finalize({
ok: false,
code: 127,
stdout,
stderr: stderr || error.message,
timedOut: false,
signal: null
});
});
child.on('close', (code, signal) => {
finalize({
ok: code === 0 && !timedOut,
code: timedOut ? 124 : (code ?? 1),
stdout,
stderr,
timedOut,
signal
});
});
});
}
+3
View File
@@ -0,0 +1,3 @@
import { EventEmitter } from 'events';
export const eventBus = new EventEmitter();
+84
View File
@@ -0,0 +1,84 @@
import crypto from 'crypto';
const SESSION_TTL_SECONDS = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
function getSessionSecret() {
return process.env.SESSION_SECRET ?? 'sysadmin-chronicles-dev-session-secret';
}
function base64urlEncode(value) {
return Buffer.from(value).toString('base64url');
}
function base64urlDecode(value) {
return Buffer.from(value, 'base64url').toString('utf8');
}
function sign(value) {
return crypto.createHmac('sha256', getSessionSecret()).update(value).digest('base64url');
}
export function issueSessionToken() {
const issuedAt = Math.floor(Date.now() / 1000);
const payload = {
sid: crypto.randomUUID(),
iat: issuedAt,
exp: issuedAt + SESSION_TTL_SECONDS
};
const encodedPayload = base64urlEncode(JSON.stringify(payload));
const signature = sign(encodedPayload);
return {
token: `${encodedPayload}.${signature}`,
expiresAt: new Date(payload.exp * 1000).toISOString()
};
}
export function verifySessionToken(token) {
if (typeof token !== 'string' || !token.includes('.')) {
return { ok: false, reason: 'invalid-token' };
}
const [encodedPayload, signature] = token.split('.', 2);
const expectedSignature = sign(encodedPayload);
if (!signature) {
return { ok: false, reason: 'bad-signature' };
}
const provided = Buffer.from(signature);
const expected = Buffer.from(expectedSignature);
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
return { ok: false, reason: 'bad-signature' };
}
try {
const payload = JSON.parse(base64urlDecode(encodedPayload));
const now = Math.floor(Date.now() / 1000);
if (Number(payload.exp ?? 0) <= now) {
return { ok: false, reason: 'expired' };
}
return {
ok: true,
payload
};
} catch {
return { ok: false, reason: 'invalid-payload' };
}
}
export function requireSession(req, res, next) {
const header = req.headers.authorization ?? '';
const match = header.match(/^Bearer\s+(.+)$/i);
const token = match?.[1] ?? '';
const verification = verifySessionToken(token);
if (!verification.ok) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
req.session = verification.payload;
next();
}
+49
View File
@@ -0,0 +1,49 @@
import os from 'os';
import path from 'path';
import { runCommand } from './command.js';
const DEFAULT_SSH_OPTIONS = [
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'BatchMode=yes',
'-o', 'LogLevel=ERROR'
];
export function expandHomePath(value) {
if (!value) {
return value;
}
if (value === '~') {
return os.homedir();
}
if (value.startsWith('~/')) {
return path.join(os.homedir(), value.slice(2));
}
return value;
}
export async function runSSH({
host,
user,
command,
keyPath = process.env.SSH_KEY_PATH ?? '~/.ssh/sc_host_key',
timeoutSec = 15,
connectTimeoutSec = 5,
extraArgs = []
}) {
const resolvedKeyPath = expandHomePath(keyPath);
const args = [
...DEFAULT_SSH_OPTIONS,
'-o', `ConnectTimeout=${connectTimeoutSec}`,
'-i', resolvedKeyPath,
...extraArgs,
`${user}@${host}`,
command
];
return await runCommand('ssh', args, { timeoutMs: timeoutSec * 1000 });
}
+15
View File
@@ -0,0 +1,15 @@
export function toArray(value) {
return Array.isArray(value) ? value : [];
}
export function normalizeWorldFlag(value) {
return typeof value === 'string' && value.startsWith('world_flag:')
? value.slice('world_flag:'.length)
: value;
}
export function createError(message, statusCode) {
const error = new Error(message);
error.statusCode = statusCode;
return error;
}
+7
View File
@@ -0,0 +1,7 @@
import { runCommand } from './command.js';
export async function runVirsh(args = [], options = {}) {
const uri = options.uri ?? process.env.LIBVIRT_URI ?? 'qemu:///system';
const timeoutMs = (options.timeoutSec ?? 10) * 1000;
return await runCommand('virsh', ['--connect', uri, ...args], { timeoutMs });
}
+43
View File
@@ -0,0 +1,43 @@
import { Router } from 'express';
import { saveState } from '../services/SaveState.js';
import { behaviorTracker } from '../services/BehaviorTracker.js';
import { narrativePhaseTracker } from '../services/NarrativePhaseTracker.js';
import { endingEvaluator } from '../services/EndingEvaluator.js';
const VALID_PHASES = ['normal_work', 'unease', 'suspicion', 'investigation', 'conflict', 'resolution'];
const router = Router();
router.get('/state', (_req, res) => {
res.json(saveState.get());
});
router.get('/ending', (_req, res) => {
res.json(endingEvaluator.evaluate());
});
router.post('/behavior', (req, res) => {
const { curiosity, obedience, risk, suspicion } = req.body ?? {};
const isNum = (v) => typeof v === 'number' && v >= 0 && v <= 100;
const override = {};
if (isNum(curiosity)) override.curiosity = curiosity;
if (isNum(obedience)) override.obedience = obedience;
if (isNum(risk)) override.risk = risk;
if (isNum(suspicion)) override.suspicion = suspicion;
if (Object.keys(override).length === 0) {
return res.status(400).json({ error: 'No valid fields. Each must be a number 0100.' });
}
behaviorTracker.setSnapshot(override);
res.json(behaviorTracker.getSnapshot());
});
router.post('/phase', (req, res) => {
const { phase } = req.body ?? {};
if (!VALID_PHASES.includes(phase)) {
return res.status(400).json({ error: `phase must be one of: ${VALID_PHASES.join(', ')}` });
}
narrativePhaseTracker.forcePhase(phase);
res.json({ phase: narrativePhaseTracker.getPhase() });
});
export default router;
+44
View File
@@ -0,0 +1,44 @@
import { Router } from 'express';
import { contentLoader } from '../services/ContentLoader.js';
import { progressionSystem } from '../services/ProgressionSystem.js';
const router = Router();
const DOC_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
router.get('/', (_req, res) => {
const docs = [...contentLoader.docs.values()]
.sort((left, right) => left.title.localeCompare(right.title))
.map((doc) => ({
id: doc.id,
title: doc.title,
locked: !progressionSystem.hasDoc(doc.id)
}));
res.json(docs);
});
router.get('/:id', (req, res) => {
if (!DOC_ID_PATTERN.test(req.params.id)) {
res.status(400).json({ error: 'Invalid document id' });
return;
}
const doc = contentLoader.get('docs', req.params.id);
if (!doc) {
res.status(404).json({ error: 'Document not found' });
return;
}
if (!progressionSystem.hasDoc(doc.id)) {
res.status(403).json({ error: 'Document locked' });
return;
}
res.json({
id: doc.id,
title: doc.title,
content: doc.body
});
});
export default router;
+48
View File
@@ -0,0 +1,48 @@
import { Router } from 'express';
import { emailService } from '../services/EmailService.js';
const router = Router();
router.get('/', (_req, res) => {
res.json(emailService.getAll());
});
router.get('/:id', (req, res) => {
const mail = emailService.getById(req.params.id);
if (!mail) {
res.status(404).json({ error: 'Mail not found' });
return;
}
res.json(mail);
});
router.post('/:id/read', (req, res) => {
try {
emailService.markRead(req.params.id);
res.json({ ok: true });
} catch (error) {
const statusCode = error.statusCode ?? 500;
res.status(statusCode).json({ error: error.message });
}
});
router.post('/:id/reply', (req, res) => {
const rawChoice = req.body?.choice;
if (!Number.isInteger(rawChoice)) {
res.status(400).json({ error: 'choice must be an integer' });
return;
}
const choice = Number(rawChoice);
try {
const result = emailService.reply(req.params.id, choice);
res.json(result);
} catch (error) {
const statusCode = error.statusCode ?? 500;
res.status(statusCode).json({ error: error.message });
}
});
export default router;
+31
View File
@@ -0,0 +1,31 @@
import { Router } from 'express';
import { saveState } from '../services/SaveState.js';
const router = Router();
const VALID_PORTRAITS = [
'player-silhouette',
'player-01',
'player-02',
'player-03',
'player-04',
'player-05'
];
router.get('/', (_req, res) => {
const state = saveState.get();
res.json({ portrait: state.player_portrait ?? 'player-silhouette' });
});
router.put('/', (req, res) => {
const { portrait } = req.body ?? {};
if (!portrait || !VALID_PORTRAITS.includes(portrait)) {
return res.status(400).json({ error: 'Invalid portrait', valid: VALID_PORTRAITS });
}
saveState.set({ player_portrait: portrait });
res.json({ portrait });
});
export default router;
+27
View File
@@ -0,0 +1,27 @@
import { Router } from 'express';
import { sageService } from '../services/SageService.js';
const router = Router();
router.post('/message', (req, res) => {
const message = req.body?.message;
if (typeof message !== 'string') {
res.status(400).json({ error: 'Message must be a string' });
return;
}
const trimmed = message.trim();
if (!trimmed) {
res.status(400).json({ error: 'Message cannot be empty' });
return;
}
if (trimmed.length > 500) {
res.status(400).json({ error: 'Message too long' });
return;
}
res.json(sageService.reply(trimmed));
});
export default router;
+10
View File
@@ -0,0 +1,10 @@
import { Router } from 'express';
import { issueSessionToken } from '../lib/session.js';
const router = Router();
router.get('/', (_req, res) => {
res.json(issueSessionToken());
});
export default router;
+31
View File
@@ -0,0 +1,31 @@
import { Router } from 'express';
import { saveState } from '../services/SaveState.js';
import { trustSystem } from '../services/TrustSystem.js';
import { progressionSystem } from '../services/ProgressionSystem.js';
import { shiftTimer } from '../services/ShiftTimer.js';
import { narrativePhaseTracker } from '../services/NarrativePhaseTracker.js';
import { hiddenHookTracker } from '../services/HiddenHookTracker.js';
import { endingEvaluator } from '../services/EndingEvaluator.js';
const router = Router();
router.get('/', (_req, res) => {
const state = saveState.get();
res.json({
trust: trustSystem.getScore(),
shiftNumber: state.shift_number,
shiftStartedAt: state.shift_started_at,
shift: shiftTimer.getSnapshot(state),
worldFlags: state.world_flags,
progression: progressionSystem._snapshot(),
certifications: state.certifications,
currentShiftStats: state.current_shift_stats ?? null,
shiftHistory: state.shift_history ?? [],
narrativePhase: narrativePhaseTracker.getPhase(),
hiddenHooksDiscovered: hiddenHookTracker.getDiscovered(),
accessLevel: progressionSystem.getAccessLevel(),
endingTrajectory: endingEvaluator.evaluate()
});
});
export default router;
+33
View File
@@ -0,0 +1,33 @@
import { Router } from 'express';
import { ticketService } from '../services/TicketService.js';
const router = Router();
router.get('/', (_req, res) => {
res.json(ticketService.getAll());
});
router.get('/:id', (req, res) => {
const ticket = ticketService.getDetail(req.params.id);
if (!ticket) {
res.status(404).json({ error: 'Ticket not found' });
return;
}
res.json(ticket);
});
router.post('/:id/complete', async (req, res) => {
const branchId = req.body?.branchId ?? null;
if (branchId !== null && typeof branchId !== 'string') {
res.status(400).json({ error: 'branchId must be a string when provided' });
return;
}
const result = await ticketService.markComplete(req.params.id, {
branchId
});
res.json(result);
});
export default router;
+24
View File
@@ -0,0 +1,24 @@
import { Router } from 'express';
import { contentLoader } from '../services/ContentLoader.js';
import { progressionSystem } from '../services/ProgressionSystem.js';
import { vmManager } from '../services/VMManager.js';
const router = Router();
router.get('/', async (_req, res) => {
const vms = await Promise.all(
[...contentLoader.vmProfiles.values()]
.sort((left, right) => left.id.localeCompare(right.id))
.map(async (profile) => ({
id: profile.id,
domain: vmManager.getDomainName(profile.id),
hostname: profile.hostname ?? profile.id,
state: await vmManager.getState(profile.id),
unlocked: profile.id === 'workstation' ? true : progressionSystem.hasVM(profile.id)
}))
);
res.json(vms);
});
export default router;
+54
View File
@@ -0,0 +1,54 @@
import { eventBus } from '../lib/eventBus.js';
import { saveState } from './SaveState.js';
class BehaviorTracker {
constructor() {
this._curiosity = 50;
this._obedience = 50;
this._risk = 50;
this._suspicion = 0;
}
initialize(state) {
const b = state?.behavior ?? {};
this._curiosity = typeof b.curiosity === 'number' ? b.curiosity : 50;
this._obedience = typeof b.obedience === 'number' ? b.obedience : 50;
this._risk = typeof b.risk === 'number' ? b.risk : 50;
this._suspicion = typeof b.suspicion === 'number' ? b.suspicion : 0;
}
apply(impact) {
if (!impact || typeof impact !== 'object') return;
const clamp = (v) => Math.max(0, Math.min(100, v));
if (typeof impact.curiosity_delta === 'number') this._curiosity = clamp(this._curiosity + impact.curiosity_delta);
if (typeof impact.obedience_delta === 'number') this._obedience = clamp(this._obedience + impact.obedience_delta);
if (typeof impact.risk_delta === 'number') this._risk = clamp(this._risk + impact.risk_delta);
if (typeof impact.suspicion_delta === 'number') this._suspicion = clamp(this._suspicion + impact.suspicion_delta);
this._persist();
eventBus.emit('behavior:changed', this.getSnapshot());
}
getSnapshot() {
return {
curiosity: this._curiosity,
obedience: this._obedience,
risk: this._risk,
suspicion: this._suspicion
};
}
setSnapshot(override) {
const clamp = (v) => Math.max(0, Math.min(100, v));
if (typeof override.curiosity === 'number') this._curiosity = clamp(override.curiosity);
if (typeof override.obedience === 'number') this._obedience = clamp(override.obedience);
if (typeof override.risk === 'number') this._risk = clamp(override.risk);
if (typeof override.suspicion === 'number') this._suspicion = clamp(override.suspicion);
this._persist();
}
_persist() {
saveState.set({ behavior: this.getSnapshot() });
}
}
export const behaviorTracker = new BehaviorTracker();
@@ -0,0 +1,95 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'os';
import path from 'path';
import { behaviorTracker } from './BehaviorTracker.js';
import { saveState } from './SaveState.js';
async function isolateSaveState(testId) {
await saveState._writeQueue.catch(() => {});
process.env.SAVE_DIR = path.join(os.tmpdir(), `sc-test-${testId}-${Date.now()}`);
saveState._savePath = null;
saveState._state = null;
saveState._writeQueue = Promise.resolve();
}
test('initialize with explicit values', async () => {
await isolateSaveState('behavior-explicit');
behaviorTracker.initialize({ behavior: { curiosity: 30, obedience: 70, risk: 40, suspicion: 10 } });
assert.deepEqual(behaviorTracker.getSnapshot(), {
curiosity: 30,
obedience: 70,
risk: 40,
suspicion: 10
});
});
test('initialize with no behavior key uses defaults', async () => {
await isolateSaveState('behavior-defaults');
behaviorTracker.initialize({});
assert.deepEqual(behaviorTracker.getSnapshot(), {
curiosity: 50,
obedience: 50,
risk: 50,
suspicion: 0
});
});
test('apply adds positive delta', async () => {
await isolateSaveState('behavior-positive-delta');
behaviorTracker.initialize({ behavior: { curiosity: 60, obedience: 50, risk: 50, suspicion: 0 } });
behaviorTracker.apply({ curiosity_delta: 5 });
assert.equal(behaviorTracker.getSnapshot().curiosity, 65);
});
test('apply subtracts negative delta', async () => {
await isolateSaveState('behavior-negative-delta');
behaviorTracker.initialize({ behavior: { curiosity: 50, obedience: 50, risk: 50, suspicion: 0 } });
behaviorTracker.apply({ curiosity_delta: -15 });
assert.equal(behaviorTracker.getSnapshot().curiosity, 35);
});
test('apply clamps at 0', async () => {
await isolateSaveState('behavior-clamp-low');
behaviorTracker.initialize({ behavior: { curiosity: 5, obedience: 50, risk: 50, suspicion: 0 } });
behaviorTracker.apply({ curiosity_delta: -20 });
assert.equal(behaviorTracker.getSnapshot().curiosity, 0);
});
test('apply clamps at 100', async () => {
await isolateSaveState('behavior-clamp-high');
behaviorTracker.initialize({ behavior: { curiosity: 50, obedience: 50, risk: 95, suspicion: 0 } });
behaviorTracker.apply({ risk_delta: 20 });
assert.equal(behaviorTracker.getSnapshot().risk, 100);
});
test('setSnapshot overrides values', async () => {
await isolateSaveState('behavior-set-snapshot');
behaviorTracker.initialize({ behavior: { curiosity: 50, obedience: 50, risk: 50, suspicion: 0 } });
behaviorTracker.setSnapshot({ curiosity: 80, obedience: 20 });
const snapshot = behaviorTracker.getSnapshot();
assert.equal(snapshot.curiosity, 80);
assert.equal(snapshot.obedience, 20);
});
test('apply ignores non-numeric delta', async () => {
await isolateSaveState('behavior-ignore-nonnumeric');
behaviorTracker.initialize({ behavior: { curiosity: 50, obedience: 50, risk: 50, suspicion: 0 } });
behaviorTracker.apply({ curiosity_delta: 'bad' });
assert.equal(behaviorTracker.getSnapshot().curiosity, 50);
});
+136
View File
@@ -0,0 +1,136 @@
import { eventBus } from '../lib/eventBus.js';
import { toArray } from '../lib/utils.js';
import { emailService } from './EmailService.js';
import { questEngine } from './QuestEngine.js';
import { saveState } from './SaveState.js';
const CERTIFICATIONS = [
{
id: 'workstation-foundations',
title: 'Axiom Works: Workstation Foundations',
description: 'Basic Linux file system navigation, permissions, and SSH troubleshooting.',
quest_ids: ['Q001', 'Q002']
},
{
id: 'service-administration',
title: 'Axiom Works: Service Administration',
description: 'systemd, service debugging, and safe operational recovery.',
quest_ids: ['Q002', 'Q003']
},
{
id: 'log-analysis',
title: 'Axiom Works: Log Analysis',
description: 'Reading auth and service logs, finding recurrence, and tracing root cause.',
quest_ids: ['Q003', 'Q006']
},
{
id: 'network-basics',
title: 'Axiom Works: Network Basics',
description: 'Service exposure, deployment checks, and basic network validation.',
quest_ids: ['Q004', 'Q005']
},
{
id: 'security-awareness',
title: 'Axiom Works: Security Awareness',
description: 'Ownership, access controls, and corrective SSH hardening.',
quest_ids: ['Q007']
}
];
export class CertificationService {
constructor({
bus = eventBus,
email = emailService,
quests = questEngine,
save = saveState
} = {}) {
this.bus = bus;
this.email = email;
this.quests = quests;
this.save = save;
this._handlersBound = false;
this._onQuestCompleted = this._handleQuestCompleted.bind(this);
}
initialize(state = this.save.get()) {
if (!Array.isArray(state?.certifications)) {
this.save.set({ certifications: [] });
}
this._bindHandlers();
this._evaluateAll();
}
dispose() {
if (!this._handlersBound) {
return;
}
this.bus.off('quest:completed', this._onQuestCompleted);
this._handlersBound = false;
}
_bindHandlers() {
this.dispose();
this.bus.on('quest:completed', this._onQuestCompleted);
this._handlersBound = true;
}
_handleQuestCompleted() {
this._evaluateAll();
}
_evaluateAll() {
for (const certification of CERTIFICATIONS) {
if (this._alreadyAwarded(certification.id)) {
continue;
}
const complete = certification.quest_ids.every((questId) => this.quests.isCompleted(questId));
if (!complete) {
continue;
}
this._award(certification);
}
}
_alreadyAwarded(certificationId) {
return toArray(this.save.get()?.certifications).some((entry) => entry.id === certificationId);
}
_award(certification) {
const awardedAt = new Date().toISOString();
const record = {
...certification,
awarded_at: awardedAt
};
this.save.set({
certifications: [...toArray(this.save.get()?.certifications), record]
});
this.email.send({
id: `mail-cert-${certification.id}-${Date.now()}`,
from: 'HR Bot <hr-bot@axiomworks.internal>',
subject: `Certification awarded: ${certification.title}`,
body: [
`Internal certification granted: ${certification.title}`,
'',
certification.description,
'',
`Completed quest chain: ${certification.quest_ids.join(', ')}`
].join('\n'),
attachments: [],
replyOptions: []
});
this.bus.emit('certification:awarded', {
id: certification.id,
title: certification.title,
awarded_at: awardedAt
});
}
}
export const certificationService = new CertificationService();
@@ -0,0 +1,62 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { CertificationService } from './CertificationService.js';
function createSave(initialState) {
let state = structuredClone(initialState);
return {
get() {
return state;
},
set(partial) {
state = {
...state,
...partial
};
return state;
}
};
}
test('CertificationService awards workstation foundations once prerequisite quests are complete', () => {
const bus = new EventEmitter();
const sent = [];
const save = createSave({ certifications: [] });
const completed = new Set();
const service = new CertificationService({
bus,
save,
quests: {
isCompleted(questId) {
return completed.has(questId);
}
},
email: {
send(payload) {
sent.push(payload);
return payload;
}
}
});
service.initialize(save.get());
assert.equal(save.get().certifications.length, 0);
completed.add('Q001');
bus.emit('quest:completed', { questId: 'Q001' });
assert.equal(save.get().certifications.length, 0);
completed.add('Q002');
bus.emit('quest:completed', { questId: 'Q002' });
assert.equal(save.get().certifications.length, 1);
assert.equal(save.get().certifications[0].id, 'workstation-foundations');
assert.equal(sent.length, 1);
bus.emit('quest:completed', { questId: 'Q002' });
assert.equal(save.get().certifications.length, 1);
assert.equal(sent.length, 1);
});
+69
View File
@@ -0,0 +1,69 @@
import path from 'path';
import { readdir, readFile } from 'fs/promises';
class ContentLoader {
constructor() {
this.contentDir = null;
this.tickets = new Map();
this.quests = new Map();
this.docs = new Map();
this.dialogue = new Map();
this.incidents = new Map();
this.pressureProfiles = new Map();
this.vmProfiles = new Map();
this.trustUnlocks = [];
this.worldFlagsRegistry = {};
}
async load() {
this.contentDir = path.resolve(process.cwd(), process.env.CONTENT_DIR ?? '../content');
this.tickets = await this._loadCollection('tickets');
this.quests = await this._loadCollection('quests');
this.docs = await this._loadCollection('docs');
this.dialogue = await this._loadCollection('dialogue');
this.incidents = await this._loadCollection('incidents');
this.pressureProfiles = await this._loadCollection('pressure_profiles');
this.vmProfiles = await this._loadCollection('vm_profiles');
this.trustUnlocks = await this._loadJsonFile(path.join(this.contentDir, 'progression', 'trust_unlocks.json'));
this.worldFlagsRegistry = await this._loadJsonFile(path.join(this.contentDir, 'world_flags', 'world_flags.json'));
return this;
}
get(type, id) {
const collection = this[type];
if (!(collection instanceof Map)) {
return undefined;
}
return collection.get(id);
}
async _loadCollection(subdirectory) {
const directory = path.join(this.contentDir, subdirectory);
const entries = await readdir(directory, { withFileTypes: true });
const map = new Map();
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
if (!entry.name.endsWith('.json') || entry.name.endsWith('.bak') || entry.name.includes('SPLIT_DONE')) {
continue;
}
const payload = await this._loadJsonFile(path.join(directory, entry.name));
if (payload?.id) {
map.set(payload.id, payload);
}
}
return map;
}
async _loadJsonFile(filePath) {
const raw = await readFile(filePath, 'utf8');
return JSON.parse(raw);
}
}
export const contentLoader = new ContentLoader();
+254
View File
@@ -0,0 +1,254 @@
import { eventBus } from '../lib/eventBus.js';
import { createError } from '../lib/utils.js';
import { contentLoader } from './ContentLoader.js';
import { saveState } from './SaveState.js';
const CHARACTER_EMAILS = {
marcus: 'Marcus Webb <m.webb@axiomworks.internal>',
sarah: 'Sarah Chen <s.chen@axiomworks.internal>',
priya: 'Priya Nair <p.nair@axiomworks.internal>',
alex: 'Alex Mercer <a.mercer@axiomworks.internal>',
dave: 'Dave Okonkwo <d.okonkwo@axiomworks.internal>',
monitoring: 'Monitoring <alerts@axiomworks.internal>'
};
class EmailService {
constructor() {
this._mail = [];
}
initialize(state) {
this._mail = Array.isArray(state.mail) ? state.mail.map((mail) => this._normalizeMail(mail)) : [];
if (this._mail.length === 0) {
this.send({
id: 'mail-T001-initial',
from: 'Marcus Webb <m.webb@axiomworks.internal>',
subject: 'Your workstation access',
body: 'Hey, welcome to the team. HR said you started today so I got you set up with an account on ares. The provisioning script runs automatically but it does not handle SSH keys \u2014 you will need to add yours manually. Your public key should be in the onboarding doc. Let me know if you get stuck.\n\n\u2014 Marcus',
attachments: ['docs/onboarding.json'],
replyOptions: [
{ label: 'Got it, I\'ll get that sorted.', dialogue_node: 'marcus-Q001-reply-a' },
{ label: 'Where do I find the onboarding doc?', dialogue_node: 'marcus-Q001-reply-b' }
]
});
}
}
getAll() {
return this._mail.map((mail) => ({
id: mail.id,
from: mail.from,
subject: mail.subject,
timestamp: mail.timestamp,
read: mail.read,
replied: mail.replied
}));
}
getById(id) {
return this._mail.find((mail) => mail.id === id) ?? null;
}
markRead(id) {
const mail = this.getById(id);
if (!mail) {
throw createError(`Unknown mail: ${id}`, 404);
}
mail.read = true;
this._persist();
}
send({ id, from, subject, body, attachments = [], replyOptions = [] }) {
const record = this._normalizeMail({
id,
from,
subject,
body,
attachments,
reply_options: replyOptions,
read: false,
replied: false,
timestamp: new Date().toISOString()
});
this._mail.push(record);
this._persist();
eventBus.emit('mail:new', { id, from, subject });
return record;
}
reply(mailId, choiceIndex) {
const mail = this.getById(mailId);
if (!mail) {
throw createError(`Unknown mail: ${mailId}`, 404);
}
const choices = mail.reply_options ?? [];
if (!Number.isInteger(choiceIndex) || choiceIndex < 0 || choiceIndex >= choices.length) {
throw createError('Invalid reply choice', 400);
}
mail.replied = true;
const selectedChoice = choices[choiceIndex];
const responseBody = this._resolveDialogueBody(selectedChoice.dialogue_node, mail, selectedChoice);
this._persist();
this.send({
id: `${mailId}-reply-${choiceIndex}`,
from: mail.from,
subject: `Re: ${mail.subject}`,
body: responseBody,
attachments: [],
replyOptions: []
});
return { ok: true };
}
sendDialogueFollowUp(dialogueNodeId, options = {}) {
const resolved = this._resolveDialogueMessage(dialogueNodeId);
if (!resolved?.body) {
return null;
}
const {
questId = resolved.questId,
ticketId = resolved.questId ? contentLoader.get('quests', resolved.questId)?.ticket_id : null,
subjectPrefix = 'Follow-up',
idPrefix = 'mail-followup'
} = options;
const from = CHARACTER_EMAILS[resolved.character] ?? CHARACTER_EMAILS.monitoring;
const subject = ticketId
? `${subjectPrefix}: ${ticketId}`
: `${subjectPrefix}: ${dialogueNodeId}`;
return this.send({
id: `${idPrefix}-${dialogueNodeId}-${Date.now()}`,
from,
subject,
body: resolved.body,
attachments: [],
replyOptions: []
});
}
_persist() {
saveState.set({ mail: this._mail });
}
_normalizeMail(mail) {
return {
...mail,
attachments: [...(mail.attachments ?? [])],
reply_options: [...(mail.reply_options ?? mail.replyOptions ?? [])],
read: Boolean(mail.read),
replied: Boolean(mail.replied),
timestamp: mail.timestamp ?? new Date().toISOString()
};
}
_resolveDialogueBody(dialogueNode, mail, choice) {
const resolvedMessage = this._resolveDialogueMessage(dialogueNode, mail);
if (resolvedMessage?.body) {
return resolvedMessage.body;
}
return choice?.label
? `Noted.\n\n${choice.label}`
: 'Noted.';
}
_resolveDialogueMessage(dialogueNode, mail = null) {
const directMatch = contentLoader.get('dialogue', dialogueNode);
if (directMatch?.body) {
return {
body: directMatch.body,
character: directMatch.character ?? null,
questId: directMatch.quest_id ?? null
};
}
const parsed = this._parseDialogueNodeReference(dialogueNode);
if (parsed) {
const dialogue = contentLoader.get('dialogue', parsed.baseId);
const message = dialogue?.messages?.find((entry) => entry.stage === parsed.stage);
if (message?.body) {
return {
body: message.body,
character: dialogue.character ?? null,
questId: dialogue.quest_id ?? null
};
}
}
const fallbackMatch = this._findDialogueFallback(dialogueNode, mail);
if (fallbackMatch?.dialogue?.body) {
return {
body: fallbackMatch.dialogue.body,
character: fallbackMatch.dialogue.character ?? null,
questId: fallbackMatch.dialogue.quest_id ?? null
};
}
if (Array.isArray(fallbackMatch?.dialogue?.messages) && fallbackMatch.dialogue.messages.length > 0) {
return {
body: fallbackMatch.dialogue.messages[0].body,
character: fallbackMatch.dialogue.character ?? null,
questId: fallbackMatch.dialogue.quest_id ?? null
};
}
return null;
}
_findDialogueFallback(dialogueNode, mail) {
const parsed = this._parseDialogueNodeReference(dialogueNode);
const baseId = parsed?.baseId ?? dialogueNode.replace(/-reply-[a-z0-9]+$/i, '');
const candidates = [baseId];
const ticketMatch = baseId.match(/^([^-]+)-T(\d{3})$/i);
if (ticketMatch) {
const [, character, ticketNumber] = ticketMatch;
const ticket = contentLoader.get('tickets', `T${ticketNumber}`);
if (ticket?.linked_quest) {
candidates.push(`${character}-${ticket.linked_quest}`);
}
}
candidates.push(mail?.from?.toLowerCase().includes('marcus') ? 'marcus-Q001' : null);
for (const candidate of candidates) {
if (!candidate) {
continue;
}
const dialogue = contentLoader.get('dialogue', candidate);
if (dialogue) {
return { dialogue, baseId: candidate };
}
}
return null;
}
_parseDialogueNodeReference(dialogueNode) {
if (typeof dialogueNode !== 'string') {
return null;
}
const match = dialogueNode.match(/^([^-]+-Q\d{3})-(.+)$/i);
if (!match) {
return null;
}
return {
baseId: match[1],
stage: match[2]
};
}
}
export const emailService = new EmailService();
+55
View File
@@ -0,0 +1,55 @@
import { eventBus } from '../lib/eventBus.js';
import { saveState } from './SaveState.js';
import { behaviorTracker } from './BehaviorTracker.js';
import { hiddenHookTracker } from './HiddenHookTracker.js';
import { narrativePhaseTracker } from './NarrativePhaseTracker.js';
const PHASE_RANK = {
normal_work: 0, unease: 1, suspicion: 2,
investigation: 3, conflict: 4, resolution: 5
};
class EndingEvaluator {
evaluate() {
const state = saveState.get() ?? {};
const trust = Number(state.trust ?? 50);
const { curiosity, obedience, risk } = behaviorTracker.getSnapshot();
const hooksDiscovered = hiddenHookTracker.getDiscovered().length;
const phaseRank = PHASE_RANK[narrativePhaseTracker.getPhase()] ?? 0;
const candidates = [];
// exposure: investigative player who found hidden hooks
if (curiosity >= 65 && hooksDiscovered >= 2 && phaseRank >= PHASE_RANK.investigation) {
candidates.push('exposure');
}
// chaos: high risk, low trust
if (risk >= 65 && trust <= 40) {
candidates.push('chaos');
}
// corporate_loop: compliant, trusted, incurious
if (obedience >= 65 && curiosity <= 40 && trust >= 65) {
candidates.push('corporate_loop');
}
// burnout: passive disengagement
if (curiosity <= 35 && obedience <= 40) {
candidates.push('burnout');
}
// Priority: exposure > chaos > corporate_loop > burnout
const priority = ['exposure', 'chaos', 'corporate_loop', 'burnout'];
const active = priority.find((e) => candidates.includes(e)) ?? 'undetermined';
return { active, candidates };
}
checkTrigger() {
const result = this.evaluate();
if (narrativePhaseTracker.getPhase() === 'resolution' && result.active !== 'undetermined') {
eventBus.emit('ending:triggered', { ending: result.active });
}
return result;
}
}
export const endingEvaluator = new EndingEvaluator();
+116
View File
@@ -0,0 +1,116 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'os';
import path from 'path';
import { behaviorTracker } from './BehaviorTracker.js';
import { narrativePhaseTracker } from './NarrativePhaseTracker.js';
import { hiddenHookTracker } from './HiddenHookTracker.js';
import { saveState } from './SaveState.js';
import { endingEvaluator } from './EndingEvaluator.js';
import { eventBus } from '../lib/eventBus.js';
async function isolateSaveState(testId) {
await saveState._writeQueue.catch(() => {});
process.env.SAVE_DIR = path.join(os.tmpdir(), `sc-ee-test-${testId}-${Date.now()}`);
saveState._savePath = null;
saveState._state = null;
saveState._writeQueue = Promise.resolve();
}
function resetAll({
curiosity = 50,
obedience = 50,
risk = 50,
suspicion = 0,
trust = 50,
phase = 'normal_work',
hooks = []
} = {}) {
behaviorTracker.initialize({ behavior: { curiosity, obedience, risk, suspicion } });
narrativePhaseTracker.initialize({ narrative_phase: phase });
hiddenHookTracker.initialize({ hidden_hooks_discovered: hooks });
saveState.set({ trust });
}
test('default state returns undetermined', async () => {
await isolateSaveState('default');
resetAll();
assert.deepEqual(endingEvaluator.evaluate(), { active: 'undetermined', candidates: [] });
});
test('exposure ending', async () => {
await isolateSaveState('exposure');
resetAll({ curiosity: 70, phase: 'investigation', hooks: ['h1', 'h2'] });
const result = endingEvaluator.evaluate();
assert.equal(result.active, 'exposure');
assert.ok(result.candidates.includes('exposure'));
});
test('chaos ending', async () => {
await isolateSaveState('chaos');
resetAll({ risk: 70, trust: 30 });
assert.equal(endingEvaluator.evaluate().active, 'chaos');
});
test('corporate_loop ending', async () => {
await isolateSaveState('corporate-loop');
resetAll({ obedience: 70, curiosity: 35, trust: 70 });
assert.equal(endingEvaluator.evaluate().active, 'corporate_loop');
});
test('burnout ending', async () => {
await isolateSaveState('burnout');
resetAll({ curiosity: 30, obedience: 35 });
assert.equal(endingEvaluator.evaluate().active, 'burnout');
});
test('exposure takes priority over chaos', async () => {
await isolateSaveState('exposure-priority');
resetAll({ curiosity: 70, risk: 70, trust: 30, phase: 'investigation', hooks: ['h1', 'h2'] });
const result = endingEvaluator.evaluate();
assert.equal(result.active, 'exposure');
assert.ok(result.candidates.includes('exposure'));
assert.ok(result.candidates.includes('chaos'));
});
test('checkTrigger at resolution emits ending:triggered', async () => {
await isolateSaveState('trigger-resolution');
resetAll({ curiosity: 70, phase: 'resolution', hooks: ['h1', 'h2'] });
let event;
eventBus.once('ending:triggered', (payload) => {
event = payload;
});
endingEvaluator.checkTrigger();
assert.deepEqual(event, { ending: 'exposure' });
});
test('checkTrigger NOT at resolution does NOT emit', async () => {
await isolateSaveState('trigger-not-resolution');
resetAll({ curiosity: 70, phase: 'investigation', hooks: ['h1', 'h2'] });
let called = false;
function fn() {
called = true;
}
eventBus.on('ending:triggered', fn);
try {
endingEvaluator.checkTrigger();
} finally {
eventBus.removeListener('ending:triggered', fn);
}
assert.equal(called, false);
});
+34
View File
@@ -0,0 +1,34 @@
import { eventBus } from '../lib/eventBus.js';
import { saveState } from './SaveState.js';
class HiddenHookTracker {
constructor() {
this._discovered = new Set();
}
initialize(state) {
const saved = state?.hidden_hooks_discovered;
this._discovered = new Set(Array.isArray(saved) ? saved : []);
}
discover(hookId) {
if (this._discovered.has(hookId)) return;
this._discovered.add(hookId);
this._persist();
eventBus.emit('hidden_hook:discovered', { hookId });
}
isDiscovered(hookId) {
return this._discovered.has(hookId);
}
getDiscovered() {
return [...this._discovered].sort();
}
_persist() {
saveState.set({ hidden_hooks_discovered: this.getDiscovered() });
}
}
export const hiddenHookTracker = new HiddenHookTracker();
@@ -0,0 +1,56 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'os';
import path from 'path';
import { hiddenHookTracker } from './HiddenHookTracker.js';
import { saveState } from './SaveState.js';
async function isolateSaveState(testId) {
await saveState._writeQueue.catch(() => {});
process.env.SAVE_DIR = path.join(os.tmpdir(), `sc-test-${testId}-${Date.now()}`);
saveState._savePath = null;
saveState._state = null;
saveState._writeQueue = Promise.resolve();
}
test('initialize restores discovered hooks', async () => {
await isolateSaveState('hooks-restore');
hiddenHookTracker.initialize({ hidden_hooks_discovered: ['hook_a', 'hook_b'] });
assert.equal(hiddenHookTracker.isDiscovered('hook_a'), true);
assert.equal(hiddenHookTracker.isDiscovered('hook_c'), false);
});
test('initialize with no saved hooks starts empty', async () => {
await isolateSaveState('hooks-empty');
hiddenHookTracker.initialize({});
assert.deepEqual(hiddenHookTracker.getDiscovered(), []);
});
test('discover adds a new hook', async () => {
await isolateSaveState('hooks-discover');
hiddenHookTracker.initialize({});
hiddenHookTracker.discover('hook_x');
assert.equal(hiddenHookTracker.isDiscovered('hook_x'), true);
});
test('discover is idempotent', async () => {
await isolateSaveState('hooks-idempotent');
hiddenHookTracker.initialize({});
hiddenHookTracker.discover('hook_y');
hiddenHookTracker.discover('hook_y');
assert.equal(hiddenHookTracker.getDiscovered().length, 1);
});
test('getDiscovered returns sorted array', async () => {
await isolateSaveState('hooks-sorted');
hiddenHookTracker.initialize({ hidden_hooks_discovered: ['zz', 'aa', 'mm'] });
assert.deepEqual(hiddenHookTracker.getDiscovered(), ['aa', 'mm', 'zz']);
});
+368
View File
@@ -0,0 +1,368 @@
import { eventBus } from '../lib/eventBus.js';
import { toArray, normalizeWorldFlag } from '../lib/utils.js';
import { contentLoader } from './ContentLoader.js';
import { emailService } from './EmailService.js';
import { questEngine } from './QuestEngine.js';
import { saveState } from './SaveState.js';
import { ticketService } from './TicketService.js';
import { validationEngine } from './ValidationEngine.js';
import { narrativePhaseTracker } from './NarrativePhaseTracker.js';
const DEFAULT_TICK_SECONDS = Number(process.env.INCIDENT_TICK_SECONDS ?? 30);
const ALERT_SENDER = 'Monitoring <alerts@axiomworks.internal>';
export class IncidentScheduler {
constructor({
loader = contentLoader,
email = emailService,
quests = questEngine,
save = saveState,
tickets = ticketService,
validator = validationEngine,
phaseTracker = narrativePhaseTracker,
now = () => Date.now(),
tickSeconds = DEFAULT_TICK_SECONDS
} = {}) {
this.loader = loader;
this.email = email;
this.quests = quests;
this.save = save;
this.tickets = tickets;
this.validator = validator;
this.phaseTracker = phaseTracker;
this.now = now;
this.tickSeconds = tickSeconds;
this._interval = null;
}
start() {
this.stop();
this.tick().catch((error) => {
console.error('Incident scheduler tick failed:', error);
});
this._interval = setInterval(() => {
this.tick().catch((error) => {
console.error('Incident scheduler tick failed:', error);
});
}, this.tickSeconds * 1000).unref();
}
stop() {
if (this._interval) {
clearInterval(this._interval);
this._interval = null;
}
}
async tick() {
this._ensureStateContainers();
await this._processPressureProfiles();
await this._processGlobalPressureProfiles();
await this._processRecurringIncidents();
}
_ensureStateContainers() {
const state = this.save.get();
if (!state.pressure || !state.incidents) {
this.save.set({
pressure: state.pressure ?? {},
incidents: state.incidents ?? {}
});
}
}
async _processPressureProfiles() {
const state = this.save.get();
const trackers = { ...(state.pressure ?? {}) };
let changed = false;
for (const [questId, entry] of this.quests.getAllEntries()) {
if (entry?.state !== 'active') {
continue;
}
const quest = this.loader.get('quests', questId);
const profileId = quest?.pressure_profile;
if (!profileId) {
continue;
}
const profile = this.loader.get('pressureProfiles', profileId);
if (!profile) {
continue;
}
const tracker = trackers[questId] ?? {
profile_id: profileId,
started_at: entry.started_at ?? new Date(this.now()).toISOString(),
fired_step_indexes: []
};
const elapsedSeconds = this._elapsedSeconds(tracker.started_at);
for (const [index, step] of toArray(profile.escalation_steps).entries()) {
const threshold = Number(step.trigger_after_seconds ?? step.after_seconds ?? -1);
if (threshold < 0 || tracker.fired_step_indexes.includes(index) || elapsedSeconds < threshold) {
continue;
}
await this._applyPressureStep({
quest,
profile,
questId,
step,
stepIndex: index
});
tracker.fired_step_indexes = [...tracker.fired_step_indexes, index];
changed = true;
}
trackers[questId] = tracker;
}
if (changed) {
this.save.set({ pressure: trackers });
}
}
async _processGlobalPressureProfiles() {
if (!(this.loader.pressureProfiles instanceof Map)) return;
const currentPhase = this.phaseTracker.getPhase();
const state = this.save.get();
const trackers = { ...(state.pressure ?? {}) };
let changed = false;
for (const profile of this.loader.pressureProfiles.values()) {
if (!profile.trigger_phase || profile.trigger_phase !== currentPhase) continue;
const profileId = profile.id;
const tracker = trackers[profileId] ?? {
profile_id: profileId,
started_at: new Date(this.now()).toISOString(),
fired_step_indexes: []
};
const elapsedSeconds = this._elapsedSeconds(tracker.started_at);
for (const [index, step] of toArray(profile.escalation_steps).entries()) {
const threshold = Number(step.trigger_after_seconds ?? step.after_seconds ?? -1);
if (threshold < 0 || tracker.fired_step_indexes.includes(index) || elapsedSeconds < threshold) {
continue;
}
await this._applyGlobalPressureStep({ profile, profileId, step, stepIndex: index });
tracker.fired_step_indexes = [...tracker.fired_step_indexes, index];
changed = true;
}
trackers[profileId] = tracker;
}
if (changed) {
this.save.set({ pressure: trackers });
}
}
async _processRecurringIncidents() {
const state = this.save.get();
const activeIncidents = { ...(state.incidents ?? {}) };
let changed = false;
for (const incident of this.loader.incidents.values()) {
const entry = activeIncidents[incident.id];
const triggered = this._incidentTriggered(incident, state.world_flags ?? []);
if (!entry && triggered) {
activeIncidents[incident.id] = {
status: 'active',
started_at: new Date(this.now()).toISOString(),
fired_step_indexes: []
};
changed = true;
if (incident.notification) {
this._sendAlert({
idPrefix: `incident-${incident.id}`,
subject: incident.title,
message: incident.notification,
severity: incident.notification_severity ?? 'warning'
});
}
}
const nextEntry = activeIncidents[incident.id];
if (!nextEntry || nextEntry.status !== 'active') {
continue;
}
const elapsedSeconds = this._elapsedSeconds(nextEntry.started_at);
for (const [index, step] of toArray(incident.escalation_steps).entries()) {
const threshold = Number(step.trigger_after_seconds ?? step.after_seconds ?? -1);
if (threshold < 0 || nextEntry.fired_step_indexes.includes(index) || elapsedSeconds < threshold) {
continue;
}
await this._applyIncidentStep({
incident,
step,
stepIndex: index
});
nextEntry.fired_step_indexes = [...nextEntry.fired_step_indexes, index];
changed = true;
}
if (await this._incidentResolved(incident)) {
const resolution = incident.resolution_requirements ?? {};
activeIncidents[incident.id] = {
...nextEntry,
status: 'resolved',
resolved_at: new Date(this.now()).toISOString()
};
this._applyWorldFlagMutation({
clearFlag: resolution.clear_flag,
setFlag: resolution.set_flag
});
changed = true;
}
}
if (changed) {
this.save.set({ incidents: activeIncidents });
}
}
async _applyPressureStep({ quest, profile, questId, step, stepIndex }) {
if (step.notification) {
this._sendAlert({
idPrefix: `pressure-${questId}-${stepIndex}`,
subject: `${quest.ticket_id ?? questId} escalation`,
message: step.notification,
severity: step.notification_severity ?? 'warning'
});
}
if (step.escalate_linked_ticket && quest.ticket_id) {
this.tickets.setPriority(quest.ticket_id, step.escalate_linked_ticket);
}
}
async _applyGlobalPressureStep({ profile, profileId, step, stepIndex }) {
if (!step.notification) return;
const from = step.sender ?? ALERT_SENDER;
const subject = step.subject ?? profile.label ?? profileId;
const mail = this.email.send({
id: `global-pressure-${profileId}-${stepIndex}-${this.now()}`,
from,
subject,
body: step.notification,
attachments: [],
replyOptions: []
});
eventBus.emit('incident:alert', {
id: mail.id,
severity: step.notification_severity ?? 'info',
message: step.notification,
subject
});
}
async _applyIncidentStep({ incident, step, stepIndex }) {
if (step.notification) {
this._sendAlert({
idPrefix: `incident-${incident.id}-${stepIndex}`,
subject: incident.title,
message: step.notification,
severity: step.notification_severity ?? 'warning'
});
}
if (Array.isArray(step.world_flags) && step.world_flags.length > 0) {
this._applyWorldFlagMutation({ setFlags: step.world_flags });
}
for (const escalation of toArray(step.escalates_tickets)) {
if (escalation.ticket_id && escalation.new_priority) {
this.tickets.setPriority(escalation.ticket_id, escalation.new_priority);
}
}
if (step.action === 'raise_ticket_priority' && step.ticket_id && step.value) {
this.tickets.setPriority(step.ticket_id, step.value);
}
if (step.action === 'trigger_new_ticket' && step.ticket_id) {
try {
this.tickets.activateTicket(step.ticket_id);
} catch {
// Ignore missing authored recurrence tickets for now.
}
}
}
async _incidentResolved(incident) {
const rule = incident.resolution_requirements?.validation;
if (!rule) {
return false;
}
const result = await this.validator.evaluateRule(rule);
return result.passed;
}
_incidentTriggered(incident, worldFlags) {
const flags = new Set(worldFlags ?? []);
const conditions = [
...toArray(incident.trigger_flags),
...toArray(incident.trigger_conditions)
];
if (conditions.length === 0) {
return false;
}
return conditions.every((condition) => flags.has(normalizeWorldFlag(condition)));
}
_applyWorldFlagMutation({ clearFlag = null, setFlag = null, setFlags = [] } = {}) {
const nextFlags = new Set(this.save.get()?.world_flags ?? []);
if (clearFlag) {
nextFlags.delete(clearFlag);
}
if (setFlag) {
nextFlags.add(setFlag);
}
for (const flag of setFlags) {
nextFlags.add(flag);
}
this.save.set({ world_flags: [...nextFlags] });
}
_sendAlert({ idPrefix, subject, message, severity }) {
const mail = this.email.send({
id: `${idPrefix}-${Date.now()}`,
from: ALERT_SENDER,
subject: `[${String(severity).toUpperCase()}] ${subject}`,
body: message,
attachments: [],
replyOptions: []
});
eventBus.emit('incident:alert', {
id: mail.id,
severity,
message,
subject
});
}
_elapsedSeconds(startedAt) {
return Math.max(0, Math.floor((this.now() - new Date(startedAt).getTime()) / 1000));
}
}
export const incidentScheduler = new IncidentScheduler();
@@ -0,0 +1,96 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { IncidentScheduler } from './IncidentScheduler.js';
function createMemorySave(state) {
return {
state: structuredClone(state),
get() {
return this.state;
},
set(partial) {
const nextState = { ...this.state };
for (const [key, value] of Object.entries(partial)) {
if (value && typeof value === 'object' && !Array.isArray(value) && nextState[key] && typeof nextState[key] === 'object' && !Array.isArray(nextState[key])) {
nextState[key] = { ...nextState[key], ...structuredClone(value) };
} else {
nextState[key] = structuredClone(value);
}
}
this.state = nextState;
return this.state;
}
};
}
test('IncidentScheduler escalates active quest pressure and raises linked ticket priority', async () => {
const sentMail = [];
const priorityUpdates = [];
const save = createMemorySave({
shift_started_at: '2026-04-25T11:00:00Z',
world_flags: [],
pressure: {},
incidents: {}
});
const scheduler = new IncidentScheduler({
loader: {
incidents: new Map(),
get(type, id) {
if (type === 'quests') {
return {
id,
ticket_id: 'T002',
pressure_profile: 'web_outage_escalation'
};
}
if (type === 'pressureProfiles') {
return {
id: 'web_outage_escalation',
escalation_steps: [
{ trigger_after_seconds: 900, notification: 'Hermes is still showing errors.', notification_severity: 'warning' },
{ trigger_after_seconds: 1800, notification: 'Priority is going up.', notification_severity: 'warning', escalate_linked_ticket: 'high' }
]
};
}
return null;
}
},
email: {
send(payload) {
sentMail.push(payload);
return payload;
}
},
quests: {
getAllEntries() {
return [[
'Q002',
{ state: 'active', started_at: '2026-04-25T11:30:00Z' }
]];
}
},
save,
tickets: {
setPriority(ticketId, value) {
priorityUpdates.push({ ticketId, value });
}
},
validator: {
async evaluateRule() {
return { passed: false, failures: ['not-applicable'] };
}
},
now: () => new Date('2026-04-25T12:05:00Z').getTime(),
tickSeconds: 30
});
await scheduler.tick();
assert.equal(sentMail.length, 2);
assert.deepEqual(priorityUpdates, [{ ticketId: 'T002', value: 'high' }]);
assert.deepEqual(save.get().pressure.Q002.fired_step_indexes, [0, 1]);
});
@@ -0,0 +1,190 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { IncidentScheduler } from './IncidentScheduler.js';
function createMemorySave(state) {
return {
state: structuredClone(state),
get() {
return this.state;
},
set(partial) {
const nextState = { ...this.state };
for (const [key, value] of Object.entries(partial)) {
if (value && typeof value === 'object' && !Array.isArray(value) && nextState[key] && typeof nextState[key] === 'object' && !Array.isArray(nextState[key])) {
nextState[key] = { ...nextState[key], ...structuredClone(value) };
} else {
nextState[key] = structuredClone(value);
}
}
this.state = nextState;
return this.state;
}
};
}
function createScheduler({ phase = 'unease', quests = [] } = {}) {
const sentMail = [];
const save = createMemorySave({
shift_started_at: '2026-04-25T11:00:00Z',
world_flags: [],
pressure: {},
incidents: {}
});
const scheduler = new IncidentScheduler({
loader: {
pressureProfiles: new Map([
['kowalski_phase_2', {
id: 'kowalski_phase_2',
trigger_phase: 'unease',
label: 'Dave Kowalski — Phase 2',
escalation_steps: [
{
trigger_after_seconds: 0,
notification: 'Phase pressure test message.',
notification_severity: 'info',
sender: 'Dave Kowalski <d.kowalski@axiomworks.internal>',
subject: 'Phase pressure test'
}
]
}]
]),
incidents: new Map(),
get(type, id) {
if (type === 'quests') {
return {
id,
ticket_id: 'T099'
};
}
return null;
}
},
email: {
send(payload) {
sentMail.push(payload);
return payload;
}
},
quests: {
getAllEntries() {
return quests;
}
},
save,
phaseTracker: {
getPhase() {
return phase;
}
},
tickets: {
setPriority() {}
},
validator: {
async evaluateRule() {
return { passed: false, failures: ['not-applicable'] };
}
},
now: () => new Date('2026-04-25T12:05:00Z').getTime(),
tickSeconds: 30
});
return { scheduler, save, sentMail };
}
test('IncidentScheduler fires trigger_phase pressure profile when narrative phase matches', async () => {
const { scheduler, save, sentMail } = createScheduler();
await scheduler.tick();
assert.equal(sentMail.length, 1);
assert.equal(sentMail[0].from, 'Dave Kowalski <d.kowalski@axiomworks.internal>');
assert.equal(sentMail[0].subject, 'Phase pressure test');
assert.equal(sentMail[0].body, 'Phase pressure test message.');
assert.deepEqual(save.get().pressure.kowalski_phase_2.fired_step_indexes, [0]);
});
test('IncidentScheduler does not fire trigger_phase pressure profile when narrative phase differs', async () => {
const { scheduler, save, sentMail } = createScheduler({ phase: 'stability' });
await scheduler.tick();
assert.equal(sentMail.length, 0);
assert.deepEqual(save.get().pressure, {});
});
test('IncidentScheduler trigger_phase pressure profile is independent of active quest pressure_profile fields', async () => {
const { scheduler, save, sentMail } = createScheduler({
quests: [[
'Q099',
{ state: 'active', started_at: '2026-04-25T11:30:00Z' }
]]
});
await scheduler.tick();
assert.equal(sentMail.length, 1);
assert.equal(sentMail[0].subject, 'Phase pressure test');
assert.deepEqual(save.get().pressure.kowalski_phase_2.fired_step_indexes, [0]);
assert.equal(save.get().pressure.Q099, undefined);
});
test('IncidentScheduler handles missing pressureProfiles map without firing global pressure', async () => {
const sentMail = [];
const scheduler = new IncidentScheduler({
loader: {
incidents: new Map(),
get() {
return null;
}
},
email: {
send(payload) {
sentMail.push(payload);
return payload;
}
},
quests: {
getAllEntries() {
return [];
}
},
save: createMemorySave({
shift_started_at: '2026-04-25T11:00:00Z',
world_flags: [],
pressure: {},
incidents: {}
}),
phaseTracker: {
getPhase() {
return 'unease';
}
},
tickets: {
setPriority() {}
},
validator: {
async evaluateRule() {
return { passed: false, failures: ['not-applicable'] };
}
},
now: () => new Date('2026-04-25T12:05:00Z').getTime(),
tickSeconds: 30
});
await assert.doesNotReject(async () => scheduler.tick());
assert.equal(sentMail.length, 0);
});
test('IncidentScheduler only fires a trigger_phase pressure step once', async () => {
const { scheduler, save, sentMail } = createScheduler();
await scheduler.tick();
await scheduler.tick();
assert.equal(sentMail.length, 1);
assert.deepEqual(save.get().pressure.kowalski_phase_2.fired_step_indexes, [0]);
});
@@ -0,0 +1,51 @@
import { eventBus } from '../lib/eventBus.js';
import { contentLoader } from './ContentLoader.js';
import { saveState } from './SaveState.js';
const PHASE_ORDER = [
'normal_work', 'unease', 'suspicion', 'investigation', 'conflict', 'resolution'
];
class NarrativePhaseTracker {
constructor() {
this._phase = 'normal_work';
}
initialize(state) {
const saved = state?.narrative_phase;
this._phase = PHASE_ORDER.includes(saved) ? saved : 'normal_work';
}
advance(questId) {
const quest = contentLoader.get('quests', questId);
if (!quest?.narrative_phase) return;
const questRank = PHASE_ORDER.indexOf(quest.narrative_phase);
const currentRank = PHASE_ORDER.indexOf(this._phase);
if (questRank <= currentRank) return;
const from = this._phase;
this._phase = quest.narrative_phase;
this._persist();
eventBus.emit('narrative:phase_changed', { from, to: this._phase });
}
getPhase() {
return this._phase;
}
forcePhase(phase) {
if (!PHASE_ORDER.includes(phase)) return;
const from = this._phase;
this._phase = phase;
this._persist();
eventBus.emit('narrative:phase_changed', { from, to: this._phase });
}
_persist() {
saveState.set({ narrative_phase: this._phase });
}
}
export const narrativePhaseTracker = new NarrativePhaseTracker();
@@ -0,0 +1,64 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'os';
import path from 'path';
import { narrativePhaseTracker } from './NarrativePhaseTracker.js';
import { saveState } from './SaveState.js';
async function isolateSaveState(testId) {
await saveState._writeQueue.catch(() => {});
process.env.SAVE_DIR = path.join(os.tmpdir(), `sc-test-${testId}-${Date.now()}`);
saveState._savePath = null;
saveState._state = null;
saveState._writeQueue = Promise.resolve();
}
test('initialize restores saved phase', async () => {
await isolateSaveState('phase-restore');
narrativePhaseTracker.initialize({ narrative_phase: 'suspicion' });
assert.equal(narrativePhaseTracker.getPhase(), 'suspicion');
});
test('initialize rejects unknown phase', async () => {
await isolateSaveState('phase-unknown');
narrativePhaseTracker.initialize({ narrative_phase: 'not_a_phase' });
assert.equal(narrativePhaseTracker.getPhase(), 'normal_work');
});
test('initialize with no key defaults to normal_work', async () => {
await isolateSaveState('phase-default');
narrativePhaseTracker.initialize({});
assert.equal(narrativePhaseTracker.getPhase(), 'normal_work');
});
test('forcePhase sets a valid phase', async () => {
await isolateSaveState('phase-force-valid');
narrativePhaseTracker.initialize({ narrative_phase: 'normal_work' });
narrativePhaseTracker.forcePhase('investigation');
assert.equal(narrativePhaseTracker.getPhase(), 'investigation');
});
test('forcePhase ignores an invalid phase', async () => {
await isolateSaveState('phase-force-invalid');
narrativePhaseTracker.initialize({ narrative_phase: 'unease' });
narrativePhaseTracker.forcePhase('not_valid');
assert.equal(narrativePhaseTracker.getPhase(), 'unease');
});
test('advance with unknown quest is a no-op', async () => {
await isolateSaveState('phase-advance-unknown');
narrativePhaseTracker.initialize({ narrative_phase: 'unease' });
// ContentLoader.quests is empty in this test context because load() is never called.
narrativePhaseTracker.advance('nonexistent-quest-id');
assert.equal(narrativePhaseTracker.getPhase(), 'unease');
});
+113
View File
@@ -0,0 +1,113 @@
import { eventBus } from '../lib/eventBus.js';
import { saveState } from './SaveState.js';
class ProgressionSystem {
constructor() {
this._access = new Set();
this._vms = new Set();
this._docs = new Set();
}
initialize(state) {
const progression = state.progression ?? {};
this._access = new Set(progression.unlocked_access ?? []);
this._vms = new Set(progression.unlocked_vms ?? []);
this._docs = new Set(progression.unlocked_docs ?? []);
}
grantUnlock(unlock) {
let changed = false;
changed = this._addAll(this._access, unlock.grants_access) || changed;
changed = this._addAll(this._vms, unlock.grants_vms) || changed;
changed = this._addAll(this._docs, unlock.grants_docs) || changed;
if (changed) {
this._persist();
eventBus.emit('progression:changed', this._snapshot());
}
}
revokeUnlock(unlock) {
let changed = false;
changed = this._removeAll(this._access, unlock.revokes) || changed;
changed = this._removeAll(this._vms, unlock.revokes_vms) || changed;
if (changed) {
this._persist();
eventBus.emit('progression:changed', this._snapshot());
}
}
hasDoc(id) {
return this._docs.has(id);
}
hasVM(id) {
return this._vms.has(id);
}
hasAccess(key) {
return this._access.has(key);
}
getAccessLevel() {
if (
this._access.has('sudo:workstation:full') ||
this._access.has('sudo:web_server:full') ||
this._access.has('sudo:build_machine:full')
) {
return 'root';
}
if (
this._access.has('sudo:workstation:systemctl') ||
this._access.has('ssh:web_server') ||
this._access.has('ssh:build_machine')
) {
return 'sudo';
}
return 'basic_user';
}
_snapshot() {
return {
unlockedDocs: [...this._docs],
unlockedVMs: [...this._vms],
unlockedAccess: [...this._access]
};
}
_persist() {
saveState.set({
progression: {
unlocked_access: [...this._access],
unlocked_vms: [...this._vms],
unlocked_docs: [...this._docs]
}
});
}
_addAll(target, values = []) {
let changed = false;
for (const value of values) {
if (!target.has(value)) {
target.add(value);
changed = true;
}
}
return changed;
}
_removeAll(target, values = []) {
let changed = false;
for (const value of values) {
if (target.delete(value)) {
changed = true;
}
}
return changed;
}
}
export const progressionSystem = new ProgressionSystem();
+118
View File
@@ -0,0 +1,118 @@
import { eventBus } from '../lib/eventBus.js';
import { normalizeWorldFlag } from '../lib/utils.js';
import { contentLoader } from './ContentLoader.js';
import { saveState } from './SaveState.js';
import { narrativePhaseTracker } from './NarrativePhaseTracker.js';
class QuestEngine {
constructor() {
this._quests = new Map();
}
initialize(state) {
this._quests = new Map(
Object.entries(state.quests ?? {}).map(([questId, entry]) => [questId, this._normalizeEntry(entry)])
);
if (this._quests.size === 0) {
this._activateInitialQuests();
}
}
getState(questId) {
return this.getEntry(questId)?.state ?? 'locked';
}
getEntry(questId) {
const entry = this._quests.get(questId);
if (!entry) {
return null;
}
return { ...entry };
}
getAllEntries() {
return [...this._quests.entries()].map(([questId, entry]) => [questId, { ...entry }]);
}
isActive(questId) {
return this.getState(questId) === 'active';
}
isCompleted(questId) {
return this.getState(questId) === 'completed';
}
canActivate(questId, state = saveState.get()) {
const quest = contentLoader.get('quests', questId);
if (!quest) {
return false;
}
const requirements = Array.isArray(quest.unlock_requirements) ? quest.unlock_requirements : [];
const worldFlags = new Set(state?.world_flags ?? []);
return requirements.every((requirement) => worldFlags.has(normalizeWorldFlag(requirement)));
}
activate(questId) {
const currentState = this.getState(questId);
if (currentState === 'active' || currentState === 'completed' || currentState === 'failed') {
return currentState;
}
const now = new Date().toISOString();
const existing = this._quests.get(questId);
this._quests.set(questId, {
state: 'active',
started_at: existing?.started_at ?? now
});
this._persist();
eventBus.emit('quest:activated', { questId });
return 'active';
}
complete(questId, metadata = {}) {
const now = new Date().toISOString();
const existing = this._quests.get(questId);
this._quests.set(questId, {
state: 'completed',
started_at: existing?.started_at ?? now,
completed_at: now,
branch_id: metadata.branchId ?? existing?.branch_id ?? null
});
this._persist();
narrativePhaseTracker.advance(questId);
eventBus.emit('quest:completed', { questId, branchId: metadata.branchId ?? null });
return 'completed';
}
_activateInitialQuests() {
for (const [questId, quest] of contentLoader.quests) {
const requirements = Array.isArray(quest.unlock_requirements) ? quest.unlock_requirements : [];
if (requirements.length === 0) {
this.activate(questId);
}
}
}
_persist() {
saveState.set({ quests: Object.fromEntries(this._quests) });
}
_normalizeEntry(entry) {
if (typeof entry === 'string') {
return { state: entry };
}
return {
state: entry?.state ?? 'locked',
started_at: entry?.started_at ?? null,
completed_at: entry?.completed_at ?? null,
branch_id: entry?.branch_id ?? null
};
}
}
export const questEngine = new QuestEngine();
+196
View File
@@ -0,0 +1,196 @@
import { contentLoader } from './ContentLoader.js';
import { questEngine } from './QuestEngine.js';
import { saveState } from './SaveState.js';
import { ticketService } from './TicketService.js';
function normalizeText(value) {
return String(value ?? '').trim().toLowerCase();
}
function includesAny(text, needles) {
return needles.some((needle) => text.includes(needle));
}
export class SageService {
constructor({
loader = contentLoader,
quests = questEngine,
save = saveState,
tickets = ticketService
} = {}) {
this.loader = loader;
this.quests = quests;
this.save = save;
this.tickets = tickets;
}
reply(message) {
const text = normalizeText(message);
const activeQuest = this._getPrimaryActiveQuest();
if (!activeQuest) {
return {
response: "Nothing urgent is active right now. Check your tickets or mail and ask again once you've got something assigned.",
followUps: ['Show my open tickets', 'What docs do I have access to?']
};
}
if (!text) {
return this._buildQuestIntro(activeQuest);
}
if (includesAny(text, ['ticket', 'task', 'what am i doing', 'what should i do'])) {
return this._buildTicketSummary(activeQuest);
}
if (includesAny(text, ['vm', 'server', 'host', 'machine'])) {
return this._buildVmSummary(activeQuest);
}
if (includesAny(text, ['doc', 'runbook', 'guide', 'manual'])) {
return this._buildDocSummary(activeQuest);
}
if (includesAny(text, ['help', 'hint', 'stuck', 'clue', 'what now'])) {
return this._buildHint(activeQuest);
}
if (includesAny(text, ['summary', 'recap', 'remind'])) {
return this._buildQuestIntro(activeQuest);
}
return {
response: "I can help with the active quest, but I'm not improvising answers yet. Ask for a hint, a summary, the target VM, or which docs are relevant.",
followUps: ['Give me a hint', 'Summarize the task', 'Which VM am I working on?']
};
}
_buildQuestIntro(quest) {
const intro = this._findDialogueMessage(quest.id, ['intro', 'welcome', 'setup']);
return {
response: intro ?? quest.summary ?? quest.description ?? `You're working on ${quest.id}.`,
followUps: ['Give me a hint', 'Summarize the task', 'Which VM am I working on?']
};
}
_buildTicketSummary(quest) {
const ticket = quest.ticket_id ? this.loader.get('tickets', quest.ticket_id) : null;
if (!ticket) {
return this._buildQuestIntro(quest);
}
return {
response: `${ticket.id}: ${ticket.subject}\n\n${ticket.body}`,
followUps: ['Give me a hint', 'Which VM am I working on?', 'Which docs are relevant?']
};
}
_buildVmSummary(quest) {
const ticket = quest.ticket_id ? this.loader.get('tickets', quest.ticket_id) : null;
const targetVm = ticket?.target_vm ?? 'workstation';
const profile = this.loader.get('vmProfiles', targetVm);
const hostname = profile?.hostname ?? targetVm;
const distro = profile?.distro ?? 'unknown distro';
return {
response: `The current target is ${targetVm} (${hostname}). It is authored as ${distro}. Start there unless the ticket explicitly says otherwise.`,
followUps: ['Give me a hint', 'Summarize the task', 'Which docs are relevant?']
};
}
_buildDocSummary(quest) {
const unlockedDocs = new Set(this.save.get()?.progression?.unlocked_docs ?? []);
const docs = [...this.loader.docs.values()]
.filter((doc) => unlockedDocs.has(doc.id))
.filter((doc) => this._docLooksRelevant(doc, quest))
.map((doc) => doc.title);
return {
response: docs.length > 0
? `Relevant unlocked docs:\n- ${docs.join('\n- ')}`
: 'You do not currently have an obviously relevant unlocked doc for this quest. Ask for a hint instead.',
followUps: ['Give me a hint', 'Which VM am I working on?', 'Summarize the task']
};
}
_buildHint(quest) {
const sageState = this.save.get()?.sage ?? {};
const nextHintIndex = Number(sageState.hint_counts?.[quest.id] ?? 0) + 1;
const stageCandidates = [`hint_${nextHintIndex}`, `hint_${nextHintIndex - 1}`, 'hint_1'];
let hint = null;
let usedIndex = nextHintIndex;
for (const stage of stageCandidates) {
hint = this._findDialogueMessage(quest.id, [stage]);
if (hint) {
const match = stage.match(/^hint_(\d+)$/);
usedIndex = Number(match?.[1] ?? 1);
break;
}
}
if (!hint) {
hint = "There isn't another authored hint for this quest yet. Check the ticket body, the target VM, and any unlocked runbooks.";
usedIndex = Math.max(1, nextHintIndex);
}
const nextHints = {
...(sageState.hint_counts ?? {}),
[quest.id]: usedIndex
};
this.save.set({
sage: {
...(sageState ?? {}),
hint_counts: nextHints
}
});
return {
response: hint,
followUps: ['Another hint', 'Summarize the task', 'Which docs are relevant?']
};
}
_getPrimaryActiveQuest() {
const active = this.quests.getAllEntries()
.filter(([, entry]) => entry?.state === 'active')
.sort(([, left], [, right]) => {
const leftTs = Date.parse(left?.started_at ?? '') || 0;
const rightTs = Date.parse(right?.started_at ?? '') || 0;
return rightTs - leftTs;
});
if (active.length === 0) {
return null;
}
return this.loader.get('quests', active[0][0]) ?? null;
}
_findDialogueMessage(questId, preferredStages) {
const dialogues = [...this.loader.dialogue.values()]
.filter((dialogue) => dialogue.quest_id === questId);
for (const stage of preferredStages) {
for (const dialogue of dialogues) {
const message = dialogue.messages?.find((entry) => entry.stage === stage);
if (message?.body) {
return message.body;
}
}
}
return null;
}
_docLooksRelevant(doc, quest) {
const text = `${doc.id} ${doc.title} ${doc.body}`.toLowerCase();
const ticket = quest.ticket_id ? this.loader.get('tickets', quest.ticket_id) : null;
const vm = ticket?.target_vm ?? '';
const tags = ticket?.tags ?? [];
return [quest.id.toLowerCase(), vm, ...tags].some((needle) => needle && text.includes(String(needle).toLowerCase()));
}
}
export const sageService = new SageService();
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { contentLoader } from './ContentLoader.js';
import { questEngine } from './QuestEngine.js';
import { saveState } from './SaveState.js';
import { sageService } from './SageService.js';
async function bootstrapActiveQ001() {
process.env.CONTENT_DIR = '../content';
await contentLoader.load();
saveState._state = {
world_flags: [],
progression: {
unlocked_access: [],
unlocked_vms: [],
unlocked_docs: ['onboarding']
},
quests: {
Q001: {
state: 'active',
started_at: '2026-04-25T12:00:00.000Z'
}
},
tickets: {},
mail: [],
certifications: [],
pressure: {},
incidents: {},
sage: {
hint_counts: {}
}
};
questEngine.initialize(saveState.get());
}
test('SageService returns intro context for active quest', async () => {
await bootstrapActiveQ001();
const result = sageService.reply('summary');
assert.match(result.response, /onboarding doc/i);
assert.ok(result.followUps.length > 0);
});
test('SageService advances through authored hints for active quest', async () => {
await bootstrapActiveQ001();
const first = sageService.reply('help');
const second = sageService.reply('another hint');
assert.match(first.response, /\.ssh folder/i);
assert.match(second.response, /permissions matter/i);
});
+183
View File
@@ -0,0 +1,183 @@
import os from 'os';
import path from 'path';
import { mkdir, readFile, writeFile } from 'fs/promises';
class SaveState {
constructor() {
this._state = null;
this._savePath = null;
this._writeQueue = Promise.resolve();
}
async load() {
const savePath = this._getSavePath();
try {
const raw = await readFile(savePath, 'utf8');
this._state = this._applyDefaults(JSON.parse(raw));
return this._state;
} catch (error) {
if (error?.code !== 'ENOENT') {
throw error;
}
const initialState = this._defaultState();
this._state = initialState;
await this.write(initialState);
return this._state;
}
}
async write(state) {
const savePath = this._getSavePath();
const nextState = this._clone(state ?? this._state ?? this._defaultState());
nextState.last_saved = new Date().toISOString();
if (!nextState.created_at) {
nextState.created_at = nextState.last_saved;
}
await mkdir(path.dirname(savePath), { recursive: true });
await writeFile(savePath, `${JSON.stringify(nextState, null, 2)}\n`, 'utf8');
this._state = nextState;
return this._state;
}
get() {
return this._state;
}
set(partial) {
if (!this._state) {
this._state = this._defaultState();
}
const nextState = { ...this._state };
for (const [key, value] of Object.entries(partial)) {
if (key === 'mail' || key === 'certifications') {
nextState[key] = Array.isArray(value) ? this._clone(value) : value;
continue;
}
if (this._isPlainObject(value) && this._isPlainObject(nextState[key])) {
nextState[key] = { ...nextState[key], ...value };
continue;
}
nextState[key] = Array.isArray(value) ? this._clone(value) : value;
}
this._state = nextState;
this._queueWrite();
return this._state;
}
_queueWrite() {
const snapshot = this._clone(this._state);
this._writeQueue = this._writeQueue
.then(() => this.write(snapshot))
.catch((error) => {
console.error('Failed to persist save state:', error);
});
return this._writeQueue;
}
_getSavePath() {
if (!this._savePath) {
const configuredDir = process.env.SAVE_DIR ?? '~/.local/share/sysadmin-chronicles';
const expandedDir = this._expandHome(configuredDir);
this._savePath = path.join(expandedDir, 'save.json');
}
return this._savePath;
}
_expandHome(value) {
if (value === '~') {
return os.homedir();
}
if (value.startsWith('~/')) {
return path.join(os.homedir(), value.slice(2));
}
return value;
}
_defaultState() {
const now = new Date().toISOString();
return {
schema_version: 3,
created_at: now,
last_saved: now,
trust: 50.0,
shift_number: 1,
shift_started_at: now,
world_flags: [],
progression: {
unlocked_access: [],
unlocked_vms: [],
unlocked_docs: []
},
quests: {},
tickets: {},
mail: [],
certifications: [],
current_shift_stats: {
assigned_ticket_ids: [],
resolved_tickets: [],
flagged_issues: []
},
shift_history: [],
pressure: {},
incidents: {},
sage: {
hint_counts: {}
},
behavior: { curiosity: 50, obedience: 50, risk: 50, suspicion: 0 },
narrative_phase: 'normal_work',
hidden_hooks_discovered: [],
player_portrait: 'player-silhouette'
};
}
_isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
_applyDefaults(state) {
return this._mergeWithDefaults(this._defaultState(), state ?? {});
}
_mergeWithDefaults(defaults, value) {
if (Array.isArray(defaults)) {
return Array.isArray(value) ? this._clone(value) : this._clone(defaults);
}
if (!this._isPlainObject(defaults)) {
return value ?? defaults;
}
const merged = {};
const source = this._isPlainObject(value) ? value : {};
for (const [key, defaultValue] of Object.entries(defaults)) {
merged[key] = this._mergeWithDefaults(defaultValue, source[key]);
}
for (const [key, sourceValue] of Object.entries(source)) {
if (!(key in merged)) {
merged[key] = Array.isArray(sourceValue) ? this._clone(sourceValue) : sourceValue;
}
}
return merged;
}
_clone(value) {
return JSON.parse(JSON.stringify(value));
}
}
export const saveState = new SaveState();
+264
View File
@@ -0,0 +1,264 @@
import { eventBus } from '../lib/eventBus.js';
import { toArray } from '../lib/utils.js';
import { contentLoader } from './ContentLoader.js';
import { emailService } from './EmailService.js';
import { saveState } from './SaveState.js';
import { ticketService } from './TicketService.js';
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function safeDurationSeconds(startedAt, endedAt) {
const start = new Date(startedAt ?? 0).getTime();
const end = new Date(endedAt ?? 0).getTime();
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) {
return null;
}
return Math.floor((end - start) / 1000);
}
export class ShiftReviewService {
constructor({
bus = eventBus,
loader = contentLoader,
email = emailService,
save = saveState,
tickets = ticketService,
now = () => Date.now()
} = {}) {
this.bus = bus;
this.loader = loader;
this.email = email;
this.save = save;
this.tickets = tickets;
this.now = now;
this._handlersBound = false;
this._onTicketActivated = this._handleTicketActivated.bind(this);
this._onTicketCompleted = this._handleTicketCompleted.bind(this);
this._onShiftEnded = this._handleShiftEnded.bind(this);
}
initialize(state = this.save.get()) {
this._ensureContainers(state);
this._seedAssignedTickets(state);
this._bindHandlers();
}
dispose() {
if (!this._handlersBound) {
return;
}
this.bus.off('ticket:activated', this._onTicketActivated);
this.bus.off('ticket:completed', this._onTicketCompleted);
this.bus.off('shift:ended', this._onShiftEnded);
this._handlersBound = false;
}
_bindHandlers() {
this.dispose();
this.bus.on('ticket:activated', this._onTicketActivated);
this.bus.on('ticket:completed', this._onTicketCompleted);
this.bus.on('shift:ended', this._onShiftEnded);
this._handlersBound = true;
}
_ensureContainers(state = this.save.get()) {
const nextState = {};
if (!state?.current_shift_stats) {
nextState.current_shift_stats = {
assigned_ticket_ids: [],
resolved_tickets: [],
flagged_issues: []
};
}
if (!Array.isArray(state?.shift_history)) {
nextState.shift_history = [];
}
if (Object.keys(nextState).length > 0) {
this.save.set(nextState);
}
}
_seedAssignedTickets(state = this.save.get()) {
const stats = state?.current_shift_stats ?? {};
if (toArray(stats.assigned_ticket_ids).length > 0) {
return;
}
const assignedTicketIds = this.tickets.getAll()
.filter((ticket) => ticket.status !== 'resolved')
.map((ticket) => ticket.id);
if (assignedTicketIds.length > 0) {
this.save.set({
current_shift_stats: {
...stats,
assigned_ticket_ids: assignedTicketIds
}
});
}
}
_handleTicketActivated({ ticketId }) {
const state = this.save.get();
const stats = clone(state?.current_shift_stats ?? {});
const assigned = new Set(toArray(stats.assigned_ticket_ids));
assigned.add(ticketId);
this.save.set({
current_shift_stats: {
...stats,
assigned_ticket_ids: [...assigned]
}
});
}
_handleTicketCompleted(payload) {
const state = this.save.get();
const stats = clone(state?.current_shift_stats ?? {});
const resolved = toArray(stats.resolved_tickets);
const existing = resolved.find((entry) => entry.ticket_id === payload.ticketId);
const nextResolved = existing
? resolved.map((entry) => entry.ticket_id === payload.ticketId ? { ...entry, ...payload } : entry)
: [...resolved, {
ticket_id: payload.ticketId,
branch_id: payload.branchId ?? null,
trust_delta: Number(payload.trustDelta) || 0,
activated_at: payload.activatedAt ?? null,
resolved_at: payload.resolvedAt ?? new Date(this.now()).toISOString()
}];
const flaggedIssues = [...toArray(stats.flagged_issues)];
if (Number(payload.trustDelta) <= 0) {
flaggedIssues.push({
ticket_id: payload.ticketId,
type: 'wrong_approach',
detail: payload.branchId
? `${payload.ticketId} resolved with ${payload.branchId}`
: `${payload.ticketId} resolved with a risky branch`
});
}
this.save.set({
current_shift_stats: {
...stats,
resolved_tickets: nextResolved,
flagged_issues: flaggedIssues
}
});
}
_handleShiftEnded(snapshot) {
const state = this.save.get();
const stats = clone(state?.current_shift_stats ?? {});
const assigned = toArray(stats.assigned_ticket_ids);
const resolved = toArray(stats.resolved_tickets);
const resolvedIds = new Set(resolved.map((entry) => entry.ticket_id));
const timedOutIds = assigned.filter((ticketId) => !resolvedIds.has(ticketId));
const flaggedIssues = [
...toArray(stats.flagged_issues),
...timedOutIds.map((ticketId) => ({
ticket_id: ticketId,
type: 'timed_out',
detail: `${ticketId} rolled into the next shift unresolved`
}))
];
const durations = resolved
.map((entry) => safeDurationSeconds(entry.activated_at, entry.resolved_at))
.filter((value) => Number.isInteger(value));
const averageResolutionSeconds = durations.length > 0
? Math.round(durations.reduce((sum, value) => sum + value, 0) / durations.length)
: null;
const review = {
shift_number: state?.shift_number ?? 1,
started_at: state?.shift_started_at ?? snapshot?.startedAt ?? new Date(this.now()).toISOString(),
ended_at: new Date(this.now()).toISOString(),
tickets_assigned: assigned.length,
tickets_resolved: resolved.length,
average_resolution_seconds: averageResolutionSeconds,
flagged_issues: flaggedIssues,
performance_tier: this._performanceTier({
assignedCount: assigned.length,
resolvedCount: resolved.length,
flaggedIssueCount: flaggedIssues.length
}),
reviewer: 'Priya Nair'
};
const reviewSentence = this._reviewSentence(review.performance_tier);
const reviewBody = [
`Shift ${review.shift_number} performance review`,
'',
`Tickets resolved: ${review.tickets_resolved}/${review.tickets_assigned}`,
`Average resolution time: ${this._formatDuration(review.average_resolution_seconds)}`,
`Flagged issues: ${flaggedIssues.length === 0 ? 'none' : flaggedIssues.map((issue) => issue.detail).join('; ')}`,
'',
reviewSentence
].join('\n');
const nextShiftNumber = review.shift_number + 1;
const nextShiftStartedAt = new Date(this.now()).toISOString();
this.save.set({
shift_number: nextShiftNumber,
shift_started_at: nextShiftStartedAt,
shift_history: [...toArray(state?.shift_history), review],
current_shift_stats: {
assigned_ticket_ids: this.tickets.getAll()
.filter((ticket) => ticket.status !== 'resolved')
.map((ticket) => ticket.id),
resolved_tickets: [],
flagged_issues: []
}
});
this.email.send({
id: `mail-shift-review-${review.shift_number}-${Date.now()}`,
from: 'Priya Nair <p.nair@axiomworks.internal>',
subject: `Shift ${review.shift_number} review`,
body: reviewBody,
attachments: [],
replyOptions: []
});
}
_performanceTier({ assignedCount, resolvedCount, flaggedIssueCount }) {
if (assignedCount > 0 && resolvedCount === assignedCount && flaggedIssueCount === 0) {
return 'excellent';
}
if (resolvedCount > 0 && flaggedIssueCount <= 1) {
return 'ok';
}
return 'poor';
}
_reviewSentence(tier) {
const dialogue = this.loader.get('dialogue', 'priya-shift-review');
const message = toArray(dialogue?.messages).find((entry) => entry.stage === tier);
return message?.body ?? 'Shift closed.';
}
_formatDuration(value) {
if (!Number.isInteger(value) || value < 0) {
return 'n/a';
}
const minutes = Math.floor(value / 60);
const seconds = value % 60;
return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
}
}
export const shiftReviewService = new ShiftReviewService();
@@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { ShiftReviewService } from './ShiftReviewService.js';
function createSave(initialState) {
let state = structuredClone(initialState);
return {
get() {
return state;
},
set(partial) {
state = {
...state,
...partial,
current_shift_stats: partial.current_shift_stats
? { ...(state.current_shift_stats ?? {}), ...partial.current_shift_stats }
: state.current_shift_stats
};
return state;
}
};
}
test('ShiftReviewService tracks ticket activity and generates a review email on shift end', () => {
const bus = new EventEmitter();
const sent = [];
const save = createSave({
shift_number: 1,
shift_started_at: '2026-04-25T12:00:00.000Z',
current_shift_stats: {
assigned_ticket_ids: [],
resolved_tickets: [],
flagged_issues: []
},
shift_history: []
});
const service = new ShiftReviewService({
bus,
save,
email: {
send(payload) {
sent.push(payload);
return payload;
}
},
tickets: {
getAll() {
return [];
}
},
loader: {
get(type, id) {
if (type === 'dialogue' && id === 'priya-shift-review') {
return {
messages: [
{ stage: 'excellent', body: 'Strong shift.' },
{ stage: 'ok', body: 'Acceptable shift.' },
{ stage: 'poor', body: 'This shift needs review.' }
]
};
}
return null;
}
},
now: () => new Date('2026-04-25T12:30:00.000Z').getTime()
});
service.initialize(save.get());
bus.emit('ticket:activated', { ticketId: 'T001' });
bus.emit('ticket:completed', {
ticketId: 'T001',
branchId: 'correct-setup',
trustDelta: 1,
activatedAt: '2026-04-25T12:05:00.000Z',
resolvedAt: '2026-04-25T12:15:00.000Z'
});
bus.emit('shift:ended', { startedAt: '2026-04-25T12:00:00.000Z', remainingSeconds: 0 });
const state = save.get();
assert.equal(state.shift_number, 2);
assert.equal(state.shift_history.length, 1);
assert.equal(state.shift_history[0].tickets_resolved, 1);
assert.equal(state.shift_history[0].tickets_assigned, 1);
assert.equal(state.shift_history[0].performance_tier, 'excellent');
assert.equal(sent.length, 1);
assert.match(sent[0].subject, /Shift 1 review/);
});
+71
View File
@@ -0,0 +1,71 @@
import { eventBus } from '../lib/eventBus.js';
import { saveState } from './SaveState.js';
const DEFAULT_DURATION_SECONDS = Number(process.env.SHIFT_DURATION_SECONDS ?? 2400);
const DEFAULT_TICK_SECONDS = Number(process.env.SHIFT_TICK_SECONDS ?? 30);
export class ShiftTimer {
constructor({
durationSeconds = DEFAULT_DURATION_SECONDS,
tickSeconds = DEFAULT_TICK_SECONDS,
save = saveState,
now = () => Date.now()
} = {}) {
this.durationSeconds = durationSeconds;
this.tickSeconds = tickSeconds;
this.save = save;
this.now = now;
this._interval = null;
this._endedShiftStartedAt = null;
}
start(state = this.save.get()) {
this.stop();
if (!state?.shift_started_at) {
this.save.set({ shift_started_at: new Date(this.now()).toISOString() });
}
this.tick();
this._interval = setInterval(() => {
this.tick();
}, this.tickSeconds * 1000).unref();
}
stop() {
if (this._interval) {
clearInterval(this._interval);
this._interval = null;
}
}
tick() {
const snapshot = this.getSnapshot();
eventBus.emit('shift:tick', snapshot);
if (snapshot.remainingSeconds === 0 && snapshot.startedAt !== this._endedShiftStartedAt) {
this._endedShiftStartedAt = snapshot.startedAt;
eventBus.emit('shift:ended', snapshot);
} else if (snapshot.remainingSeconds > 0 && snapshot.startedAt !== this._endedShiftStartedAt) {
this._endedShiftStartedAt = null;
}
return snapshot;
}
getSnapshot(state = this.save.get()) {
const shiftStartedAt = state?.shift_started_at
? new Date(state.shift_started_at).getTime()
: this.now();
const elapsedSeconds = Math.max(0, Math.floor((this.now() - shiftStartedAt) / 1000));
const remainingSeconds = Math.max(0, this.durationSeconds - elapsedSeconds);
return {
durationSeconds: this.durationSeconds,
elapsedSeconds,
remainingSeconds,
startedAt: state?.shift_started_at ?? new Date(shiftStartedAt).toISOString()
};
}
}
export const shiftTimer = new ShiftTimer();
+23
View File
@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ShiftTimer } from './ShiftTimer.js';
test('ShiftTimer computes elapsed and remaining seconds from shift start', () => {
const now = new Date('2026-04-25T12:00:00Z').getTime();
const timer = new ShiftTimer({
durationSeconds: 2400,
tickSeconds: 30,
save: {
get() {
return { shift_started_at: '2026-04-25T11:30:00Z' };
}
},
now: () => now
});
const snapshot = timer.getSnapshot();
assert.equal(snapshot.elapsedSeconds, 1800);
assert.equal(snapshot.remainingSeconds, 600);
assert.equal(snapshot.durationSeconds, 2400);
});
+375
View File
@@ -0,0 +1,375 @@
import { eventBus } from '../lib/eventBus.js';
import { createError } from '../lib/utils.js';
import { contentLoader } from './ContentLoader.js';
import { emailService } from './EmailService.js';
import { questEngine } from './QuestEngine.js';
import { saveState } from './SaveState.js';
import { trustSystem } from './TrustSystem.js';
import { validationEngine } from './ValidationEngine.js';
import { behaviorTracker } from './BehaviorTracker.js';
class TicketService {
constructor() {
this._tickets = new Map();
}
initialize(state) {
this._tickets = new Map();
for (const [ticketId, entry] of Object.entries(state.tickets ?? {})) {
const ticket = contentLoader.get('tickets', ticketId);
if (!ticket) {
continue;
}
const questState = ticket.linked_quest ? questEngine.getState(ticket.linked_quest) : 'locked';
if (!['active', 'completed', 'failed'].includes(questState)) {
continue;
}
this._tickets.set(ticketId, this._normalizeEntry(entry));
}
for (const [questId, entry] of questEngine.getAllEntries()) {
if (entry?.state === 'active') {
const quest = contentLoader.get('quests', questId);
if (quest?.ticket_id) {
this.activateTicket(quest.ticket_id);
}
}
if (entry?.state === 'completed') {
const quest = contentLoader.get('quests', questId);
if (quest?.ticket_id) {
const ticketEntry = this._tickets.get(quest.ticket_id);
if (!ticketEntry || ticketEntry.status !== 'resolved') {
this._tickets.set(quest.ticket_id, {
status: 'resolved',
activated_at: ticketEntry?.activated_at ?? entry.started_at ?? null,
resolved_at: ticketEntry?.resolved_at ?? entry.completed_at ?? null
});
}
}
}
}
this._persist();
}
getAll() {
return [...this._tickets.entries()]
.filter(([ticketId]) => this._isPrimaryTicketId(ticketId))
.sort(([leftId], [rightId]) => leftId.localeCompare(rightId))
.map(([ticketId, entry]) => {
const ticket = contentLoader.get('tickets', ticketId);
if (!ticket) {
return null;
}
return {
id: ticket.id,
subject: ticket.subject,
priority: entry.current_priority ?? ticket.current_priority,
status: entry.status ?? 'open',
linked_quest: ticket.linked_quest
};
})
.filter(Boolean);
}
getEntry(ticketId) {
const entry = this._tickets.get(ticketId);
if (!entry) {
return null;
}
return { ...entry };
}
activateTicket(ticketId) {
const ticket = contentLoader.get('tickets', ticketId);
if (!ticket) {
throw createError(`Unknown ticket: ${ticketId}`, 404);
}
const existing = this._tickets.get(ticketId);
if (existing) {
return existing.status;
}
this._tickets.set(ticketId, {
status: 'open',
current_priority: ticket.current_priority,
activated_at: new Date().toISOString(),
resolved_at: null
});
this._persist();
eventBus.emit('ticket:activated', { ticketId });
return 'open';
}
getDetail(ticketId) {
const ticket = contentLoader.get('tickets', ticketId);
const entry = this._tickets.get(ticketId);
if (!ticket || !entry) {
return null;
}
return {
...ticket,
status: entry.status ?? 'open',
activated_at: entry.activated_at ?? null,
resolved_at: entry.resolved_at ?? null
};
}
setStatus(ticketId, status) {
const ticket = contentLoader.get('tickets', ticketId);
if (!ticket) {
throw createError(`Unknown ticket: ${ticketId}`, 404);
}
const existing = this._tickets.get(ticketId) ?? this._defaultEntry(ticket);
const nextEntry = {
...existing,
status
};
if (status === 'resolved' && !nextEntry.resolved_at) {
nextEntry.resolved_at = new Date().toISOString();
}
this._tickets.set(ticketId, nextEntry);
this._persist();
}
setPriority(ticketId, priority) {
const ticket = contentLoader.get('tickets', ticketId);
if (!ticket) {
throw createError(`Unknown ticket: ${ticketId}`, 404);
}
const existing = this._tickets.get(ticketId) ?? this._defaultEntry(ticket);
this._tickets.set(ticketId, {
...existing,
current_priority: priority
});
this._persist();
}
async markComplete(ticketId, options = {}) {
const ticket = contentLoader.get('tickets', ticketId);
if (!ticket) {
return { passed: false, reason: 'ticket_not_found' };
}
const ticketEntry = this._tickets.get(ticketId);
if (!ticketEntry) {
return { passed: false, reason: 'ticket_not_active' };
}
if (ticketEntry.status === 'resolved') {
return {
passed: true,
branch: ticketEntry.branch_id ?? null,
trust_delta: 0,
failures: [],
ticket_status: 'resolved',
already_resolved: true
};
}
const quest = ticket.linked_quest ? contentLoader.get('quests', ticket.linked_quest) : null;
if (!quest) {
return { passed: false, reason: 'quest_not_found' };
}
if (!questEngine.isActive(quest.id)) {
if (!questEngine.canActivate(quest.id)) {
return { passed: false, reason: 'quest_locked' };
}
questEngine.activate(quest.id);
}
let branch = null;
let failures = [];
if (options.branchId) {
branch = this._selectBranch(quest, options.branchId);
if (!branch) {
return { passed: false, reason: 'branch_not_found' };
}
} else {
const validationResult = await validationEngine.resolveBranch(quest);
branch = validationResult.branch;
failures = validationResult.failures;
if (!branch) {
return {
passed: false,
reason: 'validation_failed',
failures
};
}
}
const appliedFlags = this._appendWorldFlags(branch.world_flags ?? []);
const trustDelta = Number(branch.trust_delta) || 0;
if (trustDelta !== 0) {
trustSystem.adjust(trustDelta);
}
questEngine.complete(quest.id, { branchId: branch.id });
const behaviorImpact = branch.behavior_impact
?? quest.behavior_impact?.[branch.id]
?? quest.behavior_impact?.default
?? null;
if (behaviorImpact && typeof behaviorImpact === 'object') {
behaviorTracker.apply(behaviorImpact);
}
const nextEntry = {
...ticketEntry,
status: 'resolved',
resolved_at: new Date().toISOString(),
branch_id: branch.id
};
this._tickets.set(ticketId, nextEntry);
const followUpTicketId = this._activateFollowUpTicket(branch.follow_up_ticket ?? null);
const followUpMailIds = [];
if (trustDelta <= 0) {
for (const dialogueId of this._collectFollowUpDialogues(branch)) {
const mail = emailService.sendDialogueFollowUp(dialogueId, {
questId: quest.id,
ticketId: ticket.id,
subjectPrefix: `Follow-up on ${ticket.id}`,
idPrefix: `mail-${ticket.id}`
});
if (mail?.id) {
followUpMailIds.push(mail.id);
}
}
}
this._persist();
eventBus.emit('ticket:completed', {
ticketId,
questId: quest.id,
branchId: branch.id,
trustDelta,
activatedAt: nextEntry.activated_at ?? ticketEntry.activated_at ?? null,
resolvedAt: nextEntry.resolved_at
});
return {
passed: true,
branch: branch.id,
trust_delta: trustDelta,
failures: [],
ticket_status: 'resolved',
world_flags: appliedFlags,
follow_up_ticket: followUpTicketId,
follow_up_mail_ids: followUpMailIds
};
}
_persist() {
saveState.set({ tickets: Object.fromEntries(this._tickets) });
}
_selectBranch(quest, branchId) {
const branches = [...(quest.solution_branches ?? [])].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
if (branchId) {
return branches.find((branch) => branch.id === branchId) ?? null;
}
return branches[0] ?? null;
}
_appendWorldFlags(flags) {
const state = saveState.get();
const nextFlags = new Set(state?.world_flags ?? []);
for (const flag of flags) {
nextFlags.add(flag);
}
const snapshot = [...nextFlags];
saveState.set({ world_flags: snapshot });
return snapshot;
}
_activateFollowUpTicket(ticketId) {
if (!ticketId) {
return null;
}
const ticket = contentLoader.get('tickets', ticketId);
if (!ticket) {
return null;
}
const questId = ticket.linked_quest;
if (questId && !questEngine.isCompleted(questId) && questEngine.canActivate(questId)) {
questEngine.activate(questId);
}
this.activateTicket(ticketId);
return ticketId;
}
_collectFollowUpDialogues(branch) {
const ids = [];
if (branch.follow_up_dialogue) {
ids.push(branch.follow_up_dialogue);
}
for (const id of branch.follow_up_dialogues ?? []) {
ids.push(id);
}
return ids;
}
_normalizeEntry(entry) {
if (typeof entry === 'string') {
return {
status: entry,
current_priority: null,
activated_at: null,
resolved_at: null,
branch_id: null
};
}
return {
status: entry?.status ?? 'open',
current_priority: entry?.current_priority ?? null,
activated_at: entry?.activated_at ?? null,
resolved_at: entry?.resolved_at ?? null,
branch_id: entry?.branch_id ?? null
};
}
_defaultEntry(ticket) {
return {
status: 'open',
current_priority: ticket.current_priority,
activated_at: new Date().toISOString(),
resolved_at: null,
branch_id: null
};
}
_isPrimaryTicketId(ticketId) {
return /^T\d{3}$/.test(ticketId);
}
}
export const ticketService = new TicketService();
+86
View File
@@ -0,0 +1,86 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'os';
import path from 'path';
import { rm } from 'fs/promises';
import { contentLoader } from './ContentLoader.js';
import { emailService } from './EmailService.js';
import { progressionSystem } from './ProgressionSystem.js';
import { questEngine } from './QuestEngine.js';
import { saveState } from './SaveState.js';
import { ticketService } from './TicketService.js';
import { trustSystem } from './TrustSystem.js';
import { validationEngine } from './ValidationEngine.js';
async function resetState(testId) {
await saveState._writeQueue.catch(() => {});
process.env.CONTENT_DIR = path.resolve(process.cwd(), '../content');
process.env.SAVE_DIR = path.join(os.tmpdir(), `sc-server-test-${testId}-${Date.now()}`);
saveState._savePath = null;
saveState._state = null;
saveState._writeQueue = Promise.resolve();
await rm(process.env.SAVE_DIR, { recursive: true, force: true });
await contentLoader.load();
await saveState.load();
const state = saveState.get();
progressionSystem.initialize(state);
trustSystem.initialize(state);
questEngine.initialize(state);
ticketService.initialize(state);
emailService.initialize(state);
await saveState._writeQueue.catch(() => {});
}
test('fresh state only exposes T001 as the active ticket', async () => {
await resetState('fresh');
const tickets = ticketService.getAll();
assert.deepEqual(tickets.map((ticket) => ticket.id), ['T001']);
assert.equal(questEngine.getState('Q001'), 'active');
assert.equal(questEngine.getState('Q002'), 'locked');
});
test('markComplete resolves the active ticket, unlocks world flags, and activates the next ticket', async () => {
await resetState('complete-clean');
const originalResolveBranch = validationEngine.resolveBranch.bind(validationEngine);
validationEngine.resolveBranch = async (quest) => ({
branch: quest.solution_branches.find((branch) => branch.id === 'correct-setup'),
failures: []
});
try {
const result = await ticketService.markComplete('T001');
assert.equal(result.passed, true);
assert.equal(result.branch, 'correct-setup');
assert.equal(result.trust_delta, 1);
assert.equal(ticketService.getEntry('T001').status, 'resolved');
assert.equal(ticketService.getEntry('T002').status, 'open');
assert.equal(questEngine.getState('Q001'), 'completed');
assert.equal(questEngine.getState('Q002'), 'active');
assert.equal(trustSystem.getScore(), 51);
const state = saveState.get();
assert.ok(state.world_flags.includes('player_ssh_configured'));
} finally {
validationEngine.resolveBranch = originalResolveBranch;
}
});
test('non-positive branch outcomes send follow-up mail and still open the next ticket', async () => {
await resetState('complete-permissive');
const result = await ticketService.markComplete('T001', { branchId: 'permissive-setup' });
assert.equal(result.passed, true);
assert.equal(result.branch, 'permissive-setup');
assert.equal(result.trust_delta, 0);
assert.equal(ticketService.getEntry('T002').status, 'open');
assert.ok(result.follow_up_mail_ids.length > 0);
const followUp = emailService.getAll().find((mail) => result.follow_up_mail_ids.includes(mail.id));
assert.ok(followUp);
});
+43
View File
@@ -0,0 +1,43 @@
import { eventBus } from '../lib/eventBus.js';
import { contentLoader } from './ContentLoader.js';
import { progressionSystem } from './ProgressionSystem.js';
import { saveState } from './SaveState.js';
class TrustSystem {
constructor() {
this._score = 50;
}
initialize(state) {
this._score = Number(state.trust ?? 50);
this._evaluateUnlocks();
}
getScore() {
return this._score;
}
adjust(delta) {
const numericDelta = Number(delta) || 0;
const previousScore = this._score;
this._score = Math.max(0, Math.min(100, previousScore + numericDelta));
this._evaluateUnlocks();
eventBus.emit('trust:changed', { score: this._score, delta: numericDelta });
saveState.set({ trust: this._score });
}
_evaluateUnlocks() {
for (const unlock of contentLoader.trustUnlocks ?? []) {
if (this._score >= unlock.trust_threshold) {
progressionSystem.grantUnlock(unlock);
}
if (unlock.revokes_below_trust >= 0 && this._score < unlock.revokes_below_trust) {
progressionSystem.revokeUnlock(unlock);
}
}
}
}
export const trustSystem = new TrustSystem();
+165
View File
@@ -0,0 +1,165 @@
import { contentLoader } from './ContentLoader.js';
import { runVirsh } from '../lib/virsh.js';
const DEFAULT_VM_PREFIX = process.env.VM_PREFIX ?? 'sc-';
const IP_CACHE_TTL_MS = 60_000;
export function extractIpv4Address(text) {
const match = String(text ?? '').match(/\b(\d{1,3}(?:\.\d{1,3}){3})(?:\/\d+)?\b/);
return match ? match[1] : '';
}
export function extractMacAddress(domainXml) {
const match = String(domainXml ?? '').match(/<mac address=['"]([^'"]+)['"]/i);
return match ? match[1].toLowerCase() : '';
}
export function findLeaseIpByMac(leasesOutput, macAddress) {
if (!leasesOutput || !macAddress) {
return '';
}
const normalizedMac = macAddress.toLowerCase();
for (const line of String(leasesOutput).split('\n')) {
if (!line.toLowerCase().includes(normalizedMac)) {
continue;
}
const ip = extractIpv4Address(line);
if (ip) {
return ip;
}
}
return '';
}
export class VMManager {
constructor({ virshRunner = runVirsh, loader = contentLoader } = {}) {
this.virshRunner = virshRunner;
this.loader = loader;
this.ipCache = new Map();
}
getProfile(vmId) {
return this.loader.get('vmProfiles', vmId) ?? {};
}
getDomainName(vmId) {
const profile = this.getProfile(vmId);
return profile.domain ?? `${DEFAULT_VM_PREFIX}${vmId}`;
}
getHostname(vmId) {
return this.getProfile(vmId).hostname ?? vmId;
}
getManagementUser(vmId) {
return this.getProfile(vmId).management_user ?? 'opsbridge';
}
getSshKeyPath(vmId) {
return this.getProfile(vmId).ssh_key ?? process.env.SSH_KEY_PATH ?? '~/.ssh/sc_host_key';
}
getDistro(vmId) {
return this.getProfile(vmId).distro ?? '';
}
async domainExists(vmId) {
const result = await this.virshRunner(['dominfo', this.getDomainName(vmId)]);
return result.ok;
}
async getState(vmId) {
const result = await this.virshRunner(['domstate', this.getDomainName(vmId)]);
return result.ok ? result.stdout.trim() : 'missing';
}
async start(vmId) {
return await this.virshRunner(['start', this.getDomainName(vmId)], { timeoutSec: 20 });
}
async ensureWorkstationLive() {
const vmId = 'workstation';
if (!await this.domainExists(vmId)) {
return { ok: false, reason: 'missing_domain' };
}
const state = await this.getState(vmId);
if (state === 'running') {
return { ok: true, started: false };
}
const result = await this.start(vmId);
return { ok: result.ok, started: result.ok, state_before: state };
}
async getIP(vmId, { refresh = false } = {}) {
const cacheKey = this.getDomainName(vmId);
if (!refresh) {
const cached = this.ipCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.ip;
}
}
if (!await this.domainExists(vmId)) {
return '';
}
const domainName = this.getDomainName(vmId);
const ipFromAgent = await this._ipFromDomifaddr(domainName);
if (ipFromAgent) {
this._cacheIp(cacheKey, ipFromAgent);
return ipFromAgent;
}
const ipFromDhcp = await this._ipFromDhcp(vmId, domainName);
if (ipFromDhcp) {
this._cacheIp(cacheKey, ipFromDhcp);
return ipFromDhcp;
}
return '';
}
_cacheIp(cacheKey, ip) {
this.ipCache.set(cacheKey, {
ip,
expiresAt: Date.now() + IP_CACHE_TTL_MS
});
}
async _ipFromDomifaddr(domainName) {
const result = await this.virshRunner(['domifaddr', domainName, '--source', 'agent']);
if (!result.ok) {
return '';
}
return extractIpv4Address(result.stdout);
}
async _ipFromDhcp(vmId, domainName) {
const profile = this.getProfile(vmId);
const networkName = profile.network?.libvirt_network ?? 'sc-internal';
const xmlResult = await this.virshRunner(['dumpxml', domainName]);
if (!xmlResult.ok) {
return '';
}
const macAddress = extractMacAddress(xmlResult.stdout);
if (!macAddress) {
return '';
}
const leasesResult = await this.virshRunner(['net-dhcp-leases', networkName]);
if (!leasesResult.ok) {
return '';
}
return findLeaseIpByMac(leasesResult.stdout, macAddress);
}
}
export const vmManager = new VMManager();
+334
View File
@@ -0,0 +1,334 @@
import { runSSH } from '../lib/ssh.js';
import { vmManager } from './VMManager.js';
function shellQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function normalizeMode(value) {
return String(value ?? '').replace(/^0+/, '').padStart(4, '0');
}
function buildFailure(rule, detail = '') {
const vm = rule.vm ? `${rule.vm}:` : '';
const path = rule.path ?? rule.service ?? rule.package ?? rule.command ?? rule.port ?? '';
const suffix = detail ? ` (${detail})` : '';
return `${vm}${rule.type}:${path}${suffix}`;
}
export class ValidationEngine {
constructor({ vmManager: vmManagerInstance = vmManager, sshRunner = runSSH } = {}) {
this.vmManager = vmManagerInstance;
this.sshRunner = sshRunner;
}
async resolveBranch(quest) {
const branches = [...(quest.solution_branches ?? [])].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
const failures = [];
for (const branch of branches) {
const result = await this.evaluateBranch(branch);
if (result.passed) {
return {
branch,
failures: []
};
}
failures.push(...result.failures.map((failure) => `${branch.id}:${failure}`));
}
return {
branch: null,
failures
};
}
async evaluateBranch(branch) {
return await this.evaluateRule(branch.validation ?? {}, '');
}
async evaluateRule(rule, defaultVmId = '') {
const type = rule?.type ?? '';
const targetVm = rule?.vm ?? defaultVmId;
switch (type) {
case 'and':
return await this._evalAnd(rule, targetVm);
case 'or':
return await this._evalOr(rule, targetVm);
case 'not':
return await this._evalNot(rule, targetVm);
case 'file_exists':
return await this._remoteAssert(targetVm, `test -e ${shellQuote(rule.path)}`, rule);
case 'file_absent':
return await this._remoteAssert(targetVm, `test ! -e ${shellQuote(rule.path)}`, rule);
case 'directory_exists':
return await this._remoteAssert(targetVm, `test -d ${shellQuote(rule.path)}`, rule);
case 'file_contains':
case 'log_contains':
return await this._remoteAssert(
targetVm,
`grep -qF ${shellQuote(rule.contains ?? '')} ${shellQuote(rule.path)}`,
rule
);
case 'file_mode':
case 'file_mode_matches':
return await this._checkFileMode(targetVm, rule);
case 'file_owner':
case 'file_owner_matches':
return await this._checkFileOwner(targetVm, rule, false);
case 'file_owner_is_not':
return await this._checkFileOwner(targetVm, {
...rule,
user: rule.expected_user ?? rule.user ?? '',
group: rule.expected_group ?? rule.group ?? ''
}, true);
case 'service_state':
case 'service_state_is':
case 'service_state_matches':
return await this._checkServiceState(targetVm, rule);
case 'service_enabled':
case 'service_enabled_is':
return await this._checkServiceEnabled(targetVm, rule);
case 'process_running':
return await this._remoteAssert(targetVm, `pgrep -x ${shellQuote(rule.process)}`, rule);
case 'process_user':
return await this._remoteAssert(targetVm, `pgrep -x -u ${shellQuote(rule.user)} ${shellQuote(rule.process)}`, rule);
case 'port_listening':
return await this._checkPortListening(targetVm, rule);
case 'package_installed':
return await this._checkPackageInstalled(targetVm, rule);
case 'mount_present':
return await this._remoteAssert(targetVm, `findmnt -M ${shellQuote(rule.path)}`, rule);
case 'disk_usage_below':
return await this._checkDiskUsage(targetVm, rule, 'below');
case 'disk_usage_above':
return await this._checkDiskUsage(targetVm, rule, 'above');
case 'command_assert':
return await this._checkCommandAssert(targetVm, rule);
default:
return {
passed: false,
failures: [buildFailure(rule, 'unsupported-rule')]
};
}
}
async _evalAnd(rule, targetVm) {
const failures = [];
for (const subRule of rule.rules ?? []) {
const result = await this.evaluateRule(subRule, targetVm);
if (!result.passed) {
failures.push(...result.failures);
}
}
return {
passed: failures.length === 0,
failures
};
}
async _evalOr(rule, targetVm) {
const failures = [];
for (const subRule of rule.rules ?? []) {
const result = await this.evaluateRule(subRule, targetVm);
if (result.passed) {
return { passed: true, failures: [] };
}
failures.push(...result.failures);
}
return {
passed: false,
failures: failures.length > 0 ? failures : [buildFailure(rule, 'no-branches-matched')]
};
}
async _evalNot(rule, targetVm) {
const result = await this.evaluateRule(rule.rule ?? {}, targetVm);
return {
passed: !result.passed,
failures: result.passed ? [buildFailure(rule.rule ?? rule, 'negated-rule-matched')] : []
};
}
async _checkFileMode(targetVm, rule) {
const result = await this._remoteExec(
targetVm,
`stat -c %a ${shellQuote(rule.path)}`
);
if (!result.ok) {
return { passed: false, failures: [buildFailure(rule)] };
}
const actual = normalizeMode(result.stdout.trim());
const expected = normalizeMode(rule.mode);
return {
passed: actual === expected,
failures: actual === expected ? [] : [buildFailure(rule, `expected ${expected}, got ${actual}`)]
};
}
async _checkFileOwner(targetVm, rule, negate = false) {
const result = await this._remoteExec(
targetVm,
`stat -c %U:%G ${shellQuote(rule.path)}`
);
if (!result.ok) {
return { passed: false, failures: [buildFailure(rule)] };
}
const [actualUser = '', actualGroup = ''] = result.stdout.trim().split(':');
const expectedUser = rule.user ?? '';
const expectedGroup = rule.group ?? '';
const matches = (!expectedUser || actualUser === expectedUser) && (!expectedGroup || actualGroup === expectedGroup);
const passed = negate ? !matches : matches;
return {
passed,
failures: passed ? [] : [buildFailure(rule, `got ${actualUser}:${actualGroup}`)]
};
}
async _checkServiceState(targetVm, rule) {
const result = await this._remoteExec(
targetVm,
`systemctl is-active ${shellQuote(rule.service)}`
);
const actual = result.ok ? result.stdout.trim() : result.stdout.trim() || result.stderr.trim() || 'unknown';
const expected = rule.state ?? 'active';
return {
passed: actual === expected,
failures: actual === expected ? [] : [buildFailure(rule, `expected ${expected}, got ${actual}`)]
};
}
async _checkServiceEnabled(targetVm, rule) {
const result = await this._remoteExec(
targetVm,
`systemctl is-enabled ${shellQuote(rule.service)}`
);
const expected = rule.enabled ?? true;
const actualEnabled = result.ok && result.stdout.trim() === 'enabled';
return {
passed: actualEnabled === expected,
failures: actualEnabled === expected ? [] : [buildFailure(rule, `expected enabled=${expected}`)]
};
}
async _checkPortListening(targetVm, rule) {
const port = Number(rule.port);
const protocol = String(rule.protocol ?? 'tcp').toLowerCase();
const listening = rule.listening ?? true;
const ssFlags = protocol === 'udp' ? '-lun' : '-ltn';
const result = await this._remoteExec(
targetVm,
`ss ${ssFlags} | grep -Eq '[:.]${port}(\\s|$)'`
);
return {
passed: result.ok === listening,
failures: result.ok === listening ? [] : [buildFailure(rule, `expected listening=${listening}`)]
};
}
async _checkPackageInstalled(targetVm, rule) {
const packageSpec = String(rule.package ?? '');
const shouldBeInstalled = rule.installed ?? true;
const distro = this.vmManager.getDistro(targetVm);
const [packageName, version] = packageSpec.split('=');
let command = '';
if (distro === 'arch') {
command = version
? `pacman -Q ${shellQuote(packageName)} | grep -F ${shellQuote(version)}`
: `pacman -Q ${shellQuote(packageName)}`;
} else {
command = version
? `dpkg-query -W -f='\\${Status} \\${Version}\\n' ${shellQuote(packageName)} | grep -F ${shellQuote(`install ok installed ${version}`)}`
: `dpkg-query -W -f='\\${Status}\\n' ${shellQuote(packageName)} | grep -F 'install ok installed'`;
}
const result = await this._remoteExec(targetVm, command);
return {
passed: result.ok === shouldBeInstalled,
failures: result.ok === shouldBeInstalled ? [] : [buildFailure(rule, `expected installed=${shouldBeInstalled}`)]
};
}
async _checkDiskUsage(targetVm, rule, mode) {
const path = rule.path ?? '/';
const threshold = Number(rule.threshold_percent ?? rule.percent ?? 0);
const result = await this._remoteExec(
targetVm,
`df -P ${shellQuote(path)} | tail -1 | awk '{print $5}' | tr -d '% '`
);
if (!result.ok) {
return { passed: false, failures: [buildFailure(rule)] };
}
const actual = Number(result.stdout.trim());
const passed = mode === 'below' ? actual < threshold : actual > threshold;
return {
passed,
failures: passed ? [] : [buildFailure(rule, `expected ${mode} ${threshold}, got ${actual}`)]
};
}
async _checkCommandAssert(targetVm, rule) {
const result = await this._remoteExec(targetVm, rule.command ?? '', { rawShell: true });
const expectedExitCode = Number(rule.exit_code ?? 0);
const stdoutContains = rule.stdout_contains ?? rule.contains ?? '';
const passedExit = result.code === expectedExitCode;
const passedOutput = !stdoutContains || result.stdout.includes(stdoutContains);
const passed = passedExit && passedOutput;
return {
passed,
failures: passed ? [] : [buildFailure(rule, `expected exit=${expectedExitCode}`)]
};
}
async _remoteAssert(targetVm, shellCommand, rule) {
const result = await this._remoteExec(targetVm, shellCommand);
return {
passed: result.ok,
failures: result.ok ? [] : [buildFailure(rule)]
};
}
async _remoteExec(targetVm, shellCommand, options = {}) {
const host = await this.vmManager.getIP(targetVm);
if (!host) {
return {
ok: false,
code: 255,
stdout: '',
stderr: `Unable to resolve host for ${targetVm}`,
command: shellCommand
};
}
const managementUser = this.vmManager.getManagementUser(targetVm);
const keyPath = this.vmManager.getSshKeyPath(targetVm);
const wrappedCommand = `sudo bash -lc ${shellQuote(shellCommand)}`;
return await this.sshRunner({
host,
user: managementUser,
keyPath,
command: wrappedCommand,
timeoutSec: options.timeoutSec ?? 15
});
}
}
export const validationEngine = new ValidationEngine();
@@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { contentLoader } from './ContentLoader.js';
import { ValidationEngine } from './ValidationEngine.js';
function createFakeEngine(overrides = {}) {
const fakeVmManager = {
async getIP() {
return '10.42.0.41';
},
getManagementUser() {
return 'opsbridge';
},
getSshKeyPath() {
return '~/.ssh/sc_host_key';
},
getDistro(vmId) {
return vmId === 'build_machine' ? 'arch' : 'debian';
}
};
const sshRunner = async ({ command }) => {
for (const [matcher, result] of overrides) {
const matched =
typeof matcher === 'string'
? command.includes(matcher)
: matcher instanceof RegExp
? matcher.test(command)
: typeof matcher === 'function'
? matcher(command)
: false;
if (matched) {
return {
ok: result.ok,
code: result.code ?? (result.ok ? 0 : 1),
stdout: result.stdout ?? '',
stderr: result.stderr ?? ''
};
}
}
return { ok: false, code: 1, stdout: '', stderr: `unmatched:${command}` };
};
return new ValidationEngine({
vmManager: fakeVmManager,
sshRunner
});
}
test('ValidationEngine selects permissive Q001 branch when owner matches but modes do not', async () => {
process.env.CONTENT_DIR = '../content';
await contentLoader.load();
const engine = createFakeEngine(new Map([
[(command) => command.includes('test -e') && command.includes('/home/player/.ssh/authorized_keys'), { ok: true, stdout: '' }],
[(command) => command.includes('stat -c %a') && command.includes('/home/player/.ssh/authorized_keys'), { ok: true, stdout: '644\n' }],
[(command) => command.includes('stat -c %a') && command.includes('/home/player/.ssh') && !command.includes('authorized_keys'), { ok: true, stdout: '755\n' }],
[(command) => command.includes('stat -c %U:%G') && command.includes('/home/player/.ssh/authorized_keys'), { ok: true, stdout: 'player:player\n' }]
]));
const quest = contentLoader.get('quests', 'Q001');
const result = await engine.resolveBranch(quest);
assert.equal(result.branch?.id, 'permissive-setup');
});
test('ValidationEngine selects highest-priority passing branch for Q002', async () => {
process.env.CONTENT_DIR = '../content';
await contentLoader.load();
const engine = createFakeEngine(new Map([
[(command) => command.includes('systemctl is-active') && command.includes('nginx'), { ok: true, stdout: 'active\n' }],
[(command) => command.includes('systemctl is-enabled') && command.includes('nginx'), { ok: true, stdout: 'enabled\n' }],
[(command) => command.includes('ss -ltn') && command.includes('80'), { ok: true, stdout: '' }],
[(command) => command.includes('grep -qF') && command.includes('listen 80;') && command.includes('axiomworks.conf'), { ok: true, stdout: '' }]
]));
const quest = contentLoader.get('quests', 'Q002');
const result = await engine.resolveBranch(quest);
assert.equal(result.branch?.id, 'config-fixed-enabled');
});