The Journal panel now has all three tabs working: contracts from the server, and a per-character notebook with its searchable index. Two ported details that a reimplementation would get wrong in a way nobody notices until they lose work: Every navigation button commits the current page FIRST. Retail's ListenToElementMessage @0x004968D0 calls SaveThisPage on the way out of all five of them, which is why paging away never eats what you just typed. And the file is written when the notes page is HIDDEN, not only at exit — a crash then costs at most the page in front of you. The search is CASE-SENSITIVE across label, title and notes: retail compares with wcsstr and lowercases neither side. Making it insensitive would be friendlier and would be a divergence, so it is ported as-is with a test naming the reason. The double-click window is a full SECOND (m_LastClickTime + 1.0, @0x00493158) rather than the 500 ms the item-interaction path uses, and firing it clears the tracker so a third click does not re-open. Two unlabelled buttons on the notes page turned out to be prev/next: retail switches on (idElement - 0x10000565), which names them without a caption. The running-timer readout is authored at the same x as the three day/hour/minute boxes, so the strip is one or the other — that overlap is the data form of ShowEditableTimer versus ShowRunningTimer, not a layout bug. DeltaTimeToString moved out of the contract code into AcDream.Core.Ui. It is ClientUISystem's, not gmContractsUI's — the journal timer and the contract repeat countdown both call it, and it only lived under Quests because that was its first caller. A bridge class to reach it across features would have been the wrong answer to the same observation. The journal file lives in the client's data directory rather than beside the executable, for the same reason the chat log does. Register QJ-1. Campaign QJ slices 3, 4 and 5 of 5 — code-complete, connected gate owed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
7.1 KiB
C#
184 lines
7.1 KiB
C#
using System;
|
|
using AcDream.Core.Quests;
|
|
using AcDream.Core.Ui;
|
|
|
|
namespace AcDream.Core.Tests.Quests;
|
|
|
|
/// <summary>
|
|
/// Campaign QT slice QT4: <c>gmContractsUI::FillProgressString @0x00498DE0</c>
|
|
/// and the <c>ClientUISystem::DeltaTimeToString @0x00565E10</c> it calls.
|
|
/// </summary>
|
|
public sealed class ContractProgressTextTests
|
|
{
|
|
private static readonly DateTime Arrival = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc);
|
|
|
|
private static ContractEntry Entry(
|
|
string descriptionProgress = "", string questflagRepeatTime = "")
|
|
=> ContractEntry.Unknown with
|
|
{
|
|
DescriptionProgress = descriptionProgress,
|
|
QuestflagRepeatTime = questflagRepeatTime,
|
|
};
|
|
|
|
// ── DeltaTimeToString ───────────────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData(0, "0s")]
|
|
[InlineData(45, "45s")]
|
|
[InlineData(60, "1m 0s")]
|
|
[InlineData(3600, "1h 0s")] // minutes are OMITTED when zero
|
|
[InlineData(3661, "1h 1m 1s")]
|
|
[InlineData(86400, "1d 0s")]
|
|
[InlineData(2592000, "1mo 0s")] // a "month" is a flat 30 days
|
|
[InlineData(2592000 + 86400 + 3600 + 61, "1mo 1d 1h 1m 1s")]
|
|
public void DeltaTimeFormatsLargestUnitFirstAndAlwaysShowsSeconds(
|
|
double seconds, string expected)
|
|
=> Assert.Equal(expected, RetailDurationText.Format(seconds));
|
|
|
|
[Fact]
|
|
public void DeltaTimeHasNoTrailingSpace()
|
|
{
|
|
// Retail emits every part WITH a trailing space and then writes the
|
|
// terminator over the last one (0x00565F0E). Missing that truncation
|
|
// gives "Done (30s to Repeat)" with a double space — and the
|
|
// instruction is invisible in the decompiler output, so this is the
|
|
// assertion that pins the byte-level reading.
|
|
string text = RetailDurationText.Format(30);
|
|
|
|
Assert.Equal("30s", text);
|
|
Assert.DoesNotContain(" ", ContractProgressText.Build(
|
|
3u, 30d, Arrival, Entry(questflagRepeatTime: "flag"), Arrival));
|
|
}
|
|
|
|
[Fact]
|
|
public void DeltaTimeTruncatesTowardZeroLikeRetailsFtol()
|
|
{
|
|
Assert.Equal("59s", RetailDurationText.Format(59.99));
|
|
}
|
|
|
|
// ── the stage arms ──────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void StageOneIsAvailable()
|
|
=> Assert.Equal("Available", ContractProgressText.Build(
|
|
1u, 0d, Arrival, Entry(), Arrival));
|
|
|
|
[Fact]
|
|
public void StageTwoIsInProgress()
|
|
=> Assert.Equal("In Progress", ContractProgressText.Build(
|
|
2u, 0d, Arrival, Entry(), Arrival));
|
|
|
|
[Fact]
|
|
public void StageThreeWithNoRepeatFlagIsDoneForGood()
|
|
{
|
|
// An empty QuestflagRepeatTime is the whole difference between a
|
|
// one-shot quest and a repeatable one on cooldown.
|
|
Assert.Equal("Done", ContractProgressText.Build(
|
|
3u, 0d, Arrival, Entry(questflagRepeatTime: ""), Arrival));
|
|
}
|
|
|
|
[Fact]
|
|
public void StageThreeWithARepeatFlagAndNoTimerIsAvailableAgain()
|
|
{
|
|
Assert.Equal("Available", ContractProgressText.Build(
|
|
3u, 0d, Arrival, Entry(questflagRepeatTime: "SomeQuestRepeat"), Arrival));
|
|
}
|
|
|
|
[Fact]
|
|
public void StageThreeWithATimerStillRunningCountsDownToTheRepeat()
|
|
{
|
|
string text = ContractProgressText.Build(
|
|
3u,
|
|
timeWhenRepeats: 3661d,
|
|
Arrival,
|
|
Entry(questflagRepeatTime: "SomeQuestRepeat"),
|
|
now: Arrival);
|
|
|
|
Assert.Equal("Done (1h 1m 1s to Repeat)", text);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheCountdownIsAnchoredAtArrivalNotRecomputedFromTheServerValue()
|
|
{
|
|
// The server sends the remaining seconds ONCE and never sends the
|
|
// instant it measured them from. Anchoring at arrival is what makes
|
|
// the timer tick; without it the same number would be shown forever.
|
|
string atArrival = ContractProgressText.Build(
|
|
3u, 600d, Arrival, Entry(questflagRepeatTime: "f"), Arrival);
|
|
string tenMinutesLater = ContractProgressText.Build(
|
|
3u, 600d, Arrival, Entry(questflagRepeatTime: "f"),
|
|
Arrival.AddMinutes(5));
|
|
|
|
Assert.Equal("Done (10m 0s to Repeat)", atArrival);
|
|
Assert.Equal("Done (5m 0s to Repeat)", tenMinutesLater);
|
|
}
|
|
|
|
[Fact]
|
|
public void ATimerThatHasRunOutSinceArrivalReadsAsAvailable()
|
|
{
|
|
Assert.Equal("Available", ContractProgressText.Build(
|
|
3u, 600d, Arrival, Entry(questflagRepeatTime: "f"),
|
|
now: Arrival.AddHours(1)));
|
|
}
|
|
|
|
[Fact]
|
|
public void TimeWhenDoneNeverReachesThisText()
|
|
{
|
|
// It IS on the wire and it does NOT drive the progress column. Passing
|
|
// it here instead of TimeWhenRepeats is the plausible misreading; the
|
|
// signature refuses it, and this test says why.
|
|
string text = ContractProgressText.Build(
|
|
3u, timeWhenRepeats: 0d, Arrival, Entry(questflagRepeatTime: ""), Arrival);
|
|
|
|
Assert.Equal("Done", text);
|
|
}
|
|
|
|
// ── the progress counter ────────────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData(4u, "0/20 Tuskers")]
|
|
[InlineData(9u, "5/20 Tuskers")]
|
|
[InlineData(24u, "20/20 Tuskers")]
|
|
public void StageFourAndAboveSubstitutesTheCountIntoTheAuthoredFormat(
|
|
uint stage, string expected)
|
|
{
|
|
// The count is stage - 4, and DescriptionProgress is a printf format,
|
|
// not a literal — rendering it verbatim shows the player "%d/20".
|
|
Assert.Equal(expected, ContractProgressText.Build(
|
|
stage, 0d, Arrival, Entry(descriptionProgress: "%d/20 Tuskers"), Arrival));
|
|
}
|
|
|
|
[Fact]
|
|
public void AProgressStageWithNoAuthoredFormatFallsBackToInProgress()
|
|
{
|
|
Assert.Equal("In Progress", ContractProgressText.Build(
|
|
7u, 0d, Arrival, Entry(descriptionProgress: ""), Arrival));
|
|
}
|
|
|
|
[Fact]
|
|
public void AFormatWithoutASpecifierIsShownVerbatim()
|
|
{
|
|
Assert.Equal("Gathering herbs", ContractProgressText.Build(
|
|
6u, 0d, Arrival, Entry(descriptionProgress: "Gathering herbs"), Arrival));
|
|
}
|
|
|
|
[Fact]
|
|
public void OnlyTheFirstSpecifierIsSubstitutedBecauseRetailPassesOneArgument()
|
|
{
|
|
// A second %d would read past the argument in retail too. No installed
|
|
// contract has one (measured: 89 formats, all exactly one %d), so this
|
|
// pins the behaviour rather than describing shipped content.
|
|
Assert.Equal("3 of %d", ContractProgressText.Build(
|
|
7u, 0d, Arrival, Entry(descriptionProgress: "%d of %d"), Arrival));
|
|
}
|
|
|
|
[Fact]
|
|
public void AnUnknownStageProducesNothingRatherThanGuessing()
|
|
{
|
|
// Retail returns without writing, leaving the caller's string as it
|
|
// found it. Inventing a label here would put text on screen that the
|
|
// real client never shows.
|
|
Assert.Equal(string.Empty, ContractProgressText.Build(
|
|
0u, 0d, Arrival, Entry(), Arrival));
|
|
}
|
|
}
|