Skip to content

Commit 2f3c4d4

Browse files
HandyS11claude
andauthored
Add clan support: detection, conditional channels, chat bridge and info embeds (#60)
* Add clan support design spec Design for clan detection, a conditional #clanchat bridge, and a #claninfo channel with anchored embeds plus a live clan-change feed. Introduces a generic workspace capability seam so channels are provisioned only while a clan exists and torn down when it does not. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add clan support implementation plan Thirteen TDD tasks covering the abstractions, the connection seam, clan persistence, the workspace capability gate, the chat bridge, the snapshot differ, the embeds, the MOTD modal, module wiring, localization and host composition. Also amend the spec to drop the logo thumbnail, which MessagePayload cannot carry, and to record the exact verified API types. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Replace the cloned clan bridge with a kind-driven shared bridge Rework the plan and spec so one ChatRelay, ChatInboundProcessor and webhook poster serve both team and clan chat via a ChatChannelKind discriminator, replacing ITeamChatSender and ITeamChatChannelLocator with unified seams. Features.Clans now owns no chat code. Also drop the unreachable ClanMotdWriteResult.NotConnected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add clan snapshot records and bus events Introduce the RustPlusApi-free clan snapshot shapes, the three-state clan probe result, and the two event-bus events the clan feature consumes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Map clan responses and extend the connection seam Add ClanMapping, which is the only place clan protobuf models and RustPlusErrorCode are interpreted, and extend IRustServerConnection with the clan probe, clan send, MOTD write, and the two clan events. no_clan maps to a definitive NoClan; every other failure degrades to Unavailable so a transient fault cannot look like a departed clan. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Require cancellation-guarded broad catches in the clan plan Task 2's socket methods copied a broad catch without the when (!cancellationToken.IsCancellationRequested) guard every sibling method in RustPlusSocketSource uses, which swallowed caller cancellation. Fix the plan's code and add the rule to the global constraints so later tasks do not repeat it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Guard broad clan-connection catches against caller cancellation; assert full clan snapshot mapping GetClanInfoAsync and SetClanMotdAsync were missing the `when (!cancellationToken.IsCancellationRequested)` guard that every other timeout-guarded method in RustPlusSocketSource uses on its broad catch, so a caller-initiated cancellation was being swallowed and mapped to a fallback value instead of propagating. Also strengthens Maps_a_populated_clan_to_a_snapshot to assert every field of the positional snapshot records, catching future constructor-argument mis-mapping. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Publish clan chat and clan state from the supervisor Subscribe to the socket's clan events for the lifetime of a connected window, probe the clan once on connect so state survives a restart, and publish both onto the event bus. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Persist clan state and cached player names Add the ClanState and ClanPlayerName entities, their configurations, the scoped IClanStore, and the ClanSupport migration. Members, roles and invites are stored as JSON because they are only ever read and written whole. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix ClanStore review findings: GuildId-agnostic name lookup, IClock injection, skip-write test coverage RecordNameAsync's existing-row lookup filtered on GuildId even though ClanPlayerName's primary key is (ServerId, SteamId) only, risking a DbUpdateException if a row's stored GuildId ever diverged from the caller-passed value. ClanStore also called DateTimeOffset.UtcNow directly instead of injecting IClock like every other store in the persistence layer, leaving its writes untestable with a controlled clock. Added a test that proves RecordNameAsync's skip-write guard by asserting UpdatedUtc is unchanged when the same name is recorded twice, and renamed the no-ids lookup test since an empty IN() clause returns nothing regardless of whether the empty-collection guard exists, so the assertion cannot actually prove the guard runs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Gate workspace channels on capabilities and support pinning Add a generic IWorkspaceCapabilityProvider seam so a ChannelSpec can name a capability: the reconciler provisions it only while available and deletes the channel and its anchored messages when it is not. A capability with no registered provider counts as unavailable. Also add MessageSpec.Pinned, pinned on first post only, and declare the two clan channels and three clan embeds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Carry Task 5 review concerns into the clan plan Task 12 must update the hard-coded key-count assertion in the localization parity test. Task 11's capability provider must never answer false on a transient failure, must register exactly one provider per name, and should cache within a reconcile pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Note the capability provider constraints in the clan plan Task 5 already added the InternalsVisibleTo grant, and its review surfaced three rules the clan capability provider must follow: never answer false on a transient failure, register exactly once, and cache within a reconcile pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Make the clan renderers' empty-payload contract explicit Task 5's review showed ServerInfoRefresher will full-reconcile on every tick for clanless servers unless the clan renderers return an empty payload when no clan is stored. Record that as a load-bearing requirement in Task 9. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Unify the chat sender, locator and dedup seams across channel kinds Replace ITeamChatSender and ITeamChatChannelLocator with kind-parameterised IChatSender and IChatChannelLocator, and move RelayDedupBuffer to Abstractions keyed by channel kind so team and clan echoes cannot cross-cancel. One seam per concept, so the clan bridge does not need a parallel set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Close chat locator Kind coverage gap in WorkspaceRegistrationTests Registration count alone can't catch a swapped Kind between TeamChatChannelLocator and ClanChatChannelLocator, which would silently misroute team/clan chat in production. Resolve the locators and assert each Kind pairs with its expected concrete type; verified the assertion fails when the two Kind values are swapped and passes when restored. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Drive the chat bridge by channel kind to serve clan chat Generalise the relay, webhook poster and inbound processor over ChatChannelKind so one bridge serves both team and clan chat, and subscribe the hosted service to both message events. The inbound path picks its kind from whichever locator claims the channel. Clan chat senders are recorded as the name source for the clan roster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Narrow the cancellation-guard constraint to fallback-returning methods The rule was written for socket probes that must distinguish an internal timeout token from a caller token. Applied to hosted-service consumer loops it is actively harmful: those own their CTS, already catch OperationCanceledException first, and are joined by a StopAsync that only swallows OCE, so the guard would let a post-cancellation exception fault shutdown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Apply Task 7 chat bridge review findings Isolate the clan display-name recording failure so it can no longer kill the clan relay loop; replace reflection-based webhook name pinning with an internal method and move its test to a dedicated file; log a warning when ChatRelay has no locator for a channel kind instead of failing silently; remove a subsumed duplicate hosted-service test. The redundant VSTHRD003 pragma flagged in the review turned out to be load-bearing (removing it reintroduces the warning under TreatWarningsAsErrors), so it was left in place. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add the clan snapshot differ Compare consecutive clan snapshots into an ordered, deterministic change list. A first snapshot and a switch to a different clan both re-baseline silently rather than announcing every existing member, and an invite-to-member transition is reported once as an acceptance. Scaffolds the RustPlusBot.Features.Clans project and its test project, mirroring the Features.Chat project structure, and wires both into the solution file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Correct the clan plan's expected test counts The counts still assumed the superseded Task 7, which created a clan chat bridge inside Features.Clans. That bridge now lives in Features.Chat, so the Features.Clans.Tests baseline is 19, not 36, and every later task's expected total shifts down by 17. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add tests pinning sub-ordering and the old-role guard branch in ClanSnapshotDiffer Close two coverage gaps found in review of Task 8: deleting the differ's .Order()/.OrderBy() calls previously left all 19 tests green because every fixture already declared its collections in ascending id order and each output category had at most one entry. New tests use descending-order fixtures for MemberJoined, MemberLeft and InviteSent so the sort is actually observable, plus a test for the untested "old role id unresolvable" half of the role-change OR-guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Render the clan overview, roster and invites embeds Add the three anchored #claninfo embeds and the name resolver behind them, which falls back to a Steam profile link for ids we have never seen a name for. The invites embed renders empty when there are none so the reconciler skips posting it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Warn that Task 12's key grep misses interpolated keys The clan.roster.perm.* keys are composed from permission flag names and never appear as quoted literals, so the grep-based collection step would miss them. A missed key fails no test - it renders as raw key text in a Discord embed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Pin clan #claninfo renderer contracts that mutation testing found unpinned Close five test-coverage gaps from Task 9 review: the three renderers' empty-payload paths, the Set MOTD button's credential/role hide-branches, and the roster field-truncation arithmetic all had implementations that were correct but not pinned by tests, so six reviewer mutations to src/ left all 43 existing tests green. Also decorrelate the roster ordering test's Rank/RoleId fixture, pin one resolver call per render, and add a cross-assembly test asserting the renderer key literals still match WorkspaceMessageKeys so a future rename can't silently unhook a renderer. No src/ changes; each new assertion was verified to fail against the corresponding mutation before being restored. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add the clan MOTD button and modal Route the Set MOTD interaction through ClanMotdWriter, which re-checks the acting player's in-game clan permission rather than trusting the button's presence, then writes through a new IRustServerQuery method. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Apply clan state, post the change feed, and wire the module Persist each snapshot, drive the workspace clan capability from its presence, and post diffed changes into #claninfo. Reconcile runs only on a none-to-clan or clan-to-none transition, and an Unavailable probe changes nothing at all. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Pin the clan capability cache and guard its invalidation race ClanCapabilityProvider had no behavioural tests at all, yet a "false" answer from it makes the workspace reconciler delete #clanchat and #claninfo together with their whole history. Wrapping its store call in a try/catch that returned false passed the entire suite. Add ClanCapabilityProviderTests covering the load-bearing contracts: a store failure faults instead of answering false and is not cached, the global scope answers false without opening a scope or touching the store, the answer follows the stored row, the 5s TTL both serves and expires, and Invalidate drops the entry. Close the TOCTOU window where a read that queried the store before a transition committed could write its stale answer into the cache after Invalidate returned, silently losing the transition reconcile until the next transition or a process restart (WorkspaceHostedService heals only at startup and on ChannelDestroyed). A version counter now guards the cache write: a read that an invalidation overtook simply declines to populate the cache. Pin the invalidation call itself with the real provider wired to a substituted store, so a wrong key or a post-reconcile ordering fails. Pin the InteractionModuleAssembly registration that ClanMotdModule discovery depends on, and give Unavailable_changes_nothing a non-null snapshot case so the early-return guard is genuinely load-bearing rather than shadowed by the missing-snapshot guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Localize the clan channels, embeds and change feed Add the English and French strings for the clan channel names, the three anchored embeds, the MOTD modal, and every clan feed event. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Translate the French clan channel names Every other channel name is localized per locale (tchat-equipe, moniteurs-stockage, interrupteurs), so the clan channels should be too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Align the French clan-info channel name with its sibling channel.info.name is the singular "info", so the clan variant reads info-clan rather than introducing a third plural form. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Compose the clan module into the host Register AddClans, document the feature and its API limits in the README, and apply the repository formatting profile. Full suite: 1217 passed, 1 skipped, 0 failed across 19 test assemblies (Clans 82, Connections 112, Chat 54, Workspace 102, Persistence 125, Commands 149, Alarms 67, ItemData.Generator 63, Pairing 56, ItemData 53, Events 52, Abstractions 48, Switches 40, StorageMonitors 37, Wipes 26, Players 22, Discord 20, Localization 12, Map 97+1 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix the cross-cutting clan defects the whole-branch review found Render an explicit "no pending invites" embed when a clan is stored but has none. An empty payload means "leave the previous message on screen" to both the reconciler and ServerInfoRefresher, so the pinned invites embed listed resolved invitations forever. The two paths that must stay empty — no server id, and no clan stored — are unchanged, since a non-empty payload on a clanless server would trigger a full reconcile on every refresh tick. Isolate each clan state change in its own try/catch inside the consumer loop. The broad catch sat outside the await foreach, so one transient store failure or Discord 5xx ended the loop for the life of the process, after which the clan channels were never created and, worse, never torn down again. Harvest member names from the live team snapshot on the HasClan path, the second of the two name sources the design calls for. Without it a fresh install renders every roster entry, leader, creator and feed line as a bare Steam profile link until that member happens to speak in clan chat. It is best-effort: a disconnected socket or a failure never blocks persistence, the reconcile or the feed. Refuse to build a WorkspaceRegistry whose channel specs name a capability no provider answers for. The reconciler reads "no provider" as "unavailable", and unavailable deletes the channel and its history, so a host composing AddWorkspace() without AddClans() would silently destroy every guild's #clanchat and #claninfo on its first heal. It must fail to start instead. Budget the roster embed's total text. Per-field 1024 truncation left the 6000-char whole-embed cap unguarded, and EmbedBuilder.Build() throws rather than trimming; six role groups at their ceiling breach it. Groups are now added while a running total fits, and a localized notice names how many were omitted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Correct three clan comments that describe code that does not exist ClanCapabilityProvider's CacheTtl doc justified its value with "the heal path reconciles every server on a timer". WorkspaceHostedService heals at startup and on ChannelDestroyed only; there is no timer. Say what actually bounds the staleness: reconciles are event-driven and Invalidate, not expiry, is what guarantees a transition is seen. ClanMessageKeyParityTests claimed Features.Clans cannot see the internal WorkspaceMessageKeys. It can — Features.Workspace grants it InternalsVisibleTo. The constants are independent by choice, which is what makes the assertion meaningful. ClanMotdWriter claimed to re-check the permission because roles can change between render and click. It reads the same cached snapshot the button was rendered from, so a demotion the poller has not observed yet still gets through. Describe what it does defend against: a forged component id and a button clicked from scrollback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Make the workspace registry guard actually fail startup fast WorkspaceHostedService.StartAsync now resolves IWorkspaceRegistry itself, outside any try/catch, before scheduling any heal work. Previously the registry's constructor guard against an unprovided channel capability was only ever triggered lazily from inside broad catches (the startup heal and each event-loop consumer), so a host composing AddWorkspace() without the module owning a capability would start cleanly and only fault quietly on the first reconcile, contradicting the guard's own comment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Skip the team-info socket call when every clan member's name is cached ClanStateService.RecordTeamNamesAsync now checks IClanStore.GetNamesAsync against the full roster first and only calls IRustServerQuery.GetTeamInfoAsync when a roster member's name is still unknown. OnClanChanged fires on every clan edit, including score changes, and score moves on every kill, so the previous unconditional call issued a companion-API RPC per kill per server. In steady state this now costs zero extra RPCs; the socket is only touched when a genuinely unknown member appears. Best-effort semantics are unchanged: a null snapshot, a failing query, or an unknown name still never blocks persistence or the feed post. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Filter clan name lookups by GuildId and self-heal stale GuildId on every clan write GetNamesAsync now filters on GuildId like the sibling stores, matching HasClanAsync/GetAsync convention. RecordNameAsync and SaveAsync keep their lookups keyed on the true primary key (ServerId[+SteamId], not GuildId) to avoid a duplicate-key crash if GuildId is ever stale, but now assign GuildId on every write (insert and update) so any stale value self-heals instead of staying permanently invisible to the newly filtered reads. SaveAsync had the same latent lookup bug as RecordNameAsync had before an earlier fix; a test reproduces the pre-fix InvalidOperationException before applying the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Bump RustPlusApi to 2.0.0-beta.5 and surface clan probe diagnostics The published beta.4 parsed clan timestamps as Unix seconds while the wire sends milliseconds, so mapping any real clan payload threw, the library downgraded the throw to a ClientMappingFailed response, and the bot silently classified it as Unavailable: no channels, no rows, no log lines. beta.5 carries the parsing fix. Two observability holes made that failure invisible; both closed: - pass the host ILoggerFactory into RustPlus so the library's own warnings (e.g. a selector mapping failure) land in the bot log instead of a NullLogger - log the connect-time clan probe status once per connect in the supervisor Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop clan embed pinning and keep gated channels in spec order The #claninfo anchor embeds are no longer pinned: the pin flag, the reconciler's pin step and the gateway pin operation are removed outright. The embeds still sit above the change feed because Discord orders messages by creation time and the reconciler already repairs declaration order. ChannelSpec.Order was declared but never applied, so a capability-gated channel created after the rest of its category (the clan pair appears only once a clan is detected) was appended at the bottom. The reconciler now ends every channel pass with EnsureChannelOrderAsync, which permutes the managed channels' existing position values into spec order — channels outside the list keep their place — and is a pure cache read when the order already matches, so the steady state issues no REST calls. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 47b342e commit 2f3c4d4

131 files changed

Lines changed: 12989 additions & 665 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Directory.Packages.props

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
<PackageVersion Include="Persistord.Core" Version="1.0.0-beta2" />
1616
<PackageVersion Include="RustMapsApi" Version="1.0.0-beta.3" />
1717
<PackageVersion Include="RustMapsApi.Assets" Version="1.0.0-beta.3" />
18-
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.4" />
19-
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.4" />
18+
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.5" />
19+
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.5" />
2020
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
2121
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.1" />
2222
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,26 @@ recycle/craft/research/decay/upkeep calculators are all shipped. Cameras are nex
4343
- Two-way relay between in-game team chat and a per-server `#teamchat` channel
4444
(via a managed webhook), with echo/loop suppression.
4545

46+
### Clans
47+
48+
- **Conditional channels**`#clanchat` and `#claninfo` appear automatically when
49+
the paired player is in a clan, and are removed again when they leave; no
50+
command to run either way.
51+
- **`#clanchat`** — two-way relay between in-game clan chat and the channel, sharing
52+
the same echo/loop suppression as the team bridge.
53+
- **`#claninfo`** — three pinned auto-refreshing embeds — **Overview** (score,
54+
member count, creation date, leader, creator, MOTD), **Roster** (members grouped
55+
by clan role, online first, with each role's permissions), and **Invites**
56+
refreshed on the existing `Workspace:InfoRefreshInterval`, plus a live feed of
57+
clan changes (members joining/leaving, promotions/demotions, invites, rename,
58+
MOTD, logo, colour, score, dissolution).
59+
- **Set MOTD** — a button on the overview embed opens a modal that writes the MOTD
60+
back to the game; it is offered only when the paired player's in-game clan role
61+
carries the permission.
62+
- **API limits** — RustPlusApi 2.0.0-beta.4 exposes no clan audit log, no per-member
63+
scores, and no kick/invite/promote actions, so none of those are implemented; the
64+
feed is instead derived by diffing successive clan snapshots.
65+
4666
### In-game `!commands`
4767

4868
Run in team chat by any teammate; replies in the guild's language with a
@@ -128,7 +148,8 @@ See [docs/development/running-locally.md](docs/development/running-locally.md).
128148
| `RustPlusBot.Features.Workspace` | Channel/message provisioning, reconciler, `#info`/`#setup`/`#settings` surfaces |
129149
| `RustPlusBot.Features.Pairing` | FCM pairing listener, credential intake, account disconnect |
130150
| `RustPlusBot.Features.Connections` | Live socket supervisor, hot-swap/failover, Rust+ query seam |
131-
| `RustPlusBot.Features.Chat` | Two-way `#teamchat` ↔ in-game chat bridge |
151+
| `RustPlusBot.Features.Chat` | Two-way `#teamchat` / `#clanchat` ↔ in-game chat bridges |
152+
| `RustPlusBot.Features.Clans` | Clan state, `#claninfo` embeds and change feed, Set MOTD |
132153
| `RustPlusBot.Features.Commands` | In-game `!commands`, slash surfaces, `/help`/`/leader` |
133154
| `RustPlusBot.Features.Events` | Live map-event classification + `#events` feed |
134155
| `RustPlusBot.Features.Map` | Map image rendering with toggleable layers |

RustPlusBot.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<Project Path="src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj" />
1111
<Project Path="src/RustPlusBot.Features.Alarms/RustPlusBot.Features.Alarms.csproj" />
1212
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
13+
<Project Path="src/RustPlusBot.Features.Clans/RustPlusBot.Features.Clans.csproj" />
1314
<Project Path="src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj" />
1415
<Project Path="src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj" />
1516
<Project Path="src/RustPlusBot.Features.ItemData/RustPlusBot.Features.ItemData.csproj" />
@@ -39,6 +40,7 @@
3940
<Project Path="tests/RustPlusBot.Discord.Tests/RustPlusBot.Discord.Tests.csproj" />
4041
<Project Path="tests/RustPlusBot.Features.Alarms.Tests/RustPlusBot.Features.Alarms.Tests.csproj" />
4142
<Project Path="tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj" />
43+
<Project Path="tests/RustPlusBot.Features.Clans.Tests/RustPlusBot.Features.Clans.Tests.csproj" />
4244
<Project Path="tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj" />
4345
<Project Path="tests/RustPlusBot.Localization.Tests/RustPlusBot.Localization.Tests.csproj" />
4446
<Project Path="tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj" />

0 commit comments

Comments
 (0)