Quest State Machine
The quest system lives in Altruist.Gaming.Questing. It provides generic quest discovery, requirement evaluation, async hook dispatch, persistence abstraction, and clean quest update emission while leaving presentation and packet shape to the host game.
The design goal is simple: Altruist owns quest runtime mechanics, while the host game owns UI and networking.
Ownership Split
Altruist Owns
- quest discovery and deterministic ordering by quest id
- requirement evaluation before each hook dispatch
- async hook dispatch
- quest state machine dispatch
- dirty-state persistence flow
- update deduplication
- reconcile flows such as relog, reconnect, and level changes
Host Game Owns
- packet format sent to the client
- UI interpretation of status and state
- hidden / locked / tracker / color semantics
- game-specific requirement evaluators
- reward content and gameplay-side services
Note:
The quest system does not decide your UI rules. It emits clean quest facts; your client decides how those facts should be presented.
Core Attributes
Quests are declared on behavior classes:
[Quest("main.blacksmith.intro")]
[QuestName("The Blacksmith's Request")]
[QuestRequirement("level", QuestOperator.GreaterOrEqual, 15)]
[QuestRequirement("class", QuestOperator.Equal, "warrior")]
public sealed class BlacksmithIntroQuest : QuestBehavior<GameQuestContext>
{
}
The requirement model is intentionally generic. Altruist does not define game-specific keys. The host registers evaluators for keys such as:
levelclassfactionalignment- any host-specific progression marker or account state
Requirement Evaluation
Requirement declarations stay on the quest class:
[QuestRequirement("level", QuestOperator.GreaterOrEqual, 80)]
[QuestRequirement("faction", QuestOperator.Equal, "alliance")]
The host implements the meaning:
public sealed class QuestRequirementContext<TContext>
where TContext : QuestContext
{
public required TContext QuestContext { get; init; }
public required QuestRequirement Requirement { get; init; }
}
public interface IQuestRequirementEvaluator<TContext>
where TContext : QuestContext
{
string Key { get; }
ValueTask<bool> EvaluateAsync(QuestRequirementContext<TContext> context);
}
Important behavior:
- requirements are evaluated before each hook dispatch
- if requirements fail, the quest becomes
Suspended - suspended quests keep persisted progress
- suspended quests do not run gameplay hooks
- missing evaluators also fail the requirement and surface as failed requirement results
That makes level-down and temporary ineligibility safe without deleting progress by default.
QuestBehavior Hooks
Quest logic is event-driven and async:
public abstract class QuestBehavior<TContext>
where TContext : QuestContext
{
public virtual Task OnEnter(TContext ctx) => Task.CompletedTask;
public virtual Task OnLeave(TContext ctx) => Task.CompletedTask;
public virtual Task OnLevel(TContext ctx) => Task.CompletedTask;
public virtual Task OnKill(TContext ctx) => Task.CompletedTask;
public virtual Task OnNpc(TContext ctx) => Task.CompletedTask;
public virtual Task OnItem(TContext ctx) => Task.CompletedTask;
public virtual Task OnButton(TContext ctx) => Task.CompletedTask;
public virtual Task OnTimer(TContext ctx) => Task.CompletedTask;
public virtual Task OnScroll(TContext ctx) => Task.CompletedTask;
}
The runtime dispatches these triggers:
EnterLeaveLevelKillNpcItemButtonTimerScroll
OnScroll is part of the normal supported runtime hook surface, just like OnKill, OnNpc, or OnItem. It is not a roadmap placeholder or a UI-only convention.
QuestContext
Each hook receives a QuestContext:
public class QuestContext
{
public string SubjectId { get; init; } = "";
public object? Subject { get; init; }
public QuestTrigger Trigger { get; init; }
public long TargetId { get; init; }
public int Value { get; init; }
public IQuestState State { get; init; } = null!;
public IReadOnlyList<QuestRequirementResult> RequirementResults { get; init; }
= Array.Empty<QuestRequirementResult>();
public IServiceProvider? Services { get; init; }
}
In practice:
SubjectIdidentifies the player or account whose quest state is being updatedSubjectcan hold your rich domain object if the host wants itTrigger,TargetId, andValuecarry event payloadStateis the persistent quest state bagRequirementResultsgives the current evaluated requirement snapshotServiceslets the host expose gameplay services to quest code
Quest State Machine
Questing uses the same attribute-driven style as Altruist's generic State Machine, but with a separate async dispatcher built for event-driven quest progression.
The quest-specific state API is:
[QuestState("name", Initial = true)][QuestStateEnter("name")][QuestStateExit("name")]
State methods may return:
string?Task<string?>ValueTask<string?>
Return another state name to transition, or null to stay in the current state.
[Quest("side.gather_ores")]
[QuestName("Gather Ores")]
public sealed class GatherOresQuest : QuestBehavior<GameQuestContext>
{
[QuestState("talk_to_blacksmith", Initial = true)]
public string? TalkToBlacksmith(GameQuestContext ctx)
{
if (ctx.Trigger == QuestTrigger.Npc && ctx.TargetId == BlacksmithNpcId)
return "collect_ore";
return null;
}
[QuestStateEnter("collect_ore")]
public void EnterCollectOre(GameQuestContext ctx)
{
ctx.State.Set("target", 10);
ctx.State.Set("count", 0);
}
[QuestState("collect_ore")]
public string? CollectOre(GameQuestContext ctx)
{
if (ctx.Trigger == QuestTrigger.Item && ctx.TargetId == IronOreItemId)
{
int collected = ctx.State.Increment("count");
if (collected >= ctx.State.Get("target", 10))
return "return_to_blacksmith";
}
return null;
}
[QuestState("return_to_blacksmith")]
public string? ReturnToBlacksmith(GameQuestContext ctx)
{
if (ctx.Trigger == QuestTrigger.Npc && ctx.TargetId == BlacksmithNpcId)
{
ctx.State.Set("done", true);
return "completed";
}
return null;
}
}
The state machine writes to the same IQuestState bag that is persisted by the runtime, so state names, counters, and completion flags automatically participate in update generation.
Persistence
Questing runtime depends on a host-provided state store:
public interface IQuestStateStore
{
Task<Dictionary<string, QuestState>> LoadAsync(string subjectId);
Task SaveDirtyAsync(string subjectId, IReadOnlyDictionary<string, QuestState> states);
Task ResetAsync(string subjectId);
}
The runtime loads the whole quest-state map for a subject, mutates only the relevant quest states, and saves back only dirty entries.
That gives you:
- one persistence abstraction for all quests
- no forced storage backend
- clean re-login and reconnect behavior
Quest Updates and Client Packets
Altruist does not send a built-in quest network packet. Instead, it produces a runtime-level QuestUpdate and hands it to the host through IQuestUpdateSink<TContext>.
public sealed record QuestUpdate(
string SubjectId,
string QuestId,
string Name,
string Category,
QuestStatus Status,
string State,
string CounterText,
IReadOnlyList<QuestRequirementResult> Requirements);
public interface IQuestUpdateSink<TContext> where TContext : QuestContext
{
Task PublishAsync(TContext context, QuestUpdate update);
}
That means:
- Altruist decides when an update should exist
- the host decides how that update becomes a packet
- the client receives your game's packet format, not an Altruist-specific wire protocol
What Altruist Sends
Each QuestUpdate contains:
SubjectId: which player or account the update belongs toQuestId: stable quest identifierName: display name declared on the questCategory: quest category string such asMain,Side, or any other host-defined kindStatus:Eligible,Suspended,Active,Completed, orFailedState: current quest state-machine node nameCounterText: preformatted progress text such as4/10Requirements: evaluated requirement results withKey,Passed, and optionalReason
When Altruist Publishes Updates
The runtime publishes through IQuestUpdateSink<TContext>.PublishAsync(...):
- after a dispatched quest hook finishes
- when requirements fail and the quest must be reported as
Suspended - during
ReconcileAsync(...) - during
ReconcileLevelAsync(...) - after level backfill or level-down reset handling changes the visible quest state
The runtime also deduplicates updates by fingerprint. If Status, State, CounterText, and requirement results did not change, nothing is published again.
Initial Full Snapshot
For first load, relog, or client resync, use:
Task<IReadOnlyList<QuestUpdate>> BuildUpdatesAsync(TContext baseContext);
BuildUpdatesAsync(...) does not push packets by itself. It returns the current full snapshot so the host can send:
- one packet per quest
- one batched packet containing many quests
- a custom "quest list" message for initial UI bootstrap
Example Host Mapping
The host packet shape is fully application-defined. A typical mapping looks like this:
public sealed record QuestRequirementDto(string Key, bool Passed, string? Reason);
public sealed record QuestUpdatePacket(
string QuestId,
string Name,
string Category,
string Status,
string State,
string CounterText,
IReadOnlyList<QuestRequirementDto> Requirements);
public sealed class GameQuestUpdateSink : IQuestUpdateSink<GameQuestContext>
{
private readonly IClientRouter _clients;
public GameQuestUpdateSink(IClientRouter clients)
{
_clients = clients;
}
public Task PublishAsync(GameQuestContext context, QuestUpdate update)
{
var packet = new QuestUpdatePacket(
update.QuestId,
update.Name,
update.Category,
update.Status.ToString(),
update.State,
update.CounterText,
update.Requirements
.Select(x => new QuestRequirementDto(x.Key, x.Passed, x.Reason))
.ToArray());
return _clients.SendToPlayerAsync(context.SubjectId, packet);
}
}
This is the critical boundary:
- Altruist emits quest facts
- your host maps them into packets
- your client renders those packets into journal, tracker, quest log, or HUD widgets
What the Client Should Expect
The client should treat quest updates as authoritative runtime facts, not as a full UI prescription.
Good client assumptions:
- patch quest rows by
QuestId - treat
Stateas machine-readable progression state - treat
CounterTextas ready-to-display progress text - use
Requirementsonly if your UI wants to surface lock reasons or eligibility hints
Important client-side rules:
- do not expect Altruist to send a built-in packet type
- do not expect UI flags such as hidden, scroll, tracker, or color
- do not full-replace the whole quest UI from a single delta update unless your host packet is defined that way
- use
BuildUpdatesAsync(...)for initial full sync, andPublishAsync(...)updates as incremental changes afterward
Status Resolution
The runtime can emit these statuses:
EligibleSuspendedActiveCompletedFailed
In practice, once a quest is eligible and loaded into runtime flow, the emitted live status is usually:
Suspendedwhen requirements currently failActivewhile the quest is in progressCompletedwhendone,_done, or a completed terminal state is reachedFailedwhen the quest setsfailed
Eligible exists as part of the public status model, but the current runtime usually resolves active eligible quests to Active.
Reconcile Flows
The runtime exposes explicit reconcile entry points:
Task ReconcileAsync(TContext baseContext);
Task ReconcileLevelAsync(TContext baseContext, int previousLevel, int currentLevel);
These exist for cases where event hooks alone are not enough:
- login / relog backfill
- level-up unlocking many quests at once
- level-down suspending quests above the current level
- idempotent recovery after reconnect
For level-down handling, quests can optionally implement:
public interface IQuestLevelResetHook<TContext>
where TContext : QuestContext
{
void ResetAboveLevel(TContext ctx, int level);
}
That lets a quest explicitly remove progress above the current level while still using the default Suspended behavior for ordinary requirement failure.
Clean Mental Model
The intended host flow is:
- Discover quest classes from assemblies.
- Register requirement evaluators by generic key.
- Dispatch gameplay events into
FireAsync(...)orFireQuestAsync(...). - Let Altruist evaluate eligibility and mutate quest state.
- Persist dirty states through
IQuestStateStore. - Translate
QuestUpdatevalues into your client packet format throughIQuestUpdateSink<TContext>.
That separation keeps the quest engine reusable across games with different UI and networking contracts.