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:
parent
d1c1368a5e
commit
47e40900f3
17 changed files with 1428 additions and 81 deletions
126
docs/ISSUES.md
126
docs/ISSUES.md
|
|
@ -94,21 +94,6 @@ Register row: TS-69.
|
|||
**Campaign:** `docs/plans/2026-08-09-chat-parity-campaign.md` (Campaign CH,
|
||||
slice CH4).
|
||||
|
||||
## #362 — Four new CH4 outbound requests have no inbound response handler
|
||||
|
||||
**Status:** OPEN — filed 2026-08-09, Campaign CH slice CH4. `@index`,
|
||||
`@clist`, `@hslist`, and `@allegiance info` send byte-correct retail
|
||||
GameAction requests (`ClientCommandRequests.BuildIndexChannels`/
|
||||
`BuildListChannel`/`BuildListAvailableHouses`/`BuildAllegianceInfoRequest`),
|
||||
but their GameEvent responses (`ChannelIndex 0x0149`, `ChannelList
|
||||
0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`) are
|
||||
registered in `GameEventType` with no `GameEventWiring` handler — ACE's
|
||||
reply is silently dropped. The request itself is correct and verifiable
|
||||
on the wire; only the response rendering is missing. Register row: TS-70.
|
||||
|
||||
**Campaign:** `docs/plans/2026-08-09-chat-parity-campaign.md` (Campaign CH,
|
||||
slice CH4).
|
||||
|
||||
## #363 — Chat refusal/usage call sites are typed ClientLocal 0x00 where retail types several 0x1A
|
||||
|
||||
**Status:** OPEN — filed 2026-08-09, CH4 REJECT-review, SHOULD-FIX 9.
|
||||
|
|
@ -1949,39 +1934,6 @@ overlaps the Slice-J ownership work. If it is ever *accepted* rather than
|
|||
fixed, it needs a divergence-register row; it is filed here as a defect
|
||||
because the intent is to fix it.
|
||||
|
||||
## #329 — The portal wait cue arms five seconds late; retail emits it per tunnel rotation segment, unconditionally
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW (cosmetic, but it is a retail divergence on every single
|
||||
portal, in both directions)
|
||||
**Filed:** 2026-08-06, #280 retail-conformance review, finding F2.
|
||||
**Register row:** AP-150.
|
||||
|
||||
acdream suppresses `"In Portal Space - Please Wait..."` until the hold has run
|
||||
five seconds (`RuntimeWorldTransitState.RetailWaitCueDelay`), then re-emits it
|
||||
per rotation segment only while `_waitCueVisible`
|
||||
(`PortalTunnelPresentation.TickRotation`).
|
||||
|
||||
Retail has no threshold. `gmSmartBoxUI::UseTime` @0x004D6E30 emits
|
||||
`ECM_UI::SendNotice_DisplayStringInfo(0x1a, …)` in the `else` arm of the
|
||||
rotation-segment-expiry test at 0x004D6FCD — i.e. every time a segment expires,
|
||||
whether or not `CellManager::blocking_for_cells` is set. Segment duration is
|
||||
`RandDouble(0.6, 1.8)` s, byte-decoded at 0x004D6FE6. acdream's own
|
||||
`RotationDurationMin`/`Max` already match retail exactly, so only the arming is
|
||||
wrong.
|
||||
|
||||
The unrelated 5.0 s constant at VA 0x007991B0 is
|
||||
`CellManager::CheckPrefetchStatus`'s retry cadence, and was mis-attributed to
|
||||
the cue by #280's commit message (retracted in the contract doc).
|
||||
|
||||
**Consequence:** every portal shorter than 5 s shows a silent tunnel where
|
||||
retail shows the notice; every portal longer than 5 s shows it 3.2-4.4 s late.
|
||||
|
||||
**Fix shape:** delete the delay and emit on the same segment boundary the
|
||||
rotation already computes. One-line arming change, but it is a user-visible
|
||||
presentation change and wants the user's eyes before it lands — do not fold it
|
||||
into an unrelated commit.
|
||||
|
||||
## #325 — Gate A's teleport test is narrower than retail's: a ForcePosition carrying a NEWER teleport stamp is misrouted into a full Apply
|
||||
|
||||
**Status:** OPEN
|
||||
|
|
@ -14162,6 +14114,84 @@ outdoors at the angle that previously erased it.
|
|||
|
||||
# Recently closed
|
||||
|
||||
## #362 — [DONE 2026-08-09] Four new CH4 outbound requests have no inbound response handler
|
||||
|
||||
**Closed:** 2026-08-09, Campaign CH user-gate round 1, item E.
|
||||
**Filed:** 2026-08-09, Campaign CH slice CH4.
|
||||
**Register row:** TS-70, RETIRED in the same commit.
|
||||
|
||||
**Resolution:** `@index`, `@clist`, `@hslist`, and `@allegiance info` sent
|
||||
byte-correct retail GameAction requests
|
||||
(`ClientCommandRequests.BuildIndexChannels`/`BuildListChannel`/
|
||||
`BuildListAvailableHouses`/`BuildAllegianceInfoRequest`), but their
|
||||
GameEvent responses (`ChannelIndex 0x0149`, `ChannelList 0x0148`,
|
||||
`AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`) had no
|
||||
`GameEventWiring` handler — ACE's reply was silently dropped. New
|
||||
`ClientCommandResponses.cs` (`src/AcDream.Core.Net/Messages/`) parses all
|
||||
four wire shapes (cross-checked against ACE's
|
||||
`GameEventChannelIndex`/`GameEventChannelList`/`GameEventHouseAvailableHouses`/
|
||||
`GameEventAllegianceInfoResponse` writers) and renders retail-shaped
|
||||
`LogTextType 0x00` (Default) lines ported verbatim from the named-retail
|
||||
decomp:
|
||||
- ChannelIndex/ChannelList: `Handle_Communication__ChannelIndex`/`ChannelList`
|
||||
@0x0057d0c0/@0x0057d230 — a header line then one line per name.
|
||||
- AvailableHouses: `Handle_House__Recv_AvailableHouses` +
|
||||
`DisplayListOfCoords` @0x00585d50/@0x00585c20 — the "There are N <type>
|
||||
available." summary, then one indented `RadarCoordinates`-formatted
|
||||
location line per landblock (skipped for apartments, which have no world
|
||||
location, matching retail's `arg2 != 4` gate), then the >400-locations
|
||||
truncation notice when `TotalAvailable > 0x190`.
|
||||
- AllegianceInfoResponse: `Handle_Allegiance__AllegianceInfoResponseEvent`
|
||||
@0x0056a1d0 — the asterisk-legend note, "Allegiance information for
|
||||
<name><* if online>:", an optional "Patron:" line, and an
|
||||
optional "Vassals:" block, all reconstructed from the wire's flat
|
||||
parent-tagged record list via ports of retail's own
|
||||
`GetData`/`GetPatron`/`GetFirstVassal`/`GetNextVassal` walk. A player with
|
||||
no allegiance record produces NO lines, matching retail's own early
|
||||
return — this is not a residual bug.
|
||||
|
||||
17 new parser/format/routing tests
|
||||
(`tests/AcDream.Core.Net.Tests/Messages/ClientCommandResponsesTests.cs`)
|
||||
cover round-trips for all four shapes (including the empty-allegiance and
|
||||
apartment-skip edge cases) and a `GameEventDispatcher`-level routing test
|
||||
proving each reaches the `ChatLog` transcript with
|
||||
`RetailLogTextType.Default`.
|
||||
|
||||
## #329 — [DONE 2026-08-09] The portal wait cue arms five seconds late; retail emits it per tunnel rotation segment, unconditionally
|
||||
|
||||
**Closed:** 2026-08-09, Campaign CH user-gate round 1, item D.
|
||||
**Severity:** LOW (cosmetic, but it is a retail divergence on every single
|
||||
portal, in both directions)
|
||||
**Filed:** 2026-08-06, #280 retail-conformance review, finding F2.
|
||||
**Register row:** AP-150, RETIRED in the same commit.
|
||||
|
||||
**Resolution:** `PortalTunnelPresentation.TickRotation` now writes
|
||||
`"In Portal Space - Please Wait..."` directly and unconditionally in the
|
||||
rotation-segment-expiry branch, on every segment boundary, matching
|
||||
`gmSmartBoxUI::UseTime`'s `else` arm at 0x004D6FCD verbatim — confirmed
|
||||
against `docs/research/named-retail/acclient_2013_pseudo_c.txt:219499-219525`
|
||||
before coding, which shows no hold/threshold test anywhere in that branch.
|
||||
acdream's own `RotationDurationMin`/`Max` (0.6-1.8 s) already matched
|
||||
retail's `RandDouble` window byte-for-byte; only the arming — gating the
|
||||
write on `_waitCueVisible`, which only ever went true after the invented
|
||||
five-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold — was wrong.
|
||||
That hold/`ObserveWait`/`SetWaitCue` plumbing remains as
|
||||
`LocalPlayerTeleportController`'s own telemetry
|
||||
(`RuntimePortalSnapshot.WaitCueShown`) but no longer gates the on-screen
|
||||
cue; a dedicated `ClearWaitCueNotice()` now hides the notice unconditionally
|
||||
on Enter/Exit/Dispose so a line written by the per-segment path can never
|
||||
survive past the presentation going invisible. The notice text also now
|
||||
renders in the same bright yellow as an incoming Tell
|
||||
(`(1, 1, 0.247, 1)`, `PortalWaitNoticeController`), per the user's live
|
||||
side-by-side observation (round 1 also pinned the SpewBox to the same
|
||||
colour, AP-178).
|
||||
|
||||
**Consequence (fixed):** every portal, regardless of duration, now shows the
|
||||
notice from the first rotation segment (which expires immediately on entry
|
||||
since `_rotationDuration` starts at 0), refreshed every 0.6-1.8 s for as
|
||||
long as the tunnel presentation is visible — matching retail instead of
|
||||
silently skipping short transits and running 3.2-4.4 s late on long ones.
|
||||
|
||||
## #234 — [DONE 2026-07-23] Cancelled close-range Use could strand the busy cursor
|
||||
|
||||
**Closed:** 2026-07-23
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -16,9 +16,13 @@ see the CH4 closeout below; 138 of 152 registry verbs now execute
|
|||
locally, 5 are deliberately deferred (issues #360/#361/#362, register
|
||||
rows TS-68/TS-69/TS-70), and 9 are retail's own null-handler help-only
|
||||
nodes. CH5 (this closeout sweep) is COMPLETE — plan/register/ISSUES/
|
||||
CLAUDE.md/roadmap ledger flip plus the chat memory digest. Pending the
|
||||
in-client user gate (colors, side channels, on-screen text, command
|
||||
spot-checks per the test script).
|
||||
CLAUDE.md/roadmap ledger flip plus the chat memory digest. **User gate
|
||||
round 1 ran 2026-08-09 and found ten live defects; see "User gate —
|
||||
round 1" below.** Items A–G are fixed in this round's commit; the three
|
||||
remaining findings (extra chat windows on 1/2/3/4, resize only working in
|
||||
one corner, transparency/artifacts) are out of this round's scope and
|
||||
filed as a new slice, CH6. Status stays CODE-COMPLETE pending the next
|
||||
user gate round.
|
||||
|
||||
**Why now:** first track of the alpha-release program (chat is the most
|
||||
visible daily surface for the friend-alpha). User-directed 2026-08-09.
|
||||
|
|
@ -102,6 +106,10 @@ implementer per slice against a pinned contract (per
|
|||
commands implemented family-by-family.
|
||||
- **CH5 — closeout.** Register sweep, ledger flip, ISSUES updates,
|
||||
in-client test script for the user gate.
|
||||
- **CH6 — chat-window shell parity (filed 2026-08-09 at user gate round
|
||||
1).** Retail multi-window chat, per-window type filters + PostInit
|
||||
defaults (see color research §4), all-corner resize, opacity; research
|
||||
first.
|
||||
|
||||
## Gates
|
||||
|
||||
|
|
@ -121,6 +129,8 @@ implementer per slice against a pinned contract (per
|
|||
| CH3 side channels | `614a1e05` | 11,964 passed / 4 skipped / 0 failed | APPROVE-WITH-FIXES; fixed `e07fba57` | pending (connected gate — see handoff below) |
|
||||
| CH4 commands | `090825e7` | 12,221 passed / 4 skipped / 0 failed | REJECT; fixed `724ef2d3`; re-review APPROVE-WITH-FIXES; closed `5d247d55` | pending |
|
||||
| CH5 closeout | (this commit) | — (docs/memory only, no build) | — | pending (connected gate — see test script) |
|
||||
| User gate round 1 | (this commit) | 12,221 passed / 4 skipped / 0 failed (baseline; items A–G fixed this commit) | — | items A–G user-gate round 1 fixed; ten findings total, see "User gate — round 1" below |
|
||||
| CH6 chat-window shell parity | not started | — | — | filed 2026-08-09 at user gate round 1; research first |
|
||||
|
||||
### CH4 closeout (2026-08-09)
|
||||
|
||||
|
|
@ -443,3 +453,35 @@ send+receive; Roleplay is now silent-but-correctly-refused until the user
|
|||
turns it on via Settings (then works); `/a` with and without an allegiance;
|
||||
`/ab`; the legacy family no longer double-prints; the TurbineChat ack
|
||||
HResult line never appears on an ordinary successful send.
|
||||
|
||||
## User gate — round 1 (2026-08-09)
|
||||
|
||||
The user tested CH5's CODE-COMPLETE build live and reported ten defects.
|
||||
Items A–G are fixed in this same commit; the last three are out of this
|
||||
round's scope and filed as slice CH6.
|
||||
|
||||
| # | User finding (condensed) | Disposition |
|
||||
|---|---|---|
|
||||
| A | Jumping while already airborne never shows retail's "You can't jump while in the air" refusal — the jump block only ever evaluates `input.Jump` inside the grounded-charge or already-charging branches. | **FIXED this SHA.** Rising-edge detection (`PlayerMovementController._prevJumpHeld`) reports `WeenieError.NotGrounded` once per press while airborne; holding the key or the grounded charge/fire path is unaffected. |
|
||||
| B | Local system text shows an invented `"[System] "` prefix; retail prints it bare. | **FIXED this SHA.** `ChatVM.FormatEntry`'s `ChatKind.System` case now returns `entry.Text` unprefixed. `[Popup]` is unchanged (AP-175, a deliberate divergent marker). |
|
||||
| C | The SpewBox's color doesn't match retail — the user recalls it as the same bright yellow as an incoming Tell. | **FIXED this SHA (color only).** `SpewBoxController.SpewBoxColor` is now the exact pinned value `(1, 1, 0.247, 1)` (`0x81C4C8`, same as `RetailChatColorTable.Yellow`). Size/position/font remain OPEN under register row AP-178 — the user reports all three still differ from retail; user gate round 1: differs, iterating. |
|
||||
| D | The portal-space "In Portal Space - Please Wait..." text never shows, and when it does (via #329's 5-second delay) it's the wrong (white) color. | **FIXED this SHA, closes #329.** `PortalTunnelPresentation.TickRotation` now emits the notice unconditionally on every rotation-segment boundary, matching `gmSmartBoxUI::UseTime`'s decompiled `else`-arm exactly (no hold/threshold gate); `PortalWaitNoticeController` now renders it in the same pinned yellow as item C. Register row AP-150 retired. |
|
||||
| E | `/hslist villas` (and the other three CH4-added request commands) is accepted server-side but produces no visible response. | **FIXED this SHA, closes #362.** New `ClientCommandResponses.cs` parses and renders `ChannelIndex`/`ChannelList`/`AvailableHouses`/`AllegianceInfoResponse`, each ported line-for-line from the named-retail decomp's inbound handlers. Register row TS-70 retired. |
|
||||
| F | Multi-line server text (e.g. `/help`'s reply) doesn't split on embedded `\n` — "probably broken in many places." | **FIXED this SHA.** `ChatWindowController.WrapText` now splits on `\n`/`\r\n` first, then word-wraps each segment independently; the confirmed-correct single-line early-out is unchanged for text with no embedded newline. |
|
||||
| G | The chat input line overflows the window's right edge when the window is resized. | **FIXED this SHA.** The input field's right edge no longer holds a fixed absolute pixel position across a resize (retail edge-mode 0's "frozen at current" fallback, or the `AnchorEdges` default with no `Right` bit) — `ChatWindowController.Bind` now upgrades it to retail edge-mode 1 (`UiLayoutPolicy`) or the equivalent `AnchorEdges.Right` stretch, so the right edge tracks every resize instead of only the bind-time/channel-change recompute. |
|
||||
| H | Extra/duplicate chat windows appear on number keys 1/2/3/4. | **NEW SLICE CH6** — see the slice list above. Retail has real multi-window chat with per-window type filters; this needs research before a fix, not a quick patch. |
|
||||
| I | Resizing the chat window only works from one corner, not every corner. | **NEW SLICE CH6** — bundled with H and J as "all-corner resize." |
|
||||
| J | The chat window has transparency issues / visual artifacts, and the user wants a transparency setting eventually. | **NEW SLICE CH6** — bundled as "opacity"; the future user-facing transparency setting is noted in the slice's own scope, not promised for CH6's first cut. |
|
||||
|
||||
Findings A–G's evidence: this commit's diff + the new/updated tests in
|
||||
`tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs`,
|
||||
`tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs`,
|
||||
`tests/AcDream.App.Tests/UI/SpewBoxControllerTests.cs` (unchanged; verified
|
||||
by inspection — no test pinned the old color),
|
||||
`tests/AcDream.App.Tests/UI/PortalWaitNoticeControllerTests.cs`,
|
||||
`tests/AcDream.Core.Net.Tests/Messages/ClientCommandResponsesTests.cs`,
|
||||
and `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs`. Full
|
||||
Release suite green (see the commit message for the exact count). Items
|
||||
H/I/J need the next visual round once CH6 lands; A–G still want a final
|
||||
in-client eyes-on pass to confirm the fix reads correctly on screen (build
|
||||
+ test green is necessary, not sufficient, for a presentation change).
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ public sealed class PortalTunnelPresentation : IDisposable
|
|||
_rotationEndAngle = 0f;
|
||||
_rotationCurrentAngle = 0f;
|
||||
_camera.DirectionDegrees = 0f;
|
||||
SetWaitCue(false);
|
||||
ClearWaitCueNotice();
|
||||
_visible = true;
|
||||
RebuildPose();
|
||||
}
|
||||
|
|
@ -271,7 +271,7 @@ public sealed class PortalTunnelPresentation : IDisposable
|
|||
if (_disposed)
|
||||
return;
|
||||
_visible = false;
|
||||
SetWaitCue(false);
|
||||
ClearWaitCueNotice();
|
||||
_animationHooks.Clear();
|
||||
_sequence.ClearAnimations();
|
||||
}
|
||||
|
|
@ -287,6 +287,18 @@ public sealed class PortalTunnelPresentation : IDisposable
|
|||
TickRotation(dt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The hold-delay-gated arm/disarm <c>LocalPlayerTeleportController</c>
|
||||
/// still drives every frame from <c>RuntimeWorldTransitState.ObserveWait</c>
|
||||
/// (own telemetry: <c>RuntimePortalSnapshot.WaitCueShown</c>). This is
|
||||
/// deliberately NOT the retail cue-emission path any more — see
|
||||
/// <see cref="TickRotation"/>'s unconditional per-segment write (item D,
|
||||
/// #329). Kept only so the controller's own hold bookkeeping still has
|
||||
/// somewhere to land; because <c>visible</c> is false for the entire
|
||||
/// common case (a transit that never crosses the invented 5-second
|
||||
/// hold), this is a same-value no-op there and never contends with the
|
||||
/// per-segment write above.
|
||||
/// </summary>
|
||||
public void SetWaitCue(bool visible)
|
||||
{
|
||||
if (_waitCueVisible == visible)
|
||||
|
|
@ -297,6 +309,21 @@ public sealed class PortalTunnelPresentation : IDisposable
|
|||
visible ? "In Portal Space - Please Wait..." : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unconditionally hides any wait-cue notice text and resets the
|
||||
/// hold-delay dedup state, independent of <see cref="_waitCueVisible"/>'s
|
||||
/// current value. <see cref="TickRotation"/> now writes the notice text
|
||||
/// directly (bypassing <see cref="SetWaitCue"/>'s dedup), so the old
|
||||
/// `SetWaitCue(false)` calls at Enter/Exit/Dispose could no-op and leave
|
||||
/// a stale "In Portal Space..." line on screen after the presentation
|
||||
/// went invisible — this always clears it.
|
||||
/// </summary>
|
||||
private void ClearWaitCueNotice()
|
||||
{
|
||||
_waitCueVisible = false;
|
||||
_displayNotice?.Invoke(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw retail portal space into the active viewport. The caller suppresses
|
||||
/// the normal world viewport while this scene is visible, then draws the
|
||||
|
|
@ -377,8 +404,29 @@ public sealed class PortalTunnelPresentation : IDisposable
|
|||
_rotationDuration = NextDouble(RotationDurationMin, RotationDurationMax);
|
||||
_rotationStartAngle = _rotationCurrentAngle;
|
||||
_rotationEndAngle = (float)NextDouble(0.0, 360.0);
|
||||
if (_waitCueVisible)
|
||||
_displayNotice?.Invoke("In Portal Space - Please Wait...");
|
||||
// Campaign CH user-gate round 1 (item D, #329): retail's
|
||||
// gmSmartBoxUI::UseTime @0x004D6E30 emits
|
||||
// ECM_UI::SendNotice_DisplayStringInfo(0x1a, "In Portal Space -
|
||||
// Please Wait...") in the else arm of the rotation-segment-
|
||||
// expiry test at 0x004D6FCD UNCONDITIONALLY -- every time a
|
||||
// segment expires, with no hold/threshold check anywhere in
|
||||
// that decompiled function. acdream's own RotationDurationMin/
|
||||
// Max already match retail's RandDouble(0.6, 1.8) segment
|
||||
// window decoded at 0x004D6FE6; the only bug was gating this
|
||||
// call on `_waitCueVisible`, which only ever became true after
|
||||
// RuntimeWorldTransitState.RetailWaitCueDelay's invented 5-
|
||||
// second hold -- a threshold most local transits never reach,
|
||||
// so the cue silently never fired. This write is deliberately
|
||||
// independent of `_waitCueVisible`/SetWaitCue (see that
|
||||
// method's own doc comment): LocalPlayerTeleportController
|
||||
// still drives SetWaitCue every frame from its own hold-delay
|
||||
// bookkeeping, but because that call is a same-value no-op for
|
||||
// the entire common case (a transit that never crosses the 5s
|
||||
// hold), it never fights this unconditional per-segment write.
|
||||
// Enter/Exit/Dispose clear the notice directly (not through
|
||||
// SetWaitCue's dedup) so a stale line can never survive past
|
||||
// this presentation going invisible.
|
||||
_displayNotice?.Invoke("In Portal Space - Please Wait...");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -469,7 +517,7 @@ public sealed class PortalTunnelPresentation : IDisposable
|
|||
try
|
||||
{
|
||||
_visible = false;
|
||||
SetWaitCue(false);
|
||||
ClearWaitCueNotice();
|
||||
_animationHooks.Clear();
|
||||
_sequence.ClearAnimations();
|
||||
_meshReferences.Dispose();
|
||||
|
|
|
|||
|
|
@ -238,6 +238,38 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
c.Input.SpriteResolve = resolve;
|
||||
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
|
||||
|
||||
// Campaign CH user-gate round 1 (item G): the imported field's right
|
||||
// edge otherwise holds a FIXED absolute pixel position across a
|
||||
// window resize — retail edge-mode 0's "frozen at current" fallback
|
||||
// (UiLayoutPolicy.ApplyFar), or the AnchorEdges default (Left|Top,
|
||||
// no stretch) when this field imported without a LayoutPolicy at
|
||||
// all. ReflowInputRow below only repositions Left/Width at bind
|
||||
// time and on channel change; nothing re-runs it on a plain window
|
||||
// RESIZE, so shrinking the window below its authored width left the
|
||||
// input's right edge frozen past the new, narrower client area —
|
||||
// the reported overflow. Retail edge-mode 1 on a FAR edge
|
||||
// ("originalEdge + parentDelta", UiLayoutPolicy.ApplyFar) keeps a
|
||||
// CONSTANT MARGIN from the parent's right edge instead, so the
|
||||
// field's right edge now tracks every resize, not just
|
||||
// bind/channel-change moments; the compatibility AnchorEdges.Right
|
||||
// stretch is the equivalent programmatic-widget fallback. Only the
|
||||
// right-edge behavior changes — Left/Top/Bottom stay whatever the
|
||||
// DAT authored (or the AnchorEdges default).
|
||||
if (c.Input.LayoutPolicy is { } inputPolicy)
|
||||
{
|
||||
c.Input.LayoutPolicy = new UiLayoutPolicy(
|
||||
inputPolicy.LeftMode,
|
||||
inputPolicy.TopMode,
|
||||
rightMode: 1u,
|
||||
inputPolicy.BottomMode,
|
||||
inputPolicy.OriginalChild,
|
||||
inputPolicy.OriginalParent);
|
||||
}
|
||||
else
|
||||
{
|
||||
c.Input.Anchors |= AnchorEdges.Right;
|
||||
}
|
||||
|
||||
// ── Scrollbar — bind the factory-built Type-11 track element ────────
|
||||
// The factory now builds the Type-11 track element (0x10000012) as a UiScrollbar
|
||||
// directly. Find it, bind it in place — no remove/add needed.
|
||||
|
|
@ -508,9 +540,40 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
/// </summary>
|
||||
public static IEnumerable<string> WrapText(string text, float maxW, Func<string, float> measure)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || maxW <= 0f || measure(text) <= maxW)
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
yield return text ?? string.Empty;
|
||||
yield return string.Empty;
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Campaign CH user-gate round 1 (item F): server text (e.g. /help's
|
||||
// reply) carries embedded '\n's. This function used to hand the
|
||||
// WHOLE blob — newlines and all — to the single early-out below,
|
||||
// rendering multi-line text as one UiText.Line with literal newline
|
||||
// characters in it instead of one rendered line per segment. Split
|
||||
// on '\n' FIRST (normalizing "\r\n"/bare "\r" the same way), then
|
||||
// word-wrap each segment independently; the early-out is now scoped
|
||||
// to one already-newline-free segment, so it only ever collapses a
|
||||
// single-segment text to one line, never a multi-line one.
|
||||
string normalized = text.Replace("\r\n", "\n").Replace('\r', '\n');
|
||||
foreach (string segment in normalized.Split('\n'))
|
||||
{
|
||||
foreach (string frag in WrapSingleLine(segment, maxW, measure))
|
||||
yield return frag;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Greedy word-wrap for a single, already newline-free line. Split out of
|
||||
/// <see cref="WrapText"/> (Campaign CH user-gate round 1, item F) so the
|
||||
/// multi-segment split there can call this once per '\n'-delimited
|
||||
/// segment without re-deriving the per-line wrap algorithm.
|
||||
/// </summary>
|
||||
private static IEnumerable<string> WrapSingleLine(string text, float maxW, Func<string, float> measure)
|
||||
{
|
||||
if (text.Length == 0 || maxW <= 0f || measure(text) <= maxW)
|
||||
{
|
||||
yield return text;
|
||||
yield break;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,16 @@ namespace AcDream.App.UI;
|
|||
/// </summary>
|
||||
internal sealed class PortalWaitNoticeController : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Register row AP-150/AP-178: CH user-gate round 1 (2026-08-09) PINNED
|
||||
/// this — the user confirmed live, side-by-side against retail, that
|
||||
/// this notice renders in the same bright yellow as an incoming Tell
|
||||
/// (<c>0x81C4C8</c>, <c>RetailChatColorTable.Yellow</c> =
|
||||
/// <c>(1, 1, 0.247, 1)</c>), not white. Same exact value as the
|
||||
/// SpewBox's pinned colour (<see cref="AcDream.App.UI.SpewBoxController"/>).
|
||||
/// </summary>
|
||||
private static readonly Vector4 RetailWaitCueColor = new(1f, 1f, 0.247f, 1f);
|
||||
|
||||
private readonly UiRoot _root;
|
||||
private readonly UiText _text;
|
||||
private UiText.Line[] _lines = [];
|
||||
|
|
@ -32,7 +42,7 @@ internal sealed class PortalWaitNoticeController : IDisposable
|
|||
OneLine = true,
|
||||
ClickThrough = true,
|
||||
ZOrder = int.MaxValue,
|
||||
DefaultColor = Vector4.One,
|
||||
DefaultColor = RetailWaitCueColor,
|
||||
Visible = false,
|
||||
};
|
||||
_text.LinesProvider = () => _lines;
|
||||
|
|
@ -49,7 +59,7 @@ internal sealed class PortalWaitNoticeController : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
_lines = [new UiText.Line(message, Vector4.One)];
|
||||
_lines = [new UiText.Line(message, RetailWaitCueColor)];
|
||||
_text.Visible = true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -106,23 +106,22 @@ internal sealed class SpewBoxController : IDisposable
|
|||
private const float SpewBoxHeight = 72f;
|
||||
|
||||
/// <summary>
|
||||
/// Register row AP-178 (colour): retail's authored colour for THIS
|
||||
/// element remains unresolved — the LayoutDesc dump (see class remarks)
|
||||
/// found only two direct-state properties on the SpewBox element/ListBox
|
||||
/// (a bool at <c>0x3B</c> and the <c>MaxConcurrentItems</c> integer at
|
||||
/// <c>0x10000028</c>); no colour property surfaced in that direct-state
|
||||
/// dump, and the per-<c>UIStateId</c> <c>States</c> dictionary (hover/
|
||||
/// pressed/etc. variants, which could carry it) was not walked this
|
||||
/// pass. The chat colour table's <c>0x1A</c> entry
|
||||
/// (<c>colorBrightRed</c>) is explicitly NOT this — retail's own
|
||||
/// Register row AP-178 (colour): CH user-gate round 1 (2026-08-09)
|
||||
/// PINNED this — the user confirmed live, side-by-side against retail,
|
||||
/// that the on-screen SpewBox text is the same bright yellow as an
|
||||
/// incoming Tell (<c>0x81C4C8</c>, <c>RetailChatColorTable.Yellow</c> =
|
||||
/// <c>(1, 1, 0.247, 1)</c>). The chat colour table's <c>0x1A</c> entry
|
||||
/// (<c>colorBrightRed</c>) is still explicitly NOT this — retail's own
|
||||
/// <c>BuildChatColorLookupTable</c> writes to <c>ChatInterface::m_chatLog</c>,
|
||||
/// a completely different element tree the SpewBox never touches
|
||||
/// (research doc §3.2.3). This warm-yellow placeholder follows the
|
||||
/// user's own recollection of the retail SpewBox's colour (unconfirmed
|
||||
/// by any decompiled or DAT-authored source) rather than an arbitrary
|
||||
/// choice.
|
||||
/// (research doc §3.2.3); the LayoutDesc dump (see class remarks) also
|
||||
/// never surfaced a colour property for this element. The exact retail
|
||||
/// value simply happens to coincide with the Tell colour, per the user's
|
||||
/// live observation. SIZE/POSITION/FONT remain OPEN — the user reports
|
||||
/// all three still differ from retail; user gate round 1: differs,
|
||||
/// iterating.
|
||||
/// </summary>
|
||||
private static readonly Vector4 SpewBoxColor = new(1f, 1f, 0.4f, 1f);
|
||||
private static readonly Vector4 SpewBoxColor = new(1f, 1f, 0.247f, 1f);
|
||||
|
||||
private readonly UiRoot _root;
|
||||
private readonly UiText _text;
|
||||
|
|
|
|||
|
|
@ -161,6 +161,42 @@ public static class GameEventWiring
|
|||
// AddTextToScroll(..., 0, 1, 0), pc:382186.
|
||||
chat.OnSystemMessage(text, chatType: 0u);
|
||||
});
|
||||
|
||||
// #362 / register row TS-70 (Campaign CH user-gate round 1, item E):
|
||||
// @index/@clist/@hslist/@allegiance info sent byte-correct requests
|
||||
// with no inbound handler — ACE's reply was silently dropped. All
|
||||
// four render LogTextType 0x00 Default lines, matching their retail
|
||||
// handlers exactly (see ClientCommandResponses' per-method doc
|
||||
// comments for the named-retail anchors).
|
||||
registrar.Register(GameEventType.ChannelIndex, e =>
|
||||
{
|
||||
var channels = ClientCommandResponses.ParseChannelIndex(e.Payload.Span);
|
||||
if (channels is null) return;
|
||||
foreach (string line in ClientCommandResponses.FormatChannelIndexLines(channels))
|
||||
chat.OnSystemMessage(line, chatType: 0u);
|
||||
});
|
||||
registrar.Register(GameEventType.ChannelList, e =>
|
||||
{
|
||||
var names = ClientCommandResponses.ParseChannelList(e.Payload.Span);
|
||||
if (names is null) return;
|
||||
foreach (string line in ClientCommandResponses.FormatChannelListLines(names))
|
||||
chat.OnSystemMessage(line, chatType: 0u);
|
||||
});
|
||||
registrar.Register(GameEventType.AvailableHouses, e =>
|
||||
{
|
||||
var houses = ClientCommandResponses.ParseAvailableHouses(e.Payload.Span);
|
||||
if (houses is null) return;
|
||||
foreach (string line in ClientCommandResponses.FormatAvailableHousesLines(houses.Value))
|
||||
chat.OnSystemMessage(line, chatType: 0u);
|
||||
});
|
||||
registrar.Register(GameEventType.AllegianceInfoResponse, e =>
|
||||
{
|
||||
var info = ClientCommandResponses.ParseAllegianceInfoResponse(e.Payload.Span);
|
||||
if (info is null) return;
|
||||
foreach (string line in ClientCommandResponses.FormatAllegianceInfoLines(info.Value))
|
||||
chat.OnSystemMessage(line, chatType: 0u);
|
||||
});
|
||||
|
||||
if (onConfirmationRequest is not null)
|
||||
{
|
||||
registrar.Register(GameEventType.CharacterConfirmationRequest, e =>
|
||||
|
|
|
|||
406
src/AcDream.Core.Net/Messages/ClientCommandResponses.cs
Normal file
406
src/AcDream.Core.Net/Messages/ClientCommandResponses.cs
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.Core.Ui;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Inbound parsers + retail-shaped text rendering for the four CH4
|
||||
/// client-command GameEvent responses that had a request builder
|
||||
/// (<see cref="ClientCommandRequests"/>) but no inbound handler — issue
|
||||
/// #362 / register row TS-70. ACE's server-side writers are the wire-shape
|
||||
/// oracle (cited per method); the retail CLIENT's decompiled handlers are
|
||||
/// the oracle for what text gets printed and in what order — see
|
||||
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c> anchors
|
||||
/// cited per method.
|
||||
/// </summary>
|
||||
public static class ClientCommandResponses
|
||||
{
|
||||
// ── 0x0149 ChannelIndex / 0x0148 ChannelList ───────────────────────────
|
||||
// ACE: GameEventChannelIndex.cs / GameEventChannelList.cs — both write
|
||||
// `Writer.Write(count)` (u32) then `count` WriteString16L entries; no
|
||||
// other framing. Retail: ClientCommunicationSystem::
|
||||
// Handle_Communication__ChannelIndex @0x0057d0c0 /
|
||||
// Handle_Communication__ChannelList @0x0057d230 — both parse a single
|
||||
// PackableList<PStringBase> the same way.
|
||||
|
||||
/// <summary>0x0149 ChannelIndex: the list of GM/staff channel names available to this account.</summary>
|
||||
public static IReadOnlyList<string>? ParseChannelIndex(ReadOnlySpan<byte> payload) =>
|
||||
ParseStringList(payload);
|
||||
|
||||
/// <summary>0x0148 ChannelList: the list of character names currently listening on the queried channel.</summary>
|
||||
public static IReadOnlyList<string>? ParseChannelList(ReadOnlySpan<byte> payload) =>
|
||||
ParseStringList(payload);
|
||||
|
||||
private static IReadOnlyList<string>? ParseStringList(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
int pos = 0;
|
||||
uint count = ReadU32(payload, ref pos);
|
||||
var list = new List<string>();
|
||||
for (uint i = 0; i < count; i++)
|
||||
list.Add(StringReader.ReadString16L(payload, ref pos));
|
||||
return list;
|
||||
}
|
||||
catch (FormatException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail-shaped lines for a ChannelIndex response — verbatim port of
|
||||
/// <c>Handle_Communication__ChannelIndex</c>'s header + per-entry loop
|
||||
/// (all LogTextType 0x00 Default). Header text at
|
||||
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:1031363</c>
|
||||
/// (<c>data_7e0b10</c>).
|
||||
/// </summary>
|
||||
public static IEnumerable<string> FormatChannelIndexLines(IReadOnlyList<string> channels)
|
||||
{
|
||||
yield return "The following channels are available to you:";
|
||||
foreach (string channel in channels)
|
||||
yield return channel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail-shaped lines for a ChannelList response — verbatim port of
|
||||
/// <c>Handle_Communication__ChannelList</c>. Header text at
|
||||
/// <c>acclient_2013_pseudo_c.txt:1031367</c> (<c>data_7e0b40</c>).
|
||||
/// </summary>
|
||||
public static IEnumerable<string> FormatChannelListLines(IReadOnlyList<string> names)
|
||||
{
|
||||
yield return "The following characters are currently listening on the channel:";
|
||||
foreach (string name in names)
|
||||
yield return name;
|
||||
}
|
||||
|
||||
// ── 0x0271 AvailableHouses ──────────────────────────────────────────────
|
||||
// ACE: GameEventHouseAvailableHouses.cs — Write((uint)type) +
|
||||
// Write(locations: List<uint>, PackableList.cs:20 — u32 count then N u32
|
||||
// landblock ids) + Write(totalAvailable: int). Retail:
|
||||
// ClientHousingSystem::Handle_House__Recv_AvailableHouses @0x00585d50 +
|
||||
// DisplayListOfCoords @0x00585c20.
|
||||
|
||||
public readonly record struct AvailableHousesResponse(
|
||||
uint HouseType,
|
||||
IReadOnlyList<uint> Locations,
|
||||
int TotalAvailable);
|
||||
|
||||
public static AvailableHousesResponse? ParseAvailableHouses(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
int pos = 0;
|
||||
uint houseType = ReadU32(payload, ref pos);
|
||||
uint count = ReadU32(payload, ref pos);
|
||||
var locations = new uint[count];
|
||||
for (uint i = 0; i < count; i++)
|
||||
locations[i] = ReadU32(payload, ref pos);
|
||||
int totalAvailable = unchecked((int)ReadU32(payload, ref pos));
|
||||
return new AvailableHousesResponse(houseType, locations, totalAvailable);
|
||||
}
|
||||
catch (FormatException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ACE <c>HouseType</c> enum values (Undef=0, Cottage=1, Villa=2,
|
||||
/// Mansion=3, Apartment=4) — matches the switch at
|
||||
/// <c>acclient_2013_pseudo_c.txt:400205-400227</c> exactly (case 1..4;
|
||||
/// out-of-range/0 leaves the type name empty, matching retail's
|
||||
/// skipped-`if` fallthrough).
|
||||
/// </summary>
|
||||
private static string HouseTypeName(uint houseType) => houseType switch
|
||||
{
|
||||
1u => "cottages",
|
||||
2u => "villas",
|
||||
3u => "mansions",
|
||||
4u => "apartments",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Retail-shaped lines for an AvailableHouses response. Verbatim port of
|
||||
/// <c>Handle_House__Recv_AvailableHouses</c> @0x00585d50 +
|
||||
/// <c>DisplayListOfCoords</c> @0x00585c20: the summary line, then one
|
||||
/// coordinate line per location UNLESS the type is Apartment (retail
|
||||
/// skips <c>DisplayListOfCoords</c> entirely for <c>arg2 == 4</c> —
|
||||
/// apartments have no world location), then the >400-locations
|
||||
/// truncation notice (<c>data_7e1d70</c>,
|
||||
/// <c>acclient_2013_pseudo_c.txt:1032459</c>) if <c>TotalAvailable > 0x190</c>.
|
||||
/// All lines are LogTextType 0x00 Default. Coordinate formatting is
|
||||
/// <see cref="AcDream.Core.Ui.RadarCoordinates"/> — the same
|
||||
/// <c>(lcoord - 1024) * 0.1 + 0.5</c> port
|
||||
/// <c>CPlayerSystem::InqPlayerCoords</c> uses — with retail's own
|
||||
/// 5-space indent and "Y, X" order
|
||||
/// (<c>" %.1f%s, %.1f%s\n"</c>, args <c>Y, Ysuffix, X</c>[, Xsuffix]).
|
||||
/// </summary>
|
||||
public static IEnumerable<string> FormatAvailableHousesLines(AvailableHousesResponse response)
|
||||
{
|
||||
yield return string.Create(
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
$"There are {response.TotalAvailable} {HouseTypeName(response.HouseType)} available.");
|
||||
|
||||
if (response.HouseType != 4u)
|
||||
{
|
||||
foreach (uint landblockId in response.Locations)
|
||||
{
|
||||
if (RadarCoordinates.TryFromCell(landblockId, out var coordinates))
|
||||
yield return $" {coordinates.YText}, {coordinates.XText}";
|
||||
}
|
||||
|
||||
if (response.TotalAvailable > 0x190)
|
||||
yield return "There were too many houses to display all the locations. Only the first 400 locations are displayed here.";
|
||||
}
|
||||
}
|
||||
|
||||
// ── 0x027C AllegianceInfoResponse ───────────────────────────────────────
|
||||
// ACE: GameEventAllegianceInfoResponse.cs -> AllegianceProfileExtensions.
|
||||
// Write / AllegianceHierarchyExtensions.Write / AllegianceDataExtensions.
|
||||
// Write. Retail: ClientAllegianceSystem::
|
||||
// Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0, walking
|
||||
// AllegianceProfile::GetData/GetPatron/GetFirstVassal/GetNextVassal.
|
||||
|
||||
/// <summary>
|
||||
/// One retail <c>AllegianceData</c> record. <paramref name="ParentGuid"/>
|
||||
/// is the wire's "treeParent" tag (0 for the monarch, who has none) —
|
||||
/// retail's own <c>GetPatron</c>/<c>GetFirstVassal</c> walk the flat
|
||||
/// record list by this tag rather than storing an explicit tree.
|
||||
/// </summary>
|
||||
public readonly record struct AllegianceMemberRecord(
|
||||
uint CharacterId,
|
||||
uint ParentGuid,
|
||||
bool IsLoggedIn,
|
||||
string Name);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="AllegianceIndex.LoggedIn"/>/<c>HasAllegianceAge</c>/
|
||||
/// <c>HasPackedLevel</c> bit values — ACE
|
||||
/// <c>Source/ACE.Server/Network/Enum/AllegianceIndex.cs</c>.
|
||||
/// </summary>
|
||||
private const uint LoggedInBit = 0x1u;
|
||||
private const uint HasAllegianceAgeBit = 0x4u;
|
||||
private const uint HasPackedLevelBit = 0x8u;
|
||||
|
||||
public readonly record struct AllegianceInfoResponse(
|
||||
uint TargetGuid,
|
||||
uint TotalMembers,
|
||||
uint TotalVassals,
|
||||
ushort RecordCount,
|
||||
string AllegianceName,
|
||||
AllegianceMemberRecord? Monarch,
|
||||
IReadOnlyList<AllegianceMemberRecord> Records)
|
||||
{
|
||||
/// <summary>
|
||||
/// Port of <c>AllegianceProfile::GetData</c>: find the record
|
||||
/// (monarch or otherwise) whose own <c>characterID</c> matches
|
||||
/// <paramref name="guid"/>.
|
||||
/// </summary>
|
||||
public AllegianceMemberRecord? FindData(uint guid)
|
||||
{
|
||||
if (Monarch is { } monarch && monarch.CharacterId == guid)
|
||||
return monarch;
|
||||
foreach (AllegianceMemberRecord record in Records)
|
||||
{
|
||||
if (record.CharacterId == guid)
|
||||
return record;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>AllegianceProfile::GetPatron</c>: the monarch has no
|
||||
/// patron; anyone else's patron is <see cref="FindData"/> applied to
|
||||
/// their own record's <see cref="AllegianceMemberRecord.ParentGuid"/>
|
||||
/// (which is the monarch's own guid when the patron IS the monarch —
|
||||
/// ACE never emits a separate patron record in that case, see
|
||||
/// <c>AllegianceHierarchy.Write</c>'s <c>!node.Patron.IsMonarch</c>
|
||||
/// guard).
|
||||
/// </summary>
|
||||
public AllegianceMemberRecord? FindPatron(uint guid)
|
||||
{
|
||||
if (Monarch is { } monarch && monarch.CharacterId == guid)
|
||||
return null;
|
||||
foreach (AllegianceMemberRecord record in Records)
|
||||
{
|
||||
if (record.CharacterId == guid)
|
||||
return FindData(record.ParentGuid);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Port of <c>GetFirstVassal</c>/<c>GetNextVassal</c>: every record whose parent is <paramref name="guid"/>.</summary>
|
||||
public IEnumerable<AllegianceMemberRecord> FindVassals(uint guid)
|
||||
{
|
||||
foreach (AllegianceMemberRecord record in Records)
|
||||
{
|
||||
if (record.ParentGuid == guid)
|
||||
yield return record;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static AllegianceInfoResponse? ParseAllegianceInfoResponse(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
int pos = 0;
|
||||
uint targetGuid = ReadU32(payload, ref pos);
|
||||
uint totalMembers = ReadU32(payload, ref pos);
|
||||
uint totalVassals = ReadU32(payload, ref pos);
|
||||
ushort recordCount = ReadU16(payload, ref pos);
|
||||
_ = ReadU16(payload, ref pos); // oldVersion — not consulted by the renderer
|
||||
|
||||
// officers: PackableHashTable<ObjectGuid,AllegianceOfficerLevel>.
|
||||
// ACE always sends this empty ("always sent as empty in retail?"
|
||||
// per AllegianceHierarchy.cs) and retail's own chat renderer never
|
||||
// reads it — skip the entries, keep the cursor faithful.
|
||||
ushort officerCount = ReadU16(payload, ref pos);
|
||||
_ = ReadU16(payload, ref pos); // numBuckets
|
||||
for (int i = 0; i < officerCount; i++)
|
||||
{
|
||||
_ = ReadU32(payload, ref pos); // guid
|
||||
_ = ReadU32(payload, ref pos); // officer level
|
||||
}
|
||||
|
||||
// officerTitles: List<string>.Write — a bare int32 count (NOT the
|
||||
// PackableHashTable u16/u16 header), then N String16L.
|
||||
int titleCount = unchecked((int)ReadU32(payload, ref pos));
|
||||
for (int i = 0; i < titleCount; i++)
|
||||
_ = StringReader.ReadString16L(payload, ref pos);
|
||||
|
||||
_ = ReadU32(payload, ref pos); // monarchBroadcastTime
|
||||
_ = ReadU32(payload, ref pos); // monarchBroadcastsToday
|
||||
_ = ReadU32(payload, ref pos); // spokesBroadcastTime
|
||||
_ = ReadU32(payload, ref pos); // spokesBroadcastsToday
|
||||
_ = StringReader.ReadString16L(payload, ref pos); // motd
|
||||
_ = StringReader.ReadString16L(payload, ref pos); // motdSetBy
|
||||
_ = ReadU32(payload, ref pos); // chatRoomID
|
||||
|
||||
// bindPoint Position: cell(u32) + pos(3xfloat) + rotation(4xfloat,
|
||||
// W/X/Y/Z order) = 32 bytes. Not surfaced by the retail chat
|
||||
// renderer (only the Allegiance UI panel's bind-point display
|
||||
// would use it) — skip with bounds checking via ReadU32.
|
||||
for (int i = 0; i < 8; i++)
|
||||
_ = ReadU32(payload, ref pos);
|
||||
|
||||
string allegianceName = StringReader.ReadString16L(payload, ref pos);
|
||||
_ = ReadU32(payload, ref pos); // nameLastSetTime
|
||||
_ = ReadU32(payload, ref pos); // isLocked
|
||||
_ = ReadU32(payload, ref pos); // approvedVassal
|
||||
|
||||
AllegianceMemberRecord? monarch = null;
|
||||
var records = new List<AllegianceMemberRecord>();
|
||||
if (recordCount > 0)
|
||||
{
|
||||
monarch = ReadAllegianceData(payload, ref pos, parentGuid: 0u);
|
||||
for (int i = 1; i < recordCount; i++)
|
||||
{
|
||||
uint parentGuid = ReadU32(payload, ref pos);
|
||||
records.Add(ReadAllegianceData(payload, ref pos, parentGuid));
|
||||
}
|
||||
}
|
||||
|
||||
return new AllegianceInfoResponse(
|
||||
targetGuid, totalMembers, totalVassals, recordCount,
|
||||
allegianceName, monarch, records);
|
||||
}
|
||||
catch (FormatException) { return null; }
|
||||
}
|
||||
|
||||
private static AllegianceMemberRecord ReadAllegianceData(
|
||||
ReadOnlySpan<byte> payload, ref int pos, uint parentGuid)
|
||||
{
|
||||
uint characterId = ReadU32(payload, ref pos);
|
||||
_ = ReadU32(payload, ref pos); // cpCached
|
||||
_ = ReadU32(payload, ref pos); // cpTithed
|
||||
uint bitfield = ReadU32(payload, ref pos);
|
||||
_ = ReadByte(payload, ref pos); // gender
|
||||
_ = ReadByte(payload, ref pos); // heritage group
|
||||
_ = ReadU16(payload, ref pos); // rank
|
||||
if ((bitfield & HasPackedLevelBit) != 0u)
|
||||
_ = ReadU32(payload, ref pos); // level
|
||||
_ = ReadU16(payload, ref pos); // loyalty
|
||||
_ = ReadU16(payload, ref pos); // leadership
|
||||
if ((bitfield & HasAllegianceAgeBit) != 0u)
|
||||
{
|
||||
_ = ReadU32(payload, ref pos); // timeOnline
|
||||
_ = ReadU32(payload, ref pos); // allegianceAge
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = ReadU32(payload, ref pos); // uTimeOnline low
|
||||
_ = ReadU32(payload, ref pos); // uTimeOnline high
|
||||
}
|
||||
string name = StringReader.ReadString16L(payload, ref pos);
|
||||
return new AllegianceMemberRecord(
|
||||
characterId, parentGuid, (bitfield & LoggedInBit) != 0u, name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's " *" online marker (<c>data_7cef28</c>,
|
||||
/// <c>acclient_2013_pseudo_c.txt:1027329-1027330</c> — UTF-16 bytes
|
||||
/// <c>20 00 2a 00</c> = space + asterisk) versus the empty string for an
|
||||
/// offline member (<c>data_794320</c>, the generic empty-PStringBase
|
||||
/// sentinel).
|
||||
/// </summary>
|
||||
private static string OnlineMarker(AllegianceMemberRecord member) =>
|
||||
member.IsLoggedIn ? " *" : "";
|
||||
|
||||
/// <summary>
|
||||
/// Retail-shaped lines for an AllegianceInfoResponse — verbatim port of
|
||||
/// <c>Handle_Allegiance__AllegianceInfoResponseEvent</c> @0x0056a1d0.
|
||||
/// Retail's own <c>AllegianceProfile::GetData</c> failure (the queried
|
||||
/// player has no record at all — e.g. no allegiance) returns early with
|
||||
/// NO text printed at all; this yields an empty sequence for that case,
|
||||
/// matching retail exactly rather than inventing a "no allegiance"
|
||||
/// message retail never shows.
|
||||
/// </summary>
|
||||
public static IEnumerable<string> FormatAllegianceInfoLines(AllegianceInfoResponse response)
|
||||
{
|
||||
AllegianceMemberRecord? self = response.FindData(response.TargetGuid);
|
||||
if (self is not { } selfRecord)
|
||||
yield break;
|
||||
|
||||
yield return "Note: An asterisk (*) indicates that the character is currently online.";
|
||||
yield return $"Allegiance information for {selfRecord.Name}{OnlineMarker(selfRecord)}:";
|
||||
|
||||
if (response.FindPatron(response.TargetGuid) is { } patron)
|
||||
yield return $" Patron: {patron.Name}{OnlineMarker(patron)}";
|
||||
|
||||
bool wroteVassalHeader = false;
|
||||
foreach (AllegianceMemberRecord vassal in response.FindVassals(response.TargetGuid))
|
||||
{
|
||||
if (!wroteVassalHeader)
|
||||
{
|
||||
yield return " Vassals: ";
|
||||
wroteVassalHeader = true;
|
||||
}
|
||||
yield return $" {vassal.Name}{OnlineMarker(vassal)}";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared primitive readers (throw on truncation, like StringReader) ──
|
||||
|
||||
private static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
|
||||
{
|
||||
if (source.Length - pos < 4) throw new FormatException("truncated u32");
|
||||
uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos));
|
||||
pos += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static ushort ReadU16(ReadOnlySpan<byte> source, ref int pos)
|
||||
{
|
||||
if (source.Length - pos < 2) throw new FormatException("truncated u16");
|
||||
ushort value = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
|
||||
pos += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static byte ReadByte(ReadOnlySpan<byte> source, ref int pos)
|
||||
{
|
||||
if (source.Length - pos < 1) throw new FormatException("truncated byte");
|
||||
byte value = source[pos];
|
||||
pos += 1;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
|
@ -455,6 +455,13 @@ public sealed class PlayerMovementController
|
|||
private bool _jumpCharging;
|
||||
private float _jumpExtent;
|
||||
|
||||
// Campaign CH user-gate round 1 (item A, #329 sibling finding): previous
|
||||
// frame's raw Jump input, so an airborne jump press can be reported on
|
||||
// its RISING edge only — retail's jump_is_allowed (called from
|
||||
// ClientCombatSystem::DoJump @0x0056B110) refuses once per press, not
|
||||
// once per frame the key is held.
|
||||
private bool _prevJumpHeld;
|
||||
|
||||
/// <summary>
|
||||
/// Current retail jump-powerbar state. Power is always zero when no jump is
|
||||
/// pending and otherwise lies in [0,1].
|
||||
|
|
@ -2574,6 +2581,21 @@ public sealed class PlayerMovementController
|
|||
_jumpCharging = false;
|
||||
_jumpExtent = 0f;
|
||||
}
|
||||
else if (input.Jump && !_prevJumpHeld && !_body.OnWalkable)
|
||||
{
|
||||
// Campaign CH user-gate round 1, item A: the whole jump block
|
||||
// above only ever evaluates `input.Jump` inside
|
||||
// `input.Jump && _body.OnWalkable` (charge) or `_jumpCharging`
|
||||
// (fire/refuse) — pressing jump while airborne and NOT already
|
||||
// charging never reached either branch, so retail's 0x24 "You
|
||||
// can't jump while in the air" (jump_is_allowed via
|
||||
// ClientCombatSystem::DoJump @0x0056B110) could never fire live.
|
||||
// Report it exactly like the grounded refusals above, gated to
|
||||
// the press EDGE only (see _prevJumpHeld) so holding space
|
||||
// in-air raises exactly one report, not one per frame.
|
||||
ReportJumpRefusal(WeenieError.NotGrounded);
|
||||
}
|
||||
_prevJumpHeld = input.Jump;
|
||||
|
||||
// ── 2. Run admitted complete-object quanta ────────────────────────────
|
||||
// CPhysicsObj::update_object (0x00515D10) retains a remainder at or
|
||||
|
|
|
|||
|
|
@ -237,7 +237,11 @@ public sealed class ChatVM : IDisposable
|
|||
ChatKind.Tell => entry.SenderGuid != 0
|
||||
? $"{entry.Sender} tells you, \"{entry.Text}\""
|
||||
: $"You tell {entry.Sender}, \"{entry.Text}\"",
|
||||
ChatKind.System => $"[System] {entry.Text}",
|
||||
// Campaign CH user-gate round 1 (item B): retail prints system text
|
||||
// bare, with no "[System]" prefix — that prefix was acdream's own
|
||||
// invention. [Popup] stays (AP-175, a deliberate divergent
|
||||
// presentation marker for a different kind).
|
||||
ChatKind.System => entry.Text,
|
||||
ChatKind.Popup => $"[Popup] {entry.Text}",
|
||||
// Phase I.5: emote rendering matches retail's leading-asterisk
|
||||
// convention ("* Caith waves at you"). SoulEmote uses the same
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue