Skip to content

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 and Code/World/README.md already name. Every proximity-filtered channel — default IC speech, /y yell, /w whisper, /me emote, /action, /looc local 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//arrest command layer (17_CRIME_FRAMEWORK.md §4). Blocked /pm until Milestone 12, when ICharacterService.FindActiveByName (Code/Characters/ICharacterService.cs) closed it for online characters — see §3.1 below. Crime's own /warrant//arrest command 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.