@twin-digital/minecraft-test-lib
v0.4.0
Published
In-memory fakes of the @minecraft/server object model, for testing Minecraft Bedrock behavior packs.
Readme
@twin-digital/minecraft-test-lib
In-memory fakes of the @minecraft/server object model, for testing Minecraft Bedrock behavior
packs. The fakes hold state and mutate it as their members are called, so a test asserts that health
is now 20 rather than that setCurrentValue was called with 20.
@minecraft/server ships type declarations with no runtime JavaScript, so a pack author has no
double to test against and hand-rolls one per test. Those doubles cannot express the conditions that
break real packs — a component that is absent, a reference that went invalid in the middle of the
event that fired — and a double that returns a plausible-looking payload lets a handler take the
wrong branch while the test still passes.
Install
npm install --save-dev @twin-digital/minecraft-test-libESM only, with type declarations, and no runtime dependencies.
Every value and behaviour here was derived from @minecraft/server 2.8.0, which the package
exports as SERVER_VERSION. The pin is stated and nothing more: no @minecraft/server peer range is
declared, and nothing at install, configuration or run time compares your engine pin against it. A
pack on a different 2.x pin installs and runs; where the two versions differ, this library answers
for 2.8.0.
It depends on no test framework at run time: the fakes are plain objects, and a caller who wants call
recording wraps one with their own spy library. The one exception is the /vitest subpath below,
which is runner tooling and reaches vi; nothing else in the package imports a runner, and vitest
is declared as an optional peer so a consumer who never imports that subpath resolves the package
without a warning.
There are two published entry points and no others: the root barrel, and
@twin-digital/minecraft-test-lib/vitest.
Getting started
import { createServer } from '@twin-digital/minecraft-test-lib'
import { installMyPack } from '../src/main.js' // the pack under test, not this library
const server = createServer()
installMyPack(server) // the pack takes { world, system, … }The second line is the pack's own entry point, whatever it is called; this library exports nothing that installs anything.
This is the injection path: a fake reaches the code under test as an object the test passes in, and nothing is installed anywhere. A suite written this way keeps working exactly as written.
A pack that reaches the engine through a direct import { world } from '@minecraft/server' instead
is reached the other way — see the next section.
Testing a pack that imports @minecraft/server
Most packs do not take their engine handles as a parameter. They import them:
import { world, system } from '@minecraft/server'
world.afterEvents.entityHurt.subscribe(/* … */)@minecraft/server ships no runtime JavaScript, so that import cannot resolve under a test runner at
all — the module has no main, module or exports entry, and the suite fails to start before any
of your code runs. This package ships the module the runner points at instead.
The install is one entry
// vitest.config.ts
import { minecraftTestLib } from '@twin-digital/minecraft-test-lib/vitest'
export default {
plugins: [minecraftTestLib()],
}That is the whole install. The plugin points the runner's resolver at the aliased surface this
package ships, points it at stubs for the @minecraft/* script modules the fakes do not cover, and
contributes a setup module that installs a fresh server before each test file evaluates. You write
no setup file, and no alias of your own.
The default: static imports, one scenario per file
With the plugin in place, a test file holds static imports and nothing else:
import { addComponent, createEntity, currentServer, withVanillaDimensions } from '@twin-digital/minecraft-test-lib'
import { world } from '@minecraft/server'
import '../src/main.js' // the pack, imported for its side effects
it('reacts to a hurt entity', () => {
const server = currentServer()
withVanillaDimensions(server)
const sheep = createEntity(server, { typeId: 'minecraft:sheep', dimension: world.getDimension('overworld') })
addComponent(sheep, 'minecraft:health', 20)
sheep.applyDamage(1)
// assert on what the pack did
})No install call, no reset prelude, no ordering you write. The setup module ran before this file
evaluated, so the pack's module-scope subscribe and system.runInterval calls landed on the server
currentServer() returns — the same object world and system are bound to.
Freshness is per file, which is the runner's own module-registry boundary. State carries between
the tests within a file: a test that spawns an entity and advances twenty ticks hands the next test
that entity and that clock. Write one scenario per file, or reach for loadPack.
The escape hatch: loadPack
import { advanceTicks } from '@twin-digital/minecraft-test-lib'
import { loadPack } from '@twin-digital/minecraft-test-lib/vitest'
it('starts from a world of its own', async () => {
const server = await loadPack(() => import('../src/main.js'))
advanceTicks(server, 20)
})loadPack resets the module registry, imports this library fresh, installs a new server, and only
then calls your importer — so the pack evaluates against a world no previous test touched — and hands
back that server. Assert through the value it returns.
This is not an equal alternative to the default; it is for the cases that need a fresh evaluation:
pack module-scope state a test mutates, load-time behaviour itself, scheduled-run accumulation, or a
server that must differ before the pack evaluates (loadPack(importer, { server })).
The package ships no reset, public or internal. A fresh start comes from a fresh module-registry generation or from an explicit unset, never from swapping a live server out from under a pack that already registered against it.
Installing your own fakes
__useServer(server) points all three bindings at a server you built, and __useServer() returns
them to the unset state. Reading through an unset binding throws ShimNotInstalledError rather than reading
undefined.
Replacing a server a pack has already registered against throws ShimServerInUseError, naming the
subscriber and scheduled-run counts it would have stranded: those registrations stay on the server the
pack evaluated against, so the replacement would see none of the pack's behaviour. Unset first, or use
loadPack.
What the aliased surface carries
Everything the pinned declarations declare as a value, and nothing else:
- every enum, as a frozen object whose members are generated from the 2.8.0 declarations
- the module-level numeric constants —
TicksPerSecond,TicksPerDay, and their siblings - every declared class, and the three bindings
world,systemandEntityTypes
The classes the fakes implement are the fake classes, so instanceof answers by class identity:
an entity this library built is an Entity the pack imported, and a fake carries the declared
inheritance too — a player is an Entity, a health component is an EntityComponent. No brand, no
Symbol.hasInstance, and nothing for a consumer to name.
A name the pinned declarations do not declare is not exported — no Proxy over unknown names, no auto-vivified stub, no fallback value. A pack importing a name 2.8.0 does not carry fails at the import.
The surface supplies values, classes and the three bindings. It models no behaviour of its own: every behaviour a test observes comes from the fakes.
The server
createServer() returns a fake server whose properties are named exactly as @minecraft/server
exports them — world, system, and the eight type catalogs BiomeTypes, BlockStates,
BlockTypes, DimensionTypes, EffectTypes, EnchantmentTypes, EntityTypes and ItemTypes —
so it is assignable to a Pick<> of the module's namespace type and a pack written to receive its
engine handles as a parameter can be handed the whole thing.
EntityTypes reads that server's own type catalog. The other seven are declared and every member
on them throws NotImplementedError.
Entity types
A server's type catalog starts empty, and registerEntityType fills it. Both branches a lookup can
take are arrangeable from the first line of a test — a type that resolves and a type that does not:
const server = createServer()
const guard = registerEntityType(server, 'mypack:guard')
server.EntityTypes.get('mypack:guard') === guard // the registered type
server.EntityTypes.get('mypack:absent') // undefinedwithVanillaEntityTypes(server) registers the vanilla ids in one call. Lookup reproduces the
engine's: a bare identifier resolves as minecraft:<id> and nothing else — no other namespace is
searched, so a pack-defined type never answers to its bare name — the match is exact, so whitespace
and case differences miss, and a miss reads undefined rather than throwing.
dimension.spawnEntity resolves through the catalog and throws InvalidArgumentError naming the
identifier where nothing registers it, taking an EntityType wherever it takes an id.
createEntity and createPlayer do not consult it: the engine declares no function at all for
those, so they are the library's own and stay typeId-string-shaped.
The engine's catalog is read-only from script and carries whatever content the world installed; this one is written by the test. Two more differences are listed under Divergences below: the engine refuses every catalog read during early execution, which the fakes have no phase for, and the wording of the guard on a wrong-typed argument.
All state a server holds belongs to that server. Two createServer() calls in one process share
nothing, so tests need no reset hook.
Every fake carries the full public shape of the type it stands in for and is assignable where the
real declared type is expected, with no cast — the classes are generated from the pinned
declarations, so implements checks completeness on every build. There is no Proxy and no runtime
interception, which is what makes the fakes behave like ordinary objects: 'teleport' in entity is
true, Object.keys reads the engine's two own properties, for-in walks its 62, and a spy
library that wraps a method by assignment works.
Free functions
Everything the real API cannot express is a free function over the fakes rather than a member the engine does not have.
| function | what it does |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| createServer() | a new server: world, system, type catalogs |
| createEntity(server, { typeId, id?, dimension?, location? }) | a fake entity registered with that world |
| createPlayer(server, { typeId?, id?, name?, dimension?, location? }) | as above, a Player |
| addComponent(entity, componentId, state?) | attach a component to a live entity |
| removeComponent(entity, componentId) | detach one |
| registerEffectBaseName(server, effectTypeId, baseName) | the base name for a custom effect type, or an override for a shipped one |
| registerEntityType(server, id, localizationKey?) | put an entity type in that server's catalog |
| invalidate(entity) | put the reference into the engine's invalid state |
| emit(signal, payload) | deliver a payload to a signal's subscribers |
| advanceTicks(server, count) | step the clock: decay effect durations, then run each tick's callbacks |
| getOutput(target) | the messages and titles sent to a player or the world |
| getTriggeredEvents(entity) | the triggerEvent calls made on an entity |
| getHandlerErrors(server) | the errors thrown by subscribers and absorbed at dispatch |
| __useServer(server?) | point the three module-scope bindings at a server, or unset them |
| currentServer() | the server the module-scope bindings point at |
Presets
Populated starting points are invoked explicitly, never as constructor behaviour, and compose freely. Each supplies only values a source pins; neither invents per-type vanilla data.
withVanillaDimensions(server)adds the three vanilla dimensions.world.getDimensionthen resolvesoverworld,nether,the_end, theirminecraft:-prefixed forms and the spaced alias"the end", each returning a dimension whoseidis the prefixed form, with height ranges −64..320, 0..128 and 0..256 and localization keysdimension.dimensionName0/1/2.withVanillaEntityTypes(server)registers the entity-type ids@minecraft/vanilla-datacarries, in that source's order, soEntityTypes.getanddimension.spawnEntityanswer for the vanilla types. An id you registered yourself is skipped rather than colliding with the duplicate refusal. The list is the source's, not a world's catalog read back: a world also carries whatever its content packs define.withVanillaWorld(server)supplies those two and nothing else.asSpawnedEntity(entity)supplies the spawn frame:nameTagthe empty string,getRotation(){x: 0, y: 0}andgetVelocity(){x: 0, y: 0, z: 0}. It supplies only what the caller left unset, so anameTagyou set survives it. It applies the same zeros to every type, includingminecraft:xp_orb, which the engine spawns with a randomized rotation and velocity — a divergence, listed below.
Construction populates nothing
A new server has no dimensions, no players, no objectives and no dynamic properties, and a new entity carries no components and no field values beyond the ones you passed. That is deliberately unlike the engine, where a freshly spawned entity always arrives carrying at least one component.
Two kinds of nothing, told apart by the declaration's own type:
- A value the engine could not lack —
nameTag,location,getRotation()— throwsUnsetValueErrornaming the member when you never supplied it. A fake that invented one would let a handler branch on fiction. - An absence the engine can exhibit reads back as the engine reports it:
getComponentfor an unattached component, an unset dynamic property, and an unknown scoreboard objective or participant all returnundefined. An empty collection is a real resting state.
Errors
None of the engine's error classes is importable at runtime, so the library declares its own.
Where the pinned declarations declare a class the library also hand-writes, the aliased surface
exports the library's class — one class object per name — so a pack's
catch (e) { e instanceof InvalidEntityError } catches what the fakes actually throw. Every other
Error-ancestry class the declarations export is a real Error subclass setting its own name, with
its declared readonly members left to whoever throws it. ArgumentOutOfBoundsError and
InvalidArgumentError are @minecraft/common's and are not re-exported by @minecraft/server, so
they stay names only this library exports.
| class | thrown when |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| InvalidEntityError | a member of an entity whose reference has gone invalid; carries the readonly id and type of that entity |
| ArgumentOutOfBoundsError | a numeric argument falls outside the bounds the engine enforces — setCurrentValue, addEffect's amplifier and duration |
| InvalidArgumentError | an argument's value is one the engine rejects outright — a bare id to triggerEvent |
| NotImplementedError | a declared member this cycle does not model; names the member |
| UnsetValueError | a modelled member reads a value the test never supplied; names the member |
| ShimNotInstalledError | the module-scope world or system is reached before a test installed a server |
| ShimServerInUseError | __useServer would replace a server a pack has already registered against; carries both counts |
Two guarded surfaces do not use InvalidEntityError, because the engine does not. On an invalid
owner an attribute component's value getters throw a plain Error reading
Failed to get property '<internal name>'. — the engine names its own field, current, value,
effectiveMaxValue and effectiveMinValue — its three resets throw
Failed to call function '<name>'., and an effect's amplifier, duration, typeId and
displayName throw Failed to get property '<member>'.
What a read that finds nothing does
Five rules, in this order. A member matching an earlier rule never reaches a later one.
- Too few arguments throw
TypeErrorfirst of all, ahead of the guard, on a valid and an invalidated reference alike:Incorrect number of arguments to function. Expected 2-3, received 0. Only the minimum is checked; extra arguments are ignored. - The validity guard fires next. On an invalidated reference every guarded member throws
InvalidEntityError— or the plainErrorits owner's table gives — whatever the member would otherwise have done. - An out-of-scope member throws
NotImplementedError, however its declaration is typed. - A modelled member reading an absence the engine can exhibit returns
undefined. - A modelled member reading a value the test never supplied throws
UnsetValueError.
Invalidation
remove() invalidates as part of removing: it raises the entityRemove before-event, then detaches
the entity and invalidates every reference to it as one act, then raises the after-event.
invalidate(entity) reaches the state remove() cannot — the reference that goes stale without
leaving the world — and may be called at any point, including
on a reference a handler is holding mid-event.
On an invalidated entity exactly four members stay readable: id, isValid (false), typeId, and
scoreboardIdentity (undefined). Every other member throws. The guard is on the call, not the
read: reading a method off an invalidated entity returns a function, and a reference captured while
the entity was still valid throws when it eventually runs.
Coverage
Every engine behaviour this library has ruled on is listed below as modelled (the fake
reproduces the engine), not modelled (the members are declared and throw NotImplementedError,
or the behaviour has no fake counterpart), or a divergence (the fake behaves, and differs from
the engine on purpose). Each divergence row carries the difference itself, so this table is the one
place to learn where a passing test would not have passed against the engine.
The table states what the design ruled on and nothing more: a behaviour outside it has not been considered, which is not the same as a promise about it.
Every row carries an id in its first column, and the id is the row's identity while its two description columns are not: pin the id, and expect the behaviour and library columns to be reworded without notice. An id names its row's subject rather than its verdict, so a row keeps its id when its coverage changes. An id is issued once — a subject that splits retires its id and both halves take new ones, and a removed subject's id is never reissued.
| id | engine behaviour | coverage | what the library does |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| dimension-registration-and-resolution | dimension registration and world.getDimension resolution | modelled | via withVanillaDimensions; ids, aliases, height ranges and localization keys as observed |
| get-dimension-unknown-id | getDimension with an unknown id | modelled | plain Error, Dimension '<id>' is invalid. — including on a world where no preset was applied |
| world-resting-state | the world's resting state — empty collections, no players, no objectives | modelled | |
| fresh-entity-components | a freshly constructed entity's components | divergence | construction populates nothing; in the engine a fresh entity always arrives carrying at least one component |
| xp-orb-spawn-frame | the spawn frame of minecraft:xp_orb | divergence | asSpawnedEntity applies zero rotation and velocity to every type; the engine spawns an xp_orb with a randomized rotation and a nonzero randomized velocity, drawn afresh per spawn |
| per-type-vanilla-data | per-type vanilla data — a sheep's fourteen components, its 8/8/0/8 health | not modelled | no preset supplies it; a package built on this one may |
| entity-id-assignment | entity id assignment | divergence | ids are decimal strings issued from 1 per server; the engine's are negative integers. Entity.id is documented opaque, so nothing may read the spelling either way |
| entity-lookups | world.getEntity, getAllPlayers, getPlayers, dimension.getEntities, dimension.getPlayers | modelled | unfiltered, in creation order |
| entity-query-options-filtering | EntityQueryOptions filtering, on the lookups and on entity.matches | divergence | six of the twenty-four fields filter — type, tags, name and their exclude counterparts; each of the other eighteen throws NotImplementedError naming itself, where the engine honours them all |
| entity-tags | entity tags — addTag, removeTag, hasTag, getTags | modelled | a per-entity set, which the tags and excludeTags filters read |
| positional-entity-lookups | the other entity lookups — getEntitiesAtBlockLocation, getEntitiesFromRay, getEntitiesFromViewDirection and the rest | not modelled | |
| spawn-entity-placement | dimension.spawnEntity placement | divergence | the entity lands exactly where asked; the engine adjusts some placements — a boat by 0.2 on x and z |
| post-spawn-motion | post-spawn motion | divergence | an entity never moves on its own; AI-driven mobs drift within a couple of dozen ticks |
| entity-type-catalog | EntityTypes.get and EntityTypes.getAll | modelled | a bare identifier resolves as minecraft:<id> and nothing else — no other namespace is searched — the match is exact so whitespace and case differences miss, a miss reads undefined rather than throwing, getAll reports in registration order, and an entry is one object the catalog keeps rather than a value rebuilt per call |
| entity-type-registration | how an entity type gets into the catalog | divergence | the engine's catalog is read-only from script and carries whatever content the world installed; here a server's catalog starts empty and registerEntityType(server, id, localizationKey?) fills it, refusing an id already registered rather than replacing the entry a test holds |
| entity-type-catalog-early-execution | a catalog read during early execution | divergence | the engine refuses every read at a pack's module evaluation and inside a system.beforeEvents.startup handler, throwing ReferenceError: Native function [EntityTypes::get] cannot be used in early execution.; the fakes have no early phase, so a lookup answers whenever a test makes it |
| entity-type-argument-guards | EntityTypes.get on a wrong-typed argument | divergence | the four wordings the engine splits into are reproduced for the six argument shapes measured, but every object outside those shapes takes the plain-object wording, which is this library's own extrapolation rather than an observation |
| entity-type-shape | an EntityType's id and localizationKey | modelled | both are own value properties on the instance rather than getters, and the type carries no other member. An omitted key derives from the id as entity.<id>.name, with a leading minecraft: stripped and any other namespace kept |
| spawn-entity-type-resolution | dimension.spawnEntity entity-type resolution | modelled | it resolves through the server's catalog, agreeing with EntityTypes.get on the same identifier, throws InvalidArgumentError naming the identifier where nothing registers it, and takes an EntityType wherever it takes an id |
| create-entity-type-resolution | createEntity and createPlayer entity-type resolution | not modelled | the engine declares no function at all for these, so they are the library's own: each takes a typeId string, consults no catalog, and registers whatever type it is handed |
| entity-remove-cascade | entity.remove() | modelled | raises the entityRemove before-event, then detaches from the registry and invalidates the reference as one act, then raises the after-event — the engine's own cascade, which raises no death event either |
| trigger-event | entity.triggerEvent | divergence | validates the prefixed id and records the call, changing no state; in the engine the event reshapes the entity |
| entity-kill-cascade | entity.kill() | modelled | the full cascade, on an entity with and without a health component |
| corpse-invalidation-after-kill | invalidation of a mob's corpse after kill() | modelled | the corpse stays valid — inside the entityDie handler and after it — and turns invalid 21 ticks later, the constant the engine was measured at, so it goes stale when the test advances that far. Distinct from remove(), which invalidates at once |
| kill-invalidation-without-health | invalidation after kill() on an entity with no health component | modelled | the reference goes invalid before entityDie is raised, as the engine's does within the call |
| attribute-shaped-components | the seven attribute-shaped components | modelled | all four values, the bounds check, and the health-write cascade |
| non-attribute-components | the other 61 entity components | not modelled | attachable, carrying typeId, isValid and entity; every other member throws |
| runtime-component-mutation | runtime component attachment and detachment | not modelled | the engine reaches it through data-driven paths; a test uses the addComponent / removeComponent free functions |
| namespace-prefix-tolerance | bare and prefixed id tolerance | modelled | per-surface, as observed — triggerEvent rejects the bare form and the others accept it |
| set-current-value-bounds | setCurrentValue bounds check | modelled | including the message and both inclusive bounds |
| apply-damage-cascade | applyDamage cascade, order and payloads | modelled | including the unclamped negative health an overkill leaves, and unrounded fractional amounts |
| apply-damage-boolean | applyDamage's boolean | modelled | reports admission — damageable entity, positive amount — not whether damage landed, as observed |
| apply-damage-cause-and-source | applyDamage cause defaults and the damagingEntity carry-through | modelled | |
| killing-hit-boundary | the killing-hit boundary | modelled | reaching effectiveMin exactly is fatal on both the damage and the component-write path |
| apply-damage-without-health | applyDamage on an entity with no health component | modelled | returns false, fires nothing, leaves the entity valid |
| damage-invulnerability-window | the damage-invulnerability window | divergence | the fake has no i-frames, so consecutive applyDamage calls each take their full amount where the engine absorbs the second — a test driving repeated damage sees more health lost against the fake than the engine would take |
| projectile-damage-adjustment | the engine's velocity-dependent projectile damage adjustment | divergence | the projectile options form applies the amount requested |
| effect-add-and-replacement-rule | addEffect / getEffect / getEffects / removeEffect and the amplifier-first replacement rule | modelled | including the duration half of the rule, compared against the duration remaining as observed |
| add-effect-argument-bounds | addEffect's argument bounds | modelled | amplifier 0…255, duration 1…20000000, ArgumentOutOfBoundsError outside either, nothing clamped, both message shapes reproduced |
| add-effect-non-integer-arguments | addEffect's non-integer arguments | modelled | truncated toward zero, then bounds-checked — so duration 0.5 is refused |
| add-effect-nan-and-infinity | addEffect on NaN or Infinity | divergence | the engine refuses these with a TypeError ahead of the bounds check; the fake does not reproduce that error's shape |
| display-name-amplifier-mapping | the display name's amplifier mapping | modelled | bare base at amplifier 0, base plus the Roman numeral of amplifier + 1 at 1–5, bare base again from 6 to 255 — reproduced for all 37 vanilla types across the whole accepted amplifier range |
| effect-duration-decay | effect duration decay | modelled | one per tick the test advances, the observed rate, applied ahead of that tick's callbacks; nothing decays unless the test advances |
| effect-duration-expiry-boundary | what the engine does when a duration reaches zero | modelled | the effect is removed on the tick its decaying duration would reach 0, which is the boundary the engine was measured at: 0 is never readable, the last tick it reads is 1, getEffect and getEffects agree, and a handle captured beforehand answers as a removed effect's does. Nothing is dispatched on the way — 2.8.0 declares no effect-remove or effect-expire signal |
| vanilla-effect-display-names | Effect.displayName for the 37 vanilla types | modelled | resolves with no test setup, from verbatim shipped base names and the computed numeral |
| effect-display-name-locale | Effect.displayName in a locale other than the observed one | divergence | the shipped bases are the strings one server returned, and the API documents only a "player-friendly name" with no locale contract; until a second locale is observed the table is that locale's, and a test needing another registers its own bases |
| custom-effect-display-name | Effect.displayName for a custom effect type | divergence | no base is shipped, so an unregistered custom type throws UnsetValueError where the engine would answer with whatever its own data holds |
| signal-subscription | signal existence, subscribe / unsubscribe, reference dedupe and subscription order | modelled | |
| filtered-subscription | a filtered subscription — an options argument to subscribe | modelled | on the five signals the fakes raise that declare an options type, every field that type carries filters as observed: entities by instance, entityTypes against the subject entity's prefixed typeId, allowedDamageCauses, and entityFilter through the entity-lookup matcher, intersecting where two are given. A bare entityTypes id matches nothing, as the engine matches nothing for one. A field a signal's options type does not carry, and any options argument on a signal the fakes never raise, throws NotImplementedError naming the field at the subscribe call |
| after-event-dispatch-timing | after-event dispatch timing | divergence | synchronous, inside the causing call; the engine defers past that call's return to later in the same tick |
| unraised-engine-signals | engine-raised signals outside the five after-events and three before-events the fakes raise | not modelled | no fake behaviour raises them; a test drives one with emit |
| before-event-cancellation | before-event cancellation | modelled | on the two signals whose payload declares cancel |
| cancelled-call-return-value | what a cancelled call returns | modelled | addEffect undefined, applyDamage true — the engine's own per-surface values, quirk included |
| before-event-payload-writes | before-event mutable payload fields | divergence | writes to entityHurt.damage and effectAdd.duration are honoured, the duration write down its own validation path as the engine takes it — truncated toward zero, dropping the add entirely at or below zero, clamped to 20000000 above the maximum, and refused by the setter itself on NaN and Infinity. The other four declared mutable fields are writable but unread, which is the library's own: the fake raises no action that would consume them, so a write to weatherChange.duration changes nothing |
| throwing-subscriber | a subscriber that throws | divergence | isolated as the engine isolates it, but the absorbed error is recorded for getHandlerErrors where the engine discards it |
| tick-loop | the tick loop | divergence | nothing runs on its own; currentTick starts at 0 and moves only under advanceTicks
