From fe1e68e5feda5fc628ef220b520edc28458be3d9 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 15:22:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(quest):=20QT6=20=E2=80=94=20plugins=20can?= =?UTF-8?q?=20read=20the=20contract=20tracker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../2026-08-21-contract-tracker-campaign.md | 7 + .../Composition/SessionPlayerComposition.cs | 24 ++++ src/AcDream.Core/Plugins/WorldGameState.cs | 13 ++ .../Plugins/HeadlessPluginHost.cs | 19 +++ .../ContractSnapshot.cs | 41 ++++++ src/AcDream.Plugin.Abstractions/IGameState.cs | 11 ++ .../Gameplay/ContractPluginProjection.cs | 59 ++++++++ .../Gameplay/ContractPluginProjectionTests.cs | 131 ++++++++++++++++++ 8 files changed, 305 insertions(+) create mode 100644 src/AcDream.Plugin.Abstractions/ContractSnapshot.cs create mode 100644 src/AcDream.Runtime/Gameplay/ContractPluginProjection.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/ContractPluginProjectionTests.cs diff --git a/docs/plans/2026-08-21-contract-tracker-campaign.md b/docs/plans/2026-08-21-contract-tracker-campaign.md index 16588821..a58fa9f4 100644 --- a/docs/plans/2026-08-21-contract-tracker-campaign.md +++ b/docs/plans/2026-08-21-contract-tracker-campaign.md @@ -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 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 - The connected user gate: accept a quest against live ACE, open the Journal diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 2af1a65b..879b9920 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -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( diff --git a/src/AcDream.Core/Plugins/WorldGameState.cs b/src/AcDream.Core/Plugins/WorldGameState.cs index 03fcb13d..e240555a 100644 --- a/src/AcDream.Core/Plugins/WorldGameState.cs +++ b/src/AcDream.Core/Plugins/WorldGameState.cs @@ -10,6 +10,19 @@ public sealed class WorldGameState : IGameState public IReadOnlyList Entities => _entities; + /// + /// Where reads from. Set once by the host. + /// + /// + /// 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. + /// + public Func>? ContractsSource { get; set; } + + public IReadOnlyList Contracts => + ContractsSource?.Invoke() ?? []; + /// /// Publish the current projection for an entity. Re-hydration replaces the /// prior snapshot instead of turning the current-state API into history. diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs index 9fa7857f..45b99503 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs @@ -99,6 +99,25 @@ internal sealed class HeadlessPluginHost } } + /// + /// Campaign QT slice QT6. Same borrow-don't-own shape as + /// : 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. + /// + public IReadOnlyList Contracts + { + get + { + ObjectDisposedException.ThrowIf(_disposed, this); + return AcDream.Runtime.Gameplay.ContractPluginProjection.Project( + _runtime.ContractsOwner.View, + catalog: null, + now: DateTime.UtcNow); + } + } + public event Action EntitySpawned { add diff --git a/src/AcDream.Plugin.Abstractions/ContractSnapshot.cs b/src/AcDream.Plugin.Abstractions/ContractSnapshot.cs new file mode 100644 index 00000000..7633d451 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/ContractSnapshot.cs @@ -0,0 +1,41 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// One tracked contract, as a plugin sees it. +/// +/// +/// +/// 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. +/// +/// +/// , and +/// 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. +/// +/// +/// Key into the dat's ContractTable. +/// +/// Raw wire stage: 1 available, 2 in progress, 3 done-or-pending-repeat, and +/// 4 + n for a progress counter with n steps done — see +/// . +/// +/// Completed steps, or 0 when the stage carries no counter. +/// Whether the server nominated this as the shown contract. +/// Authored contract name. +/// Authored long-form description. +/// +/// The progress text retail's own panel shows — "Available", "In Progress", +/// "5/20 Tuskers", "Done (1h 30s to Repeat)". +/// +public readonly record struct ContractSnapshot( + uint ContractId, + uint Stage, + uint Progress, + bool IsDisplayed, + string Name = "", + string Description = "", + string Status = ""); diff --git a/src/AcDream.Plugin.Abstractions/IGameState.cs b/src/AcDream.Plugin.Abstractions/IGameState.cs index e3d640cd..30b698a3 100644 --- a/src/AcDream.Plugin.Abstractions/IGameState.cs +++ b/src/AcDream.Plugin.Abstractions/IGameState.cs @@ -4,4 +4,15 @@ namespace AcDream.Plugin.Abstractions; public interface IGameState { IReadOnlyList Entities { get; } + + /// + /// The player's tracked contracts — the client's only structured view of + /// quest state (r10-quest-dialogs.md §1.3). Empty when the server + /// has sent none. + /// + /// + /// Defaulted so a host predating Campaign QT still satisfies the interface; + /// both in-tree hosts implement it. + /// + IReadOnlyList Contracts => []; } diff --git a/src/AcDream.Runtime/Gameplay/ContractPluginProjection.cs b/src/AcDream.Runtime/Gameplay/ContractPluginProjection.cs new file mode 100644 index 00000000..a4f69643 --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/ContractPluginProjection.cs @@ -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; + +/// +/// Projects the canonical contract tracker into the plugin-facing +/// shape. +/// +/// +/// 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. +/// +public static class ContractPluginProjection +{ + public static IReadOnlyList Project( + IRuntimeContractView contracts, + ContractCatalog? catalog, + DateTime now) + { + ArgumentNullException.ThrowIfNull(contracts); + + IReadOnlyList 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; + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/ContractPluginProjectionTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/ContractPluginProjectionTests.cs new file mode 100644 index 00000000..c30ba70e --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/ContractPluginProjectionTests.cs @@ -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; + +/// +/// Campaign QT slice QT6: what a plugin sees of the contract tracker. +/// +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 + { + [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 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 projected = ContractPluginProjection.Project( + state.View, ContractCatalog.Empty, Now); + + Assert.Equal( + new uint[] { 0x10u, 0x20u, 0x30u }, + projected.Select(c => c.ContractId).ToArray()); + } +}