fix(chat): Campaign CH user-gate round 1 — jump-in-air edge, portal cue cadence, wrap/prefix/color fixes

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>
This commit is contained in:
Erik 2026-08-09 23:42:32 +02:00
parent d1c1368a5e
commit 47e40900f3
17 changed files with 1428 additions and 81 deletions

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Chat;
@ -309,4 +310,181 @@ public class ChatWindowControllerTests
Assert.Null(ctrl);
}
// ── Input field resize: Campaign CH user-gate round 1, item G ────────────
// The input line overflowed the chat window's right edge on resize. Its
// right edge held a FIXED absolute pixel position (either the imported
// UiLayoutPolicy's mode-0 "frozen at current" fallback, or the
// AnchorEdges default with no Right bit) — nothing re-ran the
// Left/Width recompute on a plain window resize, only at bind time and
// on channel change. Bind now leaves the input's right edge tracking
// the live parent width either way.
[Fact]
public void Bind_InputField_WithNoImportedLayoutPolicy_NeverOverflowsOnNarrowerResize()
{
// BuildTestTree's synthetic ElementInfo nodes never set
// HasOriginalParentSize, so DatWidgetFactory.CreateLayoutPolicy
// returns null for every widget here — this exercises the
// AnchorEdges fallback branch of the fix.
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl);
Assert.Null(ctrl!.Input.LayoutPolicy);
Assert.Equal(AnchorEdges.Left | AnchorEdges.Right, ctrl.Input.Anchors & (AnchorEdges.Left | AnchorEdges.Right));
// First frame after Bind(): the per-frame draw pass calls ApplyAnchor
// against the LIVE (still-authored, 490px) inputBar width — this is
// the lazy margin CAPTURE, matching UiElement.ApplyAnchor's
// "!_anchorCaptured" first-call semantics. Only THEN does a resize
// (a later frame, a smaller parent width) exercise the stretch.
const float authoredParentWidth = 490f;
ctrl.Input.ApplyAnchor(authoredParentWidth, ctrl.Input.Height);
const float narrowerParentWidth = 300f;
ctrl.Input.ApplyAnchor(narrowerParentWidth, ctrl.Input.Height);
Assert.True(
ctrl.Input.Left + ctrl.Input.Width <= narrowerParentWidth,
$"input right edge ({ctrl.Input.Left + ctrl.Input.Width}) overflowed the narrower parent width ({narrowerParentWidth})");
}
[Fact]
public void Bind_InputField_WithNoImportedLayoutPolicy_GrowsWithWiderResize()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl);
const float authoredParentWidth = 490f;
ctrl!.Input.ApplyAnchor(authoredParentWidth, ctrl.Input.Height);
float originalWidth = ctrl.Input.Width;
const float widerParentWidth = 800f;
ctrl.Input.ApplyAnchor(widerParentWidth, ctrl.Input.Height);
Assert.True(ctrl.Input.Width > originalWidth, "the input should widen when the window grows");
Assert.True(ctrl.Input.Left + ctrl.Input.Width <= widerParentWidth);
}
[Fact]
public void Bind_InputField_WithImportedLayoutPolicy_RightEdgeTracksParentDeltaInsteadOfFreezing()
{
// Mirror the REAL production import path: the input field carries an
// authored UiLayoutPolicy (HasOriginalParentSize=true, as a real
// ImportInfos-resolved LayoutDesc element would). Right=0 here is
// retail's raw edge mode BEFORE the fix's upgrade — proving Bind
// replaces it with mode 1 rather than leaving mode 0's "frozen at
// current pixel position" behavior in place.
var (rootInfo, layout, vm) = BuildTestTree();
var inputInfo = FindById(rootInfo, 0x10000016u)
?? throw new System.InvalidOperationException("test fixture missing the input node");
inputInfo.HasOriginalParentSize = true;
inputInfo.OriginalParentWidth = 490f;
inputInfo.OriginalParentHeight = 17f;
inputInfo.Left = 1u; // near-edge: fixed to current (retail mode 1)
inputInfo.Top = 1u;
inputInfo.Right = 0u; // far-edge mode this fix must upgrade away from
inputInfo.Bottom = 1u;
// Rebuild the widget tree now that the fixture carries the policy
// inputs (BuildTestTree already built one without them).
layout = LayoutImporter.Build(rootInfo, NoTex, null);
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl);
Assert.NotNull(ctrl!.Input.LayoutPolicy);
Assert.Equal(1u, ctrl.Input.LayoutPolicy!.RightMode);
const float narrowerParentWidth = 300f;
ctrl.Input.ApplyAnchor(narrowerParentWidth, ctrl.Input.Height);
Assert.True(
ctrl.Input.Left + ctrl.Input.Width <= narrowerParentWidth,
$"input right edge ({ctrl.Input.Left + ctrl.Input.Width}) overflowed the narrower parent width ({narrowerParentWidth})");
}
private static ElementInfo? FindById(ElementInfo node, uint id)
{
if (node.Id == id) return node;
foreach (var child in node.Children)
{
if (FindById(child, id) is { } found) return found;
}
return null;
}
// ── WrapText: Campaign CH user-gate round 1, item F ──────────────────────
// /help (and "probably many places") never split on embedded '\n' — the
// whole multi-line blob rode the single early-out as ONE line. Split on
// '\n' first, then word-wrap each segment; a single-segment text keeps
// the pre-existing early-out behavior exactly.
private static float MeasureByCharCount(string s) => s.Length;
[Fact]
public void WrapText_EmbeddedNewlines_ProduceOneRenderedLinePerSegment()
{
string text = "line one\nline two\nline three";
// maxW is generous — every segment fits without word-wrapping, so
// this isolates the newline-split behavior specifically.
var lines = new List<string>(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount));
Assert.Equal(new[] { "line one", "line two", "line three" }, lines);
}
[Fact]
public void WrapText_CarriageReturnNewline_NormalizesTheSameAsBareNewline()
{
string text = "line one\r\nline two";
var lines = new List<string>(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount));
Assert.Equal(new[] { "line one", "line two" }, lines);
}
[Fact]
public void WrapText_SegmentLongerThanMaxWidth_StillWordWraps()
{
// Each segment is independently word-wrapped by the SAME algorithm
// the single-line path always used — a multi-line server message
// whose second line overflows the window still wraps that line.
string text = "short\nthis segment is much too long to fit on one line";
var lines = new List<string>(ChatWindowController.WrapText(text, 10f, MeasureByCharCount));
Assert.Equal("short", lines[0]);
Assert.True(lines.Count > 2, "the long second segment should have wrapped into multiple lines");
Assert.All(lines, line => Assert.True(MeasureByCharCount(line) <= 10f));
Assert.Equal(
"this segment is much too long to fit on one line",
string.Join(" ", lines.Skip(1)));
}
[Fact]
public void WrapText_SingleSegmentText_KeepsTheEarlyOutBehavior()
{
// No '\n' at all — the pre-existing single-line early-out path
// (whole text fits => returned verbatim as one fragment) is
// unchanged.
string text = "no newlines here";
var lines = new List<string>(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount));
Assert.Equal(new[] { text }, lines);
}
[Fact]
public void WrapText_ConsecutiveNewlines_ProduceABlankLine()
{
string text = "first\n\nthird";
var lines = new List<string>(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount));
Assert.Equal(new[] { "first", "", "third" }, lines);
}
}

View file

@ -1,9 +1,15 @@
using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public sealed class PortalWaitNoticeControllerTests
{
// Campaign CH user-gate round 1 (item D): the user confirmed live,
// side-by-side against retail, that this notice renders in the same
// bright yellow as an incoming Tell (RetailChatColorTable.Yellow).
private static readonly Vector4 RetailWaitCueColor = new(1f, 1f, 0.247f, 1f);
[Fact]
public void Notice_IsCenteredOverlayAndDisposesFromRetainedRoot()
{
@ -21,12 +27,14 @@ public sealed class PortalWaitNoticeControllerTests
Assert.True(text.ClickThrough);
Assert.Equal(root.Width, text.Width);
Assert.Equal(root.Height, text.Height);
Assert.Equal(RetailWaitCueColor, text.DefaultColor);
controller.Set("In Portal Space - Please Wait...");
Assert.True(text.Visible);
UiText.Line line = Assert.Single(text.LinesProvider!());
Assert.Equal("In Portal Space - Please Wait...", line.Text);
Assert.Equal(RetailWaitCueColor, line.Color);
controller.Set(null);
Assert.False(text.Visible);

View file

@ -59,6 +59,23 @@ internal sealed class AceWireWriter
return this;
}
/// <summary>BinaryWriter.Write(int) — little-endian, same bit pattern as Write(uint).</summary>
public AceWireWriter Write(int value) => Write(unchecked((uint)value));
/// <summary>BinaryWriter.Write(byte).</summary>
public AceWireWriter Write(byte value)
{
_buffer.Add(value);
return this;
}
/// <summary>BinaryWriter.Write(bool) — one byte, 0 or 1 (used via Convert.ToUInt32 sites as a plain u32 instead; kept for completeness).</summary>
public AceWireWriter Write(bool value) => Write(value ? (byte)1 : (byte)0);
/// <summary>BinaryWriter.Write(ulong) — little-endian.</summary>
public AceWireWriter Write(ulong value) =>
Write((uint)(value & 0xFFFFFFFFu)).Write((uint)(value >> 32));
/// <summary>BinaryWriter.Write(float) — little-endian IEEE-754.</summary>
public AceWireWriter Write(float value)
=> Write((uint)BitConverter.SingleToInt32Bits(value));

View file

@ -0,0 +1,439 @@
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));
}
}

View file

@ -940,6 +940,51 @@ public class PlayerMovementControllerTests
Assert.Null(exception);
}
// ── Campaign CH user-gate round 1 (item A): airborne jump refusal ──────
[Fact]
public void JumpPress_RisingEdgeWhileAirborne_ReportsCantJumpInAir_HeldOnlyOnce_NoneAfterLanding()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
// Launch into the air with an ordinary charged jump — the fix must
// not touch this grounded charge/fire path at all.
controller.Update(1.0f, new MovementInput(Jump: true)); // full charge
controller.Update(0.016f, new MovementInput(Jump: false)); // release -> jump fires
Assert.True(controller.IsAirborne);
controller.Update(0.05f, new MovementInput()); // clear the ground before pressing again
var reported = new List<string>();
controller.OnInterfaceText = (text, _) => reported.Add(text);
// Rising edge while airborne: exactly one "You can't jump while in
// the air" report.
controller.Update(0.016f, new MovementInput(Jump: true));
var report = Assert.Single(reported);
Assert.Equal(ClientTextRefusals.CantJumpInAir, report);
// Holding the key across multiple further updates raises no
// additional reports.
controller.Update(0.016f, new MovementInput(Jump: true));
controller.Update(0.016f, new MovementInput(Jump: true));
controller.Update(0.016f, new MovementInput(Jump: true));
Assert.Single(reported);
// Release, then land.
controller.Update(0.016f, new MovementInput(Jump: false));
for (int i = 0; i < 60 && controller.IsAirborne; i++)
controller.Update(0.05f, new MovementInput());
Assert.False(controller.IsAirborne, "should have landed");
// Landing then pressing again while grounded raises none (the
// grounded charge succeeds normally for an unburdened character).
reported.Clear();
controller.Update(0.016f, new MovementInput(Jump: true));
Assert.Empty(reported);
}
// ── Campaign P Slice P5 (2026-07-30): ConstraintManager leash arming (#167) ──
//
// docs/research/2026-07-30-constraint-leash-constants.md. The player's

View file

@ -102,8 +102,10 @@ public sealed class ChatVMTests
[Fact]
public void FormatEntry_System_NoSenderShown()
{
// Campaign CH user-gate round 1 (item B): retail prints system text
// bare, with no "[System]" prefix.
var entry = new ChatEntry(ChatKind.System, Sender: "", "Your spell fizzled!", 0, 0);
Assert.Equal("[System] Your spell fizzled!", ChatVM.FormatEntry(entry));
Assert.Equal("Your spell fizzled!", ChatVM.FormatEntry(entry));
}
[Fact]