Manapulse.dk: MTG/EDH Game and Collection Tracker

Manapulse.dk: MTG/EDH Game and Collection Tracker

I play EDH (the multiplayer format of Magic: The Gathering) every Friday with a local group in Hillerød. We wanted to know who was taking home the wins so we could threat assess better during the games so being a developer I naturally started building a game tracker. It turned into manapulse.dk: a tracker for EDH games, MTG collections & decks, wishlists, trading, and card prices. There were two interesting technical problems.

Scanning cards on-device

I wanted to build a scanner from the ground up: Point your phone at a card and it’s identified for usage in the app whether to check rulings, prices for trades or adding it to your collection/wishlist. I wanted to do this entirely on the phone to ensure fast response. All inference runs in the browser app, on ONNX Runtime Web over WebGPU (with WASM fallback). Three models: a YOLO
detector finds the card, a classifier reads the set symbol, and an encoder (DINOv2-small, later a distilled TinyViT)
turns the art crop into an embedding matched against a bank of ~100,000 printings.

Collection Organization and Management

Players of card games like MTG often have multiple thousand cards. Initially it might be possible to mentally keep track of these but as the collection grows this become harder and harder. The result is that it becomes harder to trade (you don’t remember what you have to sell or what you need to buy), it becomes harder to find cards when building decks and you might loose track of how much you are actually spending on the game (and what the valuation of your collection is).

Manapulse helps players digitalize their collection. It has an advanced inventory system to track the entire collection whether it is in decks, binder or loose piles. And even better, it helps track the cards as they move around. When trading cards they are automatically added or removed from your collection (with registration of paid price for further P/L evaluations). And it allows the user to share his collection with a link so others can browse with the intent of trading.

Tech Stack

  • Backend: .NET 10 Minimal APIs, vertical-slice features, EF Core + SQLite.
  • Frontend: React 19 + TypeScript, Tailwind v4, TanStack Router, served as a PWA.
  • Hosting: one Docker image (the API serves the React build), behind Caddy. SQLite on a volume mount.

Try the app at manapulse.dk if you are a fan of Magic: The Gathering we might have the best scanner on the market 🙂

Use Same Version For Build and Release Pipelines in Azure DevOps

In build pipeline set the desired build number format. The default is: 0.1.$(Rev:r) and will produce versions with patch increasing for every build

  • 0.1.1
  • 0.1.2
  • 0.1.3

In the release pipeline using one or more artefacts from the build pipeline the primary artefact will be used to populate the Build.X variables.

Set the ‘Release name format’ to: PRODUCTID $(Build.Buildnumber) (release $(Rev:r)). This will produce the following release numbers:

  • PRODUCTID 0.1.1 (release 1)
  • PRODUCTID 0.1.1 (release 2)
  • PRODUCTID 0.1.2 (release 1)

Edit: Instead of manually typing the product ID it could make sense to use the pipeline name. So the release name format would be ‘$(Release.DefinitionName) $(Build.Buildnumber) (release $(Rev:r))’

The release revision will increase every time the same build is deployed multiple times (usually during debugging of the release pipeline). New version of the build will reset the release counter back to 1.

This setup allows developers to easily see how builds and releases are related. For even more transparency the build version could be stamped into the assemblies being built.

 

Migrate away from Azure Function Consumption Plan

Follow these steps to migrate away from the Azure Function’s consumption plan to a regular App Service Plan.

  1. Create a new App Service Plan in GUI or via Powershell
  2. Select your subscription: Select-AzureRmSubscription “SubscriptionName”
  3. Move azure function to new plan: Set-AzureRmWebApp -Name “functionAppName” -ResourceGroupName “rgName” -AppServicePlan “newAppServicePlanName”

Credit goes to DeV1l: https://github.com/Azure/Azure-F5unctions/issues/15

Closing secondary windows when main window is closed in UWP

In UWP it is not possible to show multiple windows for a single app. Depending on the usage, the user might expect secondary windows to close once the main window closes.

There are two problems to solve to make this happen:

  1. Figure out when the main window has been closed
  2. Close all secondary windows once the main window is closed

Both turned out to be harder than expected.

For the first issue, one might expect Window.Current.Closed or  Window.Current.CoreWindow.Closed to be signalled when the window is closed. This is not the case when a secondary window is open. After trying several other events, the ApplicationView.Consolidated event was the only one that I had success with. It would appear that when multiple windows are opened, the main window is not actually closed, but just hidden.

For the second issue, I likewise tried calling several Close methods to get the second window to close. I initially stayed away from Application.Current.Exit because the documentation says it should not be called, but the MSDN article for multiple views in UWP actually recommends invoking it to close down the main window and thereby all secondary windows as well.

So the final solution ended up being:

ApplicationView.GetForCurrentView().Consolidated += (ss, ee) =>
     (ss, ee) => {Application.Current.Exit();};

 

 

Configuring host, scheme and base path with SwashBuckle

Configuring host, scheme and base path with SwashBuckle

Swagger 2.0 supports specifying both host, scheme and base path directly in the swagger document. By default SwashBuckle does not set any values for these properties in the swagger document.

The following snippet will dynamically add the properties taken from the request to the swagger document:

app.UseSwagger(c =>
{
    c.PreSerializeFilters.Add((swaggerDoc, httpReq) => {
        swaggerDoc.Host = httpReq.Host.Value;
        swaggerDoc.Schemes = new List<string>() { httpReq.Scheme };
        swaggerDoc.BasePath = httpReq.PathBase;
    });
});

 

Solution for: “Another user has already installed a packaged version of this app. An unpackaged version cannot replace this”

After resetting the data for my app locally and then deleting it I encountered the following error: “DEP0700 : Registration of the app failed. Another user has already installed a packaged version of this app. An unpackaged version cannot replace this. The conflicting package is xxx and it was published by CN=xxx.”

For some reason the app was still on the system for another user. I ended up having to run powershell as an administrator and running the following snippet to delete the app completely from the system:

get-appxpackage -all | where name -eq “17402Qua.xxx” | remove-appxpackage

Cannot find project info for ‘project’. This can indicate a missing project reference.

While trying out migrating a few old projects to ASP.NET Core 1.1, I stumbled upon this error when trying to the build the project.

For some reason VS does not inform about which specific references are missing. Imagine the following scenario:

Library A -> Library B

Library C -> Library A

If Library A expose any types from Library B, then C would require a direct reference to B. However, if A does not expose any of B’s types, then C can reference A without a direct reference to B.

So, VS 2017 will give you ‘Cannot find project info for (…)’ error when you don’t have the necessary direct references. Unlike previously, it will not inform which dependencies are required.

Edit: Seems the error occurs for several different issues. See more on this github issue.

Reverse Proxy in ASP.NET Web API – Part 2

At first I only needed the reverse proxy for a JSON rest API. Soon, however, it was expanded to also cover HTML content. Thus the below update to make sure any URLs in the HTML was replaced to correctly match the reverse proxy server and not the internal server:

public class ProxyHandler : DelegatingHandler
{
    private readonly string redirectUrl; 

    public ProxyHandler(string redirectUrl)
    {
        this.redirectUrl = redirectUrl;
    } 

    private async Task<HttpResponseMessage> RedirectRequest(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var redirectLocation = redirectUrl;
        var localPath = request.RequestUri.LocalPath.Replace("ExternalVirtualPath", "InternalVirtualPath"); 

        var client = new HttpClient(new HttpClientHandler() { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate }); 

        var clonedRequest = await HttpRequestMessageExtensions.CloneHttpRequestMessageAsync(request); 

        clonedRequest.RequestUri = new Uri(redirectLocation + localPath); 

        var httpResponseMessage = await client.SendAsync(clonedRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
        httpResponseMessage.Headers.Add("X-ReverseProxy", "true"); 

        if (httpResponseMessage.Content?.Headers?.ContentType != null)
        {
            if (httpResponseMessage.Content.Headers.ContentType.MediaType == "text/html")
            {
                var content = await httpResponseMessage.Content.ReadAsByteArrayAsync();
                var stringContent = Encoding.UTF8.GetString(content); 

                var newContent = stringContent.Replace("InternalVirtualPath", "ExternalVirtualPath");
                httpResponseMessage.Content = new StringContent(newContent, Encoding.UTF8, "text/html");
            }
        } 

        return httpResponseMessage;
    } 

    protected override
        Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken)
    {
        return RedirectRequest(request, cancellationToken);
    }
}