The user tested Campaign CH's CODE-COMPLETE build live and reported ten defects (docs/plans/2026-08-09-chat-parity-campaign.md, "User gate — round 1"). Items A-G are fixed here; the remaining three (extra chat windows on 1/2/3/4, resize working in only one corner, transparency/ artifacts) are out of scope for a fix and filed as slice CH6. A. Jump-in-air refusal never fired live: the jump block only ever evaluated input.Jump inside the grounded-charge or already-charging branches. PlayerMovementController now detects the press RISING EDGE while airborne and reports WeenieError.NotGrounded once per press, leaving the grounded charge/fire path untouched. B. ChatVM's invented "[System] " prefix is dropped — retail prints system text bare. [Popup] is unchanged (AP-175). C. SpewBoxController's color is now the user-pinned exact value (1, 1, 0.247, 1), the same bright yellow as an incoming Tell. Register row AP-178 updated: color CLOSES, size/position/font stay open per the user's live report that they still differ. D. Closes #329: PortalTunnelPresentation now emits the portal wait cue unconditionally on every rotation-segment boundary, matching gmSmartBoxUI::UseTime's decompiled else-arm exactly instead of gating on a 5-second hold local transits never reached. PortalWaitNotice Controller now renders it in the same pinned yellow as item C. Register row AP-150 retired. E. Closes #362: new ClientCommandResponses.cs parses and renders the four previously-unhandled inbound GameEvents (ChannelIndex, ChannelList, AvailableHouses, AllegianceInfoResponse), each ported line-for-line from the named-retail decomp's inbound handlers. Register row TS-70 retired. F. ChatWindowController.WrapText now splits on embedded '\n'/'\r\n' first, then word-wraps each segment independently — server text like /help's reply no longer collapses onto one line. G. The chat input field's right edge no longer holds a fixed absolute pixel position across a window resize; Bind now upgrades it to retail edge-mode 1 (UiLayoutPolicy) or the AnchorEdges.Right stretch fallback so it tracks the window's client width instead of overflowing past a narrower resize. Full Release suite: 12,247 passed / 4 skipped / 0 failed (baseline 12,221/4/0 + 26 new tests across items A, E, F, G). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
439 lines
16 KiB
C#
439 lines
16 KiB
C#
using System.Buffers.Binary;
|
|
using System.Linq;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Player;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Core.Ui;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Net.Tests.Messages;
|
|
|
|
/// <summary>
|
|
/// Campaign CH user-gate round 1, item E (#362 / register row TS-70):
|
|
/// parser round-trips for the four previously-unhandled inbound responses
|
|
/// (ChannelIndex 0x0149, ChannelList 0x0148, AvailableHouses 0x0271,
|
|
/// AllegianceInfoResponse 0x027C), plus a routing test proving each reaches
|
|
/// the chat transcript with retail's LogTextType.
|
|
/// </summary>
|
|
public sealed class ClientCommandResponsesTests
|
|
{
|
|
// ── ChannelIndex / ChannelList ──────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void ParseChannelIndex_RoundTrips()
|
|
{
|
|
byte[] wire = new AceWireWriter()
|
|
.Write((uint)2)
|
|
.WriteString16L("Admin")
|
|
.WriteString16L("Help")
|
|
.ToArray();
|
|
|
|
var channels = ClientCommandResponses.ParseChannelIndex(wire);
|
|
|
|
Assert.NotNull(channels);
|
|
Assert.Equal(new[] { "Admin", "Help" }, channels);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseChannelIndex_EmptyList_ParsesToEmpty()
|
|
{
|
|
byte[] wire = new AceWireWriter().Write((uint)0).ToArray();
|
|
|
|
var channels = ClientCommandResponses.ParseChannelIndex(wire);
|
|
|
|
Assert.NotNull(channels);
|
|
Assert.Empty(channels);
|
|
}
|
|
|
|
[Fact]
|
|
public void FormatChannelIndexLines_MatchesRetailHeaderAndOrder()
|
|
{
|
|
var lines = ClientCommandResponses.FormatChannelIndexLines(new[] { "Admin", "Help" }).ToArray();
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"The following channels are available to you:",
|
|
"Admin",
|
|
"Help",
|
|
},
|
|
lines);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseChannelList_RoundTrips()
|
|
{
|
|
byte[] wire = new AceWireWriter()
|
|
.Write((uint)3)
|
|
.WriteString16L("Caith")
|
|
.WriteString16L("Vandal")
|
|
.WriteString16L("Elysia")
|
|
.ToArray();
|
|
|
|
var names = ClientCommandResponses.ParseChannelList(wire);
|
|
|
|
Assert.NotNull(names);
|
|
Assert.Equal(new[] { "Caith", "Vandal", "Elysia" }, names);
|
|
}
|
|
|
|
[Fact]
|
|
public void FormatChannelListLines_MatchesRetailHeaderAndOrder()
|
|
{
|
|
var lines = ClientCommandResponses.FormatChannelListLines(new[] { "Caith" }).ToArray();
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"The following characters are currently listening on the channel:",
|
|
"Caith",
|
|
},
|
|
lines);
|
|
}
|
|
|
|
// ── AvailableHouses ──────────────────────────────────────────────────────
|
|
|
|
// Block x=10 (0x0A), y=10 (0x0A), low=1 -> a valid outdoor cell id
|
|
// (LandDefs.GidToLcoord requires low in [1,0x40]).
|
|
private const uint TestVillaLandblockId = 0x0A0A0001u;
|
|
|
|
[Fact]
|
|
public void ParseAvailableHouses_RoundTrips()
|
|
{
|
|
byte[] wire = new AceWireWriter()
|
|
.Write((uint)2) // HouseType.Villa
|
|
.Write((uint)1) // locations count
|
|
.Write(TestVillaLandblockId)
|
|
.Write(5) // totalAvailable
|
|
.ToArray();
|
|
|
|
var response = ClientCommandResponses.ParseAvailableHouses(wire);
|
|
|
|
Assert.NotNull(response);
|
|
Assert.Equal(2u, response.Value.HouseType);
|
|
Assert.Equal(new[] { TestVillaLandblockId }, response.Value.Locations);
|
|
Assert.Equal(5, response.Value.TotalAvailable);
|
|
}
|
|
|
|
[Fact]
|
|
public void FormatAvailableHousesLines_VillasIncludesSummaryAndCoordinate()
|
|
{
|
|
var response = new ClientCommandResponses.AvailableHousesResponse(
|
|
HouseType: 2u,
|
|
Locations: new[] { TestVillaLandblockId },
|
|
TotalAvailable: 5);
|
|
|
|
var lines = ClientCommandResponses.FormatAvailableHousesLines(response).ToArray();
|
|
|
|
Assert.True(RadarCoordinates.TryFromCell(TestVillaLandblockId, out var coordinates));
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"There are 5 villas available.",
|
|
$" {coordinates.YText}, {coordinates.XText}",
|
|
},
|
|
lines);
|
|
}
|
|
|
|
[Fact]
|
|
public void FormatAvailableHousesLines_ApartmentsSkipsCoordinateList()
|
|
{
|
|
// Retail's Handle_House__Recv_AvailableHouses only calls
|
|
// DisplayListOfCoords when arg2 != 4 (apartments have no world
|
|
// location) — acclient_2013_pseudo_c.txt:400247.
|
|
var response = new ClientCommandResponses.AvailableHousesResponse(
|
|
HouseType: 4u,
|
|
Locations: new[] { TestVillaLandblockId },
|
|
TotalAvailable: 3);
|
|
|
|
var lines = ClientCommandResponses.FormatAvailableHousesLines(response).ToArray();
|
|
|
|
Assert.Equal(new[] { "There are 3 apartments available." }, lines);
|
|
}
|
|
|
|
[Fact]
|
|
public void FormatAvailableHousesLines_OverFourHundred_AddsTruncationNotice()
|
|
{
|
|
var response = new ClientCommandResponses.AvailableHousesResponse(
|
|
HouseType: 1u,
|
|
Locations: System.Array.Empty<uint>(),
|
|
TotalAvailable: 401);
|
|
|
|
var lines = ClientCommandResponses.FormatAvailableHousesLines(response).ToArray();
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"There are 401 cottages available.",
|
|
"There were too many houses to display all the locations. Only the first 400 locations are displayed here.",
|
|
},
|
|
lines);
|
|
}
|
|
|
|
// ── AllegianceInfoResponse ───────────────────────────────────────────────
|
|
|
|
private static byte[] BuildAllegianceWire(
|
|
uint targetGuid,
|
|
System.Collections.Generic.List<(uint characterId, uint parentGuid, bool loggedIn, string name)> records)
|
|
{
|
|
var w = new AceWireWriter()
|
|
.Write(targetGuid)
|
|
.Write((uint)(records.Count)) // totalMembers (not consulted by renderer)
|
|
.Write((uint)0) // totalVassals (not consulted)
|
|
.Write((ushort)records.Count) // recordCount
|
|
.Write((ushort)0x000B) // oldVersion
|
|
// officers PackableHashTable header: 0 entries, 256 buckets.
|
|
.Write((ushort)0)
|
|
.Write((ushort)256)
|
|
// officerTitles: int32 count = 0.
|
|
.Write((uint)0)
|
|
.Write((uint)0) // monarchBroadcastTime
|
|
.Write((uint)0) // monarchBroadcastsToday
|
|
.Write((uint)0) // spokesBroadcastTime
|
|
.Write((uint)0) // spokesBroadcastsToday
|
|
.WriteString16L("") // motd
|
|
.WriteString16L("") // motdSetBy
|
|
.Write((uint)0) // chatRoomID
|
|
// bindPoint Position: cell + 3 floats + 4 floats = 32 bytes.
|
|
.Write((uint)0)
|
|
.Write(0f).Write(0f).Write(0f)
|
|
.Write(0f).Write(0f).Write(0f).Write(0f)
|
|
.WriteString16L("Test Allegiance") // allegianceName
|
|
.Write((uint)0) // nameLastSetTime
|
|
.Write((uint)0) // isLocked
|
|
.Write(0); // approvedVassal
|
|
|
|
for (int i = 0; i < records.Count; i++)
|
|
{
|
|
var (characterId, parentGuid, loggedIn, name) = records[i];
|
|
if (i > 0)
|
|
w.Write(parentGuid); // the wire's own "treeParent" tag precedes non-monarch records.
|
|
|
|
uint bitfield = 0x4u | 0x8u; // HasAllegianceAge | HasPackedLevel (ACE's own always-set default)
|
|
if (loggedIn) bitfield |= 0x1u; // LoggedIn
|
|
|
|
w.Write(characterId)
|
|
.Write((uint)0) // cpCached
|
|
.Write((uint)0) // cpTithed
|
|
.Write(bitfield)
|
|
.Write((byte)0) // gender
|
|
.Write((byte)0) // heritage group
|
|
.Write((ushort)1) // rank
|
|
.Write((uint)5) // level (HasPackedLevel set)
|
|
.Write((ushort)0) // loyalty
|
|
.Write((ushort)0) // leadership
|
|
.Write((uint)0) // timeOnline (HasAllegianceAge set)
|
|
.Write((uint)0) // allegianceAge
|
|
.WriteString16L(name);
|
|
}
|
|
|
|
return w.ToArray();
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseAllegianceInfoResponse_MonarchOnly_RoundTrips()
|
|
{
|
|
const uint monarchGuid = 0x50000010u;
|
|
byte[] wire = BuildAllegianceWire(
|
|
monarchGuid,
|
|
new() { (monarchGuid, 0u, true, "Grandmaster") });
|
|
|
|
var response = ClientCommandResponses.ParseAllegianceInfoResponse(wire);
|
|
|
|
Assert.NotNull(response);
|
|
Assert.Equal(monarchGuid, response.Value.TargetGuid);
|
|
Assert.Equal((ushort)1, response.Value.RecordCount);
|
|
Assert.Equal("Test Allegiance", response.Value.AllegianceName);
|
|
Assert.NotNull(response.Value.Monarch);
|
|
Assert.Equal("Grandmaster", response.Value.Monarch!.Value.Name);
|
|
Assert.True(response.Value.Monarch!.Value.IsLoggedIn);
|
|
Assert.Empty(response.Value.Records);
|
|
}
|
|
|
|
[Fact]
|
|
public void FormatAllegianceInfoLines_MonarchOnly_PrintsHeaderAndSelfNoPatronNoVassals()
|
|
{
|
|
const uint monarchGuid = 0x50000010u;
|
|
var response = new ClientCommandResponses.AllegianceInfoResponse(
|
|
TargetGuid: monarchGuid,
|
|
TotalMembers: 1,
|
|
TotalVassals: 0,
|
|
RecordCount: 1,
|
|
AllegianceName: "Test Allegiance",
|
|
Monarch: new ClientCommandResponses.AllegianceMemberRecord(monarchGuid, 0u, true, "Grandmaster"),
|
|
Records: System.Array.Empty<ClientCommandResponses.AllegianceMemberRecord>());
|
|
|
|
var lines = ClientCommandResponses.FormatAllegianceInfoLines(response).ToArray();
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"Note: An asterisk (*) indicates that the character is currently online.",
|
|
"Allegiance information for Grandmaster *:",
|
|
},
|
|
lines);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseAndFormatAllegianceInfoResponse_PatronAndVassals_RendersFullTree()
|
|
{
|
|
const uint monarchGuid = 0x50000001u;
|
|
const uint patronGuid = 0x50000002u;
|
|
const uint selfGuid = 0x50000003u;
|
|
const uint vassalGuid = 0x50000004u;
|
|
|
|
// Records order matches AllegianceHierarchy.Write: patron (parent=monarch),
|
|
// self (parent=patron), then vassals (parent=self).
|
|
byte[] wire = BuildAllegianceWire(
|
|
selfGuid,
|
|
new()
|
|
{
|
|
(monarchGuid, 0u, false, "Monarch"),
|
|
(patronGuid, monarchGuid, true, "Patron"),
|
|
(selfGuid, patronGuid, false, "Self"),
|
|
(vassalGuid, selfGuid, true, "Vassal"),
|
|
});
|
|
|
|
var response = ClientCommandResponses.ParseAllegianceInfoResponse(wire);
|
|
Assert.NotNull(response);
|
|
Assert.Equal(3, response.Value.Records.Count);
|
|
|
|
var lines = ClientCommandResponses.FormatAllegianceInfoLines(response.Value).ToArray();
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"Note: An asterisk (*) indicates that the character is currently online.",
|
|
"Allegiance information for Self:",
|
|
" Patron: Patron *",
|
|
" Vassals: ",
|
|
" Vassal *",
|
|
},
|
|
lines);
|
|
}
|
|
|
|
[Fact]
|
|
public void FormatAllegianceInfoLines_NoAllegiance_PrintsNothing()
|
|
{
|
|
// Retail's AllegianceProfile::GetData fails for a player with no
|
|
// record at all (no allegiance) and the handler returns early with
|
|
// NO text printed — acclient_2013_pseudo_c.txt:375151-375155.
|
|
const uint targetGuid = 0x50000099u;
|
|
var response = new ClientCommandResponses.AllegianceInfoResponse(
|
|
TargetGuid: targetGuid,
|
|
TotalMembers: 0,
|
|
TotalVassals: 0,
|
|
RecordCount: 0,
|
|
AllegianceName: "",
|
|
Monarch: null,
|
|
Records: System.Array.Empty<ClientCommandResponses.AllegianceMemberRecord>());
|
|
|
|
Assert.Empty(ClientCommandResponses.FormatAllegianceInfoLines(response));
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseAllegianceInfoResponse_EmptyWire_ParsesToNoRecords()
|
|
{
|
|
// ACE omits monarchData/records entirely when allegiance/node are
|
|
// null (AllegianceHierarchy.Write) -- recordCount stays 0.
|
|
const uint targetGuid = 0x50000099u;
|
|
byte[] wire = BuildAllegianceWire(targetGuid, new());
|
|
|
|
var response = ClientCommandResponses.ParseAllegianceInfoResponse(wire);
|
|
|
|
Assert.NotNull(response);
|
|
Assert.Equal((ushort)0, response.Value.RecordCount);
|
|
Assert.Null(response.Value.Monarch);
|
|
Assert.Empty(response.Value.Records);
|
|
Assert.Empty(ClientCommandResponses.FormatAllegianceInfoLines(response.Value));
|
|
}
|
|
|
|
// ── Routing (GameEventWiring -> ChatLog) ─────────────────────────────────
|
|
|
|
private static byte[] WrapEnvelope(GameEventType type, byte[] payload)
|
|
{
|
|
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameEventEnvelope.Opcode);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), 0u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), (uint)type);
|
|
payload.CopyTo(body, GameEventEnvelope.HeaderSize);
|
|
return body;
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_ChannelIndex_ReachesChatTranscriptAsDefaultLogTextType()
|
|
{
|
|
var dispatcher = new GameEventDispatcher();
|
|
var chat = new ChatLog();
|
|
GameEventWiring.WireAll(dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), chat);
|
|
|
|
byte[] payload = new AceWireWriter()
|
|
.Write((uint)1)
|
|
.WriteString16L("Sentinel")
|
|
.ToArray();
|
|
|
|
GameEventEnvelope? envelope = GameEventEnvelope.TryParse(
|
|
WrapEnvelope(GameEventType.ChannelIndex, payload));
|
|
Assert.NotNull(envelope);
|
|
dispatcher.Dispatch(envelope.Value);
|
|
|
|
ChatEntry[] entries = chat.Snapshot();
|
|
Assert.Equal(2, entries.Length);
|
|
Assert.All(entries, e => Assert.Equal(ChatKind.System, e.Kind));
|
|
Assert.All(entries, e => Assert.Equal((uint)RetailLogTextType.Default, e.ChannelId));
|
|
Assert.Equal("The following channels are available to you:", entries[0].Text);
|
|
Assert.Equal("Sentinel", entries[1].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_AvailableHouses_ReachesChatTranscript()
|
|
{
|
|
var dispatcher = new GameEventDispatcher();
|
|
var chat = new ChatLog();
|
|
GameEventWiring.WireAll(dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), chat);
|
|
|
|
byte[] payload = new AceWireWriter()
|
|
.Write((uint)2)
|
|
.Write((uint)1)
|
|
.Write(TestVillaLandblockId)
|
|
.Write(2)
|
|
.ToArray();
|
|
|
|
GameEventEnvelope? envelope = GameEventEnvelope.TryParse(
|
|
WrapEnvelope(GameEventType.AvailableHouses, payload));
|
|
Assert.NotNull(envelope);
|
|
dispatcher.Dispatch(envelope.Value);
|
|
|
|
ChatEntry[] entries = chat.Snapshot();
|
|
Assert.Equal(2, entries.Length);
|
|
Assert.Equal("There are 2 villas available.", entries[0].Text);
|
|
Assert.All(entries, e => Assert.Equal((uint)RetailLogTextType.Default, e.ChannelId));
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_AllegianceInfoResponse_ReachesChatTranscript()
|
|
{
|
|
var dispatcher = new GameEventDispatcher();
|
|
var chat = new ChatLog();
|
|
GameEventWiring.WireAll(dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), chat);
|
|
|
|
const uint monarchGuid = 0x50000010u;
|
|
byte[] payload = BuildAllegianceWire(
|
|
monarchGuid,
|
|
new() { (monarchGuid, 0u, false, "Grandmaster") });
|
|
|
|
GameEventEnvelope? envelope = GameEventEnvelope.TryParse(
|
|
WrapEnvelope(GameEventType.AllegianceInfoResponse, payload));
|
|
Assert.NotNull(envelope);
|
|
dispatcher.Dispatch(envelope.Value);
|
|
|
|
ChatEntry[] entries = chat.Snapshot();
|
|
Assert.Equal(2, entries.Length);
|
|
Assert.Equal("Note: An asterisk (*) indicates that the character is currently online.", entries[0].Text);
|
|
Assert.Equal("Allegiance information for Grandmaster:", entries[1].Text);
|
|
Assert.All(entries, e => Assert.Equal((uint)RetailLogTextType.Default, e.ChannelId));
|
|
}
|
|
}
|