acdream/tests/AcDream.Core.Net.Tests/Messages/ContractTrackerMessagesTests.cs
Erik ab3934e21d feat(quest): QT1 — parse the contract-tracker events we have been dropping
Both opcodes have been named in GameEventType since the wire-catalog work with
nothing behind them, so every contract the server has ever sent us arrived and
was discarded.

Three details that a reimplementation from the enum alone would get wrong, and
each has a test:

The two trailing flags on 0x0315 are widened bools, not bytes, and they sit
OUTSIDE the struct writer — ACE's ContractTracker.Write has them commented out
precisely because the event appends them itself. Reading them as bytes decodes
the delete flag from the wrong four bytes and silently drops contracts.

The stage is not a dense enum. Retail encodes N completed steps as
ProgressCounter + N, so a switch over the four named values sees stage 9 as
unknown and shows nothing. Progress/HasProgressCounter do that arithmetic once
here rather than leaving every caller to remember it.

The countdown anchor is not on the wire. FillProgressString @0x00498DE0 counts
down from CContractTracker::_time_of_server_update, which the server never
sends — so arrival has to be stamped at parse time or the repeat timer has
nothing to tick against.

An empty table is a valid answer rather than a decode failure: it is how the
server says "you have no contracts", and confusing the two would leave stale
quests on screen permanently. A truncated one is rejected outright instead of
decoding to its prefix, which would drop quests just as silently.

Campaign QT slice 1 of 6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:38:00 +02:00

185 lines
7.2 KiB
C#

using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Campaign QT slice QT1: retail's two contract-tracker game events.
/// </summary>
public sealed class ContractTrackerMessagesTests
{
private static readonly DateTime Arrival = new(2026, 8, 21, 13, 5, 9, DateTimeKind.Utc);
/// <summary>Writes one tracker struct exactly as ACE's writer does.</summary>
private static byte[] Tracker(
uint version, uint contractId, uint stage, double whenDone, double whenRepeats)
{
var buffer = new byte[28];
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(0), version);
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4), contractId);
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(8), stage);
BinaryPrimitives.WriteDoubleLittleEndian(buffer.AsSpan(12), whenDone);
BinaryPrimitives.WriteDoubleLittleEndian(buffer.AsSpan(20), whenRepeats);
return buffer;
}
private static byte[] Concat(params byte[][] parts)
{
var result = new List<byte>();
foreach (byte[] part in parts) result.AddRange(part);
return [.. result];
}
private static byte[] U32(uint value)
{
var buffer = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
return buffer;
}
/// <summary>The u16 count / u16 buckets header, packed into one dword.</summary>
private static byte[] HashHeader(ushort count, ushort buckets)
=> U32((uint)count | ((uint)buckets << 16));
// ── 0x0315, the single update ───────────────────────────────────────
[Fact]
public void AnUpdateDecodesEveryFieldInOrder()
{
byte[] payload = Concat(
Tracker(3u, 0x1234u, 2u, 120.5, 86400.0),
U32(0u), // DeleteContract
U32(1u)); // SetAsDisplayContract
ContractTrackerUpdate update =
ContractTrackerMessages.ParseUpdate(payload, Arrival)!.Value;
Assert.Equal(3u, update.Tracker.Version);
Assert.Equal(0x1234u, update.Tracker.ContractId);
Assert.Equal(ContractStage.InProgress, update.Tracker.Stage);
Assert.Equal(120.5, update.Tracker.TimeWhenDone);
Assert.Equal(86400.0, update.Tracker.TimeWhenRepeats);
Assert.False(update.Delete);
Assert.True(update.SetAsDisplay);
}
[Fact]
public void TheTwoFlagsAreWidenedBoolsNotBytes()
{
// ACE writes Convert.ToUInt32(bool) AFTER the struct — reading them as
// bytes would decode the delete flag out of the wrong four bytes and
// silently drop contracts.
byte[] payload = Concat(Tracker(1u, 7u, 1u, 0, 0), U32(1u), U32(0u));
ContractTrackerUpdate update =
ContractTrackerMessages.ParseUpdate(payload, Arrival)!.Value;
Assert.True(update.Delete);
Assert.False(update.SetAsDisplay);
}
[Fact]
public void ArrivalIsStampedBecauseItIsNotOnTheWire()
{
// FillProgressString counts down from _time_of_server_update, which the
// server never sends. Without the stamp there is no anchor and the
// repeat timer cannot tick.
ContractTrackerUpdate update = ContractTrackerMessages.ParseUpdate(
Concat(Tracker(1u, 7u, 3u, 0, 600.0), U32(0u), U32(0u)), Arrival)!.Value;
Assert.Equal(Arrival, update.Tracker.ReceivedAt);
}
[Fact]
public void ATruncatedUpdateIsRejectedRatherThanPartiallyDecoded()
{
// The struct alone, with the two flags missing.
Assert.Null(ContractTrackerMessages.ParseUpdate(Tracker(1u, 7u, 1u, 0, 0), Arrival));
Assert.Null(ContractTrackerMessages.ParseUpdate([], Arrival));
}
// ── the progress counter ────────────────────────────────────────────
[Theory]
[InlineData(1u, 0u, false)]
[InlineData(2u, 0u, false)]
[InlineData(3u, 0u, false)]
[InlineData(4u, 0u, true)] // counter present, zero done
[InlineData(9u, 5u, true)]
public void TheStageCarriesTheProgressCountAboveFour(
uint stage, uint expectedProgress, bool expectedHasCounter)
{
// Retail encodes N completed steps as ProgressCounter + N rather than
// as a separate field, so a naive enum switch would see stage 9 as an
// unknown value and show nothing.
ContractTracker tracker = ContractTrackerMessages.ParseUpdate(
Concat(Tracker(1u, 7u, stage, 0, 0), U32(0u), U32(0u)), Arrival)!.Value.Tracker;
Assert.Equal(expectedProgress, tracker.Progress);
Assert.Equal(expectedHasCounter, tracker.HasProgressCounter);
}
// ── 0x0314, the full table ──────────────────────────────────────────
[Fact]
public void TheTableDecodesEveryEntryKeyedByContractId()
{
byte[] payload = Concat(
HashHeader(count: 2, buckets: 8),
U32(0x1111u), Tracker(1u, 0x1111u, 1u, 0, 0),
U32(0x2222u), Tracker(1u, 0x2222u, 6u, 10.0, 20.0));
IReadOnlyDictionary<uint, ContractTracker> table =
ContractTrackerMessages.ParseTable(payload, Arrival)!;
Assert.Equal(2, table.Count);
Assert.Equal(ContractStage.Available, table[0x1111u].Stage);
Assert.Equal(2u, table[0x2222u].Progress);
Assert.Equal(Arrival, table[0x2222u].ReceivedAt);
}
[Fact]
public void AnEmptyTableIsAValidAnswerNotAFailure()
{
// "You have no contracts" is a real thing the server says, and it is
// how the panel gets cleared. Returning null here would leave stale
// contracts on screen forever.
IReadOnlyDictionary<uint, ContractTracker>? table =
ContractTrackerMessages.ParseTable(HashHeader(0, 0), Arrival);
Assert.NotNull(table);
Assert.Empty(table!);
}
[Fact]
public void ANonEmptyTableWithNoBucketsIsRejected()
{
// PackableHashTable::UnPack early-returns on a zero-bucket table, so a
// count without buckets is a corrupt frame, not an empty one.
Assert.Null(ContractTrackerMessages.ParseTable(
Concat(HashHeader(count: 1, buckets: 0), U32(1u), Tracker(1u, 1u, 1u, 0, 0)),
Arrival));
}
[Fact]
public void ATruncatedTableIsRejectedRatherThanReturningThePrefix()
{
// Claiming two entries and supplying one must not decode as one — a
// half-read table would silently drop the player's quests.
Assert.Null(ContractTrackerMessages.ParseTable(
Concat(HashHeader(count: 2, buckets: 8), U32(1u), Tracker(1u, 1u, 1u, 0, 0)),
Arrival));
}
[Fact]
public void AnImplausibleCountIsRejectedWithoutAllocatingForIt()
{
// Retail's largest observed table is ~3 KB. A count field of 60,000
// against a short payload is a decode error; it must fail fast rather
// than try to read 60,000 entries.
Assert.Null(ContractTrackerMessages.ParseTable(
HashHeader(count: 60000, buckets: 256), Arrival));
}
}