11 KiB
| description | globs | alwaysApply |
|---|---|---|
| C# naming, layout, and primary constructors; Microsoft conventions and .NET idioms. | **/*.cs | true |
C# style (Neon Sprawl)
Follow Microsoft’s C# Coding Conventions and C# identifier rules. Prefer clarity and consistency with existing server code.
Naming
- Types (classes, structs, records, interfaces, enums, delegates):
PascalCase. - Interfaces: prefix with
I(e.g.IPlayerSession). - Methods, properties, events, public fields:
PascalCase. - Parameters, local variables:
camelCase. - Private instance fields:
camelCase(no leading underscore), unless an existing file consistently does otherwise—then match the file. If a parameter or local shadows a field, usethis.or rename for clarity. - Static fields:
camelCasefor private/internal static fields;PascalCaseforpublic staticmembers (includingreadonly/constants) per Microsoft guidance; stay consistent within a project. - Async methods: suffix with
Async(e.g.LoadProfileAsync).
Primary constructors
- Prefer primary constructor syntax for classes, structs, and records when it fits: dependency injection,
IClassFixture<>test classes, small services, and any type that mainly captures parameters into fields or base calls. - Skip primary constructors when they hurt clarity: heavy logic in the body that belongs in a conventional constructor,
thisreferences before the implicit constructor runs in odd ways, or a file already uses a consistent legacy style—then match the file.
// Prefer
public sealed class OrderService(IOrderStore store, ILogger<OrderService> log)
{
public Task<Order?> GetAsync(Guid id) => store.FindAsync(id);
}
// Avoid when you would only reassign into mutable fields with non-trivial validation—use an explicit constructor instead.
Layout and syntax
- Braces: opening brace
{on a new line for types and members (Allman-style), per common Microsoft examples. - Braces for every block: never omit
{ }onif,else,for,foreach,while,do, orusingwhen the language allows a single statement without braces—always use a braced block, even for one line. This avoids accidental logic changes when editing. Expression-bodied members and expression lambdas (x => x.Id) stay valid when the whole body is a single expression.
// Prefer
if (string.IsNullOrEmpty(key))
{
return false;
}
// Avoid
if (string.IsNullOrEmpty(key))
return false;
- Indentation: 4 spaces per level; no tabs unless the file already uses tabs—never mix.
var: use when the type is obvious from the right-hand side; use explicit types when it improves readability or for literals where the type matters.- File-scoped namespaces (
namespace X;) for new single-namespace files when the SDK/version allows. - Pattern matching / nullability: prefer modern C# features where they simplify code; honor nullable reference type annotations when the project enables them.
Collection expressions (IDE0305)
- Prefer collection spread (
[.. source]) over.ToArray()/.ToList()when materializing a sequence into a new array or list for return values, recordwithcopies, or local snapshots. - Give an explicit target type when the compiler cannot infer one (CS9176), e.g.
string[] ids = [.. query.Select(static x => x.Id)];. - In assertions, avoid passing a bare spread directly to APIs with span overloads (e.g.
Assert.Equal) — assign to a typed local first:
// Prefer
return [.. query];
GrantedItems = [.. row.GrantedItems],
// When type inference fails
string[] expectedOrder = [.. registry.GetDefinitionsInIdOrder().Select(static d => d.Id)];
string[] actualOrder = [.. body.Quests.Select(static row => row.QuestId)];
Assert.Equal(expectedOrder, actualOrder);
// Avoid (ambiguous Assert overloads; unnecessary allocation helper)
Assert.Equal(expectedOrder, [.. body.Quests.Select(static row => row.QuestId)]);
return query.ToArray();
using / await using (IDE0063)
- When a
usingorawait usingblock contains exactly one statement, prefer the declaration form without braces:await using var conn = …;then the single statement on following lines only if it is still logically one resource scope (typical forNpgsqlConnection+ one command in integration-test reset helpers). - Keep braced
using (var …) { … }when the block has multiple statements (Arrange + Act + Assert inside one scope, seed loops, or any sequence that shares locals). - Do not flatten multi-statement blocks into
using varat method scope when inner scopes reuse names likestore,registry, orscope— that causes CS0136 name clashes. Use distinct names (seedStore,actScope,readStore) or keep the braced block.
Local const (RCS1118)
- When a local is initialized from a compile-time constant (string literal, numeric literal, or
public constfield), declare itconstinstead ofvar.
const string questId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
Inline declarations (IDE0018)
- Prefer inline initialization over declare-then-assign when the analyzer suggests it, e.g.
var outcome = Operation(...)andTryGet(..., out var snapshot)instead of separate upfront declarations.
// Prefer — single disposable, single follow-on statement
await using var conn = new NpgsqlConnection(cs);
await conn.OpenAsync();
// Prefer — multiple statements in one scope
using (var firstScope = Factory.Services.CreateScope())
{
var store = firstScope.ServiceProvider.GetRequiredService<IExampleStore>();
Assert.True(store.TryAppend(row));
}
// Avoid — flattens to method scope and collides with a later `var store`
using var firstScope = Factory.Services.CreateScope();
var store = firstScope.ServiceProvider.GetRequiredService<IExampleStore>();
Assert.True(store.TryAppend(row));
// … later …
var store = secondScope.ServiceProvider.GetRequiredService<IExampleStore>(); // CS0136
Postgres schema gates (IDE0330)
- In
*Bootstrap.cstypes, useSystem.Threading.Lock(notobject) for static schema-initialization gates:private static readonly Lock SchemaGate = new();.
Concrete return types (CA1859)
-
When a private helper always returns a materialized array, prefer the concrete array type as the return type (e.g.
RewardItemGrantApplied[]) instead ofIReadOnlyList<T>if callers only need the array and analyzers suggest it. -
Prefer expression-bodied members only when they stay one clear idea.
-
LINQ: favor readability over micro-chains; break complex queries across lines.
-
Exception handling: catch specific exceptions; avoid empty
catch; log or rethrow with context when appropriate.
Program.cs and minimal APIs
- Top-level statements and minimal APIs are fine for small apps; extract registration/build logic into extension methods or dedicated types when the file grows.
Documentation
- Use
///XML doc comments on public APIs (types and members) when behavior or contracts are not obvious.
Test project layout (*.Tests)
- Mirror the server project: place test types under the same relative path as the production code they exercise (e.g.
NeonSprawl.Server/Game/PositionState/PositionStateApi.cs→NeonSprawl.Server.Tests/Game/PositionState/PositionStateApiTests.cs). - Use a namespace that matches the folder tree under the test assembly root, e.g.
NeonSprawl.Server.Tests.Game.PositionStatefor files inGame/PositionState/.
Test method naming convention
- Use
MethodName_ShouldExpectedOutcome_WhenScenario(three segments,PascalCaseinside each segment, separated by underscores). MethodName: the behavior or entry under test (SUT method, HTTP operation, or short feature verb)—e.g.GetPosition,PostMove,TryApplyMoveTarget.ShouldExpectedOutcome: the outcome the test proves—e.g.ShouldReturnNotFound,ShouldPersistTargetAndIncrementSequence.WhenScenario: the condition or inputs that trigger it—e.g.WhenPlayerIsUnknown,WhenDevPlayerPostsValidMove.- Omit redundant words when the scenario is already obvious; keep names readable in test runners and failure output.
// Examples
public async Task GetPosition_ShouldReturnNotFound_WhenPlayerIsUnknown() { … }
public async Task PostMove_ShouldReturnBadRequest_WhenSchemaVersionIsWrong() { … }
Unit and integration tests (Arrange, Act, Assert)
Mandatory for every new or changed test method in *Tests.cs ([Fact] / [Theory] bodies). Do not ship partial AAA (e.g. comments present but body read still in Act). Agents and humans must treat this as merge-ready layout, not a suggestion.
Required layout
-
Phase labels — Each test method contains
// Arrange,// Act, and// Assertexactly once, in that order, each on its own line (same indentation as the test body). Use these exact words so grep and review stay consistent. No blank line is required immediately after those comments; the first statement of each phase may follow on the next line. -
Arrange — Factories,
HttpClient, queued mock transports, DTOs, seeds, and other setup. Do not assert the outcome under test in Arrange (rare guards on arrange-only helpers are acceptable). -
Act — Invoke the behavior under test only (e.g. one
PostAsJsonAsync,GetAsync, or SUT call). If the scenario is a short sequence (e.g. POST then GET to prove persistence), keep the whole sequence in Act with noAssert.*between those calls. -
Assert — All
Assert.*(and any other outcome checks). HTTP / deserialization: when the next step is verifying the response (status already observed, body for assertions), performReadFromJsonAsync/ similar reads here, not in Act beside the POST. Reads that drive the next call belong in Act, not Assert. -
One AAA triple per test method — Each
[Fact]or[Theory]case gets its own Arrange/Act/Assert; do not share a single Assert block across unrelated scenarios in one method.
Authoring defaults
- Use VS Code snippet
xut(method) orxutc(class) from.vscode/csharp.code-snippets, or copyserver/NeonSprawl.Server.Tests/TestTemplates/AAA_TEST_METHOD_TEMPLATE.cs.txt.
Example (minimal HTTP integration)
[Fact]
public async Task PostExample_ShouldReturnOk_WhenBodyValid()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var request = new { schemaVersion = 1 };
// Act
var response = await client.PostAsJsonAsync("/game/example", request);
// Assert
var body = await response.Content.ReadFromJsonAsync<ExampleResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
}
Tooling
- Prefer fixes that satisfy built-in .NET analyzers /
dotnet format(if adopted) rather than fighting IDE warnings without reason.