---
title: "VehicleFleetSimulator source"
url: "https://nsta1.github.io/Orleans.Lattice/samples/VehicleFleetSimulator/source.html"
source: "https://github.com/NSTA1/Orleans.Lattice/tree/release/9.9/samples/VehicleFleetSimulator"
documents: "Orleans.Lattice 9.9.0 (release line 9.9)"
built: "2026-10-04"
all-pages: "https://nsta1.github.io/Orleans.Lattice/llms.txt"
---
# VehicleFleetSimulator source

Part of [Vehicle Fleet Simulator](README.md).

The source of the [VehicleFleetSimulator](https://github.com/NSTA1/Orleans.Lattice/tree/release/9.9/samples/VehicleFleetSimulator) sample.

## src/VehicleFleetSimulator.Api/Program.cs

````csharp
using System.Collections.Immutable;
using Azure.Data.Tables;
using Microsoft.AspNetCore.Mvc;
using Orleans.Configuration;
using VehicleFleetSimulator.Abstractions;
using VehicleFleetSimulator.Api.Services;
using VehicleFleetSimulator.Api.Streams;

var builder = WebApplication.CreateBuilder(args);

var clusterConnection = builder.Configuration["Persistence:ConnectionString"]
    ?? builder.Configuration["Orleans:ClusterConnectionString"]
    ?? "UseDevelopmentStorage=true";

builder.Host.UseOrleansClient(client =>
{
    client.UseAzureStorageClustering(options =>
    {
        options.TableServiceClient = new TableServiceClient(clusterConnection);
    });
    client.Configure<ClusterOptions>(opts =>
    {
        opts.ClusterId = builder.Configuration["Orleans:ClusterId"] ?? "vfs-dev";
        opts.ServiceId = builder.Configuration["Orleans:ServiceId"] ?? "VehicleFleetSimulator";
    });
});

builder.Services.AddProblemDetails();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new() { Title = "Vehicle Fleet Simulator API", Version = "v1" });
});

// Milestone 6: gRPC stream consumer surface.
builder.Services.AddSingleton<IFleetStreamHub, FleetStreamHub>();
builder.Services.AddHostedService<TelemetryFanOutService>();
builder.Services.AddSingleton<RecordingService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<RecordingService>());
builder.Services.AddSingleton<SimulationEventBroadcaster>();
builder.Services.AddGrpc(options =>
{
    options.Interceptors.Add<ApiKeyInterceptor>();
});
builder.Services.AddGrpcReflection();
builder.Services.AddSingleton<ApiKeyInterceptor>();
builder.Services.AddSingleton<ApiKeyEndpointFilter>();

// Browser clients (Blazor WASM UI) need CORS + gRPC-Web. The exposed headers are required so
// trailers and the dropped-count signal survive the gRPC-Web translation layer.
const string BrowserCorsPolicy = "BrowserClients";
builder.Services.AddCors(options =>
{
    options.AddPolicy(BrowserCorsPolicy, policy =>
    {
        policy
            .SetIsOriginAllowed(_ => true)
            .AllowAnyHeader()
            .AllowAnyMethod()
            .WithExposedHeaders("Grpc-Status", "Grpc-Message", "Grpc-Encoding", "Grpc-Accept-Encoding", "dropped-count");
    });
});

var app = builder.Build();

app.UseStatusCodePages();
app.UseExceptionHandler();
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Vehicle Fleet Simulator API v1"));

app.UseCors(BrowserCorsPolicy);
app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true });

app.MapGrpcService<FleetStreamService>().EnableGrpcWeb().RequireCors(BrowserCorsPolicy);
if (app.Environment.IsDevelopment())
{
    app.MapGrpcReflectionService();
}

app.MapGet("/", () => Results.Redirect("/swagger")).ExcludeFromDescription();

// Milestone 0: round-trip ping through the Orleans cluster.
app.MapGet("/api/ping/{key}", async (string key, IGrainFactory grains, string? message) =>
{
    var grain = grains.GetGrain<IPingGrain>(key);
    var reply = await grain.Ping(message ?? "hello");
    return Results.Ok(new { key, reply });
}).WithTags("Diagnostics");

static IFleetGrain Fleet(IGrainFactory grains) => grains.GetGrain<IFleetGrain>(IFleetGrain.Key);
static ICityGraphGrain CityGraph(IGrainFactory grains) => grains.GetGrain<ICityGraphGrain>(ICityGraphGrain.Key);
static ISimulationConfigGrain SimConfig(IGrainFactory grains) => grains.GetGrain<ISimulationConfigGrain>(ISimulationConfigGrain.Key);

static ProblemDetails BadRequest(string? detail) => new()
{
    Status = StatusCodes.Status400BadRequest,
    Title = "Invalid request",
    Detail = detail,
};

static ProblemDetails Conflict(string? detail) => new()
{
    Status = StatusCodes.Status409Conflict,
    Title = "Conflict",
    Detail = detail,
};

// All /api/* endpoints sit behind an API-key endpoint filter that mirrors the gRPC
// ApiKeyInterceptor (no-op when Auth:ApiKey is unconfigured, e.g. local dev). The /api/ping/{key}
// liveness probe above is intentionally left outside this group so health checks aren't gated on
// credentials.
var api = app.MapGroup("/api").AddEndpointFilter<ApiKeyEndpointFilter>();

// ─── Vehicles ────────────────────────────────────────────────────────────────

var vehicles = api.MapGroup("/vehicles").WithTags("Vehicles");

vehicles.MapPost("/", async (VehicleSpec spec, IGrainFactory grains) =>
{
    if (spec is null)
        return Results.Problem(BadRequest("Body is required."));

    if (spec.Route is { } r && !r.IsDefault && r.Length > 0)
    {
        if (r.Length < 2)
            return Results.Problem(BadRequest("Route must contain at least two cities."));
        var graph = await CityGraph(grains).GetGraph();
        if (!IsValidRoute(graph, r, out var routeError))
            return Results.Problem(BadRequest(routeError));
    }

    if (spec.Config is { } cfg && !TryValidateVehicleConfig(cfg, out var cfgError))
        return Results.Problem(BadRequest(cfgError));

    try
    {
        var id = await Fleet(grains).AddVehicle(spec);
        return Results.Created($"/api/vehicles/{id}", new { vehicleId = id });
    }
    catch (InvalidOperationException ex)
    {
        return Results.Problem(Conflict(ex.Message));
    }
    catch (ArgumentException ex)
    {
        return Results.Problem(BadRequest(ex.Message));
    }
});

vehicles.MapPost("/batch", async (VehicleSpec[] specs, IGrainFactory grains) =>
{
    if (specs is null || specs.Length == 0)
        return Results.Problem(BadRequest("Batch must contain at least one spec."));

    var graph = await CityGraph(grains).GetGraph();
    foreach (var spec in specs)
    {
        if (spec.Route is { } r && !r.IsDefault && r.Length > 0)
        {
            if (r.Length < 2)
                return Results.Problem(BadRequest("Invalid route in batch: must contain at least two cities."));
            if (!IsValidRoute(graph, r, out var routeError))
                return Results.Problem(BadRequest($"Invalid route in batch: {routeError}"));
        }
        if (spec.Config is { } cfg && !TryValidateVehicleConfig(cfg, out var cfgError))
            return Results.Problem(BadRequest($"Invalid config in batch: {cfgError}"));
    }

    try
    {
        var ids = await Fleet(grains).AddVehicleBatch(specs);
        return Results.Ok(new { count = ids.Count, vehicleIds = ids });
    }
    catch (InvalidOperationException ex)
    {
        return Results.Problem(Conflict(ex.Message));
    }
});

vehicles.MapGet("/", async (
    IGrainFactory grains,
    [FromQuery] VehicleStatus? status,
    [FromQuery] string? routeContains,
    [FromQuery] int? skip,
    [FromQuery] int? take) =>
{
    var ids = await Fleet(grains).ListVehicles();
    var snapshots = await Task.WhenAll(
        ids.Select(id => grains.GetGrain<IVehicleGrain>(id).GetSnapshot().AsTask()));

    IEnumerable<VehicleSnapshot> filtered = snapshots.OfType<VehicleSnapshot>();
    if (status is { } s)
        filtered = filtered.Where(v => v.Status == s);
    if (!string.IsNullOrWhiteSpace(routeContains))
        filtered = filtered.Where(v => v.Route.Contains(routeContains, StringComparer.OrdinalIgnoreCase));

    var list = filtered.ToArray();
    var total = list.Length;
    var skipN = Math.Max(0, skip ?? 0);
    var takeN = Math.Clamp(take ?? 100, 1, 1000);
    var page = list.Skip(skipN).Take(takeN).ToArray();

    return Results.Ok(new { total, skip = skipN, take = takeN, count = page.Length, vehicles = page });
});

vehicles.MapGet("/{id:guid}", async (Guid id, IGrainFactory grains) =>
{
    var snapshot = await grains.GetGrain<IVehicleGrain>(id).GetSnapshot();
    return snapshot is null ? Results.NotFound() : Results.Ok(snapshot);
});

vehicles.MapDelete("/{id:guid}", async (Guid id, IGrainFactory grains) =>
{
    var removed = await Fleet(grains).RemoveVehicle(id);
    return removed ? Results.NoContent() : Results.NotFound();
});

vehicles.MapDelete("/", async (IGrainFactory grains) =>
{
    // Snapshot the roster, clear it (cheap grain call), then fan out per-vehicle Stop() calls
    // from the API host with bounded concurrency. Blocking here lets the caller observe true
    // completion without bumping into Orleans' default per-grain-call response timeout.
    var ids = await Fleet(grains).ListVehicles();
    await Fleet(grains).RemoveAllVehicles();

    if (ids.Count == 0)
        return Results.Ok(new { removed = 0, stopped = 0 });

    using var throttle = new SemaphoreSlim(32);
    int stopped = 0;
    var tasks = ids.Select(async id =>
    {
        await throttle.WaitAsync();
        try
        {
            await grains.GetGrain<IVehicleGrain>(id).Clear();
            Interlocked.Increment(ref stopped);
        }
        catch
        {
            // Grain may already be deactivated or unreachable; the roster is already cleared.
        }
        finally
        {
            throttle.Release();
        }
    });
    await Task.WhenAll(tasks);

    return Results.Ok(new { removed = ids.Count, stopped });
});

vehicles.MapPost("/{id:guid}/route", async (Guid id, AssignRouteRequest body, IGrainFactory grains) =>
{
    if (body is null || body.Route.IsDefault || body.Route.Length < 2)
        return Results.Problem(BadRequest("Route must contain at least two cities."));

    var graph = await CityGraph(grains).GetGraph();
    if (!IsValidRoute(graph, body.Route, out var routeError))
        return Results.Problem(BadRequest(routeError));

    var snapshot = await grains.GetGrain<IVehicleGrain>(id).GetSnapshot();
    if (snapshot is null) return Results.NotFound();

    try
    {
        await grains.GetGrain<IVehicleGrain>(id).SetRoute(body.Route);
        return Results.NoContent();
    }
    catch (ArgumentException ex)
    {
        return Results.Problem(BadRequest(ex.Message));
    }
    catch (InvalidOperationException ex)
    {
        return Results.Problem(Conflict(ex.Message));
    }
});

vehicles.MapPost("/{id:guid}/start", async (Guid id, IGrainFactory grains) =>
{
    try
    {
        await grains.GetGrain<IVehicleGrain>(id).Start();
        return Results.NoContent();
    }
    catch (InvalidOperationException ex)
    {
        return Results.Problem(Conflict(ex.Message));
    }
});

vehicles.MapPost("/{id:guid}/stop", async (Guid id, IGrainFactory grains) =>
{
    await grains.GetGrain<IVehicleGrain>(id).Stop();
    return Results.NoContent();
});

// ─── Cities ──────────────────────────────────────────────────────────────────

var cities = api.MapGroup("/cities").WithTags("Cities");

cities.MapGet("/", async (IGrainFactory grains) =>
{
    var graph = await CityGraph(grains).GetGraph();
    return Results.Ok(new { cities = graph.Cities, edges = graph.Edges, positionOverrides = graph.PositionOverrides });
});

cities.MapPost("/{id}/position", async (string id, CityPosition body, IGrainFactory grains, SimulationEventBroadcaster bus) =>
{
    if (body is null) return Results.Problem(BadRequest("Body is required."));
    if (!double.IsFinite(body.X) || !double.IsFinite(body.Y))
        return Results.Problem(BadRequest("X and Y must be finite numbers."));
    var ok = await CityGraph(grains).SetCityPosition(id, body.X, body.Y);
    if (!ok) return Results.NotFound();
    bus.PublishCityMoved(id, body.X, body.Y);
    return Results.NoContent();
});

cities.MapDelete("/positions", async (IGrainFactory grains) =>
{
    await CityGraph(grains).ClearCityPositions();
    return Results.NoContent();
});

// ─── Fleet ───────────────────────────────────────────────────────────────────

var fleet = api.MapGroup("/fleet").WithTags("Fleet");

fleet.MapGet("/stats", async (IGrainFactory grains) =>
{
    var stats = await Fleet(grains).GetFleetStats();
    return Results.Ok(stats);
});

// ─── Bulk fleet operations (operate on every persisted vehicle) ──────────────

vehicles.MapPost("/start-all", async (IGrainFactory grains) =>
{
    var started = await Fleet(grains).StartAllVehicles();
    return Results.Ok(new { started });
});

vehicles.MapPost("/stop-all", async (IGrainFactory grains) =>
{
    var stopped = await Fleet(grains).StopAllVehicles();
    return Results.Ok(new { stopped });
});

vehicles.MapPost("/{id:guid}/fault", async (Guid id, FaultRequest body, IGrainFactory grains) =>
{
    if (body is null) return Results.Problem(BadRequest("Body is required."));
    if (!Enum.IsDefined(body.Fault)) return Results.Problem(BadRequest($"Unknown fault kind '{body.Fault}'."));
    var snapshot = await grains.GetGrain<IVehicleGrain>(id).GetSnapshot();
    if (snapshot is null) return Results.NotFound();
    var applied = await grains.GetGrain<IVehicleGrain>(id).InjectFault(body.Fault);
    return applied ? Results.Ok(new { fault = body.Fault.ToString() }) : Results.Problem(Conflict("Fault could not be applied."));
});

// ─── Simulation control (pause / resume) + config ────────────────────────────

var sim = api.MapGroup("/simulation").WithTags("Simulation");

sim.MapPost("/pause", async (IGrainFactory grains, SimulationEventBroadcaster bus) =>
{
    var cfg = await SimConfig(grains).UpdateConfig(new SimulationConfigPatch(IsPaused: true));
    bus.PublishConfigChanged(cfg);
    return Results.Ok(cfg);
});

sim.MapPost("/resume", async (IGrainFactory grains, SimulationEventBroadcaster bus) =>
{
    var cfg = await SimConfig(grains).UpdateConfig(new SimulationConfigPatch(IsPaused: false));
    bus.PublishConfigChanged(cfg);
    return Results.Ok(cfg);
});

// SSE feed of small "things changed" pings -- the UI uses this to live-refresh the Control
// flyout without polling. Intentionally kept as a single feed (config + city moves) because the
// payload is tiny and clients don't have to fan-out subscribe; if richer typed events are
// needed in the future, switch each line to a typed `event:` field instead of just `data:`.
api.MapGet("/events/stream", async (HttpContext http, SimulationEventBroadcaster bus, CancellationToken ct) =>
{
    http.Response.Headers.ContentType = "text/event-stream";
    http.Response.Headers.CacheControl = "no-cache";
    http.Response.Headers["X-Accel-Buffering"] = "no";
    await bus.WriteToAsync(http.Response, ct);
}).WithTags("Simulation").ExcludeFromDescription();

// ─── Scenario presets ────────────────────────────────────────────────────────

var scenarios = api.MapGroup("/scenarios").WithTags("Scenarios");

scenarios.MapGet("/", () => Results.Ok(new { scenarios = ScenarioCatalog.All }));

scenarios.MapPost("/{name}", async (string name, IGrainFactory grains) =>
{
    if (!ScenarioCatalog.TryGet(name, out var preset))
        return Results.NotFound();

    if (preset.StartCityId is { } start)
    {
        var graph = await CityGraph(grains).GetGraph();
        if (!graph.Cities.Any(c => string.Equals(c.Id, start, StringComparison.OrdinalIgnoreCase)))
            return Results.Problem(BadRequest($"Scenario '{name}' references unknown start city '{start}'."));
    }

    if (preset.ResetFleetFirst)
    {
        var existingIds = await Fleet(grains).ListVehicles();
        await Fleet(grains).RemoveAllVehicles();
        // Best-effort fan-out clear so grain timers stop promptly.
        using var throttle = new SemaphoreSlim(32);
        await Task.WhenAll(existingIds.Select(async id =>
        {
            await throttle.WaitAsync();
            try { await grains.GetGrain<IVehicleGrain>(id).Clear(); }
            catch { /* roster already cleared */ }
            finally { throttle.Release(); }
        }));
    }

    var specs = new VehicleSpec[preset.VehicleCount];
    for (int i = 0; i < specs.Length; i++)
        specs[i] = new VehicleSpec(VehicleId: null, StartCityId: preset.StartCityId);
    var ids = await Fleet(grains).AddVehicleBatch(specs);
    return Results.Ok(new { name = preset.Name, count = ids.Count, vehicleIds = ids });
});

// ─── Diagnostics: per-shard fan-out observer counts ──────────────────────────

api.MapGet("/diagnostics/fanout", async (IGrainFactory grains) =>
{
    var shards = new List<object>(StreamConstants.TelemetryAllShardCount);
    for (var i = 0; i < StreamConstants.TelemetryAllShardCount; i++)
    {
        var grain = grains.GetGrain<IFleetFanOutGrain>(IFleetFanOutGrain.ShardKey(i));
        var diag = await grain.GetDiagnostics();
        shards.Add(new { shard = i, observerCount = diag.ObserverCount, publishedCount = diag.PublishedCount });
    }
    var eventsGrain = grains.GetGrain<IFleetFanOutGrain>(IFleetFanOutGrain.EventsKey());
    var eventsDiag = await eventsGrain.GetDiagnostics();
    return Results.Ok(new
    {
        telemetryShards = shards,
        eventsActivation = new { observerCount = eventsDiag.ObserverCount, publishedCount = eventsDiag.PublishedCount },
    });
}).WithTags("Diagnostics");

// ─── Recording ───────────────────────────────────────────────────────────────

var recording = api.MapGroup("/recording").WithTags("Recording");

recording.MapPost("/start", (RecordingService rec, [FromQuery] int? capacity) =>
{
    var id = rec.Start(capacity ?? 100_000);
    return Results.Ok(new { id, capacity = capacity ?? 100_000 });
});

recording.MapPost("/{id:guid}/stop", (Guid id, RecordingService rec) =>
{
    var summary = rec.Stop(id);
    return summary is null ? Results.NotFound() : Results.Ok(summary);
});

recording.MapGet("/", (RecordingService rec) => Results.Ok(new { recordings = rec.List() }));

recording.MapGet("/{id:guid}", (Guid id, RecordingService rec) =>
{
    var dump = rec.Get(id);
    return dump is null ? Results.NotFound() : Results.Ok(dump);
});

recording.MapPost("/{id:guid}/replay", async (Guid id, RecordingService rec, IGrainFactory grains) =>
{
    var dump = rec.Get(id);
    if (dump is null) return Results.NotFound();
    // Replay is intentionally simple: spawn one fresh vehicle per distinct vehicle id observed in
    // the recording, starting from each vehicle's first telemetry city. The sim drives them with
    // newly-generated routes from there -- this is "re-create the same scene", not "re-emit the
    // same ticks". A true tick-replay would require a non-driving VehicleGrain mode, which is
    // a bigger lift; this lighter shape is sufficient for the demo flow.
    var distinct = dump.Telemetry
        .GroupBy(t => t.VehicleId)
        .Select(g => g.OrderBy(t => t.TimestampUtc).First())
        .ToArray();
    var specs = distinct.Select(t => new VehicleSpec(VehicleId: null, StartCityId: t.FromCityId)).ToArray();
    var ids = specs.Length == 0 ? Array.Empty<Guid>() : (await Fleet(grains).AddVehicleBatch(specs)).ToArray();
    return Results.Ok(new { source = id, replayed = ids.Length, vehicleIds = ids });
});

// ─── Simulation config ───────────────────────────────────────────────────────

var simCfg = api.MapGroup("/config/simulation").WithTags("Configuration");

simCfg.MapGet("/", async (IGrainFactory grains) =>
{
    var cfg = await SimConfig(grains).GetConfig();
    return Results.Ok(cfg);
});

simCfg.MapPut("/", async (SimulationConfigPatch patch, IGrainFactory grains, SimulationEventBroadcaster bus) =>
{
    if (patch is null) return Results.Problem(BadRequest("Body is required."));
    if (patch.TickInterval is { } t && t <= TimeSpan.Zero)
        return Results.Problem(BadRequest("TickInterval must be positive."));
    if (patch.DefaultVehicleConfig is { } cfg && !TryValidateVehicleConfig(cfg, out var cfgError))
        return Results.Problem(BadRequest(cfgError));
    if (patch.TimeScale is { } ts && (!double.IsFinite(ts) || ts <= 0 || ts > 10000))
        return Results.Problem(BadRequest("TimeScale must be a positive finite number ≤ 10000."));

    try
    {
        var updated = await SimConfig(grains).UpdateConfig(patch);
        bus.PublishConfigChanged(updated);
        return Results.Ok(updated);
    }
    catch (ArgumentException ex)
    {
        return Results.Problem(BadRequest(ex.Message));
    }
});

app.Run();

static bool IsValidRoute(CityGraphSnapshot graph, ImmutableArray<string> route, out string? error)
{
    var cityIds = graph.Cities.Select(c => c.Id).ToHashSet(StringComparer.Ordinal);
    var edgeSet = new HashSet<(string, string)>();
    foreach (var e in graph.Edges)
    {
        edgeSet.Add((e.FromCityId, e.ToCityId));
        edgeSet.Add((e.ToCityId, e.FromCityId));
    }

    for (int i = 0; i < route.Length; i++)
    {
        if (!cityIds.Contains(route[i]))
        {
            error = $"Unknown city '{route[i]}'.";
            return false;
        }
        if (i > 0)
        {
            if (route[i] == route[i - 1])
            {
                error = $"Consecutive duplicate city '{route[i]}'.";
                return false;
            }
            if (!edgeSet.Contains((route[i - 1], route[i])))
            {
                error = $"No road between '{route[i - 1]}' and '{route[i]}'.";
                return false;
            }
        }
    }
    error = null;
    return true;
}

static bool TryValidateVehicleConfig(VehicleConfig cfg, out string? error)
{
    if (cfg.MinSpeedKph < 0) { error = "MinSpeedKph must be non-negative."; return false; }
    if (cfg.MaxSpeedKph <= cfg.MinSpeedKph) { error = "MaxSpeedKph must be greater than MinSpeedKph."; return false; }
    if (cfg.FuelCapacityLitres <= 0) { error = "FuelCapacityLitres must be positive."; return false; }
    if (cfg.LitresPerKmAtOptimal <= 0) { error = "LitresPerKmAtOptimal must be positive."; return false; }
    if (cfg.RefuelDelay < TimeSpan.Zero) { error = "RefuelDelay must be non-negative."; return false; }
    if (cfg.SpeedSmoothingAlpha is < 0 or > 1) { error = "SpeedSmoothingAlpha must be in [0,1]."; return false; }
    if (cfg.SpeedResampleInterval <= TimeSpan.Zero) { error = "SpeedResampleInterval must be positive."; return false; }
    error = null;
    return true;
}

public sealed record AssignRouteRequest(ImmutableArray<string> Route);

public sealed record FaultRequest(VehicleFault Fault);
````

## src/VehicleFleetSimulator.Silo/Program.cs

````csharp
using Azure.Data.Tables;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Orleans.Configuration;
using VehicleFleetSimulator.Abstractions;
using VehicleFleetSimulator.Grains;
using VehicleFleetSimulator.Grains.Cities;
using VehicleFleetSimulator.Grains.Telemetry;

var builder = Host.CreateApplicationBuilder(args);

// City graph: bind from "Cities" section and register a singleton provider.
builder.Services.Configure<CityGraphOptions>(builder.Configuration.GetSection("Cities"));
builder.Services.AddSingleton<ICityGraphProvider>(sp =>
{
    var opts = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<CityGraphOptions>>().Value;
    return new StaticCityGraphProvider(opts.BuildGraph());
});

// Determinism mode: ambient TimeProvider so grains and the simulator can be driven by tests.
builder.Services.AddSingleton(TimeProvider.System);

// Telemetry sink seam: VehicleGrain publishes through ITelemetrySink. The default
// FanOutTelemetrySink preserves the original direct-cross-grain dispatch to IFleetFanOutGrain;
// alternative sinks (NullTelemetrySink, future LatticeSink) can be swapped in for benchmark runs.
builder.Services.AddSingleton<ITelemetrySink, FanOutTelemetrySink>();

// Silo-scoped runtime state: shared in-process between SimulationConfigGrain (writer) and every
// VehicleGrain (reader) so slider changes propagate to thousands of grains in the time it takes
// a volatile field to flush - no polling, no streams, no per-tick grain calls.
builder.Services.AddSingleton<SimulationRuntimeState>();

var azuriteConnection = builder.Configuration["Persistence:ConnectionString"] ?? "UseDevelopmentStorage=true";
var clusterId = builder.Configuration["Orleans:ClusterId"] ?? "vfs-dev";
var serviceId = builder.Configuration["Orleans:ServiceId"] ?? "VehicleFleetSimulator";

builder.UseOrleans(silo =>
{
    silo.Configure<ClusterOptions>(opts =>
    {
        opts.ClusterId = clusterId;
        opts.ServiceId = serviceId;
    });

    // M7: Azure Storage clustering and reminders on Azurite; grain state stays in memory and no streams are configured.
    silo.UseAzureStorageClustering(options =>
    {
        options.TableServiceClient = new TableServiceClient(azuriteConnection);
    });

    silo.AddMemoryGrainStorageAsDefault();

    silo.UseAzureTableReminderService(options =>
    {
        options.TableServiceClient = new TableServiceClient(azuriteConnection);
    });

    // No streams: VehicleGrain dispatches directly to IFleetFanOutGrain via cross-grain calls.
    // This avoids the memory-stream pulling agent, queue cache, and 1 MB FixedSizeBuffer LOH
    // segments that previously dominated silo working set under load.
});

// M7: Health checks for Azurite Tables (still used for clustering + reminders).
builder.Services.AddHealthChecks()
    .AddCheck("azurite-tables", new AzuriteTableHealthCheck(azuriteConnection), tags: ["ready"]);

await builder.Build().RunAsync();

internal sealed class AzuriteTableHealthCheck(string connectionString) : IHealthCheck
{
    public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
    {
        try
        {
            var client = new TableServiceClient(connectionString);
            await foreach (var _ in client.QueryAsync(maxPerPage: 1, cancellationToken: cancellationToken))
                break;
            return HealthCheckResult.Healthy();
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("Azurite Table service unreachable", ex);
        }
    }
}
````

## src/VehicleFleetSimulator.Ui/Program.cs

````csharp
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using VehicleFleetSimulator.Ui;
using VehicleFleetSimulator.Ui.Services;

var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

var apiBase = builder.Configuration["Api:BaseAddress"]
    ?? builder.HostEnvironment.BaseAddress;

builder.Services.AddSingleton(new Uri(apiBase));
builder.Services.AddSingleton<FleetState>();
builder.Services.AddSingleton<SimulationConfigClient>(sp => new SimulationConfigClient(
    sp.GetRequiredService<Uri>()));
builder.Services.AddSingleton<FleetAdminClient>(sp => new FleetAdminClient(
    sp.GetRequiredService<Uri>()));
builder.Services.AddSingleton<FleetStreamClient>(sp => new FleetStreamClient(
    sp.GetRequiredService<Uri>(),
    sp.GetRequiredService<FleetState>(),
    sp.GetRequiredService<ILoggerFactory>().CreateLogger<FleetStreamClient>()));

await builder.Build().RunAsync();
````

## docker-compose.yml

````yaml
# Vehicle Fleet Simulator - full local stack (Azurite + Silo + Api + Ui).
#
# Usage:
#   docker compose up --build
#   docker compose down -v        # also clears Azurite persisted state
#
# The Silo and Api both reach Azurite via the "azurite" service hostname; the
# UseDevelopmentStorage=true shortcut targets 127.0.0.1, so we set explicit
# endpoints in the connection-string env vars below. The Ui is a Blazor WASM
# SPA served by nginx - it talks to the Api from the user's browser at
# http://localhost:8080 (the host-side mapping), not over the compose network.

services:
  azurite:
    image: mcr.microsoft.com/azure-storage/azurite:latest
    container_name: vfs-azurite
    command:
      - "azurite"
      - "--blobHost=0.0.0.0"
      - "--queueHost=0.0.0.0"
      - "--tableHost=0.0.0.0"
      - "--location=/data"
      - "--skipApiVersionCheck"
      - "--silent"
    ports:
      - "10000:10000" # blob
      - "10001:10001" # queue
      - "10002:10002" # table
    volumes:
      - azurite-data:/data
    healthcheck:
      test: ["CMD", "nc", "-z", "127.0.0.1", "10002"]
      interval: 5s
      timeout: 3s
      retries: 20

  silo:
    build:
      context: .
      dockerfile: src/VehicleFleetSimulator.Silo/Dockerfile
    container_name: vfs-silo
    depends_on:
      azurite:
        condition: service_healthy
    environment:
      DOTNET_ENVIRONMENT: Production
      Orleans__ClusterId: vfs-dev
      Orleans__ServiceId: VehicleFleetSimulator
      Persistence__ConnectionString: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1;QueueEndpoint=http://azurite:10001/devstoreaccount1;TableEndpoint=http://azurite:10002/devstoreaccount1;"
      Streams__ConnectionString: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;QueueEndpoint=http://azurite:10001/devstoreaccount1;TableEndpoint=http://azurite:10002/devstoreaccount1;"
    expose:
      - "11111"
      - "30000"

  api:
    build:
      context: .
      dockerfile: src/VehicleFleetSimulator.Api/Dockerfile
    container_name: vfs-api
    depends_on:
      azurite:
        condition: service_healthy
      silo:
        condition: service_started
    environment:
      ASPNETCORE_ENVIRONMENT: Development
      Orleans__ClusterId: vfs-dev
      Orleans__ServiceId: VehicleFleetSimulator
      Persistence__ConnectionString: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1;QueueEndpoint=http://azurite:10001/devstoreaccount1;TableEndpoint=http://azurite:10002/devstoreaccount1;"
    ports:
      - "8080:8080"
      - "8081:8081"

  ui:
    # Blazor WebAssembly client served by nginx. Pure static hosting - the gRPC-Web
    # connection is opened from the user's browser straight at the api container's
    # host-mapped port (8080), so the ui service has no network dependency on the
    # api beyond depends_on for predictable startup ordering.
    build:
      context: .
      dockerfile: src/VehicleFleetSimulator.Ui/Dockerfile
    container_name: vfs-ui
    depends_on:
      api:
        condition: service_started
    ports:
      - "8090:80"

volumes:
  azurite-data:
````

## run.ps1

````powershell
<#
.SYNOPSIS
    Stand up (or tear down) the full Vehicle Fleet Simulator stack locally via docker compose.

.DESCRIPTION
    Brings up Azurite + Silo + API + UI in containers, detached. The Azurite data volume
    is ALWAYS wiped before starting so every run begins with clean clustering and
    reminder tables. Grain state is in-memory and the sample does not configure
    streams, so no durable grain or stream-queue state is preserved across runs.

.PARAMETER Down
    Tear the stack down without bringing it back up. Always removes volumes.

.EXAMPLE
    ./run.ps1
    Wipe state, build (if needed), and start the stack detached.

.EXAMPLE
    ./run.ps1 -Down
    Stop the stack and remove all volumes.
#>
[CmdletBinding()]
param(
    [switch]$Down
)

$ErrorActionPreference = 'Stop'
Set-Location (Split-Path -Parent $PSCommandPath)

function Invoke-Compose {
    param([string[]]$ComposeArgs)
    Write-Host "→ docker compose $($ComposeArgs -join ' ')" -ForegroundColor Cyan
    & docker compose @ComposeArgs
    if ($LASTEXITCODE -ne 0) { throw "docker compose exited with code $LASTEXITCODE" }
}

# Always tear down with -v to wipe the azurite-data volume before (re)starting.
Invoke-Compose @('down', '-v')

if ($Down) { return }

Invoke-Compose @('up', '--build', '-d')
````

## src/VehicleFleetSimulator.Abstractions/City.cs

````csharp
namespace VehicleFleetSimulator.Abstractions;

/// <summary>A node in the city graph.</summary>
[GenerateSerializer, Immutable]
public sealed record City(
    [property: Id(0)] string Id,
    [property: Id(1)] string Name);
````

## src/VehicleFleetSimulator.Abstractions/CityGraphPersistentState.cs

````csharp
using System.Collections.Generic;

namespace VehicleFleetSimulator.Abstractions;

/// <summary>Persistent state for the singleton <c>CityGraphGrain</c>: holds operator-overridden
/// 2-D positions per city id so drags survive silo restarts.</summary>
[GenerateSerializer]
public sealed class CityGraphPersistentState
{
    [Id(0)] public Dictionary<string, CityPosition> Positions { get; set; } = new();
}
````

## src/VehicleFleetSimulator.Abstractions/CityGraphSnapshot.cs

````csharp
using System.Collections.Immutable;

namespace VehicleFleetSimulator.Abstractions;

/// <summary>Serializable snapshot of the configured city graph for API consumers.</summary>
/// <remarks>
/// <see cref="PositionOverrides"/> carries operator-supplied 2-D coordinates for cities the user
/// has dragged around in the UI. When empty (the default for a fresh silo) the client is expected
/// to fall back to its own deterministic layout. When present, the dictionary is the authoritative
/// source for those city ids only - un-overridden cities still take their layout-computed
/// positions, so a partial drag-set is rendered consistently.
/// </remarks>
[GenerateSerializer, Immutable]
public sealed record CityGraphSnapshot(
    [property: Id(0)] ImmutableArray<City> Cities,
    [property: Id(1)] ImmutableArray<RoadSegment> Edges,
    [property: Id(2)] ImmutableDictionary<string, CityPosition>? PositionOverrides = null);

/// <summary>Single city's overridden 2-D position, in the same arbitrary unit space the
/// client's layout uses (x and y are unitless and unbounded; the renderer letterboxes both).</summary>
[GenerateSerializer, Immutable]
public sealed record CityPosition(
    [property: Id(0)] double X,
    [property: Id(1)] double Y);
````

## src/VehicleFleetSimulator.Abstractions/DuplicateVehiclePolicy.cs

````csharp
namespace VehicleFleetSimulator.Abstractions;

/// <summary>
/// Behaviour when <see cref="IFleetGrain.AddVehicle"/> or <see cref="IFleetGrain.AddVehicleBatch"/> is
/// called with a <see cref="VehicleSpec.VehicleId"/> that already exists in the fleet.
/// </summary>
[GenerateSerializer]
public enum DuplicateVehiclePolicy
{
    /// <summary>Throw <see cref="InvalidOperationException"/> on duplicate (default).</summary>
    Throw = 0,

    /// <summary>Skip the duplicate silently and return the existing id.</summary>
    Skip = 1,
}
````

## src/VehicleFleetSimulator.Abstractions/FleetPersistentState.cs

````csharp
namespace VehicleFleetSimulator.Abstractions;

/// <summary>Persistent envelope for the singleton fleet grain.</summary>
[GenerateSerializer]
public sealed class FleetPersistentState
{
    [Id(0)] public HashSet<Guid> VehicleIds { get; set; } = [];
}
````

## src/VehicleFleetSimulator.Abstractions/FleetStats.cs

````csharp
namespace VehicleFleetSimulator.Abstractions;

/// <summary>Aggregate fleet statistics.</summary>
[GenerateSerializer, Immutable]
public sealed record FleetStats(
    [property: Id(0)] int Total,
    [property: Id(1)] int Driving,
    [property: Id(2)] int Refuelling,
    [property: Id(3)] int Idle,
    [property: Id(4)] int RouteCompleted);
````

## src/VehicleFleetSimulator.Abstractions/GlobalUsings.cs

````csharp
global using Orleans;
````

## src/VehicleFleetSimulator.Abstractions/ICityGraphGrain.cs

````csharp
namespace VehicleFleetSimulator.Abstractions;

/// <summary>Read-only access to the silo's loaded city graph. Single instance keyed by <see cref="Key"/>.</summary>
public interface ICityGraphGrain : IGrainWithGuidKey
{
    public static readonly Guid Key = Guid.Empty;

    /// <summary>Return a snapshot of all cities, bidirectional road segments, and any persisted
    /// per-city position overrides supplied by the UI's drag-to-move tool.</summary>
    Task<CityGraphSnapshot> GetGraph();

    /// <summary>Persist a 2-D position for a single city. Subsequent <see cref="GetGraph"/> calls
    /// will return the override in <see cref="CityGraphSnapshot.PositionOverrides"/>. Returns
    /// <c>false</c> if the city id is not part of the loaded graph.</summary>
    Task<bool> SetCityPosition(string cityId, double x, double y);

    /// <summary>Discard every position override and revert to the client's computed layout.</summary>
    Task ClearCityPositions();
}
````

## src/VehicleFleetSimulator.Abstractions/IFleetFanOutGrain.cs

````csharp
using Orleans.Concurrency;

namespace VehicleFleetSimulator.Abstractions;

/// <summary>
/// Sharded silo-side relay that receives direct cross-grain publishes from <c>VehicleGrain</c>
/// and fans every item out to all registered <see cref="IFleetStreamObserver"/> client
/// observers. There are <see cref="StreamConstants.TelemetryAllShardCount"/> telemetry shard
/// instances; clients must register an observer with every shard to receive the full fleet
/// feed. A separate singleton activation, addressed by <see cref="EventsKey"/>, owns the
/// shared events feed -- distinct from any telemetry shard so a backlog on the (much hotter)
/// events path can't block control-plane Subscribe/Unsubscribe on a telemetry shard.
/// </summary>
public interface IFleetFanOutGrain : IGrainWithGuidKey
{
    /// <summary>Grain key for telemetry shard <paramref name="shard"/>.</summary>
    public static Guid ShardKey(int shard) => StreamConstants.GetTelemetryAllShardKey(shard);

    /// <summary>Grain key for the singleton events-feed activation. Distinct from every
    /// <see cref="ShardKey"/> so events live on their own message queue.</summary>
    public static Guid EventsKey() => StreamConstants.EventsGrainKey;

    // Control-plane methods are tagged [AlwaysInterleave] so they bypass the activation's
    // message queue rather than waiting in line behind potentially millions of [OneWay] publish
    // calls. Class-level [Reentrant] should already permit this, but [AlwaysInterleave] is a
    // per-method guarantee that holds even if reentrancy is degraded under load (we observed a
    // shard-0 activation hit ~2.5M queued messages with NumRunning=1 in production, which made
    // every Subscribe time out at the back of the queue and prevented the gRPC hub from ever
    // attaching its observer -- the UI then displayed "connected, 0 vehicles" indefinitely).
    // None of these methods share mutable state with the publish path, so the interleave is
    // strictly safe: Subscribe/Unsubscribe call ObserverManager (internally synchronised), and
    // Ping is a no-op.

    /// <summary>Register an observer. Idempotent: repeated subscriptions refresh the observer's lease.</summary>
    [AlwaysInterleave] Task Subscribe(IFleetStreamObserver observer);

    /// <summary>Unregister an observer if present.</summary>
    [AlwaysInterleave] Task Unsubscribe(IFleetStreamObserver observer);

    /// <summary>Heartbeat from the client to keep this grain activated and the observer's lease fresh.</summary>
    [AlwaysInterleave] Task Ping();

    /// <summary>Lightweight diagnostics: number of registered observers on this activation.
    /// Tagged <see cref="AlwaysInterleaveAttribute"/> so a wedged shard can still be queried.</summary>
    [AlwaysInterleave] Task<FanOutDiagnostics> GetDiagnostics();

    /// <summary>Fire-and-forget publish from a <c>VehicleGrain</c> on this shard. <see cref="OneWayAttribute"/>
    /// causes the caller's await to complete as soon as the message is enqueued, with no response on the wire.</summary>
    [OneWay] Task PublishTelemetry(VehicleTelemetryEvent telemetry);

    /// <summary>Fire-and-forget publish of a discrete event. Routed exclusively to the singleton
    /// events-feed activation (see <see cref="EventsKey"/>); never published to a telemetry shard
    /// so the events queue can't compete with high-volume telemetry on the same activation.</summary>
    [OneWay] Task PublishEvent(VehicleEvent vehicleEvent);
}

/// <summary>Lightweight diagnostics record returned by <see cref="IFleetFanOutGrain.GetDiagnostics"/>.</summary>
[GenerateSerializer, Immutable]
public sealed record FanOutDiagnostics(
    [property: Id(0)] int ObserverCount,
    [property: Id(1)] long PublishedCount);
````

## src/VehicleFleetSimulator.Abstractions/IFleetGrain.cs

````csharp
namespace VehicleFleetSimulator.Abstractions;

/// <summary>
/// Singleton coordination grain for the entire fleet. Use <see cref="Key"/> as the grain key.
/// </summary>
public interface IFleetGrain : IGrainWithGuidKey
{
    public static readonly Guid Key = Guid.Empty;

    /// <summary>Add a single vehicle. Returns the assigned (or pre-supplied) vehicle id.</summary>
    /// <param name="spec">Vehicle specification.</param>
    /// <param name="onDuplicate">Behaviour when <paramref name="spec"/> carries an id already in the fleet.</param>
    Task<Guid> AddVehicle(VehicleSpec spec, DuplicateVehiclePolicy onDuplicate = DuplicateVehiclePolicy.Throw);

    /// <summary>Add many vehicles in chunks with bounded concurrency. Returns the assigned ids in order.</summary>
    /// <param name="specs">Vehicle specifications.</param>
    /// <param name="onDuplicate">Behaviour for each spec whose id is already in the fleet.</param>
    Task<IReadOnlyList<Guid>> AddVehicleBatch(IReadOnlyList<VehicleSpec> specs, DuplicateVehiclePolicy onDuplicate = DuplicateVehiclePolicy.Throw);

    /// <summary>Remove a vehicle (stops it and forgets the id). Returns true if the vehicle was known.</summary>
    Task<bool> RemoveVehicle(Guid vehicleId);

    /// <summary>Remove every vehicle in the fleet (stops each and clears the roster). Returns the number removed.</summary>
    Task<int> RemoveAllVehicles();

    /// <summary>List all known vehicle ids.</summary>
    Task<IReadOnlyList<Guid>> ListVehicles();

    /// <summary>Aggregate fleet stats by walking each vehicle grain.</summary>
    Task<FleetStats> GetFleetStats();

    /// <summary>Issue <see cref="IVehicleGrain.Start"/> to every persisted vehicle. Returns the
    /// number of vehicles successfully started. Mirrors <see cref="RemoveAllVehicles"/>'s
    /// bounded-concurrency fan-out so timing on large rosters stays inside the Orleans default
    /// response timeout.</summary>
    Task<int> StartAllVehicles();

    /// <summary>Issue <see cref="IVehicleGrain.Stop"/> to every persisted vehicle. Returns the
    /// number of vehicles successfully stopped. Vehicles remain in the roster (use
    /// <see cref="RemoveAllVehicles"/> to also forget them).</summary>
    Task<int> StopAllVehicles();
}
````

## src/VehicleFleetSimulator.Abstractions/IFleetStreamObserver.cs

````csharp
namespace VehicleFleetSimulator.Abstractions;

/// <summary>
/// Client-side observer that receives a stream of fleet telemetry and discrete events relayed
/// from the silo's <c>FleetFanOutGrain</c>. Implementations must be tolerant of out-of-order or
/// duplicate items.
/// </summary>
public interface IFleetStreamObserver : IGrainObserver
{
    Task OnTelemetry(VehicleTelemetryEvent telemetry);
    Task OnEvent(VehicleEvent vehicleEvent);
}
````

## src/VehicleFleetSimulator.Abstractions/IPingGrain.cs

````csharp
using Orleans;

namespace VehicleFleetSimulator.Abstractions;

/// <summary>Round-trip verification grain for Milestone 0.</summary>
public interface IPingGrain : IGrainWithStringKey
{
    Task<string> Ping(string message);
}
````

## src/VehicleFleetSimulator.Abstractions/ISimulationConfigGrain.cs

````csharp
namespace VehicleFleetSimulator.Abstractions;

/// <summary>Singleton grain holding the global <see cref="SimulationConfig"/> for the fleet.</summary>
public interface ISimulationConfigGrain : IGrainWithGuidKey
{
    public static readonly Guid Key = Guid.Empty;

    /// <summary>Return the current global simulation configuration.</summary>
    Task<SimulationConfig> GetConfig();

    /// <summary>Apply a partial update; returns the merged result.</summary>
    Task<SimulationConfig> UpdateConfig(SimulationConfigPatch patch);
}
````

## src/VehicleFleetSimulator.Abstractions/ITelemetrySink.cs

````csharp
namespace VehicleFleetSimulator.Abstractions;

/// <summary>
/// Sink seam that <see cref="IVehicleGrain"/> writes per-tick telemetry and discrete events to.
/// Introduced so the same simulator load can drive different downstream pipelines without
/// modifying grain code:
///
/// <list type="bullet">
///   <item><description><c>FanOutTelemetrySink</c> - current behavior, dispatches to the sharded
///     <see cref="IFleetFanOutGrain"/> relay (used by <c>FleetStreamHub</c> + load harness).</description></item>
///   <item><description><c>NullTelemetrySink</c> - discards every publish, for isolating producer-side
///     cost (benchmark scenario simulator-baseline baseline / observer-off control).</description></item>
///   <item><description>Future <c>LatticeSink</c> - writes telemetry to an <c>Orleans.Lattice</c> tree
///     (benchmark scenarios current-state-no-replication and onward).</description></item>
/// </list>
///
/// <para>Implementations must be safe to call from the <see cref="IVehicleGrain"/> turn at the
/// configured tick cadence across the entire fleet. Implementations that perform external I/O
/// should buffer off-turn so grain-tick latency is not coupled to the downstream's latency.</para>
/// </summary>
public interface ITelemetrySink
{
    /// <summary>
    /// Publish a per-tick telemetry sample. Called once per <see cref="IVehicleGrain"/> tick on the
    /// hot path; implementations should be allocation-free in steady state and must not throw.
    /// </summary>
    ValueTask PublishTelemetryAsync(VehicleTelemetryEvent telemetry, CancellationToken cancellationToken = default);

    /// <summary>
    /// Publish a discrete vehicle event (route start / stop, refuel, status transition).
    /// Called only on ticks that produce events, so cost matters less than the telemetry path.
    /// </summary>
    ValueTask PublishEventAsync(VehicleEvent vehicleEvent, CancellationToken cancellationToken = default);
}
````

## src/VehicleFleetSimulator.Abstractions/IVehicleGrain.cs

````csharp
using System.Collections.Immutable;

namespace VehicleFleetSimulator.Abstractions;

/// <summary>
/// Single vehicle simulator grain. One grain instance per vehicle, keyed by <c>VehicleId</c> (Guid).
/// </summary>
public interface IVehicleGrain : IGrainWithGuidKey
{
    /// <summary>Initialize a freshly activated grain from a spec. Idempotent if called multiple times with the same spec.</summary>
    Task Initialize(VehicleSpec spec);

    /// <summary>Begin ticking on the configured interval and publishing telemetry.</summary>
    Task Start();

    /// <summary>Stop ticking. State is preserved.</summary>
    Task Stop();

    /// <summary>Stop ticking, purge all persisted state for this vehicle, and deactivate the grain.</summary>
    Task Clear();

    /// <summary>Replace per-vehicle configuration (fuel curve, speed bounds, refuel delay, etc.).</summary>
    Task UpdateConfig(VehicleConfig config);

    /// <summary>Replace the current route. The vehicle is re-positioned at the first city of the new route.</summary>
    Task SetRoute(ImmutableArray<string> route);

    /// <summary>Return the current snapshot, or null if not yet initialized.</summary>
    /// <remarks>Returns <see cref="ValueTask{T}"/> so the synchronous fast path (state already
    /// in memory) doesn't allocate a <see cref="Task{T}"/> via <c>Task.FromResult</c>.</remarks>
    ValueTask<VehicleSnapshot?> GetSnapshot();

    /// <summary>Inject a discrete malfunction. Returns <c>true</c> if the fault was applied
    /// (always true today, but reserved for future "fault not applicable in this state" cases).</summary>
    Task<bool> InjectFault(VehicleFault fault);
}
````

## Remaining files

This sample is too large to inline in full. The remaining 83 file(s) are in the repository:

- [src/VehicleFleetSimulator.Abstractions/RoadSegment.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Abstractions/RoadSegment.cs)
- [src/VehicleFleetSimulator.Abstractions/ScenarioPreset.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Abstractions/ScenarioPreset.cs)
- [src/VehicleFleetSimulator.Abstractions/SimulationConfig.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Abstractions/SimulationConfig.cs)
- [src/VehicleFleetSimulator.Abstractions/SimulationConfigPersistentState.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Abstractions/SimulationConfigPersistentState.cs)
- [src/VehicleFleetSimulator.Abstractions/StreamConstants.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Abstractions/StreamConstants.cs)
- [src/VehicleFleetSimulator.Abstractions/VehicleConfig.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Abstractions/VehicleConfig.cs)
- [src/VehicleFleetSimulator.Abstractions/VehicleEvent.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Abstractions/VehicleEvent.cs)
- [src/VehicleFleetSimulator.Abstractions/VehicleFault.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Abstractions/VehicleFault.cs)
- [src/VehicleFleetSimulator.Abstractions/VehicleFleetSimulator.Abstractions.csproj](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Abstractions/VehicleFleetSimulator.Abstractions.csproj)
- [src/VehicleFleetSimulator.Abstractions/VehiclePersistentState.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Abstractions/VehiclePersistentState.cs)
- [src/VehicleFleetSimulator.Abstractions/VehicleSnapshot.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Abstractions/VehicleSnapshot.cs)
- [src/VehicleFleetSimulator.Abstractions/VehicleSpec.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Abstractions/VehicleSpec.cs)
- [src/VehicleFleetSimulator.Abstractions/VehicleState.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Abstractions/VehicleState.cs)
- [src/VehicleFleetSimulator.Abstractions/VehicleStatus.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Abstractions/VehicleStatus.cs)
- [src/VehicleFleetSimulator.Abstractions/VehicleTelemetryEvent.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Abstractions/VehicleTelemetryEvent.cs)
- [src/VehicleFleetSimulator.Api/appsettings.Development.json](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Api/appsettings.Development.json)
- [src/VehicleFleetSimulator.Api/appsettings.json](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Api/appsettings.json)
- [src/VehicleFleetSimulator.Api/Properties/launchSettings.json](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Api/Properties/launchSettings.json)
- [src/VehicleFleetSimulator.Api/Protos/fleet_stream.proto](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Api/Protos/fleet_stream.proto)
- [src/VehicleFleetSimulator.Api/Services/ApiKeyEndpointFilter.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Api/Services/ApiKeyEndpointFilter.cs)
- [src/VehicleFleetSimulator.Api/Services/ApiKeyInterceptor.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Api/Services/ApiKeyInterceptor.cs)
- [src/VehicleFleetSimulator.Api/Services/FleetStreamService.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Api/Services/FleetStreamService.cs)
- [src/VehicleFleetSimulator.Api/Services/RecordingService.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Api/Services/RecordingService.cs)
- [src/VehicleFleetSimulator.Api/Services/SimulationEventBroadcaster.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Api/Services/SimulationEventBroadcaster.cs)
- [src/VehicleFleetSimulator.Api/Streams/FleetStreamHub.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Api/Streams/FleetStreamHub.cs)
- [src/VehicleFleetSimulator.Api/Streams/TelemetryFanOutService.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Api/Streams/TelemetryFanOutService.cs)
- [src/VehicleFleetSimulator.Api/VehicleFleetSimulator.Api.csproj](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Api/VehicleFleetSimulator.Api.csproj)
- [src/VehicleFleetSimulator.Grains.Lattice/KeyShape.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains.Lattice/KeyShape.cs)
- [src/VehicleFleetSimulator.Grains.Lattice/LatticeSink.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains.Lattice/LatticeSink.cs)
- [src/VehicleFleetSimulator.Grains.Lattice/LatticeSinkMetrics.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains.Lattice/LatticeSinkMetrics.cs)
- [src/VehicleFleetSimulator.Grains.Lattice/LatticeSinkOptions.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains.Lattice/LatticeSinkOptions.cs)
- [src/VehicleFleetSimulator.Grains.Lattice/LatticeSinkServiceCollectionExtensions.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains.Lattice/LatticeSinkServiceCollectionExtensions.cs)
- [src/VehicleFleetSimulator.Grains/Cities/CityGraph.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains/Cities/CityGraph.cs)
- [src/VehicleFleetSimulator.Grains/Cities/CityGraphOptions.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains/Cities/CityGraphOptions.cs)
- [src/VehicleFleetSimulator.Grains/Cities/ICityGraphProvider.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains/Cities/ICityGraphProvider.cs)
- [src/VehicleFleetSimulator.Grains/Cities/RouteGenerator.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains/Cities/RouteGenerator.cs)
- [src/VehicleFleetSimulator.Grains/CityGraphGrain.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains/CityGraphGrain.cs)
- [src/VehicleFleetSimulator.Grains/FleetFanOutGrain.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains/FleetFanOutGrain.cs)
- [src/VehicleFleetSimulator.Grains/FleetGrain.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains/FleetGrain.cs)
- [src/VehicleFleetSimulator.Grains/GlobalUsings.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains/GlobalUsings.cs)
- [src/VehicleFleetSimulator.Grains/PingGrain.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains/PingGrain.cs)
- [src/VehicleFleetSimulator.Grains/SimulationConfigGrain.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains/SimulationConfigGrain.cs)
- [src/VehicleFleetSimulator.Grains/SimulationRuntimeState.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains/SimulationRuntimeState.cs)
- [src/VehicleFleetSimulator.Grains/Telemetry/FanOutTelemetrySink.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains/Telemetry/FanOutTelemetrySink.cs)
- [src/VehicleFleetSimulator.Grains/Telemetry/NullTelemetrySink.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains/Telemetry/NullTelemetrySink.cs)
- [src/VehicleFleetSimulator.Grains/VehicleFleetSimulator.Grains.csproj](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains/VehicleFleetSimulator.Grains.csproj)
- [src/VehicleFleetSimulator.Grains/VehicleGrain.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains/VehicleGrain.cs)
- [src/VehicleFleetSimulator.Grains/Vehicles/FuelModel.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains/Vehicles/FuelModel.cs)
- [src/VehicleFleetSimulator.Grains/Vehicles/SpeedModel.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains/Vehicles/SpeedModel.cs)
- [src/VehicleFleetSimulator.Grains/Vehicles/VehicleSimulator.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Grains/Vehicles/VehicleSimulator.cs)
- [src/VehicleFleetSimulator.Silo/appsettings.json](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Silo/appsettings.json)
- [src/VehicleFleetSimulator.Silo/VehicleFleetSimulator.Silo.csproj](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Silo/VehicleFleetSimulator.Silo.csproj)
- [src/VehicleFleetSimulator.Ui/_Imports.razor](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Ui/_Imports.razor)
- [src/VehicleFleetSimulator.Ui/App.razor](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Ui/App.razor)
- [src/VehicleFleetSimulator.Ui/Components/ControlFlyout.razor](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Ui/Components/ControlFlyout.razor)
- [src/VehicleFleetSimulator.Ui/Components/SimSpeedSlider.razor](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Ui/Components/SimSpeedSlider.razor)
- [src/VehicleFleetSimulator.Ui/Layout/MainLayout.razor](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Ui/Layout/MainLayout.razor)
- [src/VehicleFleetSimulator.Ui/Models/CityGraphDto.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Ui/Models/CityGraphDto.cs)
- [src/VehicleFleetSimulator.Ui/Pages/Home.razor](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Ui/Pages/Home.razor)
- [src/VehicleFleetSimulator.Ui/Properties/launchSettings.json](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Ui/Properties/launchSettings.json)
- [src/VehicleFleetSimulator.Ui/Protos/fleet_stream.proto](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Ui/Protos/fleet_stream.proto)
- [src/VehicleFleetSimulator.Ui/Rendering/CityLayout.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Ui/Rendering/CityLayout.cs)
- [src/VehicleFleetSimulator.Ui/Services/FleetAdminClient.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Ui/Services/FleetAdminClient.cs)
- [src/VehicleFleetSimulator.Ui/Services/FleetState.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Ui/Services/FleetState.cs)
- [src/VehicleFleetSimulator.Ui/Services/FleetStreamClient.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Ui/Services/FleetStreamClient.cs)
- [src/VehicleFleetSimulator.Ui/Services/SimulationConfigClient.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Ui/Services/SimulationConfigClient.cs)
- [src/VehicleFleetSimulator.Ui/VehicleFleetSimulator.Ui.csproj](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Ui/VehicleFleetSimulator.Ui.csproj)
- [src/VehicleFleetSimulator.Ui/wwwroot/appsettings.json](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/src/VehicleFleetSimulator.Ui/wwwroot/appsettings.json)
- [tests/VehicleFleetSimulator.Tests/CityGraphTests.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/tests/VehicleFleetSimulator.Tests/CityGraphTests.cs)
- [tests/VehicleFleetSimulator.Tests/ClusterFixture.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/tests/VehicleFleetSimulator.Tests/ClusterFixture.cs)
- [tests/VehicleFleetSimulator.Tests/FanOutTelemetrySinkRoutingTests.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/tests/VehicleFleetSimulator.Tests/FanOutTelemetrySinkRoutingTests.cs)
- [tests/VehicleFleetSimulator.Tests/FleetBatchTelemetryTests.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/tests/VehicleFleetSimulator.Tests/FleetBatchTelemetryTests.cs)
- [tests/VehicleFleetSimulator.Tests/FleetGrainIdempotencyTests.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/tests/VehicleFleetSimulator.Tests/FleetGrainIdempotencyTests.cs)
- [tests/VehicleFleetSimulator.Tests/FuelModelTests.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/tests/VehicleFleetSimulator.Tests/FuelModelTests.cs)
- [tests/VehicleFleetSimulator.Tests/NullTelemetrySinkTests.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/tests/VehicleFleetSimulator.Tests/NullTelemetrySinkTests.cs)
- [tests/VehicleFleetSimulator.Tests/RouteGeneratorTests.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/tests/VehicleFleetSimulator.Tests/RouteGeneratorTests.cs)
- [tests/VehicleFleetSimulator.Tests/SpeedModelTests.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/tests/VehicleFleetSimulator.Tests/SpeedModelTests.cs)
- [tests/VehicleFleetSimulator.Tests/StreamSubscriberOrderTests.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/tests/VehicleFleetSimulator.Tests/StreamSubscriberOrderTests.cs)
- [tests/VehicleFleetSimulator.Tests/TelemetrySinkSwappabilityTests.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/tests/VehicleFleetSimulator.Tests/TelemetrySinkSwappabilityTests.cs)
- [tests/VehicleFleetSimulator.Tests/TestGraph.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/tests/VehicleFleetSimulator.Tests/TestGraph.cs)
- [tests/VehicleFleetSimulator.Tests/VehicleFleetSimulator.Tests.csproj](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/tests/VehicleFleetSimulator.Tests/VehicleFleetSimulator.Tests.csproj)
- [tests/VehicleFleetSimulator.Tests/VehicleLifecycleTests.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/tests/VehicleFleetSimulator.Tests/VehicleLifecycleTests.cs)
- [tests/VehicleFleetSimulator.Tests/VehicleSimulatorTests.cs](https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/VehicleFleetSimulator/tests/VehicleFleetSimulator.Tests/VehicleSimulatorTests.cs)
