Skip to content

Writing Scenarios

Fievetl edited this page Jul 4, 2026 · 10 revisions

Writing Scenarios

Attribute reference

[assembly: AtlasMods(params string[] paths)]

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.

[assembly: CollectionBehavior(DisableTestParallelization = true)]

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.

[AtlasWorld] (class level)

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.

[AtlasScenario] (method level)

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.

IWorldSession surface

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.

Time model

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 for count server ticks to elapse.
  • await World.Until(predicate, timeoutTicks = 600): polls predicate once per tick until it returns true, or throws ScenarioTimeoutException (carrying the number of ticks waited) once timeoutTicks elapses.

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.

World lifecycle and isolation

  • 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.

The game-thread contract

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.

Dimensions

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.

  • WorldArea is a Cuboidi paired with the dimension it lives in (record struct WorldArea(Cuboidi Bounds, int Dimension)), with an implicit conversion back to Cuboidi for call sites that only need the bounds.
  • EntitiesIn(WorldArea area) is the dimension-aware query surface. BlockPos.Area(radius) returns a WorldArea that inherits the source position's dimension, so world.EntitiesIn(pos.Area(5)) queries the same dimension pos is in.
  • EntitiesIn(Cuboidi area) is kept for back-compat and is documented as dimension 0; it is implemented as EntitiesIn(new WorldArea(area, dimension: 0)).
  • SpawnEntity(entityCode, pos) spawns the entity in pos's dimension. This required an explicit fix: the underlying engine's EntityPos.SetPos(BlockPos) copies X/Y/Z only and does not read the source BlockPos's dimension, so Atlas sets entity.Pos.Dimension from pos.dimension itself after SetPos.

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.

Asserts

Atlas ships no custom assertion layer. Use standard xUnit Assert against whatever the query surface returns.

The Api escape hatch

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.

Determinism notes

  • 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 on Config.TickTime and 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.

Clone this wiki locally