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:
parent
56beeb720d
commit
fe1e68e5fe
8 changed files with 305 additions and 0 deletions
|
|
@ -174,6 +174,13 @@ the toolbar was ported — it simply had no panel registered behind it, so
|
||||||
clicking it did nothing. Registering slot 25 completed a wiring that was
|
clicking it did nothing. Registering slot 25 completed a wiring that was
|
||||||
already three-quarters present.
|
already three-quarters present.
|
||||||
|
|
||||||
|
**The plugin surface** (`r10-quest-dialogs.md` §11.6's contract half) ships as
|
||||||
|
`IGameState.Contracts`, projected through `ContractPluginProjection` — a
|
||||||
|
pull-through view of the canonical tracker, never a mirror. Both hosts
|
||||||
|
implement it; the headless one carries the numeric fields without the authored
|
||||||
|
text, since a bot has no dat access. The rest of §11.6 (chat stream, tells,
|
||||||
|
give, use, confirmations) is other features and stays out of Campaign QT.
|
||||||
|
|
||||||
### Owed
|
### Owed
|
||||||
|
|
||||||
- The connected user gate: accept a quest against live ACE, open the Journal
|
- The connected user gate: accept a quest against live ACE, open the Journal
|
||||||
|
|
|
||||||
|
|
@ -278,6 +278,30 @@ internal sealed class SessionPlayerCompositionPhase
|
||||||
d.DatLock,
|
d.DatLock,
|
||||||
world.TerrainBuild.HeightTable,
|
world.TerrainBuild.HeightTable,
|
||||||
d.Options.DumpSceneryZ);
|
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(
|
var streamerLease = scope.Acquire(
|
||||||
"landblock streamer",
|
"landblock streamer",
|
||||||
() => LandblockStreamer.CreateForRequests(
|
() => LandblockStreamer.CreateForRequests(
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,19 @@ public sealed class WorldGameState : IGameState
|
||||||
|
|
||||||
public IReadOnlyList<WorldEntitySnapshot> Entities => _entities;
|
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>
|
/// <summary>
|
||||||
/// Publish the current projection for an entity. Re-hydration replaces the
|
/// Publish the current projection for an entity. Re-hydration replaces the
|
||||||
/// prior snapshot instead of turning the current-state API into history.
|
/// prior snapshot instead of turning the current-state API into history.
|
||||||
|
|
|
||||||
|
|
@ -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
|
public event Action<WorldEntitySnapshot> EntitySpawned
|
||||||
{
|
{
|
||||||
add
|
add
|
||||||
|
|
|
||||||
41
src/AcDream.Plugin.Abstractions/ContractSnapshot.cs
Normal file
41
src/AcDream.Plugin.Abstractions/ContractSnapshot.cs
Normal 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 = "");
|
||||||
|
|
@ -4,4 +4,15 @@ namespace AcDream.Plugin.Abstractions;
|
||||||
public interface IGameState
|
public interface IGameState
|
||||||
{
|
{
|
||||||
IReadOnlyList<WorldEntitySnapshot> Entities { get; }
|
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 => [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
59
src/AcDream.Runtime/Gameplay/ContractPluginProjection.cs
Normal file
59
src/AcDream.Runtime/Gameplay/ContractPluginProjection.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Quests;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using AcDream.Runtime.Gameplay;
|
||||||
|
|
||||||
|
namespace AcDream.Runtime.Tests.Gameplay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign QT slice QT6: what a plugin sees of the contract tracker.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ContractPluginProjectionTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private static void Track(
|
||||||
|
RuntimeContractState state,
|
||||||
|
uint contractId,
|
||||||
|
uint stage,
|
||||||
|
bool setAsDisplay = false)
|
||||||
|
=> state.ApplyUpdate(new ContractTrackerUpdate(
|
||||||
|
new ContractTracker(1u, contractId, (ContractStage)stage, 0d, 0d, Now),
|
||||||
|
Delete: false,
|
||||||
|
SetAsDisplay: setAsDisplay));
|
||||||
|
|
||||||
|
private static ContractCatalog Catalog(uint id, string name, string progressFormat = "")
|
||||||
|
=> new(new Dictionary<uint, ContractEntry>
|
||||||
|
{
|
||||||
|
[id] = ContractEntry.Unknown with
|
||||||
|
{
|
||||||
|
ContractId = id,
|
||||||
|
ContractName = name,
|
||||||
|
Description = "Do the thing.",
|
||||||
|
DescriptionProgress = progressFormat,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnEmptyTrackerProjectsToNothing()
|
||||||
|
{
|
||||||
|
using var state = new RuntimeContractState();
|
||||||
|
|
||||||
|
Assert.Empty(ContractPluginProjection.Project(state.View, ContractCatalog.Empty, Now));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheProjectionCarriesTheAuthoredTextAndTheRetailStatus()
|
||||||
|
{
|
||||||
|
using var state = new RuntimeContractState();
|
||||||
|
Track(state, 0x10u, stage: 9u); // ProgressCounter + 5
|
||||||
|
|
||||||
|
ContractSnapshot snapshot = Assert.Single(ContractPluginProjection.Project(
|
||||||
|
state.View, Catalog(0x10u, "Tusker Hunt", "%d/20 Tuskers"), Now));
|
||||||
|
|
||||||
|
Assert.Equal(0x10u, snapshot.ContractId);
|
||||||
|
Assert.Equal(9u, snapshot.Stage);
|
||||||
|
Assert.Equal(5u, snapshot.Progress);
|
||||||
|
Assert.Equal("Tusker Hunt", snapshot.Name);
|
||||||
|
Assert.Equal("Do the thing.", snapshot.Description);
|
||||||
|
Assert.Equal("5/20 Tuskers", snapshot.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheDisplayContractIsFlagged()
|
||||||
|
{
|
||||||
|
using var state = new RuntimeContractState();
|
||||||
|
Track(state, 0x10u, stage: 2u);
|
||||||
|
Track(state, 0x20u, stage: 2u, setAsDisplay: true);
|
||||||
|
|
||||||
|
IReadOnlyList<ContractSnapshot> projected = ContractPluginProjection.Project(
|
||||||
|
state.View, ContractCatalog.Empty, Now);
|
||||||
|
|
||||||
|
Assert.False(projected.Single(c => c.ContractId == 0x10u).IsDisplayed);
|
||||||
|
Assert.True(projected.Single(c => c.ContractId == 0x20u).IsDisplayed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithNoCatalogTheNumbersStillProject()
|
||||||
|
{
|
||||||
|
// A headless bot has no dat access. Losing the text is expected;
|
||||||
|
// losing the QUEST would mean a bot silently unable to see what it is
|
||||||
|
// on, which is the failure this rules out.
|
||||||
|
using var state = new RuntimeContractState();
|
||||||
|
Track(state, 0x10u, stage: 6u);
|
||||||
|
|
||||||
|
ContractSnapshot snapshot = Assert.Single(
|
||||||
|
ContractPluginProjection.Project(state.View, catalog: null, Now));
|
||||||
|
|
||||||
|
Assert.Equal(0x10u, snapshot.ContractId);
|
||||||
|
Assert.Equal(6u, snapshot.Stage);
|
||||||
|
Assert.Equal(2u, snapshot.Progress);
|
||||||
|
Assert.Equal(string.Empty, snapshot.Name);
|
||||||
|
Assert.Equal(string.Empty, snapshot.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AContractTheCatalogDoesNotKnowStillProjects()
|
||||||
|
{
|
||||||
|
// Same rule as the panel: the server can track a contract this dat
|
||||||
|
// build has never heard of, and a plugin must not miss it.
|
||||||
|
using var state = new RuntimeContractState();
|
||||||
|
Track(state, 0xDEADu, stage: 2u);
|
||||||
|
|
||||||
|
ContractSnapshot snapshot = Assert.Single(ContractPluginProjection.Project(
|
||||||
|
state.View, Catalog(0x10u, "Something Else"), Now));
|
||||||
|
|
||||||
|
Assert.Equal(0xDEADu, snapshot.ContractId);
|
||||||
|
Assert.Equal(string.Empty, snapshot.Name);
|
||||||
|
// ContractEntry.Unknown still runs the progress arms, so an in-progress
|
||||||
|
// contract reads correctly even with no authored text.
|
||||||
|
Assert.Equal("In Progress", snapshot.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheProjectionOrderMatchesTheTrackersOwn()
|
||||||
|
{
|
||||||
|
using var state = new RuntimeContractState();
|
||||||
|
Track(state, 0x30u, stage: 2u);
|
||||||
|
Track(state, 0x10u, stage: 2u);
|
||||||
|
Track(state, 0x20u, stage: 2u);
|
||||||
|
|
||||||
|
IReadOnlyList<ContractSnapshot> projected = ContractPluginProjection.Project(
|
||||||
|
state.View, ContractCatalog.Empty, Now);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
new uint[] { 0x10u, 0x20u, 0x30u },
|
||||||
|
projected.Select(c => c.ContractId).ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue