Testing SDK Reference
ts
import { createModTestHarness } from '@vibelands/mod-sdk/testing';Headless harness for mod server entries. Mirrors the live host's quotas and permission gating with deterministic, in-memory infrastructure. Works with any test runner (the scaffold wires vitest).
createModTestHarness(hooks, options?)
ts
const harness = createModTestHarness(server, {
modId: 'my-treasure', // default 'test-mod'
permissions: ['world.entities', 'storage', 'players.modify'], // default: ALL
config: { chestCount: 4 }, // raw config
configSchema: manifest.configSchema, // optional - resolves config like the host
messageSchemas: manifest.messageSchemas, // optional - validates both message directions
world: {
worldId: 'test-world',
terrainSeed: 4242,
terrainSize: 600,
seaLevel: 2,
biome: 'grassland',
timeOfDay: 0.5,
weather: { type: 'clear', intensity: 0 },
getGroundHeight: (x, z) => 6, // your terrain stub
},
storage: { opened: { 'chest-1': 123 } }, // pre-seeded persistence ("previous session")
verbose: false, // echo ctx.log to console
});Pass your real manifest's permissions, configSchema, and messageSchemas to test exactly what ships.
Lifecycle drivers
| Method | Effect |
|---|---|
await harness.start() | runs onWorldStart |
harness.join(init) | adds a player, runs onPlayerJoin; init: { sessionId, name?, userId?, level?, mode?, dead?, position? } |
harness.leave(sessionId) | removes the player, runs onPlayerLeave |
harness.updatePlayer(sessionId, patch) | mutates player state (position/dead/...) without firing hooks |
await harness.message(type, data, sessionId, { operationId? }) | validates the client→server contract and operation id, then runs onMessage[type]; returns false when an at-most-once replay is suppressed |
harness.tick(dtSeconds = 0.1) | runs onTick once |
harness.advanceTimers(ms) | advances the virtual clock; fires due ctx.schedule timers in order |
await harness.harvestResource({ sessionId, resource, rewards?, ... }) | simulates a depleted node: consults canHarvestResource, grants rewards (default one base item), runs onResourceHarvested; returns { allowed, refusal?, rewards } |
await harness.craftRecipe({ sessionId, recipeId, recipe, count?, outputs? }) | consults canCraftRecipe, grants outputs, runs onRecipeCrafted; ingredient use and station range are host checks you set up yourself |
await harness.dispose() | runs onDispose, cancels timers |
Inspection
| Member | Contains |
|---|---|
harness.ctx | the full ModServerContext - poke any API directly |
harness.ctx.resources | the same reverse-order custom resource scope used by live in-process and hosted workers |
harness.entities | ctx.entities (list/has/count - what clients would see) |
harness.kv() | parsed replicated KV blob |
harness.storageDump() | persistent storage as plain JSON |
harness.sent / sentOfType(type, sessionId?) | direct messages: { sessionId, type, data } |
harness.broadcasts / broadcastsOfType(type) | broadcasts: { type, data } |
harness.rewards | { sessionId, moneyCents, xp, label } per grant |
harness.logs | every ctx.log(...) call's args |
harness.denials | permission ids that were denied |
What's enforced like production
- entity quota (384) and
propertiessize (2 KB) -spawn/updatereturnnull/false - KV blob size (8 KB), storage value (32 KB) and total (2 MB) quotas
- permission gating with identical deny semantics (logged no-op + recorded in
denials) - message payload cap (8 KB) - the harness throws, since the host would silently drop
- manifest
messageSchemasallowlists and payload validation in both directions; invalidctx.messages.send/broadcastalso throws delivery: "at-most-once"replay suppression, authenticateduserIdscope, bounded operation-id retention, and virtual-clock TTL expiry- entity id suffix rules, config resolution, reward amount bounds
ctx.resourcescleanup, including resources registered duringonDispose
What's intentionally different
| Live host | Harness | Why |
|---|---|---|
| entity transforms batch at 10 Hz, KV defers over 2/s | applied immediately | deterministic assertions |
| tick budget + circuit breaker | none | tests measure logic, not wall-clock |
| hook errors contained, mod disabled | errors rethrow | tests should fail loudly |
ctx.schedule uses real timers | virtual clock via advanceTimers() | no sleeps in tests |
One real-world note: Date.now() inside your mod is not virtualized. Make wall-clock durations (respawn minutes etc.) configurable and test with small values.
Recipes
Restart persistence:
ts
const second = createModTestHarness(server, { storage: first.storageDump(), ... });Permission regression:
ts
const harness = createModTestHarness(server, { permissions: [] });
await harness.start();
expect(harness.denials).toContain('world.entities');Scheduled behavior:
ts
await harness.start(); // mod set a 60 s announcement interval
harness.advanceTimers(60_000);
expect(harness.broadcastsOfType('announcement')).toHaveLength(1);