Starting Point
The series project can now:
- persist player profiles
- synchronize players into the world
- run AI drones
- resolve real combat
That is enough to add progression with actual gameplay events behind it.
This post builds a first working quest pipeline on the real Altruist quest runtime:
- a vault-backed
IQuestStateStore - a small
QuestRuntime<GameQuestContext> - one state-machine quest
- one combat-to-quest bridge
1. Persist Quest State in Vault
Create Persistence/QuestProgressVault.cs:
using Altruist;
using Altruist.UORM;
namespace SpaceGame.Persistence;
[Vault("quest_progress")]
public sealed class QuestProgressVault : VaultModel
{
[VaultColumn("subject_id")]
public string SubjectId { get; set; } = "";
[VaultColumn("quest_id")]
public string QuestId { get; set; } = "";
[VaultColumn("state_json")]
public string StateJson { get; set; } = "";
}
We keep it simple:
- one row per player quest
- JSON payload stores the internal
QuestState
2. Create a Quest Context
Create Quests/GameQuestContext.cs:
using Altruist.Gaming.Questing;
using SpaceGame.World;
namespace SpaceGame.Quests;
public sealed class GameQuestContext : QuestContext
{
public PlayerPrefab? Player { get; init; }
public int Level { get; init; }
}
This is your project-specific layer on top of the generic QuestContext.
3. Add a Real State Store
Create Quests/VaultQuestStateStore.cs:
using Altruist;
using Altruist.Gaming.Questing;
using SpaceGame.Persistence;
namespace SpaceGame.Quests;
public sealed class VaultQuestStateStore : IQuestStateStore
{
private readonly IVault<QuestProgressVault> _vault;
public VaultQuestStateStore(IVault<QuestProgressVault> vault)
{
_vault = vault;
}
public async Task<Dictionary<string, QuestState>> LoadAsync(string subjectId)
{
var rows = await _vault
.Where(x => x.SubjectId == subjectId)
.ToListAsync();
return rows.ToDictionary(
x => x.QuestId,
x => new QuestState(x.StateJson),
StringComparer.Ordinal);
}
public async Task SaveDirtyAsync(string subjectId, IReadOnlyDictionary<string, QuestState> states)
{
foreach (var entry in states)
{
if (!entry.Value.IsDirty)
continue;
var rowId = $"{subjectId}:{entry.Key}";
var row = await _vault
.Where(x => x.StorageId == rowId)
.FirstOrDefaultAsync()
?? new QuestProgressVault
{
StorageId = rowId,
SubjectId = subjectId,
QuestId = entry.Key,
};
row.StateJson = entry.Value.Serialize();
await _vault.SaveAsync(row);
entry.Value.MarkClean();
}
}
public async Task ResetAsync(string subjectId)
{
var rows = await _vault
.Where(x => x.SubjectId == subjectId)
.ToListAsync();
foreach (var row in rows)
await _vault.Where(x => x.StorageId == row.StorageId).DeleteAsync();
}
}
4. Define a First Quest
Create Quests/FirstHuntQuest.cs:
using Altruist.Gaming.Questing;
namespace SpaceGame.Quests;
[Quest("main.first_hunt")]
[QuestName("First Hunt")]
[QuestKind(QuestKind.Main)]
[QuestRequirement("level", QuestOperator.GreaterOrEqual, 1)]
public sealed class FirstHuntQuest : QuestBehavior<GameQuestContext>
{
[QuestState("hunt", Initial = true)]
public string? Hunt(GameQuestContext ctx)
{
if (ctx.Trigger == QuestTrigger.Kill)
{
int kills = ctx.State.Increment("kills");
if (kills >= ctx.State.Get("target", 3))
return "completed";
}
return null;
}
[QuestStateEnter("hunt")]
public void EnterHunt(GameQuestContext ctx)
{
if (!ctx.State.Has("target"))
ctx.State.Set("target", 3);
}
[QuestStateEnter("completed")]
public void EnterCompleted(GameQuestContext ctx)
{
ctx.State.Set("done", true);
}
[QuestState("completed")]
public string? Completed(GameQuestContext ctx) => null;
}
This is small, but it already uses the real async state-machine runtime shape.
5. Publish Quest Updates to the Client
Create Quests/QuestUpdatePackets.cs:
using System.Text.Json.Serialization;
using Altruist;
using Altruist.Gaming.Questing;
namespace SpaceGame.Quests;
public sealed record QuestRequirementDto(string Key, bool Passed, string? Reason);
public struct QuestUpdatePacket : IPacketBase
{
[JsonPropertyName("messageCode")] public uint MessageCode { get; set; }
[JsonPropertyName("questId")] public string QuestId { get; set; }
[JsonPropertyName("name")] public string Name { get; set; }
[JsonPropertyName("status")] public string Status { get; set; }
[JsonPropertyName("state")] public string State { get; set; }
[JsonPropertyName("counterText")] public string CounterText { get; set; }
[JsonPropertyName("requirements")] public QuestRequirementDto[] Requirements { get; set; }
public QuestUpdatePacket()
{
MessageCode = 1300;
QuestId = "";
Name = "";
Status = "";
State = "";
CounterText = "";
Requirements = Array.Empty<QuestRequirementDto>();
}
}
public sealed class PacketQuestUpdateSink : IQuestUpdateSink<GameQuestContext>
{
private readonly IAltruistRouter _router;
public PacketQuestUpdateSink(IAltruistRouter router)
{
_router = router;
}
public Task PublishAsync(GameQuestContext context, QuestUpdate update)
{
return _router.Client.SendAsync(context.SubjectId, new QuestUpdatePacket
{
QuestId = update.QuestId,
Name = update.Name,
Status = update.Status.ToString(),
State = update.State,
CounterText = update.CounterText,
Requirements = update.Requirements
.Select(x => new QuestRequirementDto(x.Key, x.Passed, x.Reason))
.ToArray()
});
}
}
6. Wrap the Runtime in a Service
Create Services/QuestService.cs:
using Altruist;
using Altruist.Gaming.Questing;
using SpaceGame.Persistence;
using SpaceGame.Quests;
using SpaceGame.World;
namespace SpaceGame.Services;
[Service]
public sealed class QuestService
{
private readonly QuestRuntime<GameQuestContext> _runtime;
private readonly PacketQuestUpdateSink _sink;
public QuestService(
IVault<QuestProgressVault> questVault,
IAltruistRouter router)
{
_sink = new PacketQuestUpdateSink(router);
var store = new VaultQuestStateStore(questVault);
var requirements = new QuestRequirementRegistry<GameQuestContext>()
.Add("level", ctx =>
{
int requiredLevel = Convert.ToInt32(ctx.Requirement.Value);
return ValueTask.FromResult(ctx.QuestContext.Level >= requiredLevel);
});
_runtime = new QuestRuntime<GameQuestContext>(
store,
_sink,
requirements,
CreateContext);
_runtime.LoadFromAssembly(typeof(FirstHuntQuest).Assembly);
}
public async Task SyncOnJoinAsync(PlayerPrefab player)
{
var baseContext = CreateBaseContext(player);
foreach (var update in await _runtime.BuildUpdatesAsync(baseContext))
await _sink.PublishAsync(baseContext, update);
await _runtime.ReconcileAsync(baseContext);
}
public Task OnKillAsync(PlayerPrefab player, uint targetEntityId)
{
return _runtime.FireAsync(
CreateBaseContext(player),
QuestTrigger.Kill,
targetEntityId);
}
private static GameQuestContext CreateBaseContext(PlayerPrefab player)
{
return new GameQuestContext
{
SubjectId = player.ClientId,
Subject = player,
Player = player,
Level = player.Level,
Services = null,
};
}
private static GameQuestContext CreateContext(
GameQuestContext baseContext,
QuestTrigger trigger,
QuestDefinition<GameQuestContext> quest,
long targetId,
int value,
QuestState state,
IReadOnlyList<QuestRequirementResult> requirements)
{
return new GameQuestContext
{
SubjectId = baseContext.SubjectId,
Subject = baseContext.Subject,
Player = baseContext.Player,
Level = baseContext.Level,
Trigger = trigger,
TargetId = targetId,
Value = value,
State = state,
RequirementResults = requirements,
Services = baseContext.Services,
};
}
}
7. Bridge Combat Into Quest Progress
Create Services/QuestCombatBridge.cs:
using Altruist;
using Altruist.Gaming.Combat;
using SpaceGame.World;
namespace SpaceGame.Services;
[Service]
public sealed class QuestCombatBridge
{
public QuestCombatBridge(ICombatService combat, QuestService quests)
{
combat.OnDeath += e =>
{
if (e.Killer is PlayerPrefab player)
_ = quests.OnKillAsync(player, e.Entity.VirtualId);
};
}
}
And when the player joins the world:
await _quests.SyncOnJoinAsync(player);
That is the whole first quest loop:
- the player enters
- the runtime reconciles current quest state
- combat kills fire
QuestTrigger.Kill - quest state changes
- the client receives a
QuestUpdatePacket
Why This Is a Real Quest System
This is not a fake one-off quest counter.
It already uses the real moving parts:
- requirement evaluation
- persistent state
- state-machine progression
- clean update emission
That means the next quests are mostly content work, not architecture rewrites.
Continue the Series