-
Notifications
You must be signed in to change notification settings - Fork 1
Writing Scenarios
Assembly-level. Declares the mod(s) staged for every scenario class in the assembly. Paths
are resolved relative to the test assembly's own output directory, not the source tree.
Accepts a folder, a .zip, or a .dll, exactly what the game's own mod loader accepts. See
Mod Staging for the full resolution rules and the MSBuild ProjectReference sugar.
Standard xUnit attribute, required in every Atlas test assembly. Atlas hosts at most one
live server per process; scenario classes must run sequentially. HostRegistry enforces
this at runtime rather than relying on it silently: requesting a second host concurrently
throws AtlasSetupException. See Troubleshooting.
Declares the world configuration a scenario class runs against. All properties are optional:
| Property | Default | Meaning |
|---|---|---|
Seed |
424242 |
World seed. Identical seeds produce identical worlds. |
WorldType |
"superflat" |
Type of world to create. |
PlayStyle |
"creativebuilding" |
Play style for the world. |
Mods |
[] |
Extra mod paths for this class, appended after the assembly-level ones. |
The defaults are deliberately fast and deterministic: superflat worldgen and a fixed seed keep boot time low and results reproducible.
Marks an async Task method as a scenario, discovered like [Fact] and run on the embedded
server's game thread.
| Property | Default | Meaning |
|---|---|---|
FreshWorld |
false |
If true, the class host is recycled (server + world reboot) before this scenario runs, instead of reusing the world shared by the rest of the class. |
TimeoutMs |
60000 |
Maximum wall-clock time, in milliseconds, the scenario may run before an off-thread watchdog fails it and marks the class host dead. |
TimeoutMs deliberately does not reuse xUnit's own [Fact(Timeout = ...)]: xUnit posts its
timeout continuation back through SynchronizationContext.Current, which for an Atlas
scenario is the game thread's own queue. If the game thread itself is the thing that is
stuck, that continuation never drains and the test hangs forever instead of failing. Atlas
enforces TimeoutMs from an independent watchdog thread instead.
AtlasScenarioBase.World (an IWorldSession) is the entry point. Every member runs on the
game thread.
| Member | Kind | Notes |
|---|---|---|
Api |
Escape hatch | Raw ICoreServerAPI. See below. |
Spawn |
Query | Default spawn position, resolved to terrain height. |
Calendar |
Query | The world's game calendar. |
BlockAt(pos) |
Query | Block at a BlockPos. |
BlockEntityAt<T>(pos) |
Query | Block entity at a position, cast to T, or null. |
EntitiesIn(Cuboidi area) |
Query | Entities in an area, dimension 0 (kept for back-compat). |
EntitiesIn(WorldArea area) |
Query | Dimension-aware entity query. See Dimensions below. |
SetBlock(blockCode, pos) |
Action | Sets a block by asset location code. |
SpawnEntity(entityCode, pos) |
Action | Spawns an entity by asset location code, in pos's dimension. |
ExecuteCommand(command) |
Action | Runs a server console command, e.g. "/time set day". |
Ticks(count) |
Time |
await; waits for count server ticks. |
Until(predicate, timeoutTicks = 600) |
Time |
await; polls predicate once per tick, throws ScenarioTimeoutException on timeout. |
Helpers on BlockPos (via BlockPosExtensions): Offset(dx, dy, dz) and Area(radius) for
building positions and areas around a reference point.
Scenario code advances the world explicitly; nothing ticks on a wall-clock timer behind your back except the server itself.
-
await World.Ticks(count): waits forcountserver ticks to elapse. -
await World.Until(predicate, timeoutTicks = 600): pollspredicateonce per tick until it returns true, or throwsScenarioTimeoutException(carrying the number of ticks waited) oncetimeoutTickselapses.
Both are tick-based, not wall-clock: they only make progress while the server is actually
ticking. This is different from TimeoutMs on [AtlasScenario], which is a wall-clock
watchdog running on an independent thread, specifically to catch the case where ticking
itself has stalled (host-dead semantics): once the watchdog fires, the scenario fails with
ScenarioTimeoutException and HostRegistry marks the class host dead, so every later
scenario in that class fails fast instead of trying to reuse a host whose game thread may
still be running the abandoned scenario.
- One xUnit class fixture equals one server, one fresh world, one scratch data path, shared by every scenario in that class. World state persists between scenarios of the same class unless you opt out.
- Use
[AtlasScenario(FreshWorld = true)]for a scenario that pollutes world state heavily (large builds, many spawned entities) and needs a clean slate rather than inheriting whatever earlier scenarios in the class left behind. - Cross-class isolation is total: every test class gets its own server, world, and scratch path.
Every member of IWorldSession runs on the game thread. The xUnit adapter posts your
scenario delegate onto a SynchronizationContext installed on that thread, and every await
continuation inside the scenario body returns to that same queue by default. This is what
makes direct, unsynchronized calls into the Vintage Story API safe from scenario code.
Never call ConfigureAwait(false) inside a scenario body. Doing so detaches the
continuation from the game thread's queue and hands it to the .NET thread pool instead, which
breaks the thread-pinning guarantee: subsequent game API calls in that scenario would run
off-thread, racing the server's own pump. This is a contract, not something Atlas detects or
enforces at runtime, so it is on you as the scenario author to avoid it.
Vintage Story stores dimensions as stacked Y-offset slices of a single flat world: a
BlockPos carries a dimension field, and box queries against the engine are
dimension-correct as long as both corners of the box carry that same dimension.
-
WorldAreais aCuboidipaired with the dimension it lives in (record struct WorldArea(Cuboidi Bounds, int Dimension)), with an implicit conversion back toCuboidifor call sites that only need the bounds. -
EntitiesIn(WorldArea area)is the dimension-aware query surface.BlockPos.Area(radius)returns aWorldAreathat inherits the source position's dimension, soworld.EntitiesIn(pos.Area(5))queries the same dimensionposis in. -
EntitiesIn(Cuboidi area)is kept for back-compat and is documented as dimension 0; it is implemented asEntitiesIn(new WorldArea(area, dimension: 0)). -
SpawnEntity(entityCode, pos)spawns the entity inpos's dimension. This required an explicit fix: the underlying engine'sEntityPos.SetPos(BlockPos)copies X/Y/Z only and does not read the sourceBlockPos's dimension, so Atlas setsentity.Pos.Dimensionfrompos.dimensionitself afterSetPos.
Full custom-dimension end-to-end coverage (spawning a second dimension and asserting queries stay isolated to it) is out of scope here: it requires a dimension-creating mod, and is validated when a real consumer (Manifold) adopts this surface.
Atlas ships no custom assertion layer. Use standard xUnit Assert against whatever the query
surface returns.
World.Api exposes the raw ICoreServerAPI the embedded server is running. The query and
action surface above is deliberately small (YAGNI): anything Atlas does not model yet, reach
through Api directly, on the game thread, exactly like the rest of IWorldSession. Every
member of IWorldSession, including Api, is documented as running on the game thread; the
same rule applies to whatever you do through Api.
- Fixed seed plus superflat worldgen produces bit-identical worlds across runs, verified empirically during the feasibility spike.
- Tick ordering and resulting world state are reproducible because everything runs on a
single game thread. Per-tick wall-clock timing inside a single
Process()call is not bit-exact (it depends onConfig.TickTimeand per-system update intervals), so "wait N ticks" is reliable but "exactly N engine ticks of subsystem X fired in this window" is not guaranteed.