Skip to content

Writing Scenarios

Léon Fievet edited this page Jul 6, 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.
SaveFile null Path to a prebuilt world save (.vcdbs), absolute or relative to the test assembly's directory. When set, the class boots against a copy of that save instead of generating a world, and Seed/WorldType/PlayStyle are ignored. See World fixtures below.

The defaults are deliberately fast and deterministic: superflat worldgen and a fixed seed keep boot time low and results reproducible.

[AtlasDataFiles] (assembly or class level, repeatable)

Declares files to seed into the embedded server's scratch data path before it boots, so files a mod reads during startup, most commonly api.LoadModConfig("….json") from ModConfig/ in StartServerSide, are already in place. Without seeding, that one-shot startup read returns null and the mod runs unconfigured for the whole scenario class.

Each source path (a file or a directory, resolved like mod paths: absolute or relative to the test assembly's directory) is copied into the data path before ServerMain launches. A directory's contents are copied, not the directory itself, into TargetPath; a file lands inside TargetPath under its own name. Two equivalent styles:

// Point a fixture folder at a specific data-path subfolder:
[AtlasDataFiles("fixtures/ModConfig", TargetPath = "ModConfig")]

// Or lay the fixture tree out like the data path itself and overlay it onto the root:
[AtlasDataFiles("fixtures/serverdata")]   // contains ModConfig/mymod.json, Macros/…, etc.
Property Default Meaning
SourcePaths (constructor) One or more source files or directories to copy.
TargetPath "" Directory under the data path to copy into, e.g. "ModConfig". Empty targets the data path root. Must stay inside the data path: rooted paths and .. segments that escape it fail the boot.

Assembly-level attributes apply to every scenario class; class-level attributes are copied after them, so on a file name collision the class-level seed wins. A missing source path (or a TargetPath escaping the data path) fails the boot with AtlasSetupException naming the offender. samples/SampleConfigMod plus ConfigScenarios in samples/Sample.Scenarios demonstrate the end-to-end pattern.

[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 await; runs a server command as the console and returns a CommandResult. See Command results below.
Ticks(count) Time await; waits for count server ticks.
Until(predicate, timeoutTicks = 600) Time await; polls predicate once per tick, throws ScenarioTimeoutException on timeout.
JoinPlayer(name) Action await; joins a headless test player. See Test players below.
StatsOf(entity) Query Read-only stats view over any entity. See Test players below.

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.

World fixtures (prebuilt saves)

A scenario class can boot against a prebuilt world instead of a generated one:

[AtlasWorld(SaveFile = "fixtures/castle-world.vcdbs")]
public class SiegeScenarios : AtlasScenarioBase
{
    ...
}

The save is copied into the class's own scratch data path before the server boots, so:

  • The fixture is never written to. Every test class runs against its own pristine copy; tests cannot corrupt the fixture or each other.
  • Any file name works. The copy is renamed to the engine's pinned save name internally.
  • World generation settings are ignored. Seed, WorldType and PlayStyle have no effect when a save is supplied: the savegame carries its own world configuration.
  • Version compatibility follows the engine's rules. A save from an older game version is auto-upgraded on load, exactly as a dedicated server would.
  • A missing fixture fails the boot fast with an AtlasSetupException naming the path.

To produce a fixture, either copy a save out of a normal game session, or build the world with an Atlas scenario and harvest the save its graceful teardown writes.

For a single prebuilt structure rather than a whole world, pasting a schematic through the real API inside the scenario is lighter than a world fixture:

string error = null;
var schematic = BlockSchematic.LoadFromFile(path, ref error);
schematic.Init(blockAccessor);
schematic.Place(blockAccessor, api.World, startPos);

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.

Command results

ExecuteCommand runs a server command as the console (admin role, every privilege) and returns the command's outcome, so a scenario can assert directly on what a command did:

CommandResult result = await World.ExecuteCommand("/time set day");
Assert.True(result.Ok, result.Message);

CommandResult carries:

Member Notes
Ok Whether the command completed successfully.
Message The status message, already resolved through the game's localization. Failures without an engine message (e.g. an unknown command) get a synthesized one, so Assert.True(result.Ok, result.Message) always names the failure.
Raw The engine's raw TextCommandResult: ErrorCode, Data, the unresolved message and its parameters.

Behavior worth knowing:

  • The command text must include the leading slash; a slashless command throws ArgumentException (the engine's dispatch strips the first character unconditionally, so it would otherwise be silently misparsed).
  • An unknown command does not throw: it completes with Ok = false and Raw.ErrorCode == "nosuchcommand", so scenarios can assert on intentional failures.
  • Commands whose argument parsing goes async (e.g. player lookups) complete on a later tick; the returned task follows them to their final result.

This replaces the SaveGame side channel earlier versions needed for driving a fixture mod through commands: register a command in the fixture mod, return a TextCommandResult from its handler, and assert on it from the scenario.

Test players and entity stats

IWorldSession.JoinPlayer(name) joins a headless test player into the world: no rendering, no real network. It works over the same in-memory dummy-network mechanism the game's own singleplayer client uses to talk to its local server (a hand-wired dummy TCP/UDP socket pair, carrying a single identification packet), which the server recognizes as a local connection and admits without auth - exactly like real singleplayer. The result is a real, world-present EntityPlayer with inventory, health, and every other behavior an ordinary connected player has.

Several players can be joined into the same world, one JoinPlayer call per distinct name; each rides its own dummy socket on the embedded server, so joined players coexist and act independently (own connection, own inventory, own entity) - enough for player-to-player interaction scenarios.

[AtlasScenario]
public async Task Player_can_receive_and_carry_an_item()
{
    ITestPlayer player = await World.JoinPlayer("Tester");

    await player.GiveItem("game:bread-spelt-perfect", 3);
    await player.TeleportTo(World.Spawn.Offset(10, 0, 10));

    Assert.True(player.Stats.Health > 0);
    Assert.Equal(3, player.Player.InventoryManager.ActiveHotbarSlot.Itemstack.StackSize);
}

ITestPlayer members (all run on the game thread):

Member Kind Notes
Entity Escape hatch The live EntityPlayer.
Player Escape hatch The live IServerPlayer.
IsConnected Query false once the server has dropped the player (kick, ban). See below.
Position Query Current position as a BlockPos.
Stats Query IEntityStats view (see below).
GiveItem(itemOrBlockCode, quantity = 1) Action Resolves an item or block code and places the stack directly into the active hotbar slot.
TeleportTo(pos) Action Dimension-aware: calls EntityPlayer.ChangeDimension first if pos.dimension differs from the player's current dimension, since the engine's own TeleportTo/TeleportToDouble never read a BlockPos's dimension (the same gotcha SpawnEntity works around).

IWorldSession.StatsOf(entity) returns the same IEntityStats view for any entity, not just players - e.g. a creature spawned via SpawnEntity:

Member Notes
Health / MaxHealth Read off the entity's health watched-attribute tree. Zero if the entity has no health behavior.
Saturation Read off the hunger watched-attribute tree. Zero if the entity has no hunger behavior.
Attribute<T>(path) Generic typed read into the watched-attribute tree by path (e.g. "hunger/currentsaturation", or a top-level key like "tempStab"). Returns null/default if the path does not resolve.

Kicks and IsConnected

ITestPlayer.IsConnected is the first-class "was the player dropped by the server" signal: it turns false once the server has removed the player (a mod-under-test kicking it via IServerPlayer.Disconnect, a ban, or any other server-side removal). Test players never leave on their own, so a false value always means the server ended the connection.

One caveat: kicks issued from a background thread (a common mod pattern, e.g. after an HTTP check inside a PlayerJoin handler) settle a few ticks late. The off-thread kick crashes the engine's own teardown halfway, and Atlas finishes that teardown on the game thread a couple of ticks later; IsConnected reports the settled truth, not the in-flight state. So wait for the state instead of asserting right after the kick:

await world.Until(() => !player.IsConnected);

Once the drop has settled, the kicked player's socket slot and name claim are released, so the scenario can rejoin under the same name.

Player names

Two rules, both enforced with an actionable AtlasSetupException rather than a confusing engine-side symptom:

  • Names must be unique within the world. The server identifies accounts by a name-derived UID, so joining the same name twice would be treated as the same account reconnecting and kick the first player mid-scenario; Atlas rejects the duplicate up front instead. Note that the world is shared by every scenario in a class (see World lifecycle above), so a player joined by an earlier scenario still counts: reuse the ITestPlayer it got back (share it via a field), pick another name, or isolate the scenario with [AtlasScenario(FreshWorld = true)].
  • Names must satisfy the engine's rule: letters, digits, underscores and dashes only, 16 characters at most. The engine rejects anything else at the identification step, which surfaces as a join timeout; the diagnosis names this rule as the most likely cause.

Real network clients (as opposed to in-memory headless players) remain a separate, larger scope, out of Atlas's current roadmap.

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