@rhost/testkit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +861 -0
- package/SECURITY.md +130 -0
- package/dist/assertions.d.ts +67 -0
- package/dist/assertions.d.ts.map +1 -0
- package/dist/assertions.js +142 -0
- package/dist/assertions.js.map +1 -0
- package/dist/client.d.ts +91 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +157 -0
- package/dist/client.js.map +1 -0
- package/dist/connection.d.ts +27 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/connection.js +149 -0
- package/dist/connection.js.map +1 -0
- package/dist/container.d.ts +38 -0
- package/dist/container.d.ts.map +1 -0
- package/dist/container.js +116 -0
- package/dist/container.js.map +1 -0
- package/dist/expect.d.ts +74 -0
- package/dist/expect.d.ts.map +1 -0
- package/dist/expect.js +164 -0
- package/dist/expect.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/reporter.d.ts +11 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/reporter.js +67 -0
- package/dist/reporter.js.map +1 -0
- package/dist/runner.d.ts +90 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +257 -0
- package/dist/runner.js.map +1 -0
- package/dist/world.d.ts +62 -0
- package/dist/world.d.ts.map +1 -0
- package/dist/world.js +130 -0
- package/dist/world.js.map +1 -0
- package/package.json +76 -0
package/README.md
ADDED
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
# @rhost/testkit
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@rhost/testkit)
|
|
4
|
+
[](https://www.npmjs.com/package/@rhost/testkit)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](https://github.com/RhostMUSH/rhostmush-docker/actions/workflows/security-tests.yml)
|
|
7
|
+
[](./SECURITY.md)
|
|
8
|
+
|
|
9
|
+
A Jest-like testing framework for [RhostMUSH](https://github.com/RhostMUSH/trunk) softcode.
|
|
10
|
+
Write tests that run directly against a real MUSH server — local, CI container, or remote.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @rhost/testkit
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Contents
|
|
19
|
+
|
|
20
|
+
- [What it does](#what-it-does)
|
|
21
|
+
- [Installation](#installation)
|
|
22
|
+
- [How the runner works](#how-the-runner-works)
|
|
23
|
+
- [Quick start](#quick-start)
|
|
24
|
+
- [API reference — RhostRunner](#api-reference--rhostrunner)
|
|
25
|
+
- [API reference — RhostExpect](#api-reference--rhostexpect)
|
|
26
|
+
- [API reference — RhostWorld](#api-reference--rhostworld)
|
|
27
|
+
- [API reference — RhostClient](#api-reference--rhostclient)
|
|
28
|
+
- [API reference — RhostContainer](#api-reference--rhostcontainer)
|
|
29
|
+
- [MUSH output format](#mush-output-format)
|
|
30
|
+
- [Using with LLM skills](#using-with-llm-skills)
|
|
31
|
+
- [Environment variables](#environment-variables)
|
|
32
|
+
- [Examples](#examples)
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## What it does
|
|
37
|
+
|
|
38
|
+
`@rhost/testkit` gives you a full test-runner loop for MUSHcode:
|
|
39
|
+
|
|
40
|
+
- **Eval** softcode expressions and capture their output
|
|
41
|
+
- **Assert** results with a Jest-like `expect()` API and MUSH-aware matchers
|
|
42
|
+
- **Manage fixtures** — create objects, set attributes, and auto-destroy everything after each test
|
|
43
|
+
- **Spin up RhostMUSH in Docker** for isolated, reproducible CI runs
|
|
44
|
+
- **Report** results with a pretty, indented pass/fail tree
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm install @rhost/testkit
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Peer requirements:**
|
|
55
|
+
- Node.js ≥ 18
|
|
56
|
+
- Docker (only for `RhostContainer` — not required when connecting to an existing server)
|
|
57
|
+
- TypeScript ≥ 5.0 (if using TypeScript)
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## How the runner works
|
|
62
|
+
|
|
63
|
+
`RhostRunner` uses a **two-phase** collect → run model identical to Jest:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
Phase 1 — collect: runner.describe() / runner.describe.skip() calls build a tree in memory.
|
|
67
|
+
Phase 2 — run: runner.run(options) connects to the MUSH server and executes the tree.
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The `describe()` callback runs **synchronously** during collection.
|
|
71
|
+
The `it()` callback runs **asynchronously** during execution.
|
|
72
|
+
All `await` calls belong inside `it()` callbacks, not inside `describe()` callbacks.
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
import { RhostRunner } from '@rhost/testkit';
|
|
76
|
+
|
|
77
|
+
const runner = new RhostRunner();
|
|
78
|
+
|
|
79
|
+
// Phase 1 — synchronous collection
|
|
80
|
+
runner.describe('add()', ({ it }) => {
|
|
81
|
+
it('adds two numbers', async ({ expect }) => {
|
|
82
|
+
await expect('add(2,3)').toBe('5');
|
|
83
|
+
});
|
|
84
|
+
it('handles negatives', async ({ expect }) => {
|
|
85
|
+
await expect('add(-1,1)').toBe('0');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Phase 2 — async execution (require explicit password — no fallback)
|
|
90
|
+
const PASS = process.env.RHOST_PASS;
|
|
91
|
+
if (!PASS) { console.error('RHOST_PASS env var is required'); process.exit(1); }
|
|
92
|
+
|
|
93
|
+
const result = await runner.run({
|
|
94
|
+
host: 'localhost',
|
|
95
|
+
port: 4201,
|
|
96
|
+
username: 'Wizard',
|
|
97
|
+
password: PASS,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
process.exit(result.failed > 0 ? 1 : 0);
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Quick start
|
|
106
|
+
|
|
107
|
+
### Against an existing server
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import { RhostClient } from '@rhost/testkit';
|
|
111
|
+
|
|
112
|
+
const PASS = process.env.RHOST_PASS;
|
|
113
|
+
if (!PASS) throw new Error('RHOST_PASS env var is required');
|
|
114
|
+
|
|
115
|
+
const client = new RhostClient({ host: 'localhost', port: 4201 });
|
|
116
|
+
await client.connect();
|
|
117
|
+
await client.login('Wizard', PASS);
|
|
118
|
+
|
|
119
|
+
const result = await client.eval('add(2,3)');
|
|
120
|
+
console.log(result); // '5'
|
|
121
|
+
|
|
122
|
+
await client.disconnect();
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Spinning up Docker for CI
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
import { RhostRunner, RhostContainer } from '@rhost/testkit';
|
|
129
|
+
|
|
130
|
+
const PASS = process.env.RHOST_PASS;
|
|
131
|
+
if (!PASS) throw new Error('RHOST_PASS env var is required');
|
|
132
|
+
|
|
133
|
+
// Build from the rhostmush-docker source (first run is slow — compiles from source)
|
|
134
|
+
const container = RhostContainer.fromSource();
|
|
135
|
+
const info = await container.start(); // { host, port }
|
|
136
|
+
|
|
137
|
+
const runner = new RhostRunner();
|
|
138
|
+
runner.describe('sanity', ({ it }) => {
|
|
139
|
+
it('add works', async ({ expect }) => {
|
|
140
|
+
await expect('add(1,1)').toBe('2');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const result = await runner.run({ ...info, username: 'Wizard', password: PASS });
|
|
145
|
+
await container.stop();
|
|
146
|
+
process.exit(result.failed > 0 ? 1 : 0);
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Using the world fixture manager
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
import { RhostRunner } from '@rhost/testkit';
|
|
153
|
+
|
|
154
|
+
const runner = new RhostRunner();
|
|
155
|
+
|
|
156
|
+
runner.describe('attributes', ({ it, beforeEach, afterEach }) => {
|
|
157
|
+
// world is auto-created fresh for each test and auto-cleaned after
|
|
158
|
+
it('set and get attribute', async ({ world, client }) => {
|
|
159
|
+
const obj = await world.create('TestObj');
|
|
160
|
+
await world.set(obj, 'HP', '100');
|
|
161
|
+
const val = await client.eval(`get(${obj}/HP)`);
|
|
162
|
+
if (val.trim() !== '100') throw new Error(`Expected 100, got ${val}`);
|
|
163
|
+
});
|
|
164
|
+
// world.cleanup() is called automatically — no afterEach needed
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
> **Note:** A fresh `RhostWorld` instance is provided to each `it()` test via the `TestContext`.
|
|
169
|
+
> It is automatically cleaned up (all created objects destroyed) after the test finishes, even on failure.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## API reference — RhostRunner
|
|
174
|
+
|
|
175
|
+
### Constructor
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
const runner = new RhostRunner();
|
|
179
|
+
// No arguments. Use runner.run(options) to set connection details.
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Collection methods
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
runner.describe(name: string, fn: (ctx: SuiteContext) => void): this
|
|
186
|
+
runner.describe.skip(name: string, fn: (ctx: SuiteContext) => void): this
|
|
187
|
+
runner.describe.only(name: string, fn: (ctx: SuiteContext) => void): this
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Calling `describe.only()` causes only suites marked `only` (at that level) to run; all others are skipped.
|
|
191
|
+
|
|
192
|
+
### `runner.run(options)`
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
await runner.run(options: RunnerOptions): Promise<RunResult>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Connects to the MUSH server, runs all collected tests, disconnects, and returns the result.
|
|
199
|
+
|
|
200
|
+
**`RunnerOptions`** (extends `RhostClientOptions`):
|
|
201
|
+
|
|
202
|
+
| Option | Type | Default | Description |
|
|
203
|
+
|--------|------|---------|-------------|
|
|
204
|
+
| `username` | `string` | — | **Required.** Character name to log in as. |
|
|
205
|
+
| `password` | `string` | — | **Required.** Character password. |
|
|
206
|
+
| `host` | `string` | `'localhost'` | MUSH server hostname. |
|
|
207
|
+
| `port` | `number` | `4201` | MUSH telnet port. |
|
|
208
|
+
| `verbose` | `boolean` | `true` | Print per-test results to stdout while running. |
|
|
209
|
+
| `timeout` | `number` | `10000` | Per-eval/command timeout in ms. |
|
|
210
|
+
| `bannerTimeout` | `number` | `300` | Ms to wait for welcome banner to finish. |
|
|
211
|
+
| `connectTimeout` | `number` | `10000` | Raw TCP connection timeout in ms. |
|
|
212
|
+
| `paceMs` | `number` | `0` | Delay between sent commands in ms (flood control). |
|
|
213
|
+
| `stripAnsi` | `boolean` | `true` | Strip ANSI color codes from eval results. |
|
|
214
|
+
|
|
215
|
+
**`RunResult`:**
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
interface RunResult {
|
|
219
|
+
passed: number; // tests that threw no error
|
|
220
|
+
failed: number; // tests that threw
|
|
221
|
+
skipped: number; // tests marked skip
|
|
222
|
+
total: number; // passed + failed + skipped
|
|
223
|
+
duration: number; // wall-clock ms
|
|
224
|
+
failures: Array<{ suite: string; test: string; error: Error }>;
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### `SuiteContext` — what `describe()` receives
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
interface SuiteContext {
|
|
232
|
+
it(name: string, fn: TestFn, timeout?: number): void
|
|
233
|
+
it.skip(name: string, fn: TestFn, timeout?: number): void
|
|
234
|
+
it.only(name: string, fn: TestFn, timeout?: number): void
|
|
235
|
+
|
|
236
|
+
test(name: string, fn: TestFn, timeout?: number): void // alias for it
|
|
237
|
+
test.skip / test.only // same as it.skip / it.only
|
|
238
|
+
|
|
239
|
+
describe(name: string, fn: (ctx: SuiteContext) => void): void
|
|
240
|
+
describe.skip(...)
|
|
241
|
+
describe.only(...)
|
|
242
|
+
|
|
243
|
+
beforeAll(fn: HookFn): void // runs once before all tests in this suite
|
|
244
|
+
afterAll(fn: HookFn): void // runs once after all tests in this suite
|
|
245
|
+
beforeEach(fn: HookFn): void // runs before every test in this suite (inherited by nested suites)
|
|
246
|
+
afterEach(fn: HookFn): void // runs after every test in this suite
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### `TestContext` — what `it()` receives
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
interface TestContext {
|
|
254
|
+
expect(expression: string): RhostExpect // create an expect for a softcode expression
|
|
255
|
+
client: RhostClient // the live MUSH connection
|
|
256
|
+
world: RhostWorld // fresh per-test fixture manager (auto-cleaned)
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### `HookFn` — what `beforeAll` / `beforeEach` / etc. receive
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
type HookFn = (ctx: { client: RhostClient; world: RhostWorld }) => Promise<void> | void
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Hook execution order
|
|
267
|
+
|
|
268
|
+
```
|
|
269
|
+
beforeAll (suite level, once)
|
|
270
|
+
beforeEach (inherited from parent suites, then this suite)
|
|
271
|
+
test body
|
|
272
|
+
afterEach (this suite only)
|
|
273
|
+
afterAll (suite level, once)
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`beforeEach` hooks are **inherited** by nested `describe` blocks.
|
|
277
|
+
`afterEach` hooks are **not** inherited — they only run for tests in the suite where they were registered.
|
|
278
|
+
|
|
279
|
+
If a `beforeAll` hook throws, all tests in that suite (and nested suites) are counted as failed. The error is reported for each test.
|
|
280
|
+
|
|
281
|
+
If a `beforeEach` hook throws, that individual test is counted as failed and `afterEach` is skipped.
|
|
282
|
+
|
|
283
|
+
### `it.only` / `describe.only` semantics
|
|
284
|
+
|
|
285
|
+
`only` applies at the **sibling level**. If any sibling at a given level is marked `only`, all siblings NOT marked `only` are skipped. `only` does not affect other describe blocks.
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## API reference — RhostExpect
|
|
290
|
+
|
|
291
|
+
Inside a test, `expect('expression')` evaluates the softcode expression and returns a `RhostExpect`. The result is **lazily evaluated and cached** — calling multiple matchers on the same `expect()` call evaluates the expression only once.
|
|
292
|
+
|
|
293
|
+
```typescript
|
|
294
|
+
// Inside it():
|
|
295
|
+
async ({ expect }) => {
|
|
296
|
+
await expect('add(2,3)').toBe('5');
|
|
297
|
+
await expect('strlen(hello)').toBeNumber();
|
|
298
|
+
await expect('lattr(#1)').toContainWord('ALIAS');
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Negation
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
await expect('add(1,1)').not.toBe('3');
|
|
306
|
+
await expect('add(1,1)').not.toBeError();
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### All matchers
|
|
310
|
+
|
|
311
|
+
| Matcher | Behavior |
|
|
312
|
+
|---------|----------|
|
|
313
|
+
| `.toBe(expected: string)` | Exact match after `.trim()`. |
|
|
314
|
+
| `.toContain(substring: string)` | Result includes the substring. |
|
|
315
|
+
| `.toMatch(pattern: RegExp \| string)` | Regex test, or substring inclusion if string. |
|
|
316
|
+
| `.toStartWith(prefix: string)` | Result starts with prefix. |
|
|
317
|
+
| `.toEndWith(suffix: string)` | Result ends with suffix. |
|
|
318
|
+
| `.toBeNumber()` | Result parses as a finite number. Empty string fails. |
|
|
319
|
+
| `.toBeCloseTo(expected: number, precision?: number)` | `\|actual − expected\| < 10^(−precision)`. Default precision: 3. |
|
|
320
|
+
| `.toBeTruthy()` | Non-empty, not `"0"`, and not a MUSH error (`#-1`/`#-2`/`#-3`). |
|
|
321
|
+
| `.toBeFalsy()` | Empty string, `"0"`, or a MUSH error. |
|
|
322
|
+
| `.toBeError()` | Result starts with `#-1`, `#-2`, or `#-3`. |
|
|
323
|
+
| `.toBeDbref()` | Result matches `/^#\d+$/` (a positive object reference). |
|
|
324
|
+
| `.toContainWord(word: string, sep?: string)` | Word is present in the space-delimited list (or custom separator). |
|
|
325
|
+
| `.toHaveWordCount(n: number, sep?: string)` | List has exactly `n` words. Empty string has 0. |
|
|
326
|
+
|
|
327
|
+
### Failure message format
|
|
328
|
+
|
|
329
|
+
When a matcher fails, it throws `RhostExpectError` with this message:
|
|
330
|
+
|
|
331
|
+
```
|
|
332
|
+
expect("add(2,3)")
|
|
333
|
+
● .toBe failed
|
|
334
|
+
Expected: "6"
|
|
335
|
+
Received: "5"
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### Using `RhostExpect` without the runner
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
import { RhostClient, RhostExpect } from '@rhost/testkit';
|
|
342
|
+
|
|
343
|
+
const client = new RhostClient({ host: 'localhost', port: 4201 });
|
|
344
|
+
await client.connect();
|
|
345
|
+
await client.login('Wizard', 'Nyctasia');
|
|
346
|
+
|
|
347
|
+
const ex = new RhostExpect(client, 'add(2,3)');
|
|
348
|
+
await ex.toBe('5');
|
|
349
|
+
await ex.not.toBe('6'); // expression is already cached from toBe() above
|
|
350
|
+
|
|
351
|
+
await client.disconnect();
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## API reference — RhostWorld
|
|
357
|
+
|
|
358
|
+
`RhostWorld` manages MUSH object fixtures. Objects created through `world` are registered and destroyed automatically in `world.cleanup()`. In the runner, `cleanup()` is called after every `it()` test — even on failure.
|
|
359
|
+
|
|
360
|
+
### Constructor
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
const world = new RhostWorld(client: RhostClient);
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### Methods
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
await world.create(name: string, cost?: number): Promise<string>
|
|
370
|
+
```
|
|
371
|
+
Creates a THING via `create(name)` or `create(name,cost)`. Returns the dbref (`#42`). Registers for cleanup.
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
await world.dig(name: string): Promise<string>
|
|
375
|
+
```
|
|
376
|
+
Creates a ROOM via `@dig name`. Returns the room dbref. Registers for cleanup.
|
|
377
|
+
|
|
378
|
+
```typescript
|
|
379
|
+
await world.set(dbref: string, attr: string, value: string): Promise<void>
|
|
380
|
+
```
|
|
381
|
+
Sets an attribute: `&attr dbref=value`.
|
|
382
|
+
|
|
383
|
+
```typescript
|
|
384
|
+
await world.get(dbref: string, attr: string): Promise<string>
|
|
385
|
+
```
|
|
386
|
+
Gets an attribute value via `get(dbref/attr)`.
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
await world.flag(dbref: string, flag: string, clear?: boolean): Promise<void>
|
|
390
|
+
```
|
|
391
|
+
Sets (`@set dbref=FLAG`) or clears (`@set dbref=!FLAG`) a flag. `clear` defaults to `false`.
|
|
392
|
+
|
|
393
|
+
```typescript
|
|
394
|
+
await world.lock(dbref: string, lockstring: string): Promise<void>
|
|
395
|
+
```
|
|
396
|
+
Locks an object: `@lock dbref=lockstring`.
|
|
397
|
+
|
|
398
|
+
```typescript
|
|
399
|
+
await world.trigger(dbref: string, attr: string, args?: string): Promise<string[]>
|
|
400
|
+
```
|
|
401
|
+
Triggers `@trigger dbref/attr=args`. Returns all output lines captured before the sentinel.
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
await world.destroy(dbref: string): Promise<void>
|
|
405
|
+
```
|
|
406
|
+
Destroys a single object with `@nuke`. Also removes it from the cleanup list.
|
|
407
|
+
|
|
408
|
+
```typescript
|
|
409
|
+
await world.cleanup(): Promise<void>
|
|
410
|
+
```
|
|
411
|
+
Destroys all registered objects in reverse-creation order. Errors from individual destroys are silently swallowed (the object may already be gone).
|
|
412
|
+
|
|
413
|
+
```typescript
|
|
414
|
+
world.size: number
|
|
415
|
+
```
|
|
416
|
+
Number of objects currently registered for cleanup.
|
|
417
|
+
|
|
418
|
+
### Input safety
|
|
419
|
+
|
|
420
|
+
All string inputs to `world` methods are validated by `guardInput()` before interpolation into MUSH commands. The guard rejects any string containing `\n` (newline) or `\r` (carriage return), which are the characters that split a single TCP send into multiple MUSH command lines.
|
|
421
|
+
|
|
422
|
+
**Accepted:** any string without `\n` or `\r` — including spaces, punctuation, and special MUSH characters.
|
|
423
|
+
**Rejected:** strings containing `\n` or `\r` — throws `RangeError`.
|
|
424
|
+
|
|
425
|
+
```typescript
|
|
426
|
+
await world.create('Test Obj-42'); // ✓ OK
|
|
427
|
+
await world.set('#1', 'HP', '100'); // ✓ OK
|
|
428
|
+
await world.set('#1', 'ATTR', 'a;b'); // ✓ OK (semicolons are not split chars)
|
|
429
|
+
await world.create('name\n@pemit me=injected'); // ✗ throws RangeError
|
|
430
|
+
await world.set('#1', 'AT\rTR', 'val'); // ✗ throws RangeError
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
> **LLM note:** The guard covers newline-based command splitting. MUSH-level injection (e.g., semicolons or `[` brackets in softcode contexts) is out of scope — `world` methods are test infrastructure, not a user-input sanitizer. Do not pass arbitrary end-user input directly to `world` methods.
|
|
434
|
+
|
|
435
|
+
---
|
|
436
|
+
|
|
437
|
+
## API reference — RhostClient
|
|
438
|
+
|
|
439
|
+
Low-level TCP client. All higher-level classes are built on top of this.
|
|
440
|
+
|
|
441
|
+
### Constructor
|
|
442
|
+
|
|
443
|
+
```typescript
|
|
444
|
+
const client = new RhostClient(options?: RhostClientOptions);
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
**`RhostClientOptions`:**
|
|
448
|
+
|
|
449
|
+
| Option | Type | Default | Description |
|
|
450
|
+
|--------|------|---------|-------------|
|
|
451
|
+
| `host` | `string` | `'localhost'` | Server hostname |
|
|
452
|
+
| `port` | `number` | `4201` | Telnet port |
|
|
453
|
+
| `timeout` | `number` | `10000` | Per-eval/command timeout (ms) |
|
|
454
|
+
| `bannerTimeout` | `number` | `300` | Ms to wait for welcome banner |
|
|
455
|
+
| `stripAnsi` | `boolean` | `true` | Strip ANSI/VT100 codes from results |
|
|
456
|
+
| `paceMs` | `number` | `0` | Delay between commands (flood control) |
|
|
457
|
+
| `connectTimeout` | `number` | `10000` | TCP connection establishment timeout (ms) |
|
|
458
|
+
|
|
459
|
+
### Methods
|
|
460
|
+
|
|
461
|
+
```typescript
|
|
462
|
+
await client.connect(): Promise<void>
|
|
463
|
+
```
|
|
464
|
+
Establishes the TCP connection and drains the welcome banner.
|
|
465
|
+
|
|
466
|
+
```typescript
|
|
467
|
+
await client.login(username: string, password: string): Promise<void>
|
|
468
|
+
```
|
|
469
|
+
Sends `connect <username> <password>` and waits for the login sentinel. Throws `RangeError` if either credential contains `\n` or `\r`.
|
|
470
|
+
|
|
471
|
+
```typescript
|
|
472
|
+
await client.eval(expression: string, timeout?: number): Promise<string>
|
|
473
|
+
```
|
|
474
|
+
Evaluates a softcode expression using `think` and captures the output. Trims trailing newlines. Strips ANSI if `stripAnsi: true` (the default). Returns the raw output string — may be empty, a number, a dbref, an error code, or a space-delimited list.
|
|
475
|
+
|
|
476
|
+
```typescript
|
|
477
|
+
await client.command(cmd: string, timeout?: number): Promise<string[]>
|
|
478
|
+
```
|
|
479
|
+
Sends a MUSH command and captures all output lines until the sentinel. Returns an array of lines (may be empty).
|
|
480
|
+
|
|
481
|
+
```typescript
|
|
482
|
+
client.onLine(handler: (line: string) => void): void
|
|
483
|
+
client.offLine(handler: (line: string) => void): void
|
|
484
|
+
```
|
|
485
|
+
Subscribe/unsubscribe to every raw line received from the server. Useful for debugging or log capture.
|
|
486
|
+
|
|
487
|
+
```typescript
|
|
488
|
+
await client.disconnect(): Promise<void>
|
|
489
|
+
```
|
|
490
|
+
Sends `QUIT` and closes the TCP connection.
|
|
491
|
+
|
|
492
|
+
### `stripAnsi` utility
|
|
493
|
+
|
|
494
|
+
```typescript
|
|
495
|
+
import { stripAnsi } from '@rhost/testkit';
|
|
496
|
+
stripAnsi('\x1b[32mgreen\x1b[0m'); // => 'green'
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
### `isRhostError` utility
|
|
500
|
+
|
|
501
|
+
```typescript
|
|
502
|
+
import { isRhostError } from '@rhost/testkit';
|
|
503
|
+
isRhostError('#-1 NO MATCH'); // => true
|
|
504
|
+
isRhostError('#-2'); // => true
|
|
505
|
+
isRhostError('#42'); // => false
|
|
506
|
+
isRhostError('5'); // => false
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Returns `true` if the string starts with `#-1`, `#-2`, or `#-3`.
|
|
510
|
+
|
|
511
|
+
---
|
|
512
|
+
|
|
513
|
+
## API reference — RhostContainer
|
|
514
|
+
|
|
515
|
+
Spins up a RhostMUSH Docker container for isolated test runs. Uses [testcontainers](https://node.testcontainers.org/) under the hood.
|
|
516
|
+
|
|
517
|
+
### Factory methods
|
|
518
|
+
|
|
519
|
+
```typescript
|
|
520
|
+
RhostContainer.fromImage(image?: string): RhostContainer
|
|
521
|
+
```
|
|
522
|
+
Use a pre-built Docker image. Default image: `'rhostmush:latest'`.
|
|
523
|
+
Build it first: `docker build -t rhostmush:latest .` (from the rhostmush-docker repo root).
|
|
524
|
+
|
|
525
|
+
```typescript
|
|
526
|
+
RhostContainer.fromSource(projectRoot?: string): RhostContainer
|
|
527
|
+
```
|
|
528
|
+
Build the image from the `Dockerfile` in the rhostmush-docker project root.
|
|
529
|
+
First run: ~5–10 minutes (clones and compiles RhostMUSH from source). Subsequent runs use Docker layer cache.
|
|
530
|
+
`projectRoot` defaults to `'../'` relative to the installed package location.
|
|
531
|
+
|
|
532
|
+
### Instance methods
|
|
533
|
+
|
|
534
|
+
```typescript
|
|
535
|
+
await container.start(startupTimeout?: number): Promise<ContainerConnectionInfo>
|
|
536
|
+
```
|
|
537
|
+
Starts the container and waits for port 4201 to accept connections.
|
|
538
|
+
`startupTimeout` defaults to `120000` ms (2 minutes).
|
|
539
|
+
Returns `{ host: string; port: number }` — the dynamically assigned host/port.
|
|
540
|
+
|
|
541
|
+
```typescript
|
|
542
|
+
await container.stop(): Promise<void>
|
|
543
|
+
```
|
|
544
|
+
Stops and removes the container. Safe to call even if `start()` was never called.
|
|
545
|
+
|
|
546
|
+
```typescript
|
|
547
|
+
container.getConnectionInfo(): ContainerConnectionInfo
|
|
548
|
+
```
|
|
549
|
+
Returns the current `{ host, port }`. Throws if the container is not running.
|
|
550
|
+
|
|
551
|
+
### Full usage example
|
|
552
|
+
|
|
553
|
+
```typescript
|
|
554
|
+
import { RhostRunner, RhostContainer } from '@rhost/testkit';
|
|
555
|
+
|
|
556
|
+
const container = RhostContainer.fromSource();
|
|
557
|
+
const info = await container.start();
|
|
558
|
+
// info = { host: 'localhost', port: 32XXX } (random high port)
|
|
559
|
+
|
|
560
|
+
const runner = new RhostRunner();
|
|
561
|
+
runner.describe('my system', ({ it }) => {
|
|
562
|
+
it('works', async ({ expect }) => {
|
|
563
|
+
await expect('add(1,1)').toBe('2');
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const PASS = process.env.RHOST_PASS;
|
|
568
|
+
if (!PASS) throw new Error('RHOST_PASS env var is required');
|
|
569
|
+
|
|
570
|
+
const result = await runner.run({
|
|
571
|
+
...info, // spreads host + port
|
|
572
|
+
username: 'Wizard',
|
|
573
|
+
password: PASS,
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
await container.stop();
|
|
577
|
+
process.exit(result.failed > 0 ? 1 : 0);
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
---
|
|
581
|
+
|
|
582
|
+
## MUSH output format
|
|
583
|
+
|
|
584
|
+
Understanding what `client.eval()` returns is essential for writing correct assertions.
|
|
585
|
+
|
|
586
|
+
### Normal results
|
|
587
|
+
|
|
588
|
+
| Softcode | Returns |
|
|
589
|
+
|----------|---------|
|
|
590
|
+
| `add(2,3)` | `'5'` |
|
|
591
|
+
| `strlen(hello)` | `'5'` |
|
|
592
|
+
| `lcstr(HELLO)` | `'hello'` |
|
|
593
|
+
| `list(a b c)` | `'a b c'` (space-delimited) |
|
|
594
|
+
| `lattr(#1)` | `'ALIAS MONIKER ...'` (space-delimited attribute names) |
|
|
595
|
+
| `encode64(hello)` | `'aGVsbG8='` |
|
|
596
|
+
| `create(Foo)` | `'#42'` (a dbref) |
|
|
597
|
+
|
|
598
|
+
### MUSH error codes
|
|
599
|
+
|
|
600
|
+
| Value | Meaning |
|
|
601
|
+
|-------|---------|
|
|
602
|
+
| `#-1` or `#-1 NO MATCH` | Generic error / object not found |
|
|
603
|
+
| `#-2` | Permission denied |
|
|
604
|
+
| `#-3` | Invalid arguments |
|
|
605
|
+
|
|
606
|
+
Use `isRhostError(result)` or `.toBeError()` to detect these.
|
|
607
|
+
|
|
608
|
+
### Multi-line output
|
|
609
|
+
|
|
610
|
+
`client.eval()` captures everything between the start and end sentinels and joins with `\n`. Most functions return a single line. `client.command()` returns an array of lines.
|
|
611
|
+
|
|
612
|
+
### ANSI codes
|
|
613
|
+
|
|
614
|
+
Color codes are stripped by default (`stripAnsi: true`). To preserve them, set `stripAnsi: false` in `RhostClientOptions`.
|
|
615
|
+
|
|
616
|
+
### Trailing whitespace
|
|
617
|
+
|
|
618
|
+
`client.eval()` trims trailing newlines. The `.toBe()` matcher trims both ends before comparing. Other matchers compare the raw (trimmed-newline-only) value.
|
|
619
|
+
|
|
620
|
+
---
|
|
621
|
+
|
|
622
|
+
## Using with LLM skills
|
|
623
|
+
|
|
624
|
+
This section describes the standard workflow for an LLM (such as a Claude skill) to write and verify MUSHcode using `@rhost/testkit`.
|
|
625
|
+
|
|
626
|
+
### Security considerations for LLM-generated code
|
|
627
|
+
|
|
628
|
+
When an LLM uses this SDK to generate and deploy softcode:
|
|
629
|
+
|
|
630
|
+
1. **Never auto-deploy from user input.** Generate softcode, present it for human review, then deploy.
|
|
631
|
+
2. **Always use environment variables for credentials.** Never hardcode passwords — not even the default.
|
|
632
|
+
3. **`world` methods are for test fixtures only.** Do not pass arbitrary end-user strings to `world.create()`, `world.set()`, etc. without review. The newline guard prevents command splitting, but not MUSH-level injection in values.
|
|
633
|
+
4. **`execscript()` runs shell code.** Never pass user-controlled strings as script names or arguments to `execscript()`.
|
|
634
|
+
5. **Use `paceMs`** to avoid flooding the server when generating many rapid eval calls.
|
|
635
|
+
6. **Telnet and the HTTP API are cleartext protocols.** Use them only on localhost or a private network.
|
|
636
|
+
|
|
637
|
+
```typescript
|
|
638
|
+
// ✗ UNSAFE — passes unreviewed user input to the server
|
|
639
|
+
const userInput = getUserInput();
|
|
640
|
+
await world.create(userInput);
|
|
641
|
+
|
|
642
|
+
// ✓ SAFE — validate, present for review, then act
|
|
643
|
+
if (!/^[A-Za-z0-9 _-]+$/.test(userInput)) throw new Error('Invalid object name');
|
|
644
|
+
console.log(`Deploying: create object "${userInput}" — review before proceeding`);
|
|
645
|
+
await world.create(userInput); // only after human approval
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
---
|
|
649
|
+
|
|
650
|
+
### The workflow
|
|
651
|
+
|
|
652
|
+
```
|
|
653
|
+
1. Deploy softcode to the MUSH server
|
|
654
|
+
└─ Paste commands into the running container via scripts/eval.js
|
|
655
|
+
node scripts/eval.js "@create MySystem"
|
|
656
|
+
node scripts/eval.js "&CMD_DOSOMETHING #42=..."
|
|
657
|
+
|
|
658
|
+
2. Write a test file
|
|
659
|
+
└─ Use RhostRunner + describe/it/expect
|
|
660
|
+
|
|
661
|
+
3. Run the tests
|
|
662
|
+
└─ RHOST_PASS=<your-password> npx ts-node my-system.test.ts
|
|
663
|
+
|
|
664
|
+
4. Red → fix softcode → Green → refactor
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
### Minimal test file template
|
|
668
|
+
|
|
669
|
+
```typescript
|
|
670
|
+
import { RhostRunner } from '@rhost/testkit';
|
|
671
|
+
|
|
672
|
+
// Require explicit password — never fall back to a default
|
|
673
|
+
const PASS = process.env.RHOST_PASS;
|
|
674
|
+
if (!PASS) { console.error('RHOST_PASS env var is required'); process.exit(1); }
|
|
675
|
+
|
|
676
|
+
const runner = new RhostRunner();
|
|
677
|
+
|
|
678
|
+
runner.describe('MySystem', ({ it, beforeAll }) => {
|
|
679
|
+
// Suppress background cron/queue output that can bleed into eval results.
|
|
680
|
+
// @halt/all me clears any pending server-side queue for the logged-in character.
|
|
681
|
+
beforeAll(async ({ client }) => {
|
|
682
|
+
await client.command('@halt/all me');
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// Test the happy path
|
|
686
|
+
it('does the thing', async ({ expect }) => {
|
|
687
|
+
// Replace #42 with the actual dbref of your system object
|
|
688
|
+
await expect('u(#42/FN_MYTHING,arg1)').toBe('expected output');
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// Test that bad input is handled correctly
|
|
692
|
+
it('returns error on bad input', async ({ expect }) => {
|
|
693
|
+
await expect('u(#42/FN_MYTHING,)').toBeError();
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
runner
|
|
698
|
+
.run({ host: 'localhost', port: 4201, username: 'Wizard', password: PASS, timeout: 10000 })
|
|
699
|
+
.then((r) => {
|
|
700
|
+
console.log(`${r.passed} passed, ${r.failed} failed, ${r.skipped} skipped`);
|
|
701
|
+
process.exit(r.failed > 0 ? 1 : 0);
|
|
702
|
+
})
|
|
703
|
+
.catch((err) => {
|
|
704
|
+
console.error('Fatal: could not connect to MUSH server:', err.message);
|
|
705
|
+
process.exit(1);
|
|
706
|
+
});
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
### Testing with object fixtures (world)
|
|
710
|
+
|
|
711
|
+
```typescript
|
|
712
|
+
runner.describe('stat system', ({ it }) => {
|
|
713
|
+
it('sets and reads HP', async ({ world, client, expect }) => {
|
|
714
|
+
// world is fresh per test and auto-cleaned after
|
|
715
|
+
const char = await world.create('TestChar');
|
|
716
|
+
await world.set(char, 'HP', '50');
|
|
717
|
+
await world.set(char, 'HP_MAX', '100');
|
|
718
|
+
|
|
719
|
+
// Load your system's UDFs onto the char or call via #dbref
|
|
720
|
+
await expect(`u(#42/FN_GETHP,${char})`).toBe('50');
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it('triggers a command', async ({ world, client }) => {
|
|
724
|
+
const char = await world.create('TestChar');
|
|
725
|
+
const lines = await world.trigger(char, 'CMD_ATTACK', 'goblin');
|
|
726
|
+
if (!lines.some(l => l.includes('attacks'))) {
|
|
727
|
+
throw new Error(`Expected attack output, got: ${JSON.stringify(lines)}`);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
### Common patterns
|
|
734
|
+
|
|
735
|
+
**Test a user-defined function (UDF):**
|
|
736
|
+
```typescript
|
|
737
|
+
await expect(`u(#42/FN_GREET,Alice)`).toBe('Hello, Alice!');
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
**Test a command's output:**
|
|
741
|
+
```typescript
|
|
742
|
+
const lines = await client.command('+vote Alice');
|
|
743
|
+
// lines is string[] of all output until the sentinel
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
**Test that a command modifies an attribute:**
|
|
747
|
+
```typescript
|
|
748
|
+
const obj = await world.create('Target');
|
|
749
|
+
await client.command(`+setstat ${obj}=STR/18`);
|
|
750
|
+
await expect(`get(${obj}/STAT.STR)`).toBe('18');
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
**Test MUSH error handling:**
|
|
754
|
+
```typescript
|
|
755
|
+
await expect('u(#42/FN_DIVIDE,10,0)').toBeError();
|
|
756
|
+
await expect('u(#42/FN_DIVIDE,10,0)').toMatch(/#-1/);
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
**Test a list result:**
|
|
760
|
+
```typescript
|
|
761
|
+
await expect('iter(1 2 3,mul(##,2))').toBe('2 4 6');
|
|
762
|
+
await expect('lattr(#1)').toContainWord('ALIAS');
|
|
763
|
+
await expect('lattr(#1)').toHaveWordCount(5);
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
**Test numeric output:**
|
|
767
|
+
```typescript
|
|
768
|
+
await expect('add(0.1,0.2)').toBeCloseTo(0.3, 2);
|
|
769
|
+
await expect('sqrt(2)').toBeNumber();
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
**Suppress MUSH background output:**
|
|
773
|
+
```typescript
|
|
774
|
+
beforeAll(async ({ client }) => {
|
|
775
|
+
await client.command('@halt/all me');
|
|
776
|
+
await client.command('@pemit me=ready');
|
|
777
|
+
});
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
### Connecting to the Docker development server
|
|
781
|
+
|
|
782
|
+
```bash
|
|
783
|
+
# Start the server (from the repo root)
|
|
784
|
+
docker compose up --build -d
|
|
785
|
+
|
|
786
|
+
# Run tests (from sdk/) — set your own password
|
|
787
|
+
RHOST_PASS=<your-password> npx ts-node my-system.test.ts
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
### Running tests in a self-contained container (no pre-existing server)
|
|
791
|
+
|
|
792
|
+
```typescript
|
|
793
|
+
import { RhostRunner, RhostContainer } from '@rhost/testkit';
|
|
794
|
+
|
|
795
|
+
const container = RhostContainer.fromImage('rhostmush:latest'); // or fromSource()
|
|
796
|
+
const info = await container.start(); // waits until port 4201 is ready
|
|
797
|
+
|
|
798
|
+
// Deploy softcode using the scripts/eval.js tool first, or inline:
|
|
799
|
+
// const { execSync } = require('child_process');
|
|
800
|
+
// execSync(`node scripts/eval.js "@create MySystem" --host ${info.host} --port ${info.port}`);
|
|
801
|
+
|
|
802
|
+
const runner = new RhostRunner();
|
|
803
|
+
// ... add describe blocks ...
|
|
804
|
+
|
|
805
|
+
const result = await runner.run({ ...info, username: 'Wizard', password: process.env.RHOST_PASS! });
|
|
806
|
+
await container.stop();
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
---
|
|
810
|
+
|
|
811
|
+
## Environment variables
|
|
812
|
+
|
|
813
|
+
> **Security:** `RHOST_PASS` defaults to `Nyctasia`, which is public knowledge. Always set it explicitly — in any environment, including local dev. The examples in this README require it to be set; they will fail loudly if it is absent.
|
|
814
|
+
|
|
815
|
+
| Variable | Dev default | Description |
|
|
816
|
+
|----------|-------------|-------------|
|
|
817
|
+
| `RHOST_HOST` | `localhost` | Server hostname |
|
|
818
|
+
| `RHOST_PORT` | `4201` | Telnet port |
|
|
819
|
+
| `RHOST_USER` | `Wizard` | Login character name |
|
|
820
|
+
| `RHOST_PASS` | `Nyctasia` **(change this)** | Login password — always override explicitly |
|
|
821
|
+
| `RHOST_API_PORT` | `4202` | HTTP API port (examples 09–10) |
|
|
822
|
+
|
|
823
|
+
```bash
|
|
824
|
+
# Correct — explicit password
|
|
825
|
+
RHOST_PASS=my-secret-pass npx ts-node my-system.test.ts
|
|
826
|
+
|
|
827
|
+
# Wrong — relies on the public default
|
|
828
|
+
npx ts-node my-system.test.ts
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
---
|
|
832
|
+
|
|
833
|
+
## Examples
|
|
834
|
+
|
|
835
|
+
The [`examples/`](https://github.com/RhostMUSH/rhostmush-docker/tree/main/sdk/examples) directory contains runnable test files. Start a server first (`docker compose up --build -d` from the repo root), then:
|
|
836
|
+
|
|
837
|
+
```bash
|
|
838
|
+
cd sdk
|
|
839
|
+
npx ts-node examples/01-functions.ts
|
|
840
|
+
# or via npm:
|
|
841
|
+
npm run example:01
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
| File | What it covers |
|
|
845
|
+
|------|----------------|
|
|
846
|
+
| `01-functions.ts` | Math, strings, lists, control flow, type checks |
|
|
847
|
+
| `02-rhost-specific.ts` | encode64, digest, strdistance, soundex, localize |
|
|
848
|
+
| `03-attributes.ts` | Create objects, set/get attributes, flags, softcode |
|
|
849
|
+
| `04-triggers.ts` | @trigger: output capture, argument passing, chaining |
|
|
850
|
+
| `05-runner-features.ts` | it.skip, it.only, hooks, timeouts, RunResult |
|
|
851
|
+
| `06-game-system.ts` | End-to-end: stat system, modifiers, dice, character sheets |
|
|
852
|
+
| `07-direct-client.ts` | Low-level `RhostClient` without the runner |
|
|
853
|
+
| `08-execscript.ts` | Call shell/Python scripts from softcode via execscript() |
|
|
854
|
+
| `09-api.ts` | HTTP API: eval softcode over HTTP with Basic Auth |
|
|
855
|
+
| `10-lua.ts` | Embedded Lua via HTTP API |
|
|
856
|
+
|
|
857
|
+
---
|
|
858
|
+
|
|
859
|
+
## License
|
|
860
|
+
|
|
861
|
+
MIT
|