Skip to content

Example: Database with stats and more

Julgers edited this page Aug 24, 2023 · 3 revisions

Getting started

In this example, we want to connect to a database to store player stats, ban certain weapons and whitelist gameservers. We will use EntityFramework for this with a MySQL database. You will need to already have database for this. This example is a bit more advanced, so I recommend that you read the infected example first, to get a grasp of how the API works.

Features of this example:

  • Banning and unbanning weapons from the servers using the API using !banweapon and !unbanweapon commands. These are stored in the database.
  • Storing player stats / progression in the database. These are pulled from the database when a player joins the server and pushed to the database when a player leaves the server.
  • Storing the "whitelisted" servers in the database with their IP, port, and API token. Whenever a server connects to our API, we check if the server is whitelisted, that is, if its IP+port+token combination is in our database. If it is, we allow the server to use the API. If it isn't, we deny the server access to the API.

The full code for this example can be found here.

To install all of our dependencies, run the following command:

dotnet add package CommunityServerAPI Microsoft.EntityFrameworkCore.SqlServer Microsoft.EntityFrameworkCore.Design Pomelo.EntityFrameworkCore.MySql Microsoft.EntityFrameworkCore.Proxies Microsoft.EntityFrameworkCore.Relational Microsoft.Hosting Microsoft.Hosting.Abstractions

This installs the Community API to interact with the gameserver(s), EntityFramework for the database connection and Microsoft.Hosting for our services.

Models

Then, we need to create our models. We will create a model for our player and a model for our weapon bans.

Models/ServerPlayer.cs:

using System.ComponentModel.DataAnnotations.Schema;
using BattleBitAPI.Common;
using Microsoft.EntityFrameworkCore;

namespace CommunityServerAPI.Models;

[PrimaryKey(nameof(steamId))] // Defines the primary key
public class ServerPlayer
{
    // Remove auto increment etc.
    [DatabaseGenerated(DatabaseGeneratedOption.None)]
    public ulong steamId { get; set; }
    
    public PlayerStats stats { get; set; }
}

Models/BannedWeapon.cs:

using Microsoft.EntityFrameworkCore;

namespace CommunityServerAPI.Models;

[PrimaryKey(nameof(Name))]
public class BannedWeapon
{
    public string Name { get; set; }
}

Models/GameServer.cs:

using System.Net;
using Microsoft.EntityFrameworkCore;

namespace DatabaseExample.Models;

// We will have these three as information, and want all 3 of them to match.
// Someone can have multiple ports / tokens per IP, there will be multiple IPs / tokens per port
// And probably even multiple IPs / ports per token, for if you want to re-use the same token
// because of this, we use a composite key for all 3 of them.
[PrimaryKey(nameof(Ip), nameof(Port), nameof(Token))]
public class GameServer
{
    public IPAddress Ip { get; set; }
    public ushort Port { get; set; }
    public string Token { get; set; }
}

Now, we want to create our database context. This is the class that will handle the connection to the database and the queries.

Models/DatabaseContext.cs:

using BattleBitAPI.Common;
using Microsoft.EntityFrameworkCore;

namespace DatabaseExample.Models;

/*
 *  In implementations of DbContext we define all entities that we want to have included in the database.
 *  These will be converted into tables. We can also do additional configuration that we didn't put in the entity classes (models) yet.
 */
public class DatabaseContext : DbContext
{
    // These are your database tables
    public DbSet<ServerPlayer> Player { get; set; }
    public DbSet<BannedWeapon> BannedWeapons { get; set; }
    public DbSet<GameServer> GameServers { get; set; }

    public DatabaseContext(DbContextOptions<DatabaseContext> options) : base(options)
    {
    }

    // Oki made a nice conversion to serialise to byte array for us, and create a new playerstats object out of a byte array.
    // Here we tell EF that it has to convert the "stats" property to byte array whenever storing it inside the DB, and to
    // make a new playerstats object using the blob from the DB whenever pulling it from the DB.
    // If EF didn't do this for us, we would have to manually serialize to byte[] every time we write to DB
    // and manually create a new PlayerStats from the byte array in the DB every time we pull from the db.
    // Storing the full struct in DB instead of a blob is not at all convenient or necessary for our use case.
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder
            .Entity<ServerPlayer>()
            .Property(p => p.Stats)
            .HasConversion(
                s => s.SerializeToByteArray(),
                s => new PlayerStats(s));
    }
}

Repositories

To access our database, we will create repositories. These will handle the queries to the database. We will create a repository for our player and a repository for our banned weapons.

Repositories/IRepository.cs:

namespace CommunityServerAPI.Repositories;

/*
 * This repository interface serves as an abstraction of repository objects in some storage system.
 * This way, the application can use a set of model instances without having to be aware of the storage implementation.
 * This allows for a nice separation of concerns and you can change storage systems
 * easily without having to change the main application logic, by just changing IRepository implementations.
 */
public interface IRepository<T, TKey> where T : class
{
    public Task CreateAsync(T item);
    public Task DeleteAsync(T item);
    public Task UpdateAsync(T item);
    public Task<bool> ExistsAsync(TKey key);
    public Task<T?> FindAsync(TKey key);
}

Repositories/PlayerRepository.cs:

using DatabaseExample.Models;
using Microsoft.EntityFrameworkCore;

namespace DatabaseExample.Repositories;

public sealed class PlayerRepository : IRepository<ServerPlayer, ulong>
{
    private readonly DatabaseContext _context;

    public PlayerRepository(DatabaseContext context)
    {
        _context = context;
    }

    public async Task CreateAsync(ServerPlayer player)
    {
        _context.Player.Add(player);
        await _context.SaveChangesAsync();
    }

    public async Task DeleteAsync(ServerPlayer player)
    {
        _context.Player.Remove(player);
        await _context.SaveChangesAsync();
    }

    public async Task UpdateAsync(ServerPlayer player)
    {
        _context.Player.Update(player);
        await _context.SaveChangesAsync();
    }

    public async Task<bool> ExistsAsync(ulong steamId)
    {
        return await _context.Player.AnyAsync(p => p.SteamId == steamId);
    }

    public async Task<ServerPlayer?> FindAsync(ulong steamId)
    {
        return await _context.Player.FirstOrDefaultAsync(p => p.SteamId == steamId);
    }
}

Repositories/BannedWeaponRepository.cs:

#nullable enable
using DatabaseExample.Models;
using Microsoft.EntityFrameworkCore;

namespace DatabaseExample.Repositories;

public sealed class BannedWeaponRepository : IRepository<BannedWeapon, string>
{
    private readonly DatabaseContext _context;

    public BannedWeaponRepository(DatabaseContext context)
    {
        _context = context;
    }

    public async Task CreateAsync(BannedWeapon weapon)
    {
        _context.BannedWeapons.Add(weapon);
        await _context.SaveChangesAsync();
    }

    public async Task DeleteAsync(BannedWeapon weapon)
    {
        _context.BannedWeapons.Remove(weapon);
        await _context.SaveChangesAsync();
    }

    public async Task UpdateAsync(BannedWeapon weapon)
    {
        _context.BannedWeapons.Update(weapon);
        await _context.SaveChangesAsync();
    }

    public async Task<bool> ExistsAsync(string weaponName)
    {
        return await _context.BannedWeapons.AnyAsync(w => w.Name == weaponName);
    }

    public async Task<BannedWeapon?> FindAsync(string weaponName)
    {
        return await _context.BannedWeapons.FirstOrDefaultAsync(w => w.Name == weaponName);
    }
}

Repositories/GameServerRepository.cs:

using System.Net;
using DatabaseExample.Models;
using Microsoft.EntityFrameworkCore;

namespace DatabaseExample.Repositories;

// Reminder that we use a composite key for this table
public sealed class GameServerRepository : IRepository<GameServer, (IPAddress ip, ushort port, string token)>
{
    private readonly DatabaseContext _context;

    public GameServerRepository(DatabaseContext context)
    {
        _context = context;
    }

    public async Task CreateAsync(GameServer server)
    {
        _context.GameServers.Add(server);
        await _context.SaveChangesAsync();
    }

    public async Task DeleteAsync(GameServer server)
    {
        _context.GameServers.Remove(server);
        await _context.SaveChangesAsync();
    }

    public async Task UpdateAsync(GameServer server)
    {
        _context.GameServers.Update(server);
        await _context.SaveChangesAsync();
    }

    public async Task<bool> ExistsAsync((IPAddress ip, ushort port, string token) server)
    {
        return await _context.GameServers.AnyAsync(
            s => server.ip == s.Ip && server.port == s.Port && server.token == s.Token);
    }

    public async Task<GameServer?> FindAsync((IPAddress ip, ushort port, string token) server)
    {
        return await _context.GameServers.FirstOrDefaultAsync(
            s => server.ip == s.Ip && server.port == s.Port && server.token == s.Token);
    }
}

MyGameServer & MyPlayer

It is now time to create our own subclasses of MyGameServer & MyPlayer that the gameserver listener will use. Here we determine how the game shall be played and how we respond to callbacks and requests from in-game events. We can use the service provider from the constructor to create service scopes and get our repositories from there, as they will be registered as scoped services in our main application builder.

MyGameServer.cs:

using BattleBitAPI.Common;
using BattleBitAPI.Server;
using DatabaseExample.Models;
using DatabaseExample.Repositories;
using Microsoft.Extensions.DependencyInjection;

namespace DatabaseExample;

internal class MyGameServer : GameServer<MyPlayer>
{
    private readonly IServiceProvider _services;

    public MyGameServer(IServiceProvider services)
    {
        _services = services;
    }

    public override async Task<bool> OnPlayerTypedMessage(MyPlayer author, ChatChannel channel, string message)
    {
        // Here we make commands like "!banweapon M4A1" etc. to ban and unban weapons.
        // These commands use our repository to put and remove them from the database.
        // returning true means putting the message in chat, false for not putting it in chat.

        if (author.SteamID != 76561198173566107 || !message.StartsWith("!"))
            return true; // Whatever checks you want to do.

        var words = message.Split(" ");

        using var scope = _services.CreateScope();
        var bannedWeapons = scope.ServiceProvider.GetRequiredService<BannedWeaponRepository>();

        switch (words[0])
        {
            case "!banweapon":
                if (!await bannedWeapons.ExistsAsync(words[1]))
                    await bannedWeapons.CreateAsync(new BannedWeapon { Name = words[1] });
                break;

            case "!unbanweapon":
                if (await bannedWeapons.ExistsAsync(words[1]))
                    await bannedWeapons.DeleteAsync(new BannedWeapon { Name = words[1] });

                break;
        }

        return false;
    }

    public override async Task OnSavePlayerStats(ulong steamId, PlayerStats stats)
    {
        using var scope = _services.CreateScope();
        var players = scope.ServiceProvider.GetRequiredService<PlayerRepository>();

        var player = new ServerPlayer { SteamId = steamId, Stats = stats };

        // Check if there's already an entry in the DB, if so, update it, otherwise, create one.
        if (await players.ExistsAsync(steamId))
            await players.UpdateAsync(player);
        else
            await players.CreateAsync(player);
    }

    public override async Task OnPlayerJoiningToServer(ulong steamId, PlayerJoiningArguments args)
    {
        using var scope = _services.CreateScope();
        var players = scope.ServiceProvider.GetRequiredService<PlayerRepository>();

        // Here we try to get the player out of the database. Return a new PlayersStats() if null, otherwise
        // we will put player in a variable and return its stats.
        args.Stats = await players.FindAsync(steamId) switch
        {
            null => new PlayerStats(),
            var player => player.Stats
        };
    }

    public override async Task<OnPlayerSpawnArguments?> OnPlayerSpawning(MyPlayer player,
        OnPlayerSpawnArguments request)
    {
        using var scope = _services.CreateScope();
        var bannedWeapons = scope.ServiceProvider.GetRequiredService<BannedWeaponRepository>();

        // Check if the it's in the banned weapons table, if so, we don't allow it.
        if (await bannedWeapons.ExistsAsync(request.Loadout.PrimaryWeapon.Tool.Name))
        {
            player.Message($"Cannot use banned weapon {request.Loadout.PrimaryWeapon.Tool.Name}!", 1f);
            return null; // Deny spawn request.
        }

        return request;
    }
}

MyPlayer.cs (empty class for now):

using BattleBitAPI;

namespace DatabaseExample;

internal class MyPlayer : Player<MyPlayer>
{
}

Gameserver listener

We want this app to have a gameserver listener running and listening for requests from the gameserver. For this, we will use a hosted service (IHostedService). This will run in the background and will be started when the application starts. Since the listener is an IDisposable that can and wants to be disposed, this service will also be an IDisposable.

ListenerService.cs:

using System.Net;
using BattleBitAPI.Server;
using DatabaseExample.Repositories;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using LogLevel = BattleBitAPI.Common.LogLevel;

namespace DatabaseExample;

public sealed class ListenerService : IHostedService, IDisposable
{
    /*
     * Since this is a hosted service, we can receive things like the IServiceProvider and ILogger in our constructor
     * to use them here. We want the service provider to create service scopes and resolve our repository services,
     * which is also why we pass it in the constructor of MyPlayer and MyGameServer.
     */

    private readonly ServerListener<MyPlayer, MyGameServer> _listener;
    private readonly ILogger<ListenerService> _logger;
    private readonly IServiceProvider _services;

    public ListenerService(ILogger<ListenerService> logger, IServiceProvider services)
    {
        _services = services;

        _listener = new ServerListener<MyPlayer, MyGameServer>();
        _listener.LogLevel = LogLevel.All;
        _listener.OnValidateGameServerToken += OnValidateGameToken;
        _listener.OnCreatingGameServerInstance += OnCreatingGameServerInstance;
        _listener.OnCreatingPlayerInstance += OnCreatingPlayerInstance;
        _listener.OnLog += OnLog;

        _logger = logger;
    }

    private void OnLog(LogLevel lvl, string msg, object obj)
    {
        _logger.LogInformation($"Log (level {lvl}): {msg}");
    }

    private MyPlayer OnCreatingPlayerInstance(ulong arg)
    {
        return new MyPlayer();
    }

    private MyGameServer OnCreatingGameServerInstance(IPAddress arg1, ushort arg2)
    {
        return new MyGameServer(_services);
    }

    private async Task<bool> OnValidateGameToken(IPAddress ip, ushort port, string token)
    {
        // We have a "whitelist" of IP + port + token entries in database.
        // ExistsAsync checks if the DB has any entries with exactly this composite key of ip, port and token.
        using var scope = _services.CreateScope();
        var gameServers = scope.ServiceProvider.GetRequiredService<GameServerRepository>();
        return await gameServers.ExistsAsync((ip, port, token));
    }

    // Since this is an IHostedservice, here we tell it what to do when starting and (below) stopping the service.
    public Task StartAsync(CancellationToken cancellationToken)
    {
        _listener.Start(IPAddress.Loopback, 29294);

        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        _listener.Stop();

        return Task.CompletedTask;
    }

    // From IDisposable, make sure we dispose the listener when disposing this service.
    public void Dispose()
    {
        _listener.Dispose();

        GC.SuppressFinalize(this);
    }
}

Application entry point

Now that we have our listener service, we need to add it to the services collection and start it. We also need to have the services ready for the DatabaseContext to inject into our repositories, and the services for the repositories themselves. All of this will be done in the application entry point.

Program.cs:

using DatabaseExample;
using DatabaseExample.Models;
using DatabaseExample.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

/*
 * This place is basically the entry point of the application, with a nicer syntax.
 * Normally we would be in Main(), this is basically the same thing, to avoid writing boilerplate code.
 */

/*
 * First we set up the IoC/DI system: https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection
 * We now begin setting up and configuring our application class, which we start by creating a generic host builder for configuration
 * https://learn.microsoft.com/en-us/dotnet/core/extensions/generic-host
 */
var builder = Host.CreateApplicationBuilder(args);

// We then register the services to the IoC container, allowing us to inject them into other classes / services.

/*
 * The CreateApplicationBuilder method:
 * Loads app configuration from:
 * 
 *  - appsettings.json.
 *  - appsettings.{Environment}.json.
 *  - Secret Manager when the app runs in the Development environment.
 *  - Environment variables.
 *  - Command-line arguments.
 *
 *  We can now get the desired connection string for the database out of one of those.
 */
var dbConnectionString = builder.Configuration.GetConnectionString("defaultConnection");

// Add our DB connection, this is scoped service that can be injected.
builder.Services.AddDbContext<DatabaseContext>(options =>
{
    options.UseLazyLoadingProxies();
    options.UseMySql(dbConnectionString, ServerVersion.AutoDetect(dbConnectionString));
});

// Register the repositories, they will be disposed when the service scope ends.
builder.Services.AddScoped<PlayerRepository>();
builder.Services.AddScoped<BannedWeaponRepository>();
builder.Services.AddScoped<GameServerRepository>();

// Game server listener. This is our hosted service.
builder.Services.AddHostedService<ListenerService>();

var app = builder.Build();

// Auto apply any pending db migrations on startup.
using (var scope = app.Services.CreateScope())
{
    var context = scope.ServiceProvider.GetService<DatabaseContext>();
    context.Database.Migrate();
}

app.Run();

As you can see, the Program.cs file is the entry point of the application. Here we set up the IoC container and register our services. We also add the hosted service (the gameserver listener) to the services collection, so it will be started when the application starts.

Configuration

The configuration (like our connection string, which has our database credentials so that we can connect to the database) is pulled from (in order of lowest to highest priority):

  • appsettings.json
  • appsettings.{Environment}.json
  • Secret Manager when the app runs in the Development environment.
  • Environment variables.
  • Command-line arguments.

For your convenience, here's an example appsettings.json file with the connection string:

{
  "ConnectionStrings": {
    "defaultConnection": "server=localhost;port=3306;database=commapi;user=root;password="
  }
}

Migrations

After creating our models, we want to run the migrations to create the tables in our database. For this, you first need to have EF core tools installed. To install, run

dotnet tool install --global dotnet-ef

Then, you need to add a migration.

To do this, run

dotnet ef migrations add InitialCreate

Our code in Program.cs already applies migrations on startup. However, if you want to do this manually, you can run

dotnet ef database update

After this, you can finally run the code and it should work.