Chat Framework¶
Channel routing — recipient computation, permission/cooldown/cost gates, and a typed event for whatever eventually renders it. See Legacy/09_CHAT_AND_COMMANDS.md §3-4 for the full legacy trace. Dispatch and the command registry itself are Commands' concern, not this module's — see 15_COMMANDS_FRAMEWORK.md.
1. Scope: six real channels, not the legacy thirteen¶
Legacy's channel table (§4) has thirteen entries. Two genuine platform gaps cut that down:
- No
Transform/pawn-position binding exists anywhere in this codebase — the same gap 17_CRIME_FRAMEWORK.md §2 andCode/World/README.mdalready name. Every proximity-filtered channel — default IC speech,/yyell,/wwhisper,/meemote,/action,/looclocal OOC — needs a radius check against it and cannot be built for real this pass. - No target-resolution-by-name — the same gap blocking Crime's
/warrant//arrestcommand layer (17_CRIME_FRAMEWORK.md §4). Blocked/pmuntil Milestone 12, whenICharacterService.FindActiveByName(Code/Characters/ICharacterService.cs) closed it for online characters — see §3.1 below. Crime's own/warrant//arrestcommand entry point remains unbuilt; closing the resolution gap doesn't retroactively wire a caller nothing registered.
What's left is every channel that is server-wide or group-filtered, not
proximity-filtered, plus Pm (Milestone 12, §3.1):
namespace Applejack.Chat;
public enum ChatChannel { Ooc, GlobalAction, AdminChat, ModeratorChat, Radio, Advert, Pm }
Unbuildable channels are not modelled as dead enum members that would silently do nothing if
invoked — they simply aren't represented, and are named in prose here and in
Code/Chat/README.md's "Not responsible for", the same discipline
Documentation/Architecture/14_NEEDS_FRAMEWORK.md used when it dropped
HungerDamagePerSecond rather than model a field nothing reads.
/note (a physical item — Items/Inventory's concern) and /details (character data display,
not routing) are not chat channels at all and are out of scope by definition, not deferred.
2. The gap this pass had to fix first: enumerating who's online¶
Every one of the six channels needs to compute "everyone currently playing" as a recipient
set. ICharacterService only ever exposed GetActive(SteamId) — a keyed lookup, useless
without already knowing who's online. CharacterStore already tracked the full roster
internally (_active: Dictionary<SteamId, Character>); it just wasn't exposed. Fixed by
adding IReadOnlyList<Character> GetAllActive() to ICharacterService, backed directly by
_active.Values — the same class of real, minimal, justified service-interface fix IClock
was for Jobs (16_JOBS_FRAMEWORK.md §3), landed in its own commit before Chat's feature
commits.
3. IChatService — one entry point, channel-specific gates inside¶
public interface IChatService
{
Task<Result> SendAsync(Character sender, ChatChannel channel, string text);
}
ChatRouter is the one implementation. Per channel:
| Channel | Recipients | Sender gate | Cost / cooldown |
|---|---|---|---|
Ooc |
everyone active | none | 60s per sender |
GlobalAction |
everyone active | chat.globalaction permission |
none |
AdminChat |
active characters with chat.admin |
chat.admin permission |
none |
ModeratorChat |
active characters with chat.moderator |
chat.moderator permission |
none |
Radio |
active characters whose current job's Group == Officials |
none | none |
Advert |
everyone active | none | $60, 150s per sender |
Sender permission gates are enforced once, centrally, by CommandDefinition.RequiredPermission
at the command-dispatch layer (CommandDispatchComponent, see 15_COMMANDS_FRAMEWORK.md §4) —
ChatRouter does not re-check who is allowed to send. What ChatRouter alone decides is
who receives a given line, because that's a routing fact, not a dispatch fact: AdminChat
and ModeratorChat filter the roster by the recipient's own permission, and Radio filters
it by the recipient's own job group, via IJobService.GetCurrentJobName resolved through
IJobRegistry.Find(...)?.Group.
Radio deliberately has no sender gate — legacy's own access flag for /radio is b
(everyone), with "recipients filtered by the officials plugin." Anyone may key up a radio;
only officials hear it. Preserved as specified, not tightened to "officials only," because
it's real, slightly interesting legacy behaviour worth keeping deliberately.
3.1 Pm — the odd one out, and why it needs a second method¶
Every channel in §3's table computes its recipients from the roster alone —
ComputeRecipients(ChatChannel) needs nothing but which channel was sent on. A private
message can't work that way: its one recipient is a caller-supplied target, resolved from
free text the sender typed, not a property of the channel itself. Bolting that onto
SendAsync's existing (Character sender, ChatChannel channel, string text) shape would mean
either a fourth parameter every other channel ignores, or (worse) trusting ComputeRecipients'
_ => active default case for Pm — which would silently broadcast a "private" message to
everyone active.
Instead, IChatService gains a second entry point:
Task<Result> SendDirectAsync(Character sender, Character recipient, string text);
sharing SendAsync's mute/empty/length checks (extracted into a private
CheckCanSendAsync, not duplicated) but computing recipients as exactly [recipient].
SendAsync itself now throws if called with ChatChannel.Pm — a caller-code bug, never
reachable from a real command, so it's a thrown exception per
13_ERROR_HANDLING.md, not a Result.Failure.
Resolving recipient is the caller's job, not ChatRouter's — ChatModule.PmAsync
resolves /pm's first argument via
ICharacterService.FindActiveByName, the
default-interface-method addition (Milestone 12) that closed the target-resolution-by-name gap
§1 named. FindActiveByName is built only on GetAllActive()'s already-loaded roster — no
LoadAsync, no persistence, no side effect — matching exactly, ordinal first, falling back to
case-insensitive; two active characters sharing a name (nothing enforces uniqueness) is a
refused "ambiguous" failure, not a silent pick.
/pm <name> <text...>, registered by ChatModule.Ready(), no RequiredPermission (legacy's
own /pm access flag is b, everyone — Legacy/09_CHAT_AND_COMMANDS.md §5). One real, named
limitation: arguments[0] is a single whitespace-delimited token (CommandContext.Arguments
is already split), so a character whose Name contains a space cannot be /pm's target today
— accepted, not solved, the same class of trade-off AdminModule.cs's own header comment takes
for /ban's trailing-duration argument.
4. Cooldowns: in-memory only, unlike Jobs' or Crime's¶
Ooc and Advert cooldowns are absolute timestamps compared against
IClock.UtcNow — the same lazy-computed-expiry shape Jobs'
rejoin cooldown and Crime's warrant/arrest expiry already use. The difference: they are kept
in memory only, never persisted. Jobs' rejoin cooldown and Crime's arrest must survive a
reconnect — that's the whole point of persisting them. A chat spam guard resetting on server
restart is correct behaviour, not a bug nobody noticed; persisting it would just be
speculative I/O for a guard nobody needs to survive a restart. ChatModule therefore has no
IPersistenceBackend dependency and registers no document schema.
5. Delivery: ChatNetworkComponent and the chat panel¶
ChatRouter.SendAsync/SendDirectAsync compute the real recipient list, enforce every real
gate, log the delivery under LogCategory.Chat (sender, channel, recipient count, full text —
matching legacy's "every command is logged with its full argument text"), and publish:
public readonly record struct ChatMessageSent(
Character Sender, ChatChannel Channel, IReadOnlyList<Character> Recipients, string Text) : IEvent;
— the same shape BulletinChanged already uses. This is now a real, consumed hook, not just a
future one: ChatNetworkComponent (Code/Chat/ChatNetworkComponent.cs) subscribes to it
host-side (if (GameObject.IsProxy) return; guards the subscription to the host's own
instance), resolves each Recipients entry to its Connection via
Character.FindConnection(),
and re-broadcasts a compact RPC scoped to exactly those connections:
using (Rpc.FilterInclude(connections))
{
ReceiveChatLine(sender.Name, channel, text); // [Rpc.Broadcast]
}
The payload is a plain (string senderName, ChatChannel channel, string text) triple, never a
Character (not [Rpc.Broadcast]-serializable, and a remote client has no ICharacterService
to resolve one against anyway). senderName is a name snapshot at send time — a later
rename doesn't retroactively rewrite history, which is correct chat-log behaviour.
ChatNetworkComponent is a scene-level singleton (one GameObject in Assets/scenes/main.scene),
not a per-character sibling component — there is exactly one in the whole scene, and every
connected client's own proxy of it receives the RPC identically when the host calls it. Because
there is no single "local instance" for a UI panel to fetch the way LawsTab/CharacterTab
resolve CommandDispatchComponent via LocalPawn.Find(Scene), ChatPanel (Code/Chat/Ui/
ChatPanel.razor) instead subscribes to a plain static C# event raised from inside
ReceiveChatLine's own body (which runs locally on every receiving client, host and proxies
alike):
public static event Action<string, ChatChannel, string>? ChatLineReceived;
This is new precedent for this codebase — every other panel instead reads a [Sync] property
or resolves a live sibling Component — flagged here deliberately rather than presented as
an established pattern; it should get real multiplayer verification before a second feature
copies it without re-examining whether it still fits.
Command outcomes reach the caller through a parallel, single-target mechanism on
CommandDispatchComponent itself (ReceiveCommandReply, Rpc.FilterInclude(Connection) — see
15_COMMANDS_FRAMEWORK.md §4), not through ChatNetworkComponent —
a command's result is not a ChatMessageSent, it has exactly one recipient (the caller) and no
Recipients list to compute.
6. Commands: the fourth module to prove Commands' registry¶
ChatModule.Ready() registers /ooc, /globalaction, /a, /m, /radio, /advert —
after Jobs' /team and Law's /laws//setlaw. Each Execute joins its arguments with a
single space (matching /setlaw's existing string.Join(' ', context.Arguments.Skip(...))
precedent) and calls IChatService.SendAsync directly — the closure is a one-line adapter,
the same reasoning that left /team's and /setlaw's closures untested beyond what their
service-level tests already cover.
/pm <name> <text...> (Milestone 12, §3.1) is the exception: its Execute calls
ChatModule.PmAsync, an internal static method (the same AdminModule.GrantAsync shape)
rather than a one-line adapter, since it has real logic of its own — resolve, then forward —
worth testing directly. ChatModule therefore holds an ICharacterService field alongside
its existing IChatService one, resolved in RegisterServices the same way.
ChatModule.DependsOn: Core (IClock, IEventBus, IGameLog), Character (GetAllActive,
FindActiveByName for /pm), Commands (registration), Jobs (Radio's group filter), Economy
(Advert's cost). Not Persistence — see §4.
7. Testing¶
ChatRouter is plain C#, tested against fakes for IClock/IEventBus/IGameLog, a fake
ICharacterService with a settable roster, fake IJobService/IJobRegistry for the Radio
filter, and a fake IEconomyService for Advert's cost/cooldown — mirroring
CrimeStoreTests.cs's/JobStoreTests.cs's local-fixture convention. No test exists for the
five channel-based command closures beyond what ChatRouterTests already covers via
SendAsync directly. SendDirectAsync gets the same direct ChatRouterTests coverage:
delivers only to the named recipient (not everyone active), the shared mute/empty/length
checks, no cooldown. ChatModule.PmAsync — unlike the other closures, real logic worth testing
on its own — has its own ChatModulePmTests.cs: known target, unknown target (fails without
sending), ambiguous target (fails without sending, never picks either), multi-word message
text. FindActiveByName itself is tested where it's declared,
UnitTests/Characters/CharacterNameResolutionTests.cs, not duplicated here.