After spending most of this chapter working to create our game services, we are going to return to the SimpleRPG game engine. We want to replace our local game data files with requests to our game services. That way we can retrieve updated data from the services and cache it locally for the running game instance. We will simply only cache the data in memory for the extent that the game is running. Every time we restart the game, the data will be fetched from the game services.
Having these service calls will make our game reliant on having an internet connection and the game services running successfully. In the future, we may look at caching data locally between sessions to be able to run while intermittently offline, but that’s an advanced scenario.
To enable communication with our game services, we are going to write a client class that encapsulates that functionality. We will do this so that our factory classes don’t have any logic specific to web service requests, and so that we can mock our client interface to build robust tests that don’t require internet connections.
DTO Updates
First, we will take a side-track to our DTO classes. We currently have duplicate DTO classes defined in our game engine and in the game services project. These duplicate classes are great candidates for a shared library. In future lessons, we will extract these types from both projects and build them into their own NuGet package that can be published and used in various projects. But we don’t want to get distracted from changing our game to communicate with the game services, so we will make a little mess to fix later.
Our immediate problem is that our DTO types don’t match, but we want them to. So we will modify the game engine DTO classes for the time being.
1. First, let’s load our ‘simple-rpg-game’ solution file from our local file system. This is the first project we worked with for the Blazor game and engine. It should be in a path similar to: C:\dev\repos\d20tek\simplerpg\simple-rpg-game\src.
2. Let’s create the NamedElement
base class that will be used for all of our templates in the Factories\DTO folder. This matches the exact definition in our game services project.
using System;
namespace SimpleRPG.Game.Engine.Factories.DTO
{
public abstract class NamedElement<T> : IEquatable<NamedElement<T>>
where T : struct
{
private string _name = string.Empty;
protected NamedElement(T id, string name)
{
Id = id;
Name = name;
}
protected NamedElement()
{
}
public T Id { get; set; }
public string Name
{
get => _name;
set => _name = !string.IsNullOrEmpty(value) ? value : throw new ArgumentNullException(nameof(Name));
}
public override string ToString()
{
return $"{Name} [{Id}]";
}
public bool Equals(NamedElement<T> other)
{
if (other is null)
{
return false;
}
return Id.Equals(other.Id);
}
public override bool Equals(object obj)
{
if (obj is NamedElement<T> element)
{
return Equals(element);
}
return false;
}
public override int GetHashCode()
{
return Id.GetHashCode();
}
}
}
3. Let’s change the ItemTemplate
class to derive from NamedElement
, and remove its Id
and Name
properties.
using SimpleRPG.Game.Engine.Models;
namespace SimpleRPG.Game.Engine.Factories.DTO
{
public class ItemTemplate : NamedElement<int>
{
public GameItem.ItemCategory Category { get; set; }
public int Price { get; set; }
public string Damage { get; set; } = string.Empty;
public int Heals { get; set; }
}
}
These changes will enable us to work with ItemTemplates
in this lesson.
Define IGameServiceClient
Since our services all follow a similar pattern, we can define a generic interface that works for all of our game services. This interface will expose the operations that are available in our game services, but not any details about the implementation of communicating with those services.
Let’s create the IGameServiceClient
interface in the SimpleRPG.Game.Engine project and Services folder.
using SimpleRPG.Game.Engine.Factories.DTO;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace SimpleRPG.Game.Engine.Services
{
public interface IGameServiceClient<T, TId>
where T : NamedElement<TId>
where TId : struct
{
Task<IEnumerable<T>> GetAllEntities();
Task<IEnumerable<T>> GetEntities(int offset, int limit, string filters);
Task<int> GetEntityCount(string filters);
Task<T> GetEntityById(TId id);
}
}
- This interface is defined as a generic with two types (line #7): one for the entity type and one for the entity’s id.
- Then we constrain the entity types to always derive from
NamedElement
(line #8). This constraint matches a similar one on our services. - Then we constrain the id type to be a value type (line #9).
- We define methods to match our service endpoints and return the retrieved data (lines #11-17).
- All of these methods return a
Task
of the return type.Task
is used for asynchronous operations. Since service calls may be long-running, we will use the async-await pattern in C# to make these non-blocking calls. If you would like more information on how async works, please review the async/await concepts in .NET.
Implement GameServiceClient Class
With the interface and methods defined, we can create a class that implements these retrieval methods. Let’s create the GameServiceClient
class in the SimpleRPG.Game.Engine project and Services folder.
using Microsoft.AspNetCore.Components;
using SimpleRPG.Game.Engine.Factories.DTO;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
namespace SimpleRPG.Game.Engine.Services
{
public class GameServiceClient<T, TId> : IGameServiceClient<T, TId>
where T : NamedElement<TId>
where TId : struct
{
private readonly HttpClient _httpClient;
private readonly string _serviceUrl;
private readonly JsonSerializerOptions options = new JsonSerializerOptions
{
AllowTrailingCommas = true,
PropertyNameCaseInsensitive = true,
};
public GameServiceClient(HttpClient http, string serviceUrl)
{
_httpClient = http ?? throw new ArgumentNullException(nameof(http));
_serviceUrl = serviceUrl ?? throw new ArgumentNullException(nameof(serviceUrl));
}
public async Task<IEnumerable<T>> GetAllEntities()
{
return await _httpClient.GetJsonAsync<IEnumerable<T>>(_serviceUrl).ConfigureAwait(true);
}
public async Task<IEnumerable<T>> GetEntities(int offset, int limit, string filters)
{
if (offset < 0)
{
throw new ArgumentOutOfRangeException(nameof(offset));
}
if (limit <= 0)
{
throw new ArgumentOutOfRangeException(nameof(limit));
}
string fullUrl = $"{_serviceUrl}/?offset={offset}&limit={limit}{filters}";
return await _httpClient.GetJsonAsync<IEnumerable<T>>(fullUrl).ConfigureAwait(true);
}
public async Task<T> GetEntityById(TId id)
{
string fullUrl = $"{_serviceUrl}/{id}";
var response = await _httpClient.GetAsync(fullUrl).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var result = JsonSerializer.Deserialize<T>(json, this.options);
return result;
}
else if (response.StatusCode == HttpStatusCode.NotFound)
{
throw new EntityNotFoundException("Id", id);
}
else
{
throw new HttpRequestException(
$"Response status code does not indicate success: {response.StatusCode}.");
}
}
public async Task<int> GetEntityCount(string filters)
{
var countUrl = $"{_serviceUrl}-count?{filters}";
return await _httpClient.GetJsonAsync<int>(countUrl).ConfigureAwait(true);
}
}
}
- The
GameServiceClient
class derives fromIGameServiceClient
with the same two generic types and the same constraints as the interface (lines #12-14). - The constructor (lines #24-28) takes an
HttpClient
to process any requests and aserviceUrl
, which is the fully qualified url for this particular resource type (for example,ItemTemplate => ./api/item
). - The
GetAllEntites
method (lines #30-33) processes a GET request for the item endpoint with no query string parameters, which return all of the items. - The
GetEntities
method (lines #35-49) also processes a GET request for the item endpoint root, but it passes query string parameters to support pagination and filtering. - The
GetEntityById
method (lines #51-70) retrieves a singleItemTemplate
use a GET request that specifies the id in the url. - The
GetEntityCount
method (lines #72-76) allows the service to return how many entities of this type are available from the service. This is also a GET request with optional filters in the query string. This total count is also useful in pagination scenarios.
As we can see, these methods have generic names (Entity
rather than ItemTemplate
). This is because we are using generics and the method names are used for different entity types. This makes names a little less specific, but enables code reuse rather than implementing multiple service clients.
Also, all of the service request code and JSON conversion code is in this class, so our game engine code only needs to know how to work with ItemTemplate
types… and has no knowledge whether they come from resource files or our game services. That is great isolation to keep our game code portable.
And these methods in this GameServiceClient
class map pretty closely to the service endpoints we built in Azure Functions. As we extend capabilities (like being able to post ItemTemplates
), the service client could be extended to support that as well.
Finally, let’s create the EntityNotFoundException
in the SimpleRPG.Game.Engine project and Services folder.
using System;
namespace SimpleRPG.Game.Engine.Services
{
public class EntityNotFoundException : Exception
{
public EntityNotFoundException(string entityIdName, object entityIdValue, Exception? innerException = null)
: base($"Entity with {entityIdName} = {entityIdValue} was not found in repository.", innerException)
{
this.EntityIdName = entityIdName;
this.EntityIdValue = entityIdValue;
}
public string EntityIdName { get; private set; }
public object EntityIdValue { get; private set; }
}
}
This is a simple, typed exception that is thrown when a specified id is not found in our service. We convert the service exception to this typed exception, so that we can handle that error case in specific ways… like letting the user know the item they were looking for does not exist. Rather than showing a generic web request failed message.
Testing GameServiceClient
As part of this lesson’s commit, there are several test classes that validate the behavior of the GameServiceClient
. Some of these tests actually make live service calls (since these are just retrieval methods), so we can verify that our client code actually calls our Azure Functions service and we get back the expected results.
We’re not going to review every test, but they validate each method with expected results and how they are expected to fail. And while we won’t initially use all of these service methods in our game engine, we are sure that their behavior is correct.
Minor GameScreen Update
To allow us to easily differentiate our stand-alone game from our new game that integrates with our web services, we made a simple change to the UI to rename the game to Simple RPG Online. This way we can tell when we’re working with an older version of the game.
@page "/"
@inject IJSRuntime jsRuntime
@inject IGameSession ViewModel
<div @onkeydown="@KeyDown" tabindex="0" @ref="pageRoot">
<Row Margin="Margin.Is0" Style="height: 5vh; min-height: 32px">
<Column ColumnSize="ColumnSize.Is12" Style="background-color: lightgrey">
<Heading Size="HeadingSize.Is3">Simple RPG Online</Heading>
</Column>
</Row>
<Row Margin="Margin.Is0" Style="height: 60vh">
<Column ColumnSize="ColumnSize.Is4.OnWidescreen.Is12" Style="background-color: aquamarine">
<PlayerComponent Player="@ViewModel.CurrentPlayer" />
</Column>
<Column ColumnSize="ColumnSize.Is8.OnWidescreen.Is12" Style="background-color: beige">
<Row Margin="Margin.Is2.OnY">
<Column ColumnSize="ColumnSize.Is8.OnWidescreen.Is12">
<DisplayMessageListView Messages="@ViewModel.Messages" />
</Column>
<Column ColumnSize="ColumnSize.Is4.OnWidescreen.Is12">
<LocationComponent Location="@ViewModel.CurrentLocation" />
<MonsterComponent Monster="@ViewModel.CurrentMonster" />
<TraderComponent Trader="@ViewModel.CurrentTrader" Player="@ViewModel.CurrentPlayer"
InventoryChanged="@StateHasChanged" />
</Column>
</Row>
</Column>
</Row>
<Row Margin="Margin.Is0" Style="height: 33vh">
<Column ColumnSize="ColumnSize.Is4.OnWidescreen.Is12" Padding="Padding.Is2.OnY"
Style="background-color: burlywood">
<PlayerTabs Player="@ViewModel.CurrentPlayer" CraftItemClicked="@ViewModel.CraftItemUsing"
DisplayMessageCreated="@ViewModel.AddDisplayMessage" />
</Column>
<Column ColumnSize="ColumnSize.Is8.OnWidescreen.Is12" Style="background-color: lavender">
<Row Margin="Margin.Is2.OnY">
<Column ColumnSize="ColumnSize.Is8.OnWidescreen.Is12">
<CombatComponent WeaponList="@ViewModel.CurrentPlayer.Inventory.Weapons"
AttackClicked="@ViewModel.AttackCurrentMonster"
LocationHasMonster="@ViewModel.HasMonster"
ConsumableList="@ViewModel.CurrentPlayer.Inventory.Consumables"
ConsumeClicked="@ViewModel.ConsumeCurrentItem" />
</Column>
<Column ColumnSize="ColumnSize.Is4.OnWidescreen.Is12">
<MovementComponent Movement="@ViewModel.Movement"
LocationChanged="@ViewModel.OnLocationChanged" />
</Column>
</Row>
</Column>
</Row>
</div>
@code {
protected ElementReference pageRoot; // set the @ref for attribute
protected async override Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await jsRuntime.InvokeVoidAsync("SetFocusToElement", pageRoot);
}
}
protected override void OnInitialized()
{
DisplayMessageBroker.Instance.OnMessageRaised += OnGameMessageRaised;
}
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
private void KeyDown(KeyboardEventArgs args) =>
ViewModel.ProcessKeyPress(args.ToKeyProcessingEventArgs());
private void OnGameMessageRaised(object sender, DisplayMessage message) =>
ViewModel.AddDisplayMessage(message);
}
We can build our code again and run all of these tests. While the game will load with the new name, and our tests verify that we can call our game services through our client. We haven’t hooked up this GameServiceClient
with our factories completely yet.
In our next lesson, we will look at changes that are needed to the ItemFactory
and game engine to use the IGameServiceClient
to load our ItemTemplate
data and integrate with our existing game logic.