SSAL.Testing 1.0.0

dotnet add package SSAL.Testing --version 1.0.0
                    
NuGet\Install-Package SSAL.Testing -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SSAL.Testing" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SSAL.Testing" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="SSAL.Testing" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SSAL.Testing --version 1.0.0
                    
#r "nuget: SSAL.Testing, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SSAL.Testing@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SSAL.Testing&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=SSAL.Testing&version=1.0.0
                    
Install as a Cake Tool

SSAL — s&box Shared API Layer

Status: Release · Revision 1.0.0

A versioned, interface-driven C# library that gives addon creators and gamemode developers a single shared contract to target, instead of depending on each other.


Table of Contents


Overview

s&box encourages a modular ecosystem. A user running a survival gamemode might simultaneously equip addons for a custom inventory UI, a weather system, and a voice-chat overlay, all authored by different people, all executing inside the same C# scripting sandbox.

Without a formal contract, these packages discover each other through reflection, dynamic casts, and copy-pasted stubs. The result is silent runtime failures, versioning nightmares, and a fragmented ecosystem.

SSAL solves this with three focused packages that define the vocabulary for cross-boundary communication: types, interfaces, events, and service locators that turn ad-hoc calls into well-typed, discoverable, forward-compatible contracts.


Why This Exists

Without SAL With SAL
Type.GetMethod("GetInventory").Invoke(...) ctx.GetService<IInventory>()
Static events tied to a specific gamemode class ctx.Events.Subscribe<PlayerDiedEvent>(...)
Silent NullReferenceException at runtime Compile-time error via Roslyn analyzer
No way to know if a gamemode supports a feature gamemode.json declares "provides": [...]
Reflection breaks on every gamemode update Interfaces are versioned with [ApiVersion]

Package Structure

SSAL
├── SSAL.Contracts   ← Both addons AND gamemodes reference this
├── SSAL.Core        ← Gamemodes only (ServiceRegistry, EventBus, BootstrapHost)
└── SSAL.Testing     ← Test projects only (FakeGameContext, FakeInventory, ...)

Dependency rule: Addons depend on Contracts only. Gamemodes depend on Contracts + Core. This direction is never reversed and is enforced by the Roslyn analyzer (SAL001).

  s&box Runtime / Sandbox.*
          │
  SSAL.Core        ← Gamemode layer
  ServiceRegistry | EventBus | BootstrapHost
          │  implements / references
  SSAL.Contracts   ← Shared surface
  IGameContext | IInventory | IPlayerState | IEventChannel
       │                              │
  Gamemode A                     Addon Pack B

Quick Start

For Addon Authors

Add only the Contracts package to your .csproj:

<ItemGroup>
  
  <PackageReference Include="SSAL.Contracts" Version="1.*" />
  <PackageReference Include="SSAL.Analyzers" Version="1.*" PrivateAssets="all" />
</ItemGroup>

Implement IAddon as your entry point:

[AddonEntry]
public sealed class WeatherAddon : IAddon
{
    private IEventChannel? _events;

    public void OnLoad(IGameContext ctx)
    {
        _events = ctx.Events;

        // Optional service — gracefully degrade if not present
        var scoreboard = ctx.GetService<IScoreboard>();
        if (scoreboard is not null)
            HookScoreboardWeatherBonus(scoreboard);

        _events.Subscribe<RoundStartedEvent>(OnRoundStarted);
    }

    public void OnUnload() { /* clean up subscriptions */ }

    private void OnRoundStarted(RoundStartedEvent e)
        => SpawnWeatherForRound(e.RoundIndex);
}

Capability negotiation — never throw on a missing optional service:

public void OnLoad(IGameContext ctx)
{
    // Hard requirement
    var players = ctx.GetService<IPlayerRegistry>()
        ?? throw new AddonLoadException($"{Manifest.Name} requires IPlayerRegistry.");

    // Soft requirement — disable the feature if absent
    var teams = ctx.GetService<ITeamManager>();
    if (teams is null)
    {
        Log.Warning("Team colors disabled: ITeamManager not registered.");
        _teamColorsEnabled = false;
    }
}

For Gamemode Developers

Reference both Contracts and Core:

<ItemGroup>
  <PackageReference Include="SSAL.Contracts" Version="1.*" />
  <PackageReference Include="SSAL.Core"      Version="1.*" />
  <PackageReference Include="SSAL.Analyzers" Version="1.*" PrivateAssets="all" />
</ItemGroup>

Register your services during startup and declare capabilities in gamemode.json:

public override void OnStart()
{
    base.OnStart();

    var registry = ServiceRegistry.Create();
    registry.Register<IInventory>(() => new SurvivalInventory(maxSlots: 10));
    registry.Register<ITeamManager>(() => new TwoTeamManager());
    registry.Register<IScoreboard>(() => new SurvivalScoreboard());

    GameContextHost.Initialize(registry);
}
{
  "title": "Survival Wars",
  "sal": {
    "minVersion": "1.0",
    "provides": ["IInventory", "ITeamManager", "IScoreboard", "IPlayerRegistry"]
  }
}

Publish standard events when things happen:

private void HandlePlayerDeath(SurvivalPlayer victim, DamageInfo dmg)
{
    victim.IsAliveInternal = false;

    _context.Events.Publish(new PlayerDiedEvent
    {
        Player     = victim,
        DamageInfo = DamageInfoAdapter.FromSandbox(dmg),
        Attacker   = _playerRegistry.Find(dmg.Attacker),
        Timestamp  = DateTimeOffset.UtcNow,
    });
}

Core Interfaces

IGameContext

Root context for a running session. Resolved by addons via ServiceRegistry.Current at load time.

[ApiVersion(1, 0)]
public interface IGameContext
{
    Guid SessionId { get; }
    T? GetService<T>() where T : class;
    IEnumerable<T> GetServices<T>() where T : class;
    IEventChannel Events { get; }
    IGamemodeInfo GamemodeInfo { get; }
}

IPlayerState

Observable state of a single player — no concrete gamemode type needed.

[ApiVersion(1, 0)]
public interface IPlayerState
{
    Guid PlayerId { get; }
    string DisplayName { get; }
    bool IsAlive { get; }
    float Health { get; }
    float MaxHealth { get; }
    Vector3 Position { get; }
    ITeam? Team { get; }
    ITaggedDataBag Tags { get; }  // gamemode-specific extension data
}

IInventory

[ApiVersion(1, 0)]
public interface IInventory
{
    IReadOnlyList<IItem> Items { get; }
    int MaxSlots { get; }
    bool TryAdd(IItem item);
    bool TryRemove(Guid itemId);
    IItem? FindById(Guid itemId);
    event EventHandler<InventoryChangedEventArgs>? Changed;
}

IEventChannel

Type-safe publish/subscribe. All inter-boundary communication that isn't a direct method call goes through here.

[ApiVersion(1, 0)]
public interface IEventChannel
{
    IDisposable Subscribe<T>(Action<T> handler) where T : ISharedEvent;
    void Publish<T>(T @event) where T : ISharedEvent;
    ValueTask PublishAsync<T>(T @event, CancellationToken ct = default)
        where T : ISharedEvent;
}

ITaggedDataBag

Extension data bag for gamemode-specific values. Avoids an ever-growing IPlayerState interface.

public interface ITaggedDataBag
{
    bool HasTag(string key);
    bool TryGet<T>(string key, out T? value);
    void Set<T>(string key, T value);
    void Remove(string key);
}

// Usage in an addon:
if (playerState.Tags.TryGet<int>("stamina", out var stamina))
    RenderStaminaBar(stamina);

Standard Events

All events are defined in SSAL.Contracts. Gamemodes raise them; addons subscribe to them.

Event Description
PlayerSpawnedEvent Player (re)spawned. Carries IPlayerState snapshot.
PlayerDiedEvent Player died. Includes DamageInfo and attacker reference.
RoundStartedEvent New round started. Carries round index and metadata.
RoundEndedEvent Round concluded. Carries winning team or draw flag.
ItemPickedUpEvent Player picked up an IItem.
ItemDroppedEvent Player dropped an IItem.
ScoreChangedEvent Any team or player score changed.
GamemodePhaseChangedEvent Phase transition (e.g. Warmup → Live).
ChatMessageEvent Text chat message. Addons may inspect or suppress.

All event types implement ISharedEvent and should be immutable records.


Service Registry

The ServiceRegistry in SSAL.Core is a minimal IoC container with no runtime reflection. Three lifetime scopes are supported:

Lifetime Behaviour
Singleton One instance per session (default).
Transient New instance per GetService<T>() call.
Scoped (Player) One instance per IPlayerState — useful for per-player inventory.

Versioning

Every interface and event carries [ApiVersion(major, minor)]. The Roslyn analyzer warns at compile time when version mismatches are detected.

Change Policy
Patch 1.0.x → 1.0.y Implementation bug fixes only. No interface changes.
Minor 1.0 → 1.1 New members via C# 14 default interface methods, or new interfaces. Backward compatible.
Major 1.x → 2.0 Removed or changed members. Addons on 1.x get compile-time errors. Migration guide required.

Adding optional members without breaking existing implementors (minor bump):

// SAL 1.1 — backward compatible addition
[ApiVersion(1, 1)]
public interface IPlayerState
{
    bool IsSprinting => false;      // safe default for old gamemodes
    float StaminaPercent => 1.0f;
}

Roslyn Analyzer Rules

SSAL.Analyzers ships with the package and enforces correct usage at compile time.

ID Severity Rule
SAL001 Error Addon references Core package directly. Use Contracts only.
SAL002 Warning GetService<T>() result used without null check. Services may be absent.
SAL003 Error Concrete gamemode type referenced from addon. Use the interface.
SAL004 Warning ISharedEvent implementation has public setters. Events should be immutable records.
SAL005 Error Interface member added without bumping [ApiVersion] minor version.
SAL006 Info TaggedDataBag key is a magic string literal. Consider a constants class.

Testing

SSAL.Testing provides pre-built fakes so addon authors can unit-test without a running s&box instance.

Type Purpose
FakeGameContext In-memory IGameContext with a real EventChannel and configurable services.
FakePlayerState Mutable IPlayerState for scenario setup.
FakeInventory List-backed IInventory with event tracking.
FakeTeamManager Pre-configured with Team.Red and Team.Blue; fully mutable.
RecordingEventChannel Records all published events for assertion.
public class WeatherAddonTests
{
    [Fact]
    public void SpawnsStorm_WhenRoundStartsWithIndex3()
    {
        var ctx   = new FakeGameContext();
        var addon = new WeatherAddon();
        addon.OnLoad(ctx);

        ctx.Events.Publish(new RoundStartedEvent { RoundIndex = 3 });

        Assert.Equal(WeatherType.Storm, addon.CurrentWeather);
    }
}

Migrating from Ad-Hoc Interop

Reflection → Service Resolution

// ❌ Before
var method = Game.Current.GetType().GetMethod("GetPlayerInventory");
var inv    = method?.Invoke(Game.Current, new[] { playerId });

// ✅ After
var inv = ctx.GetService<IInventory>();

Custom Static Events → IEventChannel

// ❌ Before
SurvivalGame.OnPlayerDied += MyHandler;

// ✅ After — subscription is IDisposable, auto-cleaned on OnUnload
ctx.Events.Subscribe<PlayerDiedEvent>(MyHandler);

Roadmap

Version Scope
1.0 Core interfaces, ServiceRegistry, BootstrapHost, Roslyn analyzer (SAL001–006), Testing package, gamemode.json manifest schema.
1.1 IPhysicsInteraction, IRichPresence, stamina/sprint additions to IPlayerState via default interface methods, async event pipeline.
1.2 IRadioChannel (proximity voice contracts), IReplayRecorder hooks, SAL007 diagnostic for missing [AddonEntry].
2.0 Source-generated proxy layer, renamed event args aligned to platform conventions, dotnet-sal-migrate CLI migration tool.

Full Interface Reference

All types in SSAL.Contracts v1.0:

Type Kind Description
IGameContext Interface Root session context and service locator.
IGamemodeInfo Interface Read-only metadata about the hosting gamemode.
IAddon Interface Lifecycle contract for addon entry points.
IAddonManifest Interface Parsed .addon.json descriptor.
IPlayerState Interface Observable state of a single player.
IPlayerRegistry Interface Collection of all connected IPlayerState instances.
IInventory Interface Slot-based item container.
IItem Interface Individual item with Id, DisplayName, Tags.
ITeam Interface Identifies a team; carries color and display name.
ITeamManager Interface Manages team membership and scoring.
IScoreboard Interface Read/write scoreboard for players and teams.
IEventChannel Interface Type-safe publish/subscribe event bus.
ISharedEvent Interface Marker interface for all SAL event types.
ITaggedDataBag Interface Typed extension data dictionary.
PlayerSpawnedEvent Event Raised when any player spawns or respawns.
PlayerDiedEvent Event Raised on player death with damage context.
RoundStartedEvent Event Raised at the start of each round.
RoundEndedEvent Event Raised at the end of each round.
ItemPickedUpEvent Event Raised when a player acquires an item.
ItemDroppedEvent Event Raised when a player drops an item.
ScoreChangedEvent Event Raised when any score value changes.
GamemodePhaseChangedEvent Event Raised on phase transitions.
ChatMessageEvent Event Raised for every in-game chat message.

Contributing

This project is in draft — the interface surface is not yet frozen.

  • Open an issue to propose new interfaces or events before implementing
  • All PRs targeting Contracts require a discussion thread first (changes affect the entire ecosystem)
  • Breaking changes (major version bumps) require a migration guide in docs/migrations/
  • Run dotnet test and ensure all analyzer tests pass before opening a PR
  • Follow the dependency rule — addon-facing code must never reference Core

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 111 4/1/2026