Files
LightlessClient/CONTRIBUTING.md

27 KiB

Lightless Sync Development Guidelines

This document outlines the coding standards, architectural patterns, and best practices for contributing to the Lightless Sync project.

Table of Contents

  1. Project Structure
  2. Coding Standards
  3. Architecture Patterns
  4. Dependency Injection
  5. Mediator Pattern
  6. Service Development
  7. UI Development
  8. Dalamud Integration
  9. Performance Considerations
  10. Testing
  11. Common Patterns

Project Structure

Directory Organization

LightlessSync/
├── Changelog/              # Version history and update notes
├── FileCache/              # File caching and management
├── Interop/                # Game interop and IPC with other plugins
│   └── Ipc/               # IPC callers for external plugins
├── LightlessConfiguration/ # Configuration management
├── PlayerData/             # Player data handling and pairs
│   ├── Factories/
│   ├── Handlers/
│   ├── Pairs/
│   └── Services/
├── Services/               # Core business logic services
│   ├── CharaData/         # Character data management
│   ├── Events/            # Event aggregation
│   ├── Mediator/          # Mediator pattern implementation
│   └── ServerConfiguration/
├── UI/                     # ImGui-based user interfaces
│   ├── Components/        # Reusable UI components
│   ├── Handlers/          # UI event handlers
│   └── Models/            # UI data models
├── Utils/                  # Utility classes
└── WebAPI/                # SignalR and HTTP client logic
    ├── Files/             # File transfer operations
    └── SignalR/           # SignalR hub connections

File Organization Rules

  • One primary class per file: File name must match the primary type name
  • Partial classes: Use for large classes (e.g., CharaDataManager.cs, CharaDataManager.Upload.cs)
  • Nested types: Keep in the same file unless they grow large enough to warrant separation
  • Interfaces: Prefix with I (e.g., IIpcCaller, IMediatorSubscriber)

Coding Standards

General C# Conventions

Follow the .editorconfig settings:

# Indentation
indent_size = 4
tab_width = 4
end_of_line = crlf

# Naming
- Interfaces: IPascalCase (begins with I)
- Types: PascalCase
- Methods: PascalCase
- Properties: PascalCase
- Private fields: _camelCase (underscore prefix)
- Local variables: camelCase
- Parameters: camelCase
- Constants: PascalCase or UPPER_CASE

Code Style Preferences

Namespaces

// Use block-scoped namespaces (not file-scoped)
namespace LightlessSync.Services;

public class MyService
{
    // ...
}

Braces

// Always use braces for control statements
if (condition)
{
    DoSomething();
}

// Even for single-line statements
if (condition)
{
    return;
}

Expression-Bodied Members

// Prefer expression bodies for properties and accessors
public string Name { get; init; } = string.Empty;
public bool IsEnabled => _config.Current.Enabled;

// Avoid for methods, constructors, and operators
public void DoWork()
{
    // Full method body
}

Object Initialization

// Prefer object initializers
var notification = new LightlessNotification
{
    Id = "example",
    Title = "Example",
    Message = "This is an example",
    Type = NotificationType.Info
};

// Prefer collection initializers
var list = new List<string> { "item1", "item2", "item3" };

Null Handling

// Prefer null coalescing
var value = possiblyNull ?? defaultValue;

// Prefer null propagation
var length = text?.Length ?? 0;

// Prefer pattern matching for null checks
if (value is null)
{
    return;
}

// Use nullable reference types
public string? OptionalValue { get; set; }
public string RequiredValue { get; set; } = string.Empty;

Modern C# Features

// Use target-typed new expressions when type is apparent
List<string> items = new();
Dictionary<string, int> map = new();

// Use primary constructors sparingly (prefer traditional constructors for clarity)
// AVOID: public class MyService(ILogger logger) : IService

// Use record types for DTOs and immutable data
public record UserData(string Name, string Id);
public record struct PairRequestEntry(string HashedCid, string MessageTemplate, DateTime ReceivedAt);

// Use pattern matching
if (obj is MyType { Property: "value" } typedObj)
{
    // Use typedObj
}

Architecture Patterns

Mediator Pattern (Core Communication)

The Lightless Sync uses a custom mediator pattern for decoupled communication between components.

Key Components

  1. LightlessMediator: Central message bus
  2. MessageBase: Base class for all messages
  3. IMediatorSubscriber: Interface for subscribers
  4. MediatorSubscriberBase: Base class with auto-cleanup

Creating Messages

// In Services/Mediator/Messages.cs
public record MyCustomMessage(string Data, int Value) : MessageBase;

// For messages that need to stay on the same thread
public record SynchronousMessage : MessageBase
{
    public override bool KeepThreadContext => true;
}

Subscribing to Messages

public class MyService : DisposableMediatorSubscriberBase, IHostedService
{
    public MyService(ILogger<MyService> logger, LightlessMediator mediator)
        : base(logger, mediator)
    {
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        // Subscribe to messages
        Mediator.Subscribe<MyCustomMessage>(this, HandleMyMessage);
        Mediator.Subscribe<AnotherMessage>(this, HandleAnotherMessage);
        return Task.CompletedTask;
    }

    private void HandleMyMessage(MyCustomMessage message)
    {
        Logger.LogDebug("Received: {Data}, {Value}", message.Data, message.Value);
        // Handle the message
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        // Cleanup is automatic with DisposableMediatorSubscriberBase
        return Task.CompletedTask;
    }
}

Publishing Messages

// Publish to all subscribers
Mediator.Publish(new MyCustomMessage("test", 42));

// Publish from non-mediator classes
_mediator.Publish(new NotificationMessage(
    "Title",
    "Message",
    NotificationType.Info));

Message Guidelines

  • Use records for messages (immutable and concise)
  • Keep messages simple: Only carry data, no logic
  • Name clearly: <Action><Subject>Message (e.g., PairRequestReceivedMessage)
  • Thread-safe data: Messages are processed asynchronously unless KeepThreadContext = true
  • Avoid circular dependencies: Messages should flow in one direction

Dependency Injection

Service Registration (Plugin.cs)

// In Plugin.cs constructor:
.ConfigureServices(collection =>
{
    // Singleton services (shared state, long-lived)
    collection.AddSingleton<LightlessMediator>();
    collection.AddSingleton<ApiController>();
    collection.AddSingleton<PairManager>();
    
    // Scoped services (per-resolution scope)
    collection.AddScoped<UiService>();
    collection.AddScoped<CacheMonitor>();
    
    // Hosted services (background services with lifecycle)
    collection.AddHostedService(p => p.GetRequiredService<LightlessMediator>());
    collection.AddHostedService(p => p.GetRequiredService<NotificationService>());
    
    // Lazy dependencies (avoid circular dependencies)
    collection.AddSingleton(s => new Lazy<ApiController>(() => s.GetRequiredService<ApiController>()));
    
    // Factory pattern
    collection.AddSingleton<GameObjectHandlerFactory>();
})

Service Patterns

Standard Service

public class MyService
{
    private readonly ILogger<MyService> _logger;
    private readonly SomeDependency _dependency;

    public MyService(
        ILogger<MyService> logger,
        SomeDependency dependency)
    {
        _logger = logger;
        _dependency = dependency;
    }

    public void DoWork()
    {
        _logger.LogInformation("Working...");
        _dependency.PerformAction();
    }
}

Hosted Service (Background Service)

public class MyHostedService : IHostedService, IMediatorSubscriber
{
    private readonly ILogger<MyHostedService> _logger;
    private readonly LightlessMediator _mediator;

    public LightlessMediator Mediator => _mediator;

    public MyHostedService(
        ILogger<MyHostedService> logger,
        LightlessMediator mediator)
    {
        _logger = logger;
        _mediator = mediator;
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("Starting {Service}", nameof(MyHostedService));
        
        // Subscribe to mediator messages
        _mediator.Subscribe<SomeMessage>(this, HandleSomeMessage);
        
        // Initialize resources
        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("Stopping {Service}", nameof(MyHostedService));
        
        // Cleanup
        _mediator.UnsubscribeAll(this);
        
        return Task.CompletedTask;
    }

    private void HandleSomeMessage(SomeMessage msg)
    {
        // Handle message
    }
}

Mediator Subscriber Service

public sealed class MyService : DisposableMediatorSubscriberBase
{
    private readonly SomeDependency _dependency;

    public MyService(
        ILogger<MyService> logger,
        LightlessMediator mediator,
        SomeDependency dependency)
        : base(logger, mediator)
    {
        _dependency = dependency;
        
        // Subscribe in constructor or in separate Init method
        Mediator.Subscribe<SomeMessage>(this, HandleMessage);
    }

    private void HandleMessage(SomeMessage msg)
    {
        Logger.LogDebug("Handling message: {Msg}", msg);
        // Process message
    }

    // Dispose is handled by base class
}

Service Development

Service Responsibilities

Services should follow Single Responsibility Principle:

  • NotificationService: Handles all in-game notifications
  • BroadcastService: Manages Lightfinder broadcast state
  • PairRequestService: Manages incoming pair requests
  • DalamudUtilService: Utility methods for Dalamud framework operations

Service Guidelines

  1. Logging: Always use ILogger<T> with appropriate log levels
  2. Error Handling: Wrap risky operations in try-catch, log exceptions
  3. Async/Await: Use ConfigureAwait(false) for non-UI operations
  4. Thread Safety: Use locks, ConcurrentDictionary, or SemaphoreSlim for shared state
  5. Disposal: Implement IDisposable or inherit from DisposableMediatorSubscriberBase

Example Service Template

using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace LightlessSync.Services;

public sealed class TemplateService : DisposableMediatorSubscriberBase, IHostedService
{
    private readonly ILogger<TemplateService> _logger;
    private readonly SomeDependency _dependency;
    private readonly SemaphoreSlim _lock = new(1);
    
    public TemplateService(
        ILogger<TemplateService> logger,
        LightlessMediator mediator,
        SomeDependency dependency)
        : base(logger, mediator)
    {
        _logger = logger;
        _dependency = dependency;
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("Starting {Service}", nameof(TemplateService));
        
        Mediator.Subscribe<SomeMessage>(this, HandleSomeMessage);
        
        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("Stopping {Service}", nameof(TemplateService));
        
        _lock.Dispose();
        
        return Task.CompletedTask;
    }

    private async void HandleSomeMessage(SomeMessage msg)
    {
        await _lock.WaitAsync().ConfigureAwait(false);
        try
        {
            _logger.LogDebug("Processing message: {Msg}", msg);
            
            // Do work
            await _dependency.ProcessAsync(msg).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to process message");
        }
        finally
        {
            _lock.Release();
        }
    }

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            _lock?.Dispose();
        }
        
        base.Dispose(disposing);
    }
}

UI Development

UI Base Classes

All UI windows inherit from WindowMediatorSubscriberBase:

public class MyWindow : WindowMediatorSubscriberBase
{
    private readonly UiSharedService _uiShared;

    public MyWindow(
        ILogger<MyWindow> logger,
        LightlessMediator mediator,
        UiSharedService uiShared,
        PerformanceCollectorService performanceCollector)
        : base(logger, mediator, "My Window Title", performanceCollector)
    {
        _uiShared = uiShared;

        // Window configuration
        Flags = ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse;
        SizeConstraints = new WindowSizeConstraints
        {
            MinimumSize = new Vector2(600, 400),
            MaximumSize = new Vector2(800, 600)
        };
    }

    protected override void DrawInternal()
    {
        // ImGui drawing code here
        ImGui.Text("Hello, World!");
    }
}

UI Guidelines

  1. Separation of Concerns: UI should only handle rendering and user input
  2. Use UiSharedService: Centralized UI utilities (icons, buttons, styling)
  3. Performance: Use PerformanceCollectorService to track rendering performance
  4. Disposal: Windows are scoped; don't store state that needs to persist
  5. Mediator: Use mediator to communicate with services
  6. ImGui Best Practices:
    • Use ImRaii for push/pop operations
    • Always pair Begin/End calls
    • Use ImGuiHelpers.ScaledDummy() for responsive spacing
    • Check IsOpen property for visibility

ImGui Patterns

// Using ImRaii for automatic cleanup
using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessGreen")))
using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 6f))
{
    if (ImGui.Button("Styled Button"))
    {
        // Handle click
    }
}

// Tab bars
using (var tabBar = ImRaii.TabBar("###my_tabs"))
{
    if (!tabBar) return;

    using (var tab1 = ImRaii.TabItem("Tab 1"))
    {
        if (tab1)
        {
            ImGui.Text("Tab 1 content");
        }
    }

    using (var tab2 = ImRaii.TabItem("Tab 2"))
    {
        if (tab2)
        {
            ImGui.Text("Tab 2 content");
        }
    }
}

// Child windows for scrolling regions
using (var child = ImRaii.Child("###content", new Vector2(0, -40), false, 
    ImGuiWindowFlags.AlwaysVerticalScrollbar))
{
    if (!child) return;
    
    // Scrollable content here
}

Dalamud Integration

Dalamud Services

Dalamud provides various services accessible through constructor injection:

// Common Dalamud services:
IClientState clientState      // Player and world state
IFramework framework          // Game framework events
IObjectTable objectTable      // Game objects (players, NPCs, etc.)
IChatGui chatGui             // Chat window access
ICommandManager commandManager // Command registration
IDataManager gameData        // Game data sheets
IPluginLog pluginLog        // Logging
INotificationManager notificationManager // Toast notifications
IGameGui gameGui            // Game UI access
ITextureProvider textureProvider // Icon/texture loading

Framework Threading

CRITICAL: Dalamud operates on the game's main thread.

// Safe: Already on framework thread
private void OnFrameworkUpdate(IFramework framework)
{
    // Can safely access game state
    var player = _clientState.LocalPlayer;
}

// Unsafe: Called from background thread
private async Task BackgroundWork()
{
    // DO NOT access game state here!
    
    // Use DalamudUtilService to run on framework thread
    await _dalamudUtil.RunOnFrameworkThread(() =>
    {
        // Now safe to access game state
        var player = _clientState.LocalPlayer;
    });
}

Game Object Access

// Always check for null
var player = _clientState.LocalPlayer;
if (player == null)
{
    return;
}

// Iterating game objects
foreach (var obj in _objectTable)
{
    if (obj is IPlayerCharacter pc)
    {
        // Handle player character
    }
}

// Finding specific objects
var target = _targetManager.Target;
if (target is IPlayerCharacter targetPlayer)
{
    // Handle targeted player
}

IPC with Other Plugins

Use the IPC pattern for communicating with other Dalamud plugins:

public sealed class IpcCallerExample : DisposableMediatorSubscriberBase, IIpcCaller
{
    private readonly IDalamudPluginInterface _pi;
    private readonly ICallGateSubscriber<int> _apiVersion;

    public string Name => "ExamplePlugin";
    public bool Available { get; private set; }

    public IpcCallerExample(
        ILogger<IpcCallerExample> logger,
        IDalamudPluginInterface pluginInterface,
        LightlessMediator mediator)
        : base(logger, mediator)
    {
        _pi = pluginInterface;
        
        try
        {
            _apiVersion = _pi.GetIpcSubscriber<int>("ExamplePlugin.GetApiVersion");
            CheckAvailability();
        }
        catch (Exception ex)
        {
            Logger.LogWarning(ex, "Failed to initialize IPC for ExamplePlugin");
        }
    }

    private void CheckAvailability()
    {
        try
        {
            var version = _apiVersion.InvokeFunc();
            Available = version >= 1;
            Logger.LogInformation("ExamplePlugin available, version: {Version}", version);
        }
        catch
        {
            Available = false;
        }
    }

    public void DoSomething()
    {
        if (!Available)
        {
            Logger.LogWarning("ExamplePlugin not available");
            return;
        }

        // Call IPC methods
    }
}

Performance Considerations

Framework Updates

The game runs at ~60 FPS. Avoid heavy operations in framework updates:

Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, OnTick);

private void OnTick(PriorityFrameworkUpdateMessage _)
{
    // Throttle expensive operations
    if ((DateTime.UtcNow - _lastCheck).TotalSeconds < 1)
    {
        return;
    }
    _lastCheck = DateTime.UtcNow;

    // Do lightweight work only
}

Async Operations

// Good: Non-blocking async
public async Task<Data> FetchDataAsync()
{
    var result = await _httpClient.GetAsync(url).ConfigureAwait(false);
    return await result.Content.ReadFromJsonAsync<Data>().ConfigureAwait(false);
}

// Bad: Blocking async
public Data FetchDataBlocking()
{
    return FetchDataAsync().GetAwaiter().GetResult(); // Blocks thread!
}

Collection Performance

// Good: Thread-safe concurrent collections
private readonly ConcurrentDictionary<string, Data> _cache = new();

// Good: Lock-protected regular collections
private readonly List<Item> _items = new();
private readonly object _itemsLock = new();

public void AddItem(Item item)
{
    lock (_itemsLock)
    {
        _items.Add(item);
    }
}

// Good: SemaphoreSlim for async locks
private readonly SemaphoreSlim _asyncLock = new(1);

public async Task ProcessAsync()
{
    await _asyncLock.WaitAsync().ConfigureAwait(false);
    try
    {
        // Critical section
    }
    finally
    {
        _asyncLock.Release();
    }
}

Memory Management

// Dispose pattern for unmanaged resources
public class ResourceManager : IDisposable
{
    private bool _disposed;
    private readonly SemaphoreSlim _semaphore = new(1);

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;

        if (disposing)
        {
            // Dispose managed resources
            _semaphore?.Dispose();
        }

        // Free unmanaged resources

        _disposed = true;
    }
}

Testing

Unit Testing

[TestClass]
public class MyServiceTests
{
    private Mock<ILogger<MyService>> _mockLogger;
    private Mock<LightlessMediator> _mockMediator;
    private MyService _service;

    [TestInitialize]
    public void Setup()
    {
        _mockLogger = new Mock<ILogger<MyService>>();
        _mockMediator = new Mock<LightlessMediator>();
        _service = new MyService(_mockLogger.Object, _mockMediator.Object);
    }

    [TestMethod]
    public void DoWork_WithValidInput_ReturnsExpectedResult()
    {
        // Arrange
        var input = "test";

        // Act
        var result = _service.DoWork(input);

        // Assert
        Assert.AreEqual("expected", result);
    }
}

Integration Testing

Test with real Dalamud services in a controlled environment:

// Create test harness that mimics Dalamud environment
public class DalamudTestHarness
{
    public IClientState ClientState { get; }
    public IFramework Framework { get; }
    // ... other services

    public void SimulateFrameworkUpdate()
    {
        // Trigger framework update event
    }
}

Common Patterns

Notification Pattern

// Simple notification
Mediator.Publish(new NotificationMessage(
    "Title",
    "Message body",
    NotificationType.Info));

// Rich notification with actions
var notification = new LightlessNotification
{
    Id = "unique_id",
    Title = "Action Required",
    Message = "Do you want to proceed?",
    Type = NotificationType.Warning,
    Duration = TimeSpan.FromSeconds(10),
    Actions = new List<LightlessNotificationAction>
    {
        new()
        {
            Id = "confirm",
            Label = "Confirm",
            Icon = FontAwesomeIcon.Check,
            Color = UIColors.Get("LightlessGreen"),
            IsPrimary = true,
            OnClick = (n) =>
            {
                _logger.LogInformation("User confirmed");
                DoAction();
                n.IsDismissed = true;
            }
        },
        new()
        {
            Id = "cancel",
            Label = "Cancel",
            Icon = FontAwesomeIcon.Times,
            OnClick = (n) => n.IsDismissed = true
        }
    }
};

Mediator.Publish(new LightlessNotificationMessage(notification));

Configuration Pattern

public class MyConfigService : ConfigurationServiceBase<MyConfig>
{
    public MyConfigService(string configDirectory)
        : base(Path.Combine(configDirectory, "myconfig.json"))
    {
    }
}

// Usage
_configService.Current.SomeSetting = newValue;
_configService.Save();

Factory Pattern

public class GameObjectHandlerFactory
{
    private readonly DalamudUtilService _dalamudUtil;
    private readonly LightlessMediator _mediator;

    public GameObjectHandlerFactory(
        DalamudUtilService dalamudUtil,
        LightlessMediator mediator)
    {
        _dalamudUtil = dalamudUtil;
        _mediator = mediator;
    }

    public GameObjectHandler Create(IntPtr address, string identifier)
    {
        return new GameObjectHandler(
            _dalamudUtil,
            _mediator,
            () => address,
            identifier);
    }
}

Error Handling

Logging Guidelines

// Debug: Verbose information for debugging
_logger.LogDebug("Processing request {RequestId}", requestId);

// Information: General informational messages
_logger.LogInformation("User {UserId} connected", userId);

// Warning: Recoverable issues
_logger.LogWarning("Cache miss for {Key}, will fetch from server", key);

// Error: Errors that need attention
_logger.LogError(ex, "Failed to process message {MessageId}", messageId);

// Critical: Application-breaking errors
_logger.LogCritical(ex, "Database connection failed");

Exception Handling

public async Task<Result> DoWorkAsync()
{
    try
    {
        // Risky operation
        return await PerformOperationAsync().ConfigureAwait(false);
    }
    catch (HttpRequestException ex)
    {
        _logger.LogError(ex, "Network error during operation");
        // Show user-friendly notification
        Mediator.Publish(new NotificationMessage(
            "Network Error",
            "Failed to connect to server. Please check your connection.",
            NotificationType.Error));
        return Result.Failure;
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Unexpected error during operation");
        throw; // Re-throw unexpected exceptions
    }
}

Best Practices Summary

DO

  • Use DisposableMediatorSubscriberBase for services that subscribe to mediator
  • Use IHostedService for background services
  • Always inject ILogger<T> for logging
  • Use ConfigureAwait(false) for async operations not requiring UI thread
  • Dispose of resources properly (SemaphoreSlim, HttpClient, etc.)
  • Check for null when accessing Dalamud game objects
  • Use thread-safe collections for shared state
  • Keep UI logic separate from business logic
  • Use mediator for cross-component communication
  • Write unit tests for business logic
  • Document public APIs with XML comments

DON'T

  • Don't access game objects from background threads without RunOnFrameworkThread
  • Don't block async methods with .Result or .Wait()
  • Don't store UI state in services (services are often singletons)
  • Don't use async void except for event handlers
  • Don't catch Exception without re-throwing or logging
  • Don't create circular dependencies between services
  • Don't perform heavy operations in framework updates
  • Don't forget to unsubscribe from mediator messages
  • Don't hardcode file paths (use IDalamudPluginInterface.ConfigDirectory)
  • Don't use primary constructors (prefer traditional for clarity)

Code Review Checklist

Before submitting a pull request, ensure:

  • Code follows naming conventions
  • All new services are registered in Plugin.cs
  • Mediator messages are defined in Messages.cs
  • Logging is appropriate and informative
  • Thread safety is considered for shared state
  • Disposable resources are properly disposed
  • Async operations use ConfigureAwait(false)
  • UI code is separated from business logic
  • No direct game object access from background threads
  • Error handling is comprehensive
  • Performance impact is considered
  • Comments explain "why", not "what"
  • No compiler warnings
  • Tests pass (if applicable)

Resources


Happy Coding! 🚀