feat(quest): QT6 — plugins can read the contract tracker

The last piece of QT6's own scope: r10-quest-dialogs.md §11.6's contract half.
IGameState.Contracts exposes what the client structurally knows about quests,
which — per that same research — is the tracker and nothing else. The rest of
§11.6 (chat stream, tells, give, use, confirmations) is other features and
stays out of this campaign.

A pull-through source rather than a pushed mirror. Contracts change rarely and
are already owned canonically, so a second copy would only be a thing to keep
in step; reading through means a plugin cannot observe a stale list.

Both hosts implement it. The headless one carries contract id, stage and
progress but no names — a bot has no dat access — because losing the TEXT is
expected while losing the QUEST would leave a bot silently unable to see what
it is on. Same rule covers a contract the installed dat has never heard of: it
still projects, with empty text and a correct status, rather than vanishing.

The interface member is defaulted so a host predating this campaign still
satisfies IGameState.

Two lazy catalog loads exist (the panel's and this one) rather than one shared
instance. That is deliberate: threading a shared ContractCatalog through three
composition records to avoid reading a 322-row immutable table at most twice
per session would be plumbing for no correctness or performance gain, and the
comment at the call site says so.

Campaign QT is complete; the connected user gate is owed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 15:22:22 +02:00
parent 56beeb720d
commit fe1e68e5fe
8 changed files with 305 additions and 0 deletions

View file

@ -278,6 +278,30 @@ internal sealed class SessionPlayerCompositionPhase
d.DatLock,
world.TerrainBuild.HeightTable,
d.Options.DumpSceneryZ);
// Campaign QT slice QT6: the plugin-facing contract view. A
// pull-through source rather than a mirror, so a plugin always reads
// the canonical tracker instead of a copy that could fall behind it.
//
// The catalog is loaded lazily and independently of the Journal
// panel's own. Two reads of a 322-row immutable table across a whole
// session is not worth threading a shared instance through three
// composition records for; correctness is identical either way.
AcDream.Core.Quests.ContractCatalog? pluginContractCatalog = null;
d.WorldGameState.ContractsSource = () =>
{
if (pluginContractCatalog is null)
{
lock (d.DatLock)
pluginContractCatalog =
AcDream.Content.ContractTableReader.Load(content.Dats);
}
return AcDream.Runtime.Gameplay.ContractPluginProjection.Project(
d.Runtime.ContractsOwner.View,
pluginContractCatalog,
DateTime.UtcNow);
};
var streamerLease = scope.Acquire(
"landblock streamer",
() => LandblockStreamer.CreateForRequests(

View file

@ -10,6 +10,19 @@ public sealed class WorldGameState : IGameState
public IReadOnlyList<WorldEntitySnapshot> Entities => _entities;
/// <summary>
/// Where <see cref="Contracts"/> reads from. Set once by the host.
/// </summary>
/// <remarks>
/// A pull-through source rather than a pushed list: contracts change rarely
/// and are already owned canonically elsewhere, so mirroring them here
/// would add a second copy to keep in step for no gain.
/// </remarks>
public Func<IReadOnlyList<ContractSnapshot>>? ContractsSource { get; set; }
public IReadOnlyList<ContractSnapshot> Contracts =>
ContractsSource?.Invoke() ?? [];
/// <summary>
/// Publish the current projection for an entity. Re-hydration replaces the
/// prior snapshot instead of turning the current-state API into history.

View file

@ -99,6 +99,25 @@ internal sealed class HeadlessPluginHost
}
}
/// <summary>
/// Campaign QT slice QT6. Same borrow-don't-own shape as
/// <see cref="Entities"/>: projected from the canonical tracker on read.
/// Names and status text are empty here — a headless host has no dat
/// access — while every numeric field a bot actually branches on
/// (contract id, stage, progress) is present.
/// </summary>
public IReadOnlyList<ContractSnapshot> Contracts
{
get
{
ObjectDisposedException.ThrowIf(_disposed, this);
return AcDream.Runtime.Gameplay.ContractPluginProjection.Project(
_runtime.ContractsOwner.View,
catalog: null,
now: DateTime.UtcNow);
}
}
public event Action<WorldEntitySnapshot> EntitySpawned
{
add

View file

@ -0,0 +1,41 @@
namespace AcDream.Plugin.Abstractions;
/// <summary>
/// One tracked contract, as a plugin sees it.
/// </summary>
/// <remarks>
/// <para>
/// This is the ONLY structured view of quest state a client ever has. The
/// retail client stores no quest flags and is never told one changed; it learns
/// about quests through NPC dialogue, generic error text, and this tracker. A
/// plugin asking "what quests am I on?" is asking this and nothing else.
/// </para>
/// <para>
/// <paramref name="Name"/>, <paramref name="Description"/> and
/// <paramref name="Status"/> come from the installed dat and may be empty on a
/// host with no dat access (a headless bot), or for a contract the installed
/// dat build has never heard of. The numeric fields are always present.
/// </para>
/// </remarks>
/// <param name="ContractId">Key into the dat's ContractTable.</param>
/// <param name="Stage">
/// Raw wire stage: 1 available, 2 in progress, 3 done-or-pending-repeat, and
/// <c>4 + n</c> for a progress counter with n steps done — see
/// <paramref name="Progress"/>.
/// </param>
/// <param name="Progress">Completed steps, or 0 when the stage carries no counter.</param>
/// <param name="IsDisplayed">Whether the server nominated this as the shown contract.</param>
/// <param name="Name">Authored contract name.</param>
/// <param name="Description">Authored long-form description.</param>
/// <param name="Status">
/// The progress text retail's own panel shows — "Available", "In Progress",
/// "5/20 Tuskers", "Done (1h 30s to Repeat)".
/// </param>
public readonly record struct ContractSnapshot(
uint ContractId,
uint Stage,
uint Progress,
bool IsDisplayed,
string Name = "",
string Description = "",
string Status = "");

View file

@ -4,4 +4,15 @@ namespace AcDream.Plugin.Abstractions;
public interface IGameState
{
IReadOnlyList<WorldEntitySnapshot> Entities { get; }
/// <summary>
/// The player's tracked contracts — the client's only structured view of
/// quest state (<c>r10-quest-dialogs.md</c> §1.3). Empty when the server
/// has sent none.
/// </summary>
/// <remarks>
/// Defaulted so a host predating Campaign QT still satisfies the interface;
/// both in-tree hosts implement it.
/// </remarks>
IReadOnlyList<ContractSnapshot> Contracts => [];
}

View file

@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Net.Messages;
using AcDream.Core.Quests;
using AcDream.Plugin.Abstractions;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Projects the canonical contract tracker into the plugin-facing
/// <see cref="ContractSnapshot"/> shape.
/// </summary>
/// <remarks>
/// Lives here rather than in either host because both of them need it and
/// neither owns the tracker. The authored catalog is optional: a headless bot
/// has no dat access, and a contract the installed dat has never heard of still
/// has to appear — a plugin must not silently miss a live quest because the
/// text for it is unavailable.
/// </remarks>
public static class ContractPluginProjection
{
public static IReadOnlyList<ContractSnapshot> Project(
IRuntimeContractView contracts,
ContractCatalog? catalog,
DateTime now)
{
ArgumentNullException.ThrowIfNull(contracts);
IReadOnlyList<ContractTracker> tracked = contracts.GetContracts();
if (tracked.Count == 0)
return [];
uint displayed = contracts.Snapshot.DisplayContractId;
var result = new ContractSnapshot[tracked.Count];
for (int i = 0; i < tracked.Count; i++)
{
ContractTracker tracker = tracked[i];
ContractEntry? entry = catalog?.Lookup(tracker.ContractId);
result[i] = new ContractSnapshot(
tracker.ContractId,
(uint)tracker.Stage,
tracker.Progress,
tracker.ContractId == displayed,
entry?.ContractName ?? string.Empty,
entry?.Description ?? string.Empty,
entry is null
? string.Empty
: ContractProgressText.Build(
(uint)tracker.Stage,
tracker.TimeWhenRepeats,
tracker.ReceivedAt,
entry,
now));
}
return result;
}
}