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:

  • level
  • class
  • faction
  • alignment
  • 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:

  • Enter
  • Leave
  • Level
  • Kill
  • Npc
  • Item
  • Button
  • Timer
  • Scroll

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:

  • SubjectId identifies the player or account whose quest state is being updated
  • Subject can hold your rich domain object if the host wants it
  • Trigger, TargetId, and Value carry event payload
  • State is the persistent quest state bag
  • RequirementResults gives the current evaluated requirement snapshot
  • Services lets 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 to
  • QuestId: stable quest identifier
  • Name: display name declared on the quest
  • Category: quest category string such as Main, Side, or any other host-defined kind
  • Status: Eligible, Suspended, Active, Completed, or Failed
  • State: current quest state-machine node name
  • CounterText: preformatted progress text such as 4/10
  • Requirements: evaluated requirement results with Key, Passed, and optional Reason

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 State as machine-readable progression state
  • treat CounterText as ready-to-display progress text
  • use Requirements only 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, and PublishAsync(...) updates as incremental changes afterward

Status Resolution

The runtime can emit these statuses:

  • Eligible
  • Suspended
  • Active
  • Completed
  • Failed

In practice, once a quest is eligible and loaded into runtime flow, the emitted live status is usually:

  • Suspended when requirements currently fail
  • Active while the quest is in progress
  • Completed when done, _done, or a completed terminal state is reached
  • Failed when the quest sets failed

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:

  1. Discover quest classes from assemblies.
  2. Register requirement evaluators by generic key.
  3. Dispatch gameplay events into FireAsync(...) or FireQuestAsync(...).
  4. Let Altruist evaluate eligibility and mutate quest state.
  5. Persist dirty states through IQuestStateStore.
  6. Translate QuestUpdate values into your client packet format through IQuestUpdateSink<TContext>.

That separation keeps the quest engine reusable across games with different UI and networking contracts.