-
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. |
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.
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.
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 |
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.
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.
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,WorldTypeandPlayStylehave 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
AtlasSetupExceptionnaming 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);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.
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 = falseandRaw.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.
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. |
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.
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
ITestPlayerit 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.
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.