fix(tell): restore outgoing tell routing from the input bar

Input-bar tells went out as a bare "/t" without the target, so the game
rejected them with "you must add the World name". Rebuild the full
"/tell name@world" from the 1.5.6 target chain in a pure BuildOutgoing:

- leg2/leg3 gated on current == Tell so a stale tell target on a Say tab
  can't send a say line silently as /tell (CORR-1)
- world-resolve gate: an unresolvable world falls back to the channel
  prefix, never "/tell name@ text" (COMP-1)
- ResetTempChannel after the send, tell-only

Also clear the runtime tell state on PromoteToPermanent so a promoted tab
can't route a typed line to the old partner, and surface the tell partner
("-> name@world") in the channel pill so a misfire stays visible. Adds
tell-routing and pill-transparency self tests.
This commit is contained in:
2026-06-04 17:11:32 +02:00
parent e372afc8ac
commit d40b120b91
6 changed files with 358 additions and 17 deletions
+101 -14
View File
@@ -8,6 +8,7 @@ using Dalamud.Interface.Utility.Raii;
using HellionChat._Helpers;
using HellionChat.Code;
using HellionChat.GameFunctions;
using HellionChat.GameFunctions.Types;
using HellionChat.Resources;
using HellionChat.Themes;
using HellionChat.Ui;
@@ -201,6 +202,34 @@ internal sealed class InputBar
// saved default and is null for most non-FC tabs, which produced
// the "—" placeholder users saw.
var current = tab?.CurrentChannel?.Channel ?? InputChannel.Invalid;
// Privacy transparency: a game-side tell or reply writes {Channel=Tell,
// TellTarget} onto the active tab's CurrentChannel even on a NORMAL tab
// (Tab.TellTarget stays empty, so the isTell branch above is false). In
// that state BuildOutgoing's leg2/leg3 would route the next typed line as
// /tell to that partner — but the bare "Tell" label hid WHO. Mirror the
// exact leg2/leg3 source (current==Tell, TempTellTarget ?? TellTarget) AND
// the COMP-1 world-resolve gate, so the pill names the partner ONLY when a
// /tell would actually be built; an unresolvable world sends no /tell and
// falls through to the plain label below. Read-only — no routing effect.
// 1.5.6 showed the partner name here; this restores that transparency.
if (current == InputChannel.Tell)
{
// Mirror BuildOutgoing's exact target chain for the tell channel (leg1
// Tab.TellTarget first, then leg2/leg3 CurrentChannel) so the pill names
// precisely who the next line would reach — no drift between shown and sent.
var ccTarget =
tab is not null && tab.TellTarget.IsSet()
? tab.TellTarget
: tab?.CurrentChannel?.TempTellTarget ?? tab?.CurrentChannel?.TellTarget;
if (ccTarget is not null && ccTarget.IsSet())
{
var world = ccTarget.ToWorldString();
if (!string.IsNullOrEmpty(world))
return $"→ {ccTarget.Name}@{world}";
}
}
if (current != InputChannel.Invalid)
return current.ToChatType().Name();
@@ -382,20 +411,11 @@ internal sealed class InputBar
}
_disclosureArmedBuffer = null;
// Slash commands route through verbatim — the game's chat parser
// handles /tell, /fc, /hellion etc. on its own. Other text gets
// the active channel's prefix so the line lands on the channel
// the user is reading instead of the game-side default.
string toSend;
if (text.StartsWith('/'))
{
toSend = text;
}
else
{
var current = activeTab?.CurrentChannel?.Channel ?? InputChannel.Invalid;
toSend = current == InputChannel.Invalid ? text : $"{current.Prefix()} {text}";
}
// Route the trimmed buffer into the exact send string. BuildOutgoing is
// pure (no send, no field write) so the SelfTest can exercise the tell
// routing without firing a real chat line; the wasTell flag drives the
// post-send ResetTempChannel below.
var (toSend, wasTell) = BuildOutgoing(activeTab, text);
try
{
@@ -415,6 +435,13 @@ internal sealed class InputBar
}
ChatBox.SendMessageUnsafe(bytes);
_pendingMessage = string.Empty;
// 1.5.6 parity (1d3b429:ChatLogWindow.cs:1558): clear the temp channel
// after a tell so a one-off /tell doesn't stick to the tab. Tell-only,
// so Say/Party/FC stay untouched. A no-op in today's input-bar path
// (TempTellTarget is inert), kept for an eventual temp-channel revival.
if (wasTell)
activeTab?.CurrentChannel?.ResetTempChannel();
}
catch (Exception ex)
{
@@ -422,6 +449,52 @@ internal sealed class InputBar
}
}
// Pure routing: turns the trimmed buffer into the bytes-source string and
// reports whether it became a tell. No send, no field mutation — the
// ResetTempChannel side-effect lives in TrySend, gated by wasTell, so this
// stays exercisable from the SelfTest. Slash input is verbatim (the game
// parser owns /tell, /fc, …); everything else gets the channel prefix,
// except a tell tab, which needs the full "/tell name@world" because
// InputChannel.Tell.Prefix() is only "/t" and would drop the target.
private (string toSend, bool wasTell) BuildOutgoing(Tab? activeTab, string text)
{
if (text.StartsWith('/'))
return (text, false);
var current = activeTab?.CurrentChannel?.Channel ?? InputChannel.Invalid;
// 1.5.6 tell-target chain (1d3b429:ChatLogWindow.cs:1543-1546).
TellTarget? target = null;
if (activeTab is not null && activeTab.TellTarget.IsSet())
{
// leg1 — unconditional: a freshly spawned temp tab carries its target
// only here, with CurrentChannel still Invalid until a sidebar/top-bar
// click runs EnsureCurrentChannel. A current==Tell gate would miss it.
target = activeTab.TellTarget;
}
else if (current == InputChannel.Tell)
{
// leg2/leg3 — gated on Tell (CORR-1): CurrentChannel.TellTarget is NOT
// channel-bound. After a game-side tell, switching the pill to Say leaves
// the tell target standing (SetChannel only sets Channel), so without this
// gate a say line would silently go out as /tell — a privacy misfire.
target =
activeTab?.CurrentChannel?.TempTellTarget ?? activeTab?.CurrentChannel?.TellTarget;
}
// One world lookup, reused by the gate and the string build (ToTargetString
// would resolve the sheet twice). The !IsNullOrEmpty(world) check is the
// COMP-1 guard: IsSet() only proves World > 0, not that the id resolves in
// the Lumina sheet. A miss yields an empty world, and "/tell Name@ text" is
// exactly what the game rejects with "you must add the World name". On a miss
// we fall through to the channel-prefix path.
var world = target?.ToWorldString();
if (target != null && target.IsSet() && !string.IsNullOrEmpty(world))
return ($"/tell {target.Name}@{world} {text}", true);
return (current == InputChannel.Invalid ? text : $"{current.Prefix()} {text}", false);
}
private void DrawQuickButtons()
{
using (_fonts.FontAwesome.Push())
@@ -480,6 +553,20 @@ internal sealed class InputBar
// so a SelfTest leaves no residual arm state.
internal void TestResetDisclosureForSelfTest() => _disclosureArmedBuffer = null;
// Test-only hook; do not call from production code. Exposes the pure routing
// so the tell SelfTest can assert the string + wasTell flag without ever
// reaching ChatBox.SendMessageUnsafe (no real chat line).
internal (string toSend, bool wasTell) TestBuildOutgoingForSelfTest(
Tab? activeTab,
string text
) => BuildOutgoing(activeTab, text);
// Test-only hook; do not call from production code. Exposes the pure pill-label
// resolution so the tell-transparency SelfTest can assert the partner name is
// shown in the stale-/reply-tell state. Static (ResolvePillLabel is static).
internal static string TestResolvePillLabelForSelfTest(Tab? tab, bool isTell) =>
ResolvePillLabel(tab, isTell);
private void DrawAutoCompletePopup()
{
if (_autoCompleteInfo == null)