From 20eade86b21af9ccfde089778d80c43224fee8c6 Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 11:03:02 -0700 Subject: [PATCH 01/19] docs: RDM manager design spec Victim-driven RDM reporting + staff case queue (chat commands) + slay-queue punishment, persisted to SQLite. Sits alongside existing auto-karma; reuses an extracted shared IDamageTracker for suspect-kill detection. Claude-Session: https://claude.ai/code/session_015VpDpUwhCqDKEHWofQfjXH --- .../specs/2026-06-28-rdm-manager-design.md | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-28-rdm-manager-design.md diff --git a/docs/superpowers/specs/2026-06-28-rdm-manager-design.md b/docs/superpowers/specs/2026-06-28-rdm-manager-design.md new file mode 100644 index 0000000..e1899d4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-rdm-manager-design.md @@ -0,0 +1,193 @@ +# RDM Manager — Design + +**Date:** 2026-06-28 +**Status:** Approved (pending implementation plan) +**Source inspiration:** SourceMod/CS:GO TTT `ttt_rdm.sp` +() + +## Summary + +Add a human-in-the-loop RDM (Random Death Match) management system to the CS2 TTT +port: victims opt-in to report suspect kills, staff review a persistent case queue +via chat commands, and guilty verdicts apply a slay-based punishment queue. The +system sits **alongside** the existing automatic karma penalties (Karma is left +functionally intact); RDM verdicts do **not** adjust karma. + +## Goals + +- Let the **victim** of a suspect kill decide whether it was RDM. Non-response + always defaults to "not RDM" (no case is created). +- Give staff a faithful SourceMod-style chat workflow: `!cases`, `!handle`, + `!info`, `!verdict`. +- Punish guilty offenders with a **slay queue** scaled by victim role, paid down + over upcoming rounds, plus an immediate slay if the offender is currently alive. +- Persist death logs, cases, and slay debts to SQLite so they survive round + changes, map changes, and server restarts (offenders cannot dodge slays by + forcing a map change). + +## Non-Goals + +- No changes to the automatic karma scoring in `KarmaListener` beyond the shared + detection extraction (§ Detection). +- RDM verdicts do not modify karma or issue bans (the existing `KarmaBanner` + continues to handle karma-threshold bans independently). +- SourceMod's "weapon last-fired" evidence timing is dropped as YAGNI. (Easy to + add later via `DeathRecord` if desired.) + +## Decisions (resolved during brainstorming) + +| Question | Decision | +|----------|----------| +| Relationship to auto-karma | Parallel staff layer; auto-karma untouched. | +| Verdict effects | Slay queue + immediate slay + forgive/dismiss. **No** karma change. | +| Persistence | SQLite (`rdm.db`), like `karma.db`. | +| Victim report trigger | Auto-prompt on suspect kills **and** `!rdm` fallback. Non-response = not RDM. | +| Staff UX | Chat commands (faithful port). | +| Detection reuse | **Option A**: extract shared `IDamageTracker` from `KarmaListener`. | +| Staff flag | Reuse existing `@ttt/admin` (same flag `!logs` uses); configurable. | + +## Architecture + +New engine-agnostic module **`TTT/RDM`**, mirroring `TTT/Karma`: + +- `RDM.csproj` targets `net10.0`, references `Game.csproj`, and adds + `Microsoft.Data.Sqlite` (already used by `Plugin.csproj`). +- Registered via `RdmServiceCollection.AddRdmService()`, called from + `Plugin/TTTServiceCollection.cs` alongside `AddKarmaService()`. +- All modules register through the existing `AddModBehavior` pattern. + +Integration points (all existing): + +- Events: `PlayerDamagedEvent`, `PlayerDeathEvent`, `GameStateUpdateEvent` via + `IEventBus` + `[EventHandler]`. +- Slaying: `IOnlinePlayer.Health = 0` (triggers suicide in `CS2Player`) — no + CS2-specific code needed. +- Staff gating: `IPermissionManager.HasFlags` + `ICommand.RequiredFlags`. +- Player lookup: `IPlayerFinder`. Roles: `IRoleAssigner`. Messaging: `IMessenger`. +- Localization: `RDM/lang/RdmMsgs.cs` + `lang/en.yml`. + +## Detection (shared `IDamageTracker`) + +The "who damaged whom first / who is at fault" logic currently lives privately in +`KarmaListener.firstDamage`. Extract it into a shared service so Karma and RDM +have one source of truth. + +- New `IDamageTracker` (and impl) lives in **`Game`** (both `Karma` and `RDM` + reference `Game`). +- Responsibilities: maintain the per-round first-damage pairing map (cleared on + round start) and expose: + - `void RecordDamage(attackerId, victimId)` — first-damage bookkeeping. + - `KillFault GetFault(killerId, victimId)` → `KillerGuilty | VictimGuilty | Unknown`. +- `KarmaListener` is refactored to consume `IDamageTracker` instead of its private + `firstDamage` list. Behavior must remain identical (covered by existing + + added tests). +- A small classification helper decides `IsSuspect` for a kill: a kill is + **suspect** when it is a "bad kill" between non-Traitor parties (same logic the + Karma role-delta switch already encodes — e.g. inno-on-inno, inno-on-detective; + Traitor-involved legitimate kills are not suspect). + +## Data Model (records) + +- **`DeathRecord`**: `Id`, `Round`, victim (`Id`/`Name`/`Role`), attacker + (`Id`/`Name`/`Role`), `Weapon`, `Timestamp`, `IsSuspect`, `Fault`. +- **`RdmCase`**: `Id`, `DeathId`, `ReporterId`, `Reason?`, `State` + (`Open`/`Claimed`/`Resolved`), `HandlerAdminId?`, `Verdict` + (`None`/`Forgiven`/`Guilty`), `CreatedAt`. +- **`SlayDebt`**: `PlayerId`, `RemainingSlays`, `SourceCaseId`. + +## Persistence (SQLite `rdm.db`) + +- `IRdmStore` interface with two implementations: + - **In-memory** impl — used by unit tests. + - **SQLite** impl — production, registered in `Plugin` (swappable-backend + pattern matching `KarmaStorageKV` vs `KarmaStorageAPI`). +- Tables: `deaths`, `cases`, `slays`. +- Slay debts are keyed by steam id, so they persist across disconnect/reconnect, + map change, and restart. + +## Components & Data Flow + +1. **`DeathLogListener`** — on `PlayerDeathEvent`, classify via `IDamageTracker` + and write a `DeathRecord`. Provides "recent suspect deaths for victim X" (for + `!rdm`) and "death by id" (for `!info`). On a suspect kill (and if + `AutoPromptOnSuspectKill`), prompt the victim (chat + screen): *"You were + killed by <attacker>. Type `!rdm` to report, or ignore."* +2. **`RdmCommand` (`!rdm`)** — victim-side. Lists the victim's recent suspect + deaths (numbered); `!rdm [reason]` files a report within the configurable + report window → creates an `RdmCase` (Open) → notifies staff. No report ⇒ no + case. Double-reports and out-of-window reports are rejected with a message. +3. **`ICaseManager` + store** — case lifecycle. Staff commands + (`RequiredFlags = [StaffFlag]`): + - `!cases` — open-case count + brief list. + - `!handle [id]` — claim next open case (or specific id); becomes handler; + echoes case info + verdict options. + - `!info ` — full details: victim/attacker names, roles, weapon, time, + reason, and both players' karma as evidence. + - `!verdict ` — applies the verdict to the handler's claimed + case. +4. **`ISlayService` + `SlayQueueListener`** — punishment: + - **Guilty** ⇒ owed slays = `RdmConfig` by victim role (traitor 5 / detective 5 + / innocent 3). If offender alive now → immediate slay (`Health = 0`) and + decrement; remainder stored as `SlayDebt`. + - On round start (`State.IN_PROGRESS`): for each alive player with debt, slay + + decrement until paid. + - **Forgive** ⇒ close case, no punishment. +5. **Notifications** — `NotifyAdmins` (default on): when a report is filed, message + online staff (admins among `IPlayerFinder.GetOnline()` via + `IPermissionManager.HasFlags`) with both names + karma. + +## Configuration (`RdmConfig` record) + +- `DbString` (default `"Data Source=rdm.db"`) +- `TraitorSlays` = 5, `DetectiveSlays` = 5, `InnocentSlays` = 3 +- `NotifyAdmins` = true +- `AutoPromptOnSuspectKill` = true +- `ReportWindowSeconds` (e.g. 60) +- `MaxReportsPerVictimPerRound` +- `StaffFlag` = `"@ttt/admin"` + +## Error Handling & Edge Cases + +- **Offender disconnected**: slay debt persists in SQLite; applied on reconnect at + the next round start while alive. +- **Victim disconnects before reporting**: no case (default not RDM). +- **Report after window / duplicate report**: rejected with a localized message. +- **Self-kills / world kills**: not suspect; no prompt, no case. +- **Map change / restart**: SQLite persists cases + slay debts. +- **`!handle`/`!verdict` with no claimed case or bad id**: localized error. + +## Testing (`TTT/Test/RDM`) + +- Slay-debt payoff across multiple rounds (incl. immediate slay when alive). +- Case lifecycle: report → handle → verdict (guilty and forgive). +- Suspect classification correctness (Traitor-involved kills not suspect). +- "No report ⇒ no case" (default not RDM). +- Report-window expiry and duplicate-report rejection. +- Disconnect/reconnect slay persistence. +- `KarmaListener` parity after `IDamageTracker` extraction (no behavior change). +- Reuses existing fakes (`FakePermissionManager`, etc.). + +## Module file sketch + +``` +TTT/RDM/ + RDM.csproj + RdmServiceCollection.cs + RdmConfig.cs + IRdmStore.cs # + InMemoryRdmStore, SqliteRdmStore + ICaseManager.cs / CaseManager.cs + ISlayService.cs / SlayService.cs + DeathLogListener.cs + SlayQueueListener.cs + RdmCommand.cs # !rdm (victim) + CasesCommand.cs # !cases + HandleCommand.cs # !handle + InfoCommand.cs # !info + VerdictCommand.cs # !verdict + Models/ (DeathRecord, RdmCase, SlayDebt, enums) + lang/RdmMsgs.cs + lang/en.yml + +TTT/Game/ (shared detection) + IDamageTracker.cs / DamageTracker.cs # extracted from KarmaListener +``` From c5fc3ccf522ecc7eaf9a89a71e39e1bd77f6aaff Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 11:25:19 -0700 Subject: [PATCH 02/19] docs: RDM manager implementation plan 12 TDD tasks: module scaffold, shared IDamageTracker extraction, suspect classifier, SQLite store, death-log + victim prompt, case manager, !rdm, staff !cases/!info/!handle/!verdict, slay queue, production wiring + e2e. Claude-Session: https://claude.ai/code/session_015VpDpUwhCqDKEHWofQfjXH --- .../plans/2026-06-28-rdm-manager.md | 2919 +++++++++++++++++ 1 file changed, 2919 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-rdm-manager.md diff --git a/docs/superpowers/plans/2026-06-28-rdm-manager.md b/docs/superpowers/plans/2026-06-28-rdm-manager.md new file mode 100644 index 0000000..99c5497 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-rdm-manager.md @@ -0,0 +1,2919 @@ +# RDM Manager Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a victim-driven RDM (Random Death Match) reporting system with a staff case queue (chat commands) and a slay-based punishment queue, persisted to SQLite, alongside the existing auto-karma system. + +**Architecture:** A new engine-agnostic module `TTT/RDM` (mirrors `TTT/Karma`) listens to existing game events to log deaths and classify "suspect" kills via a shared `IDamageTracker` extracted from `KarmaListener`. Victims opt-in to file reports (auto-prompt on suspect kills + `!rdm`); staff process an SQLite-backed case queue with `!cases`/`!handle`/`!info`/`!verdict`; guilty verdicts enqueue role-scaled slays paid down over rounds. Non-response defaults to "not RDM" (no case). + +**Tech Stack:** C# / .NET 10, CounterStrikeSharp, xUnit v3 on Microsoft.Testing.Platform (MTP), `Microsoft.Data.Sqlite`, YAML→JSON localization. + +## Global Constraints + +- **Target framework:** `net10.0` for every project (copy from `TTT/Karma/Karma.csproj`). +- **Module registration:** production code registers `ITerrorModule`s via `AddModBehavior` (from `TTT.API.Extensions`). **In `TTT/Test/Startup.cs`, register RDM services with plain `AddScoped`/`AddSingleton` — NOT `AddModBehavior`** — otherwise the module-count assertions in `Test/Abstract/ModuleInitializationTest.cs` (which enumerate `ITerrorModule`/`IPluginModule`) break. +- **Config is optional:** every RDM service loads config via `provider.GetService>()?.Load().GetAwaiter().GetResult() ?? new RdmConfig()` so tests need no config registration. `IStorage.Load()` returns `Task`. +- **Player identity:** `IPlayer.Id` (string, the steam id) is the persistence key everywhere. Roles are read via `IRoleAssigner.GetRoles(player).First()`; concrete roles are `InnocentRole`, `TraitorRole`, `DetectiveRole` in `TTT.Game.Roles`. +- **Slaying:** kill a live player by setting `IOnlinePlayer.Health = 0` (triggers suicide in `CS2Player`). Never call CS2 APIs from the `RDM`/`Game` projects. +- **Staff flag:** default `@ttt/admin` (same flag `LogsCommand` uses), exposed as `RdmConfig.StaffFlag`. Gate staff commands with `RequiredFlags`. +- **Localization:** user-facing strings go through `IMsgLocalizer` with keys in `TTT/RDM/lang/RdmMsgs.cs` + `TTT/RDM/lang/en.yml`. Rebuilding any project that references `Locale` regenerates `lang/en.json` (the `PreprocessYaml` target globs `**/lang/*.yml`). Mirror existing token style: `%PREFIX%`, `{yellow}`, `{grey}`, `{red}`, `{0}`, `%s%`. + +## Build & Test Commands (use these verbatim) + +- **Generate the version file once per fresh worktree** (gitignored, required by `Directory.Build.props`): + `cp /home/gkh/projects/TTT/GitVersionInformation.g.cs ./GitVersionInformation.g.cs` + (Already present in this worktree. If a build fails with `CS2001: GitVersionInformation.g.cs could not be found`, re-run the copy.) +- **Build (compiles all referenced code projects + regenerates `lang/en.json`):** + `dotnet build TTT/Test/Test.csproj -v q --nologo` +- **Run tests (MTP binary — classic `dotnet test` is rejected on the .NET 10 SDK):** + `./TTT/Test/bin/Debug/net10.0/Test` + The run prints a `Test run summary` with `total/failed/succeeded` (≈1s). Failures are listed as `failed `. +- **Baseline:** `185 passed, 1 failed`. The one failure, `TTT.Test.Abstract.ModuleInitializationTest.Started_ShouldNotBeInit_IfNotStarted`, is a pre-existing order-dependent flake — **not** caused by this work. Regression gate: **no NEW failing tests** (that one may flip between pass/fail depending on ordering; ignore only it). + +--- + +### Task 1: RDM module scaffold + RdmConfig + registration + +**Files:** +- Create: `TTT/RDM/RDM.csproj` +- Create: `TTT/RDM/RdmConfig.cs` +- Create: `TTT/RDM/RdmServiceCollection.cs` +- Modify: `TTT.sln` (add project) +- Modify: `TTT/Plugin/Plugin.csproj` (add ProjectReference) +- Modify: `TTT/Plugin/TTTServiceCollection.cs` (call `AddRdmService`) +- Test: `TTT/Test/RDM/RdmConfigTests.cs` +- Modify: `TTT/Test/Test.csproj` (add `..\RDM\RDM.csproj` ProjectReference) + +**Interfaces:** +- Produces: `record RdmConfig` with init properties: `string DbString = "Data Source=rdm.db"`, `int TraitorSlays = 5`, `int DetectiveSlays = 5`, `int InnocentSlays = 3`, `bool NotifyAdmins = true`, `bool AutoPromptOnSuspectKill = true`, `int ReportWindowSeconds = 60`, `int MaxReportsPerVictimPerRound = 3`, `string StaffFlag = "@ttt/admin"`; method `int SlaysForRole(string roleName)`. +- Produces: `static class RdmServiceCollection { static void AddRdmService(this IServiceCollection) }`. + +- [ ] **Step 1: Create the project file** `TTT/RDM/RDM.csproj` + +```xml + + + + net10.0 + enable + enable + TTT.RDM + + + + + + + + + + + +``` + +- [ ] **Step 2: Write `TTT/RDM/RdmConfig.cs`** + +```csharp +namespace TTT.RDM; + +public record RdmConfig { + public string DbString { get; init; } = "Data Source=rdm.db"; + + public int TraitorSlays { get; init; } = 5; + public int DetectiveSlays { get; init; } = 5; + public int InnocentSlays { get; init; } = 3; + + public bool NotifyAdmins { get; init; } = true; + public bool AutoPromptOnSuspectKill { get; init; } = true; + public int ReportWindowSeconds { get; init; } = 60; + public int MaxReportsPerVictimPerRound { get; init; } = 3; + public string StaffFlag { get; init; } = "@ttt/admin"; + + /// + /// Number of slays owed when a guilty verdict's victim held the given role. + /// roleName is compared against IRole.Name (case-insensitive). + /// + public int SlaysForRole(string roleName) { + if (roleName.Contains("Traitor", StringComparison.OrdinalIgnoreCase)) + return TraitorSlays; + if (roleName.Contains("Detective", StringComparison.OrdinalIgnoreCase)) + return DetectiveSlays; + return InnocentSlays; + } +} +``` + +- [ ] **Step 3: Write the (empty) registration** `TTT/RDM/RdmServiceCollection.cs` + +```csharp +using Microsoft.Extensions.DependencyInjection; + +namespace TTT.RDM; + +public static class RdmServiceCollection { + public static void AddRdmService(this IServiceCollection collection) { + // Services are registered by later tasks. + } +} +``` + +- [ ] **Step 4: Add the project to the solution and references** + +Run: `dotnet sln TTT.sln add TTT/RDM/RDM.csproj` + +In `TTT/Plugin/Plugin.csproj`, add inside the existing `` of `ProjectReference`s (after the Karma line): +```xml + +``` + +In `TTT/Test/Test.csproj`, add inside the first `` of `ProjectReference`s: +```xml + +``` + +In `TTT/Plugin/TTTServiceCollection.cs`, add `using TTT.RDM;` at the top and add this line right after `serviceCollection.AddKarmaService();`: +```csharp + serviceCollection.AddRdmService(); +``` + +- [ ] **Step 5: Write the failing test** `TTT/Test/RDM/RdmConfigTests.cs` + +```csharp +using TTT.RDM; +using Xunit; + +namespace TTT.Test.RDM; + +public class RdmConfigTests { + [Fact] + public void Defaults_AreSourceModParity() { + var config = new RdmConfig(); + Assert.Equal(5, config.TraitorSlays); + Assert.Equal(5, config.DetectiveSlays); + Assert.Equal(3, config.InnocentSlays); + Assert.Equal("@ttt/admin", config.StaffFlag); + } + + [Theory] + [InlineData(" Traitor", 5)] + [InlineData(" Detective", 5)] + [InlineData(" Innocent", 3)] + public void SlaysForRole_MapsByRoleName(string roleName, int expected) { + Assert.Equal(expected, new RdmConfig().SlaysForRole(roleName)); + } +} +``` + +- [ ] **Step 6: Build and run tests** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo && ./TTT/Test/bin/Debug/net10.0/Test` +Expected: PASS — `RdmConfigTests` (4 cases) pass; no NEW failures vs baseline. + +- [ ] **Step 7: Commit** + +```bash +git add TTT/RDM TTT.sln TTT/Plugin/Plugin.csproj TTT/Plugin/TTTServiceCollection.cs TTT/Test/Test.csproj TTT/Test/RDM/RdmConfigTests.cs +git commit -m "feat(rdm): scaffold RDM module + RdmConfig" +``` + +--- + +### Task 2: Extract shared `IDamageTracker` and refactor `KarmaListener` + +Behavior-preserving refactor: move `KarmaListener`'s private `firstDamage` first-damage tracking into a shared `DamageTracker` (in `Game`) consumed by both Karma and (later) RDM. + +**Files:** +- Create: `TTT/Game/Damage/KillFault.cs` +- Create: `TTT/Game/Damage/IDamageTracker.cs` +- Create: `TTT/Game/Damage/DamageTracker.cs` +- Modify: `TTT/Game/GameServiceCollection.cs` (register tracker) +- Modify: `TTT/Karma/KarmaListener.cs` (consume tracker; drop private `firstDamage`) +- Modify: `TTT/Test/Startup.cs` (register `IDamageTracker` via plain `AddScoped`) +- Modify: `TTT/Test/Karma/KarmaListenerTests.cs` (register the tracker on the bus) +- Test: `TTT/Test/Game/Damage/DamageTrackerTests.cs` + +**Interfaces:** +- Produces: `enum KillFault { Unknown, KillerGuilty, VictimGuilty }` (namespace `TTT.Game.Damage`). +- Produces: `interface IDamageTracker : ITerrorModule { void RecordFirstDamage(string attackerId, string victimId); KillFault GetFault(string killerId, string victimId); void Clear(); }`. +- Consumes (DamageTracker): `PlayerDamagedEvent` (`.Player`, `.Attacker`), `GameStateUpdateEvent` (`.NewState`), `IGameManager.ActiveGame`, `State.IN_PROGRESS`. + +- [ ] **Step 1: Write the failing test** `TTT/Test/Game/Damage/DamageTrackerTests.cs` + +```csharp +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Events; +using TTT.API.Game; +using TTT.API.Player; +using TTT.Game.Damage; +using TTT.Game.Events.Player; +using Xunit; + +namespace TTT.Test.Game.Damage; + +public class DamageTrackerTests { + private readonly IEventBus bus; + private readonly IGameManager games; + private readonly IPlayerFinder players; + private readonly IDamageTracker tracker; + + public DamageTrackerTests(IServiceProvider provider) { + bus = provider.GetRequiredService(); + games = provider.GetRequiredService(); + players = provider.GetRequiredService(); + tracker = provider.GetRequiredService(); + bus.RegisterListener(tracker); + } + + [Fact] + public void GetFault_NoDamage_ReturnsUnknown() { + Assert.Equal(KillFault.Unknown, tracker.GetFault("a", "b")); + } + + [Fact] + public void OnHurt_DuringGame_RecordsKillerGuilty() { + var victim = TestPlayer.Random(); + var attacker = TestPlayer.Random(); + players.AddPlayers(victim, attacker); + games.CreateGame()?.Start(); + + bus.Dispatch(new PlayerDamagedEvent(victim, attacker, 100)); + + Assert.Equal(KillFault.KillerGuilty, + tracker.GetFault(attacker.Id, victim.Id)); + Assert.Equal(KillFault.VictimGuilty, + tracker.GetFault(victim.Id, attacker.Id)); + } + + [Fact] + public void RoundStart_ClearsPriorDamage() { + var victim = TestPlayer.Random(); + var attacker = TestPlayer.Random(); + players.AddPlayers(victim, attacker); + var game = games.CreateGame(); + game?.Start(); + bus.Dispatch(new PlayerDamagedEvent(victim, attacker, 100)); + + // New round start clears first-damage state. + bus.Dispatch(new TTT.Game.Events.Game.GameStateUpdateEvent(game!, + State.IN_PROGRESS)); + + Assert.Equal(KillFault.Unknown, + tracker.GetFault(attacker.Id, victim.Id)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo` +Expected: FAIL to compile — `KillFault`/`IDamageTracker` do not exist. + +- [ ] **Step 3: Create `TTT/Game/Damage/KillFault.cs`** + +```csharp +namespace TTT.Game.Damage; + +public enum KillFault { + Unknown, + KillerGuilty, + VictimGuilty +} +``` + +- [ ] **Step 4: Create `TTT/Game/Damage/IDamageTracker.cs`** + +```csharp +using TTT.API; + +namespace TTT.Game.Damage; + +public interface IDamageTracker : ITerrorModule { + /// Record that attacker dealt the first damage to victim this round. + void RecordFirstDamage(string attackerId, string victimId); + + /// Who threw the first punch between these two this round. + KillFault GetFault(string killerId, string victimId); + + /// Clear all first-damage state (called at round start). + void Clear(); +} +``` + +- [ ] **Step 5: Create `TTT/Game/Damage/DamageTracker.cs`** + +```csharp +using JetBrains.Annotations; +using TTT.API.Events; +using TTT.API.Game; +using TTT.Game.Events.Game; +using TTT.Game.Events.Player; +using TTT.Game.Listeners; + +namespace TTT.Game.Damage; + +public class DamageTracker(IServiceProvider provider) + : BaseListener(provider), IDamageTracker { + // Ordered pairs (attackerId, victimId) of who damaged whom first this round. + private readonly HashSet<(string, string)> firstDamage = []; + + public void RecordFirstDamage(string attackerId, string victimId) { + // If the victim already hit the attacker first, this is not first damage. + if (firstDamage.Contains((victimId, attackerId))) return; + firstDamage.Add((attackerId, victimId)); + } + + public KillFault GetFault(string killerId, string victimId) { + if (firstDamage.Contains((killerId, victimId))) + return KillFault.KillerGuilty; + if (firstDamage.Contains((victimId, killerId))) + return KillFault.VictimGuilty; + return KillFault.Unknown; + } + + public void Clear() { firstDamage.Clear(); } + + [EventHandler] + [UsedImplicitly] + public void OnHurt(PlayerDamagedEvent ev) { + if (Games.ActiveGame is not { State: State.IN_PROGRESS }) return; + var attacker = ev.Attacker; + if (attacker == null) return; + RecordFirstDamage(attacker.Id, ev.Player.Id); + } + + [EventHandler] + [UsedImplicitly] + public void OnRoundStart(GameStateUpdateEvent ev) { + if (ev.NewState == State.IN_PROGRESS) Clear(); + } +} +``` + +- [ ] **Step 6: Register the tracker (production)** in `TTT/Game/GameServiceCollection.cs` + +Add `using TTT.Game.Damage;` and, in the `// Listeners` block, add: +```csharp + collection.AddModBehavior(); +``` + +- [ ] **Step 7: Register the tracker (tests)** in `TTT/Test/Startup.cs` + +Add `using TTT.Game.Damage;` and, near the other `AddScoped` lines, add: +```csharp + services.AddScoped(); +``` +(Plain `AddScoped` — do NOT use `AddModBehavior` here; see Global Constraints.) + +- [ ] **Step 8: Refactor `KarmaListener` to use the tracker** + +In `TTT/Karma/KarmaListener.cs`: + +1. Add `using TTT.Game.Damage;`. +2. Add a field (next to the other injected services): +```csharp + private readonly IDamageTracker damageTracker = + provider.GetRequiredService(); +``` +3. Delete the field `private readonly List<(string, string)> firstDamage = new();`. +4. Delete the entire `OnHurt(PlayerDamagedEvent ev)` method (first-damage recording now lives in `DamageTracker`). +5. In `OnRoundStart`, remove the `firstDamage.Clear();` line (keep `badKills.Clear();`). +6. In `OnKill`, replace: +```csharp + var killerIsGuilty = firstDamage.Contains((killer.Id, victim.Id)); + var victimIsGuilty = firstDamage.Contains((victim.Id, killer.Id)); +``` +with: +```csharp + var fault = damageTracker.GetFault(killer.Id, victim.Id); + var killerIsGuilty = fault == KillFault.KillerGuilty; + var victimIsGuilty = fault == KillFault.VictimGuilty; +``` +(The existing `if (!killerIsGuilty && !victimIsGuilty) { killerIsGuilty = true; ... }` block stays and handles `KillFault.Unknown` identically to before.) + +- [ ] **Step 9: Keep `KarmaListenerTests` green by subscribing the tracker** + +In `TTT/Test/Karma/KarmaListenerTests.cs` constructor, add `using TTT.Game.Damage;` and, immediately before `var listener = new KarmaListener(provider);`, add: +```csharp + bus.RegisterListener(provider.GetRequiredService()); +``` +(The `KarmaListener` resolves the same scoped `IDamageTracker` instance, so the damage events it dispatches are now recorded by the tracker.) + +- [ ] **Step 10: Build and run tests** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo && ./TTT/Test/bin/Debug/net10.0/Test` +Expected: PASS — new `DamageTrackerTests` pass AND all existing `KarmaListenerTests` (the `[Theory]` karma tables) still pass unchanged. No NEW failures. + +- [ ] **Step 11: Commit** + +```bash +git add TTT/Game/Damage TTT/Game/GameServiceCollection.cs TTT/Karma/KarmaListener.cs TTT/Test/Startup.cs TTT/Test/Karma/KarmaListenerTests.cs TTT/Test/Game/Damage/DamageTrackerTests.cs +git commit -m "refactor(karma): extract shared IDamageTracker (RDM prep)" +``` + +--- + +### Task 3: Suspect-kill classifier + +A kill is "suspect" (RDM-worthy) per the SourceMod `BadKill` truth table: suspect when the two share a role (teamkill) OR neither is a Traitor; not suspect when the roles differ and exactly one party is a Traitor. + +**Files:** +- Create: `TTT/RDM/RdmClassifier.cs` +- Test: `TTT/Test/RDM/RdmClassifierTests.cs` + +**Interfaces:** +- Produces: `static class RdmClassifier { static bool IsSuspectKill(IRole killer, IRole victim) }` (namespace `TTT.RDM`). + +- [ ] **Step 1: Write the failing test** `TTT/Test/RDM/RdmClassifierTests.cs` + +```csharp +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Role; +using TTT.Game.Roles; +using TTT.RDM; +using Xunit; + +namespace TTT.Test.RDM; + +public class RdmClassifierTests { + public enum R { Innocent, Traitor, Detective } + + private readonly IList roles; + + public RdmClassifierTests(IServiceProvider provider) { + roles = new List { + new InnocentRole(provider), + new TraitorRole(provider), + new DetectiveRole(provider) + }; + } + + [Theory] + // same role -> suspect + [InlineData(R.Innocent, R.Innocent, true)] + [InlineData(R.Traitor, R.Traitor, true)] + [InlineData(R.Detective, R.Detective, true)] + // different roles, neither traitor -> suspect + [InlineData(R.Innocent, R.Detective, true)] + [InlineData(R.Detective, R.Innocent, true)] + // different roles, one is traitor -> NOT suspect + [InlineData(R.Traitor, R.Innocent, false)] + [InlineData(R.Traitor, R.Detective, false)] + [InlineData(R.Innocent, R.Traitor, false)] + [InlineData(R.Detective, R.Traitor, false)] + public void IsSuspectKill_MatchesBadKillTable(R killer, R victim, + bool expected) { + Assert.Equal(expected, + RdmClassifier.IsSuspectKill(roles[(int)killer], roles[(int)victim])); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo` +Expected: FAIL to compile — `RdmClassifier` does not exist. + +- [ ] **Step 3: Write `TTT/RDM/RdmClassifier.cs`** + +```csharp +using TTT.API.Role; +using TTT.Game.Roles; + +namespace TTT.RDM; + +public static class RdmClassifier { + /// + /// A kill is "suspect" (worth an RDM report) when the parties share a role + /// (teamkill) or neither is a Traitor. A Traitor killing a non-Traitor (or + /// vice versa) is legitimate and not suspect. + /// + public static bool IsSuspectKill(IRole killer, IRole victim) { + var killerIsTraitor = killer is TraitorRole; + var victimIsTraitor = victim is TraitorRole; + + if (killer.GetType() == victim.GetType()) return true; // same role + return !killerIsTraitor && !victimIsTraitor; // neither is traitor + } +} +``` + +- [ ] **Step 4: Build and run tests** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo && ./TTT/Test/bin/Debug/net10.0/Test` +Expected: PASS — all 9 `RdmClassifierTests` cases pass. No NEW failures. + +- [ ] **Step 5: Commit** + +```bash +git add TTT/RDM/RdmClassifier.cs TTT/Test/RDM/RdmClassifierTests.cs +git commit -m "feat(rdm): suspect-kill classifier" +``` + +--- + +### Task 4: Domain models + `IRdmStore` + in-memory store + +**Files:** +- Create: `TTT/RDM/Models/CaseState.cs` +- Create: `TTT/RDM/Models/Verdict.cs` +- Create: `TTT/RDM/Models/DeathRecord.cs` +- Create: `TTT/RDM/Models/RdmCase.cs` +- Create: `TTT/RDM/Models/SlayDebt.cs` +- Create: `TTT/RDM/IRdmStore.cs` +- Create: `TTT/RDM/InMemoryRdmStore.cs` +- Test: `TTT/Test/RDM/InMemoryRdmStoreTests.cs` + +**Interfaces:** +- Produces: enums `CaseState { Open, Claimed, Resolved }`, `Verdict { None, Forgiven, Guilty }` (namespace `TTT.RDM.Models`). +- Produces: records (namespace `TTT.RDM.Models`) — `DeathRecord`, `RdmCase`, `SlayDebt` (fields below). +- Produces: `interface IRdmStore` (namespace `TTT.RDM`) with the methods used by every later task: + - `Task AddDeath(DeathRecord death)` — returns assigned id (id on the input is ignored/overwritten). + - `Task GetDeath(int id)` + - `Task> GetSuspectDeathsForVictim(string victimId, int round)` + - `Task AddCase(RdmCase rdmCase)` — returns assigned id. + - `Task GetCase(int id)` + - `Task> GetOpenCases()` — `State != Resolved`, oldest first. + - `Task UpdateCase(RdmCase rdmCase)` + - `Task HasReport(string reporterId, int deathId)` + - `Task CountReportsByVictim(string reporterId, int round)` + - `Task SetSlayDebt(string playerId, int remaining, int sourceCaseId)` + - `Task GetSlayDebt(string playerId)` — 0 if none. + - `Task> GetAllSlayDebts()` — `RemainingSlays > 0`. + +- [ ] **Step 1: Write the model files** + +`TTT/RDM/Models/CaseState.cs`: +```csharp +namespace TTT.RDM.Models; + +public enum CaseState { Open, Claimed, Resolved } +``` + +`TTT/RDM/Models/Verdict.cs`: +```csharp +namespace TTT.RDM.Models; + +public enum Verdict { None, Forgiven, Guilty } +``` + +`TTT/RDM/Models/DeathRecord.cs`: +```csharp +using TTT.Game.Damage; + +namespace TTT.RDM.Models; + +public record DeathRecord { + public int Id { get; init; } + public required int Round { get; init; } + public required string VictimId { get; init; } + public required string VictimName { get; init; } + public required string VictimRole { get; init; } + public required string AttackerId { get; init; } + public required string AttackerName { get; init; } + public required string AttackerRole { get; init; } + public string? Weapon { get; init; } + public required DateTime Timestamp { get; init; } + public required bool IsSuspect { get; init; } + public required KillFault Fault { get; init; } +} +``` + +`TTT/RDM/Models/RdmCase.cs`: +```csharp +namespace TTT.RDM.Models; + +public record RdmCase { + public int Id { get; init; } + public required int DeathId { get; init; } + public required string ReporterId { get; init; } + public string? Reason { get; init; } + public CaseState State { get; init; } = CaseState.Open; + public string? HandlerAdminId { get; init; } + public Verdict Verdict { get; init; } = Verdict.None; + public required DateTime CreatedAt { get; init; } +} +``` + +`TTT/RDM/Models/SlayDebt.cs`: +```csharp +namespace TTT.RDM.Models; + +public record SlayDebt { + public required string PlayerId { get; init; } + public required int RemainingSlays { get; init; } + public int SourceCaseId { get; init; } +} +``` + +- [ ] **Step 2: Write `TTT/RDM/IRdmStore.cs`** + +```csharp +using TTT.RDM.Models; + +namespace TTT.RDM; + +public interface IRdmStore { + Task AddDeath(DeathRecord death); + Task GetDeath(int id); + Task> GetSuspectDeathsForVictim(string victimId, + int round); + + Task AddCase(RdmCase rdmCase); + Task GetCase(int id); + Task> GetOpenCases(); + Task UpdateCase(RdmCase rdmCase); + Task HasReport(string reporterId, int deathId); + Task CountReportsByVictim(string reporterId, int round); + + Task SetSlayDebt(string playerId, int remaining, int sourceCaseId); + Task GetSlayDebt(string playerId); + Task> GetAllSlayDebts(); +} +``` + +- [ ] **Step 3: Write the failing test** `TTT/Test/RDM/InMemoryRdmStoreTests.cs` + +```csharp +using TTT.Game.Damage; +using TTT.RDM; +using TTT.RDM.Models; +using Xunit; + +namespace TTT.Test.RDM; + +public class InMemoryRdmStoreTests { + private static DeathRecord SampleDeath(string victimId = "v", + bool suspect = true, int round = 1) { + return new DeathRecord { + Round = round, VictimId = victimId, VictimName = "Victim", + VictimRole = "Innocent", AttackerId = "k", AttackerName = "Killer", + AttackerRole = "Innocent", Weapon = "ak47", + Timestamp = new DateTime(2026, 1, 1), IsSuspect = suspect, + Fault = KillFault.KillerGuilty + }; + } + + private static IRdmStore NewStore() { return new InMemoryRdmStore(); } + + [Fact] + public async Task AddDeath_AssignsIncrementingIds() { + var store = NewStore(); + var id1 = await store.AddDeath(SampleDeath()); + var id2 = await store.AddDeath(SampleDeath()); + Assert.True(id2 > id1); + Assert.Equal(id1, (await store.GetDeath(id1))!.Id); + } + + [Fact] + public async Task GetSuspectDeathsForVictim_FiltersBySuspectAndRound() { + var store = NewStore(); + await store.AddDeath(SampleDeath("v", true, 1)); + await store.AddDeath(SampleDeath("v", false, 1)); // not suspect + await store.AddDeath(SampleDeath("v", true, 2)); // wrong round + await store.AddDeath(SampleDeath("w", true, 1)); // wrong victim + + var result = await store.GetSuspectDeathsForVictim("v", 1); + Assert.Single(result); + } + + [Fact] + public async Task Cases_RoundTripAndOpenFilter() { + var store = NewStore(); + var deathId = await store.AddDeath(SampleDeath()); + var id = await store.AddCase(new RdmCase { + DeathId = deathId, ReporterId = "v", CreatedAt = new DateTime(2026, 1, 1) + }); + + var open = await store.GetOpenCases(); + Assert.Single(open); + + await store.UpdateCase((await store.GetCase(id))! with { + State = CaseState.Resolved, Verdict = Verdict.Forgiven + }); + Assert.Empty(await store.GetOpenCases()); + } + + [Fact] + public async Task HasReport_And_CountReportsByVictim() { + var store = NewStore(); + var deathId = await store.AddDeath(SampleDeath()); + Assert.False(await store.HasReport("v", deathId)); + + await store.AddCase(new RdmCase { + DeathId = deathId, ReporterId = "v", CreatedAt = new DateTime(2026, 1, 1) + }); + Assert.True(await store.HasReport("v", deathId)); + Assert.Equal(1, await store.CountReportsByVictim("v", 1)); + } + + [Fact] + public async Task SlayDebt_SetGetAndList() { + var store = NewStore(); + Assert.Equal(0, await store.GetSlayDebt("p")); + await store.SetSlayDebt("p", 3, 7); + Assert.Equal(3, await store.GetSlayDebt("p")); + Assert.Single(await store.GetAllSlayDebts()); + + await store.SetSlayDebt("p", 0, 7); + Assert.Empty(await store.GetAllSlayDebts()); + } +} +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo` +Expected: FAIL to compile — `InMemoryRdmStore` does not exist. + +- [ ] **Step 5: Write `TTT/RDM/InMemoryRdmStore.cs`** + +```csharp +using System.Collections.Concurrent; +using TTT.RDM.Models; + +namespace TTT.RDM; + +public sealed class InMemoryRdmStore : IRdmStore { + private readonly ConcurrentDictionary deaths = new(); + private readonly ConcurrentDictionary cases = new(); + private readonly ConcurrentDictionary slays = new(); + private int nextDeathId; + private int nextCaseId; + + public Task AddDeath(DeathRecord death) { + var id = Interlocked.Increment(ref nextDeathId); + deaths[id] = death with { Id = id }; + return Task.FromResult(id); + } + + public Task GetDeath(int id) { + return Task.FromResult(deaths.GetValueOrDefault(id)); + } + + public Task> GetSuspectDeathsForVictim( + string victimId, int round) { + IReadOnlyList result = deaths.Values + .Where(d => d.IsSuspect && d.VictimId == victimId && d.Round == round) + .OrderBy(d => d.Id) + .ToList(); + return Task.FromResult(result); + } + + public Task AddCase(RdmCase rdmCase) { + var id = Interlocked.Increment(ref nextCaseId); + cases[id] = rdmCase with { Id = id }; + return Task.FromResult(id); + } + + public Task GetCase(int id) { + return Task.FromResult(cases.GetValueOrDefault(id)); + } + + public Task> GetOpenCases() { + IReadOnlyList result = cases.Values + .Where(c => c.State != CaseState.Resolved) + .OrderBy(c => c.Id) + .ToList(); + return Task.FromResult(result); + } + + public Task UpdateCase(RdmCase rdmCase) { + cases[rdmCase.Id] = rdmCase; + return Task.CompletedTask; + } + + public Task HasReport(string reporterId, int deathId) { + return Task.FromResult(cases.Values.Any(c + => c.ReporterId == reporterId && c.DeathId == deathId)); + } + + public Task CountReportsByVictim(string reporterId, int round) { + var count = cases.Values.Count(c + => c.ReporterId == reporterId + && deaths.TryGetValue(c.DeathId, out var d) && d.Round == round); + return Task.FromResult(count); + } + + public Task SetSlayDebt(string playerId, int remaining, int sourceCaseId) { + if (remaining <= 0) + slays.TryRemove(playerId, out _); + else + slays[playerId] = new SlayDebt { + PlayerId = playerId, RemainingSlays = remaining, + SourceCaseId = sourceCaseId + }; + return Task.CompletedTask; + } + + public Task GetSlayDebt(string playerId) { + return Task.FromResult( + slays.TryGetValue(playerId, out var d) ? d.RemainingSlays : 0); + } + + public Task> GetAllSlayDebts() { + IReadOnlyList result = + slays.Values.Where(s => s.RemainingSlays > 0).ToList(); + return Task.FromResult(result); + } +} +``` + +- [ ] **Step 6: Build and run tests** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo && ./TTT/Test/bin/Debug/net10.0/Test` +Expected: PASS — all `InMemoryRdmStoreTests` pass. No NEW failures. + +- [ ] **Step 7: Commit** + +```bash +git add TTT/RDM/Models TTT/RDM/IRdmStore.cs TTT/RDM/InMemoryRdmStore.cs TTT/Test/RDM/InMemoryRdmStoreTests.cs +git commit -m "feat(rdm): domain models + IRdmStore + in-memory store" +``` + +--- + +### Task 5: SQLite store (`SqliteRdmStore`) + +Production `IRdmStore` backed by `Microsoft.Data.Sqlite`. Tests run against `Data Source=:memory:` held open by a persistent connection. + +**Files:** +- Create: `TTT/RDM/SqliteRdmStore.cs` +- Test: `TTT/Test/RDM/SqliteRdmStoreTests.cs` + +**Interfaces:** +- Produces: `sealed class SqliteRdmStore : IRdmStore, IDisposable` with ctor `SqliteRdmStore(string connectionString)`. Opens one shared `SqliteConnection` (kept open so `:memory:` persists), creates tables on construction. + +- [ ] **Step 1: Write the failing test** `TTT/Test/RDM/SqliteRdmStoreTests.cs` + +```csharp +using TTT.Game.Damage; +using TTT.RDM; +using TTT.RDM.Models; +using Xunit; + +namespace TTT.Test.RDM; + +public class SqliteRdmStoreTests { + private static DeathRecord SampleDeath(string victimId = "v", + bool suspect = true, int round = 1) { + return new DeathRecord { + Round = round, VictimId = victimId, VictimName = "Victim", + VictimRole = "Innocent", AttackerId = "k", AttackerName = "Killer", + AttackerRole = "Innocent", Weapon = "ak47", + Timestamp = new DateTime(2026, 1, 1), IsSuspect = suspect, + Fault = KillFault.KillerGuilty + }; + } + + // Unique in-memory DB per test (shared cache so the schema persists across + // the store's internal connection lifetime). + private static SqliteRdmStore NewStore() { + return new SqliteRdmStore("Data Source=:memory:"); + } + + [Fact] + public async Task Death_RoundTrip_PreservesFields() { + using var store = NewStore(); + var id = await store.AddDeath(SampleDeath()); + var got = await store.GetDeath(id); + Assert.NotNull(got); + Assert.Equal("Killer", got!.AttackerName); + Assert.Equal(KillFault.KillerGuilty, got.Fault); + Assert.True(got.IsSuspect); + } + + [Fact] + public async Task SuspectDeaths_FilterByVictimRoundSuspect() { + using var store = NewStore(); + await store.AddDeath(SampleDeath("v", true, 1)); + await store.AddDeath(SampleDeath("v", false, 1)); + await store.AddDeath(SampleDeath("v", true, 2)); + Assert.Single(await store.GetSuspectDeathsForVictim("v", 1)); + } + + [Fact] + public async Task Cases_OpenFilter_And_Update() { + using var store = NewStore(); + var deathId = await store.AddDeath(SampleDeath()); + var id = await store.AddCase(new RdmCase { + DeathId = deathId, ReporterId = "v", CreatedAt = new DateTime(2026, 1, 1) + }); + Assert.Single(await store.GetOpenCases()); + await store.UpdateCase((await store.GetCase(id))! with { + State = CaseState.Resolved, Verdict = Verdict.Guilty, + HandlerAdminId = "admin" + }); + Assert.Empty(await store.GetOpenCases()); + Assert.Equal(Verdict.Guilty, (await store.GetCase(id))!.Verdict); + } + + [Fact] + public async Task SlayDebt_Persisted_AcrossNewStoreSameFile() { + var file = $"Data Source=rdm-test-{Guid.NewGuid():N}.db"; + try { + using (var store = new SqliteRdmStore(file)) + await store.SetSlayDebt("p", 4, 1); + using (var store2 = new SqliteRdmStore(file)) + Assert.Equal(4, await store2.GetSlayDebt("p")); + } finally { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + var path = file.Replace("Data Source=", ""); + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public async Task HasReport_And_CountReportsByVictim() { + using var store = NewStore(); + var deathId = await store.AddDeath(SampleDeath()); + Assert.False(await store.HasReport("v", deathId)); + await store.AddCase(new RdmCase { + DeathId = deathId, ReporterId = "v", CreatedAt = new DateTime(2026, 1, 1) + }); + Assert.True(await store.HasReport("v", deathId)); + Assert.Equal(1, await store.CountReportsByVictim("v", 1)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo` +Expected: FAIL to compile — `SqliteRdmStore` does not exist. + +- [ ] **Step 3: Write `TTT/RDM/SqliteRdmStore.cs`** + +```csharp +using Microsoft.Data.Sqlite; +using TTT.Game.Damage; +using TTT.RDM.Models; + +namespace TTT.RDM; + +public sealed class SqliteRdmStore : IRdmStore, IDisposable { + private readonly SqliteConnection connection; + + public SqliteRdmStore(string connectionString) { + connection = new SqliteConnection(connectionString); + connection.Open(); + CreateTables(); + } + + private void CreateTables() { + using var cmd = connection.CreateCommand(); + cmd.CommandText = + """ + CREATE TABLE IF NOT EXISTS deaths ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + round INTEGER NOT NULL, + victim_id TEXT NOT NULL, victim_name TEXT NOT NULL, victim_role TEXT NOT NULL, + attacker_id TEXT NOT NULL, attacker_name TEXT NOT NULL, attacker_role TEXT NOT NULL, + weapon TEXT, timestamp TEXT NOT NULL, is_suspect INTEGER NOT NULL, fault INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS cases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + death_id INTEGER NOT NULL, reporter_id TEXT NOT NULL, reason TEXT, + state INTEGER NOT NULL, handler_admin_id TEXT, verdict INTEGER NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS slays ( + player_id TEXT PRIMARY KEY, remaining INTEGER NOT NULL, source_case_id INTEGER NOT NULL + ); + """; + cmd.ExecuteNonQuery(); + } + + private static DeathRecord ReadDeath(SqliteDataReader r) { + return new DeathRecord { + Id = r.GetInt32(0), Round = r.GetInt32(1), + VictimId = r.GetString(2), VictimName = r.GetString(3), + VictimRole = r.GetString(4), AttackerId = r.GetString(5), + AttackerName = r.GetString(6), AttackerRole = r.GetString(7), + Weapon = r.IsDBNull(8) ? null : r.GetString(8), + Timestamp = DateTime.Parse(r.GetString(9)), + IsSuspect = r.GetInt32(10) != 0, Fault = (KillFault)r.GetInt32(11) + }; + } + + private const string DeathCols = + "id, round, victim_id, victim_name, victim_role, attacker_id, attacker_name, attacker_role, weapon, timestamp, is_suspect, fault"; + + public async Task AddDeath(DeathRecord d) { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + """ + INSERT INTO deaths (round, victim_id, victim_name, victim_role, attacker_id, attacker_name, attacker_role, weapon, timestamp, is_suspect, fault) + VALUES ($round, $vid, $vname, $vrole, $aid, $aname, $arole, $weapon, $ts, $suspect, $fault); + SELECT last_insert_rowid(); + """; + cmd.Parameters.AddWithValue("$round", d.Round); + cmd.Parameters.AddWithValue("$vid", d.VictimId); + cmd.Parameters.AddWithValue("$vname", d.VictimName); + cmd.Parameters.AddWithValue("$vrole", d.VictimRole); + cmd.Parameters.AddWithValue("$aid", d.AttackerId); + cmd.Parameters.AddWithValue("$aname", d.AttackerName); + cmd.Parameters.AddWithValue("$arole", d.AttackerRole); + cmd.Parameters.AddWithValue("$weapon", (object?)d.Weapon ?? DBNull.Value); + cmd.Parameters.AddWithValue("$ts", d.Timestamp.ToString("o")); + cmd.Parameters.AddWithValue("$suspect", d.IsSuspect ? 1 : 0); + cmd.Parameters.AddWithValue("$fault", (int)d.Fault); + return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + } + + public async Task GetDeath(int id) { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = $"SELECT {DeathCols} FROM deaths WHERE id = $id"; + cmd.Parameters.AddWithValue("$id", id); + await using var r = await cmd.ExecuteReaderAsync(); + return await r.ReadAsync() ? ReadDeath(r) : null; + } + + public async Task> GetSuspectDeathsForVictim( + string victimId, int round) { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + $"SELECT {DeathCols} FROM deaths WHERE victim_id = $v AND round = $r AND is_suspect = 1 ORDER BY id"; + cmd.Parameters.AddWithValue("$v", victimId); + cmd.Parameters.AddWithValue("$r", round); + var list = new List(); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) list.Add(ReadDeath(r)); + return list; + } + + public async Task AddCase(RdmCase c) { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + """ + INSERT INTO cases (death_id, reporter_id, reason, state, handler_admin_id, verdict, created_at) + VALUES ($did, $rid, $reason, $state, $handler, $verdict, $created); + SELECT last_insert_rowid(); + """; + cmd.Parameters.AddWithValue("$did", c.DeathId); + cmd.Parameters.AddWithValue("$rid", c.ReporterId); + cmd.Parameters.AddWithValue("$reason", (object?)c.Reason ?? DBNull.Value); + cmd.Parameters.AddWithValue("$state", (int)c.State); + cmd.Parameters.AddWithValue("$handler", + (object?)c.HandlerAdminId ?? DBNull.Value); + cmd.Parameters.AddWithValue("$verdict", (int)c.Verdict); + cmd.Parameters.AddWithValue("$created", c.CreatedAt.ToString("o")); + return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + } + + private static RdmCase ReadCase(SqliteDataReader r) { + return new RdmCase { + Id = r.GetInt32(0), DeathId = r.GetInt32(1), ReporterId = r.GetString(2), + Reason = r.IsDBNull(3) ? null : r.GetString(3), + State = (CaseState)r.GetInt32(4), + HandlerAdminId = r.IsDBNull(5) ? null : r.GetString(5), + Verdict = (Verdict)r.GetInt32(6), + CreatedAt = DateTime.Parse(r.GetString(7)) + }; + } + + private const string CaseCols = + "id, death_id, reporter_id, reason, state, handler_admin_id, verdict, created_at"; + + public async Task GetCase(int id) { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = $"SELECT {CaseCols} FROM cases WHERE id = $id"; + cmd.Parameters.AddWithValue("$id", id); + await using var r = await cmd.ExecuteReaderAsync(); + return await r.ReadAsync() ? ReadCase(r) : null; + } + + public async Task> GetOpenCases() { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + $"SELECT {CaseCols} FROM cases WHERE state != $resolved ORDER BY id"; + cmd.Parameters.AddWithValue("$resolved", (int)CaseState.Resolved); + var list = new List(); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) list.Add(ReadCase(r)); + return list; + } + + public async Task UpdateCase(RdmCase c) { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + """ + UPDATE cases SET death_id=$did, reporter_id=$rid, reason=$reason, + state=$state, handler_admin_id=$handler, verdict=$verdict, created_at=$created + WHERE id=$id + """; + cmd.Parameters.AddWithValue("$id", c.Id); + cmd.Parameters.AddWithValue("$did", c.DeathId); + cmd.Parameters.AddWithValue("$rid", c.ReporterId); + cmd.Parameters.AddWithValue("$reason", (object?)c.Reason ?? DBNull.Value); + cmd.Parameters.AddWithValue("$state", (int)c.State); + cmd.Parameters.AddWithValue("$handler", + (object?)c.HandlerAdminId ?? DBNull.Value); + cmd.Parameters.AddWithValue("$verdict", (int)c.Verdict); + cmd.Parameters.AddWithValue("$created", c.CreatedAt.ToString("o")); + await cmd.ExecuteNonQueryAsync(); + } + + public async Task HasReport(string reporterId, int deathId) { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + "SELECT COUNT(*) FROM cases WHERE reporter_id = $r AND death_id = $d"; + cmd.Parameters.AddWithValue("$r", reporterId); + cmd.Parameters.AddWithValue("$d", deathId); + return Convert.ToInt32(await cmd.ExecuteScalarAsync()) > 0; + } + + public async Task CountReportsByVictim(string reporterId, int round) { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + """ + SELECT COUNT(*) FROM cases c JOIN deaths d ON c.death_id = d.id + WHERE c.reporter_id = $r AND d.round = $round + """; + cmd.Parameters.AddWithValue("$r", reporterId); + cmd.Parameters.AddWithValue("$round", round); + return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + } + + public async Task SetSlayDebt(string playerId, int remaining, + int sourceCaseId) { + await using var cmd = connection.CreateCommand(); + if (remaining <= 0) { + cmd.CommandText = "DELETE FROM slays WHERE player_id = $p"; + cmd.Parameters.AddWithValue("$p", playerId); + } else { + cmd.CommandText = + """ + INSERT INTO slays (player_id, remaining, source_case_id) + VALUES ($p, $rem, $src) + ON CONFLICT(player_id) DO UPDATE SET remaining=$rem, source_case_id=$src + """; + cmd.Parameters.AddWithValue("$p", playerId); + cmd.Parameters.AddWithValue("$rem", remaining); + cmd.Parameters.AddWithValue("$src", sourceCaseId); + } + await cmd.ExecuteNonQueryAsync(); + } + + public async Task GetSlayDebt(string playerId) { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT remaining FROM slays WHERE player_id = $p"; + cmd.Parameters.AddWithValue("$p", playerId); + var result = await cmd.ExecuteScalarAsync(); + return result == null ? 0 : Convert.ToInt32(result); + } + + public async Task> GetAllSlayDebts() { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + "SELECT player_id, remaining, source_case_id FROM slays WHERE remaining > 0"; + var list = new List(); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + list.Add(new SlayDebt { + PlayerId = r.GetString(0), RemainingSlays = r.GetInt32(1), + SourceCaseId = r.GetInt32(2) + }); + return list; + } + + public void Dispose() { connection.Dispose(); } +} +``` + +- [ ] **Step 4: Build and run tests** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo && ./TTT/Test/bin/Debug/net10.0/Test` +Expected: PASS — all `SqliteRdmStoreTests` pass. No NEW failures. + +- [ ] **Step 5: Commit** + +```bash +git add TTT/RDM/SqliteRdmStore.cs TTT/Test/RDM/SqliteRdmStoreTests.cs +git commit -m "feat(rdm): SQLite-backed IRdmStore" +``` + +--- + +### Task 6: DeathLogListener — record deaths, round counter, suspect detect, auto-prompt + +**Files:** +- Create: `TTT/RDM/lang/RdmMsgs.cs` +- Create: `TTT/RDM/lang/en.yml` +- Create: `TTT/RDM/DeathLogListener.cs` +- Test: `TTT/Test/RDM/DeathLogListenerTests.cs` + +**Interfaces:** +- Consumes: `IRdmStore`, `IDamageTracker`, `RdmClassifier.IsSuspectKill`, `PlayerDeathEvent` (`.Victim`, `.Killer`, `.Weapon`), `GameStateUpdateEvent` (`.NewState == State.IN_PROGRESS`), `IRoleAssigner.GetRoles(...).First()`, `RdmConfig.AutoPromptOnSuspectKill`. +- Produces: `class DeathLogListener : BaseListener` exposing `int CurrentRound { get; }` (1-based; increments each `State.IN_PROGRESS`). + +- [ ] **Step 1: Create localization** `TTT/RDM/lang/RdmMsgs.cs` + +```csharp +using TTT.Locale; + +namespace TTT.RDM.lang; + +public static class RdmMsgs { + public static IMsg RDM_PROMPT(string attacker) { + return MsgFactory.Create(nameof(RDM_PROMPT), attacker); + } +} +``` + +`TTT/RDM/lang/en.yml`: +```yaml +RDM_PROMPT: "%PREFIX%You were killed by {yellow}{0}{grey}. Type {yellow}!rdm{grey} to report it, or ignore (defaults to not RDM)." +``` + +- [ ] **Step 2: Write the failing test** `TTT/Test/RDM/DeathLogListenerTests.cs` + +```csharp +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Events; +using TTT.API.Game; +using TTT.API.Player; +using TTT.API.Role; +using TTT.Game.Events.Player; +using TTT.Game.Roles; +using TTT.Locale; +using TTT.RDM; +using TTT.RDM.lang; +using Xunit; + +namespace TTT.Test.RDM; + +public class DeathLogListenerTests { + private readonly IEventBus bus; + private readonly IGameManager games; + private readonly IPlayerFinder players; + private readonly IRoleAssigner roles; + private readonly IRdmStore store; + private readonly IMsgLocalizer locale; + private readonly IList roleSet; + + public DeathLogListenerTests(IServiceProvider provider) { + bus = provider.GetRequiredService(); + games = provider.GetRequiredService(); + players = provider.GetRequiredService(); + roles = provider.GetRequiredService(); + store = provider.GetRequiredService(); + locale = provider.GetRequiredService(); + roleSet = new List { + new InnocentRole(provider), new TraitorRole(provider), + new DetectiveRole(provider) + }; + bus.RegisterListener(provider.GetRequiredService()); + bus.RegisterListener(new DeathLogListener(provider)); + } + + private (TestPlayer victim, TestPlayer killer) StartRoundWith( + IRole victimRole, IRole killerRole) { + var victim = TestPlayer.Random(); + var killer = TestPlayer.Random(); + players.AddPlayers(victim, killer); + var game = games.CreateGame(); + game?.Start(); + // Drive a round-start so DeathLogListener.CurrentRound becomes 1 (the round + // the deaths are stored under and that the tests query). + bus.Dispatch(new TTT.Game.Events.Game.GameStateUpdateEvent(game!, + State.IN_PROGRESS)); + roles.SetRole(victim, victimRole); + roles.SetRole(killer, killerRole); + return (victim, killer); + } + + [Fact] + public async Task SuspectKill_RecordsDeath_AndPromptsVictim() { + var (victim, killer) = + StartRoundWith(roleSet[0], roleSet[0]); // inno on inno + bus.Dispatch(new PlayerDamagedEvent(victim, killer, 100)); + var death = new PlayerDeathEvent(victim).WithKiller(killer) + .WithWeapon("ak47"); + bus.Dispatch(death); + + var recorded = await store.GetSuspectDeathsForVictim(victim.Id, 1); + Assert.Single(recorded); + Assert.Contains(locale[RdmMsgs.RDM_PROMPT(killer.Name)], victim.Messages); + } + + [Fact] + public async Task LegitKill_RecordsNonSuspect_NoPrompt() { + var (victim, killer) = + StartRoundWith(roleSet[0], roleSet[1]); // traitor kills inno + bus.Dispatch(new PlayerDamagedEvent(victim, killer, 100)); + bus.Dispatch(new PlayerDeathEvent(victim).WithKiller(killer)); + + Assert.Empty(await store.GetSuspectDeathsForVictim(victim.Id, 1)); + Assert.Empty(victim.Messages); + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo` +Expected: FAIL to compile — `DeathLogListener` does not exist. + +- [ ] **Step 4: Write `TTT/RDM/DeathLogListener.cs`** + +```csharp +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Events; +using TTT.API.Game; +using TTT.API.Storage; +using TTT.Game.Damage; +using TTT.Game.Events.Game; +using TTT.Game.Events.Player; +using TTT.Game.Listeners; +using TTT.RDM.lang; + +namespace TTT.RDM; + +public class DeathLogListener(IServiceProvider provider) + : BaseListener(provider) { + private readonly IRdmStore store = provider.GetRequiredService(); + + private readonly IDamageTracker damage = + provider.GetRequiredService(); + + public int CurrentRound { get; private set; } + + private RdmConfig config + => Provider.GetService>()?.Load().GetAwaiter() + .GetResult() ?? new RdmConfig(); + + [EventHandler] + [UsedImplicitly] + public void OnRoundStart(GameStateUpdateEvent ev) { + if (ev.NewState == State.IN_PROGRESS) CurrentRound++; + } + + [EventHandler] + [UsedImplicitly] + public void OnKill(PlayerDeathEvent ev) { + if (Games.ActiveGame is not { State: State.IN_PROGRESS }) return; + var victim = ev.Victim; + var killer = ev.Killer; + if (killer == null || victim.Id == killer.Id) return; + + var victimRole = Roles.GetRoles(victim).First(); + var killerRole = Roles.GetRoles(killer).First(); + var suspect = RdmClassifier.IsSuspectKill(killerRole, victimRole); + var fault = damage.GetFault(killer.Id, victim.Id); + + var record = new Models.DeathRecord { + Round = CurrentRound, + VictimId = victim.Id, VictimName = victim.Name, + VictimRole = victimRole.Name, + AttackerId = killer.Id, AttackerName = killer.Name, + AttackerRole = killerRole.Name, Weapon = ev.Weapon, + Timestamp = DateTime.UtcNow, IsSuspect = suspect, Fault = fault + }; + + Task.Run(async () => await store.AddDeath(record)); + + if (suspect && config.AutoPromptOnSuspectKill) + Messenger.Message(victim, Locale[RdmMsgs.RDM_PROMPT(killer.Name)]); + } +} +``` + +- [ ] **Step 5: Register `IRdmStore` (in-memory) for tests** in `TTT/Test/Startup.cs` + +Add `using TTT.RDM;` and, near the other `AddScoped` lines, add: +```csharp + services.AddScoped(); +``` + +- [ ] **Step 6: Build and run tests** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo && ./TTT/Test/bin/Debug/net10.0/Test` +Expected: PASS — `DeathLogListenerTests` pass (the rebuild regenerates `lang/en.json` with `RDM_PROMPT`). No NEW failures. + +- [ ] **Step 7: Commit** + +```bash +git add TTT/RDM/DeathLogListener.cs TTT/RDM/lang TTT/Test/Startup.cs TTT/Test/RDM/DeathLogListenerTests.cs +git commit -m "feat(rdm): death log listener with suspect detection + victim prompt" +``` + +--- + +### Task 7: Case manager (report lifecycle + staff notification) + +**Files:** +- Create: `TTT/RDM/ICaseManager.cs` +- Create: `TTT/RDM/CaseManager.cs` +- Modify: `TTT/RDM/lang/RdmMsgs.cs` (add `RDM_STAFF_NEW_REPORT`) +- Modify: `TTT/RDM/lang/en.yml` +- Test: `TTT/Test/RDM/CaseManagerTests.cs` + +**Interfaces:** +- Produces: `interface ICaseManager` with: + - `Task FileReport(IOnlinePlayer reporter, int deathId, string? reason)` — null if rejected (no such death / not the victim / duplicate / over per-round cap / outside report window). On success persists a case (`Open`) and notifies staff. + - `Task ClaimNext(IPlayer admin)` — claims the oldest open (`Open`) case for admin → `Claimed`; null if none. + - `Task Claim(IPlayer admin, int caseId)` — claim a specific case; null if missing/already resolved. + - `Task Resolve(int caseId, Verdict verdict, IPlayer admin)` — set `Resolved` + verdict + handler. + - `Task> GetOpen()`. +- Produces: `class CaseManager : ICaseManager`. + +- [ ] **Step 1: Add localization keys** + +In `TTT/RDM/lang/RdmMsgs.cs` add: +```csharp + public static IMsg RDM_STAFF_NEW_REPORT(string victim, string attacker, + int caseId) { + return MsgFactory.Create(nameof(RDM_STAFF_NEW_REPORT), victim, attacker, + caseId); + } +``` + +In `TTT/RDM/lang/en.yml` add: +```yaml +RDM_STAFF_NEW_REPORT: "%PREFIX%{red}[RDM]{grey} New report #{2}: {yellow}{0}{grey} reported {yellow}{1}{grey}." +``` + +- [ ] **Step 2: Write the failing test** `TTT/Test/RDM/CaseManagerTests.cs` + +```csharp +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Player; +using TTT.Game.Damage; +using TTT.RDM; +using TTT.RDM.Models; +using Xunit; + +namespace TTT.Test.RDM; + +public class CaseManagerTests { + private readonly ICaseManager manager; + private readonly IRdmStore store; + private readonly IPlayerFinder players; + + public CaseManagerTests(IServiceProvider provider) { + manager = provider.GetRequiredService(); + store = provider.GetRequiredService(); + players = provider.GetRequiredService(); + } + + private async Task SeedSuspectDeath(IPlayer victim) { + return await store.AddDeath(new DeathRecord { + Round = 1, VictimId = victim.Id, VictimName = victim.Name, + VictimRole = "Innocent", AttackerId = "k", AttackerName = "Killer", + AttackerRole = "Innocent", Timestamp = DateTime.UtcNow, + IsSuspect = true, Fault = KillFault.KillerGuilty + }); + } + + [Fact] + public async Task FileReport_CreatesOpenCase() { + var victim = TestPlayer.Random(); + players.AddPlayer(victim); + var deathId = await SeedSuspectDeath(victim); + + var c = await manager.FileReport(victim, deathId, "shot me in spawn"); + Assert.NotNull(c); + Assert.Equal(CaseState.Open, c!.State); + Assert.Single(await manager.GetOpen()); + } + + [Fact] + public async Task FileReport_ByNonVictim_Rejected() { + var victim = TestPlayer.Random(); + var other = TestPlayer.Random(); + players.AddPlayers(victim, other); + var deathId = await SeedSuspectDeath(victim); + Assert.Null(await manager.FileReport(other, deathId, null)); + } + + [Fact] + public async Task FileReport_Duplicate_Rejected() { + var victim = TestPlayer.Random(); + players.AddPlayer(victim); + var deathId = await SeedSuspectDeath(victim); + Assert.NotNull(await manager.FileReport(victim, deathId, null)); + Assert.Null(await manager.FileReport(victim, deathId, null)); + } + + [Fact] + public async Task ClaimNext_MovesOldestToClaimed() { + var victim = TestPlayer.Random(); + players.AddPlayer(victim); + var deathId = await SeedSuspectDeath(victim); + await manager.FileReport(victim, deathId, null); + + var admin = TestPlayer.Random(); + var c = await manager.ClaimNext(admin); + Assert.NotNull(c); + Assert.Equal(CaseState.Claimed, c!.State); + Assert.Equal(admin.Id, c.HandlerAdminId); + } + + [Fact] + public async Task Resolve_ClosesCase() { + var victim = TestPlayer.Random(); + players.AddPlayer(victim); + var deathId = await SeedSuspectDeath(victim); + var c = await manager.FileReport(victim, deathId, null); + var admin = TestPlayer.Random(); + + await manager.Resolve(c!.Id, Verdict.Forgiven, admin); + Assert.Empty(await manager.GetOpen()); + Assert.Equal(Verdict.Forgiven, (await store.GetCase(c.Id))!.Verdict); + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo` +Expected: FAIL to compile — `ICaseManager`/`CaseManager` do not exist. + +- [ ] **Step 4: Write `TTT/RDM/ICaseManager.cs`** + +```csharp +using TTT.API.Player; +using TTT.RDM.Models; + +namespace TTT.RDM; + +public interface ICaseManager { + Task FileReport(IOnlinePlayer reporter, int deathId, + string? reason); + Task ClaimNext(IPlayer admin); + Task Claim(IPlayer admin, int caseId); + Task Resolve(int caseId, Verdict verdict, IPlayer admin); + Task> GetOpen(); +} +``` + +- [ ] **Step 5: Write `TTT/RDM/CaseManager.cs`** + +```csharp +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Messages; +using TTT.API.Player; +using TTT.API.Storage; +using TTT.Locale; +using TTT.RDM.lang; +using TTT.RDM.Models; + +namespace TTT.RDM; + +public class CaseManager(IServiceProvider provider) : ICaseManager { + private readonly IRdmStore store = provider.GetRequiredService(); + + private readonly IPlayerFinder finder = + provider.GetRequiredService(); + + private readonly IPermissionManager perms = + provider.GetRequiredService(); + + private readonly IMessenger messenger = + provider.GetRequiredService(); + + private readonly IMsgLocalizer locale = + provider.GetRequiredService(); + + private RdmConfig config + => provider.GetService>()?.Load().GetAwaiter() + .GetResult() ?? new RdmConfig(); + + public async Task FileReport(IOnlinePlayer reporter, int deathId, + string? reason) { + var death = await store.GetDeath(deathId); + if (death == null) return null; + if (death.VictimId != reporter.Id) return null; // only the victim + if (await store.HasReport(reporter.Id, deathId)) return null; // duplicate + + var cfg = config; + if (await store.CountReportsByVictim(reporter.Id, death.Round) + >= cfg.MaxReportsPerVictimPerRound) return null; // per-round cap + + var age = DateTime.UtcNow - death.Timestamp; + if (age.TotalSeconds > cfg.ReportWindowSeconds) return null; // window + + var id = await store.AddCase(new RdmCase { + DeathId = deathId, ReporterId = reporter.Id, Reason = reason, + State = CaseState.Open, CreatedAt = DateTime.UtcNow + }); + var created = await store.GetCase(id); + + if (cfg.NotifyAdmins && created != null) + NotifyStaff(death, created); + return created; + } + + private void NotifyStaff(DeathRecord death, RdmCase rdmCase) { + var staffFlag = config.StaffFlag; + foreach (var online in finder.GetOnline()) + if (perms.HasFlags(online, staffFlag)) + messenger.Message(online, + locale[RdmMsgs.RDM_STAFF_NEW_REPORT(death.VictimName, + death.AttackerName, rdmCase.Id)]); + } + + public async Task ClaimNext(IPlayer admin) { + var open = await store.GetOpenCases(); + var next = open.FirstOrDefault(c => c.State == CaseState.Open); + if (next == null) return null; + return await Claim(admin, next.Id); + } + + public async Task Claim(IPlayer admin, int caseId) { + var c = await store.GetCase(caseId); + if (c == null || c.State == CaseState.Resolved) return null; + var claimed = c with { + State = CaseState.Claimed, HandlerAdminId = admin.Id + }; + await store.UpdateCase(claimed); + return claimed; + } + + public async Task Resolve(int caseId, Verdict verdict, IPlayer admin) { + var c = await store.GetCase(caseId); + if (c == null) return; + await store.UpdateCase(c with { + State = CaseState.Resolved, Verdict = verdict, + HandlerAdminId = admin.Id + }); + } + + public Task> GetOpen() { return store.GetOpenCases(); } +} +``` + +- [ ] **Step 6: Register `ICaseManager` for tests** in `TTT/Test/Startup.cs` + +Add near the other `AddScoped` lines: +```csharp + services.AddScoped(); +``` + +- [ ] **Step 7: Build and run tests** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo && ./TTT/Test/bin/Debug/net10.0/Test` +Expected: PASS — `CaseManagerTests` pass. No NEW failures. + +- [ ] **Step 8: Commit** + +```bash +git add TTT/RDM/ICaseManager.cs TTT/RDM/CaseManager.cs TTT/RDM/lang TTT/Test/Startup.cs TTT/Test/RDM/CaseManagerTests.cs +git commit -m "feat(rdm): case manager (report lifecycle + staff notify)" +``` + +--- + +### Task 8: `!rdm` victim command + +Lists the victim's recent suspect deaths this round; `!rdm [reason]` files a report for the n-th listed death. + +**Files:** +- Modify: `TTT/RDM/lang/RdmMsgs.cs` (add `RDM_LIST_HEADER`, `RDM_LIST_ENTRY`, `RDM_LIST_EMPTY`, `RDM_REPORT_FILED`, `RDM_REPORT_REJECTED`) +- Modify: `TTT/RDM/lang/en.yml` +- Create: `TTT/RDM/Commands/RdmCommand.cs` +- Test: `TTT/Test/RDM/RdmCommandTests.cs` + +**Interfaces:** +- Consumes: `ICaseManager`, `IRdmStore`, `DeathLogListener.CurrentRound`, `ICommandInfo.Args`. +- Produces: `class RdmCommand : ICommand` with `Id => "rdm"`. + +- [ ] **Step 1: Add localization keys** + +In `TTT/RDM/lang/RdmMsgs.cs` add: +```csharp + public static IMsg RDM_LIST_HEADER() { + return MsgFactory.Create(nameof(RDM_LIST_HEADER)); + } + + public static IMsg RDM_LIST_ENTRY(int index, string attacker) { + return MsgFactory.Create(nameof(RDM_LIST_ENTRY), index, attacker); + } + + public static IMsg RDM_LIST_EMPTY() { + return MsgFactory.Create(nameof(RDM_LIST_EMPTY)); + } + + public static IMsg RDM_REPORT_FILED(int caseId) { + return MsgFactory.Create(nameof(RDM_REPORT_FILED), caseId); + } + + public static IMsg RDM_REPORT_REJECTED() { + return MsgFactory.Create(nameof(RDM_REPORT_REJECTED)); + } +``` + +In `TTT/RDM/lang/en.yml` add: +```yaml +RDM_LIST_HEADER: "%PREFIX%Recent suspect deaths — type {yellow}!rdm {grey} to report:" +RDM_LIST_ENTRY: "{yellow}{0}{grey}. Killed by {yellow}{1}" +RDM_LIST_EMPTY: "%PREFIX%You have no recent suspect deaths to report." +RDM_REPORT_FILED: "%PREFIX%Report {yellow}#{0}{grey} filed. Staff have been notified." +RDM_REPORT_REJECTED: "%PREFIX%{red}Could not file that report{grey} (too late, already reported, or invalid)." +``` + +- [ ] **Step 2: Write the failing test** `TTT/Test/RDM/RdmCommandTests.cs` + +```csharp +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Command; +using TTT.API.Player; +using TTT.Game.Damage; +using TTT.Locale; +using TTT.RDM; +using TTT.RDM.Commands; +using TTT.RDM.lang; +using TTT.RDM.Models; +using TTT.Test.Game.Command; +using Xunit; + +namespace TTT.Test.RDM; + +public class RdmCommandTests : CommandTest { + private readonly IRdmStore store; + private readonly IMsgLocalizer locale; + private readonly IPlayerFinder players; + + public RdmCommandTests(IServiceProvider provider) : base(provider, + new RdmCommand(provider)) { + store = provider.GetRequiredService(); + locale = provider.GetRequiredService(); + players = provider.GetRequiredService(); + } + + private async Task SeedSuspectDeath(IPlayer victim) { + return await store.AddDeath(new DeathRecord { + Round = 0, VictimId = victim.Id, VictimName = victim.Name, + VictimRole = "Innocent", AttackerId = "k", AttackerName = "Killer", + AttackerRole = "Innocent", Timestamp = DateTime.UtcNow, + IsSuspect = true, Fault = KillFault.KillerGuilty + }); + } + + [Fact] + public async Task NoArgs_NoDeaths_ShowsEmpty() { + var victim = TestPlayer.Random(); + players.AddPlayer(victim); + var info = new TestCommandInfo(Provider, victim, "rdm"); + var result = await Commands.ProcessCommand(info); + Assert.Equal(CommandResult.SUCCESS, result); + Assert.Contains(locale[RdmMsgs.RDM_LIST_EMPTY()], victim.Messages); + } + + [Fact] + public async Task NoArgs_WithDeaths_ListsThem() { + var victim = TestPlayer.Random(); + players.AddPlayer(victim); + await SeedSuspectDeath(victim); + var info = new TestCommandInfo(Provider, victim, "rdm"); + Assert.Equal(CommandResult.SUCCESS, await Commands.ProcessCommand(info)); + Assert.Contains(locale[RdmMsgs.RDM_LIST_ENTRY(1, "Killer")], + victim.Messages); + } + + [Fact] + public async Task WithIndex_FilesReport() { + var victim = TestPlayer.Random(); + players.AddPlayer(victim); + await SeedSuspectDeath(victim); + var info = new TestCommandInfo(Provider, victim, "rdm", "1"); + Assert.Equal(CommandResult.SUCCESS, await Commands.ProcessCommand(info)); + Assert.Single(await store.GetOpenCases()); + Assert.Contains(locale[RdmMsgs.RDM_REPORT_FILED(1)], victim.Messages); + } +} +``` + +Note: `CurrentRound` starts at 0 before any round-start event, so the test seeds deaths with `Round = 0` to match what `RdmCommand` queries in a no-game test context. + +- [ ] **Step 3: Run test to verify it fails** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo` +Expected: FAIL to compile — `RdmCommand` does not exist. + +- [ ] **Step 4: Write `TTT/RDM/Commands/RdmCommand.cs`** + +```csharp +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Command; +using TTT.API.Player; +using TTT.Locale; +using TTT.RDM.lang; + +namespace TTT.RDM.Commands; + +public class RdmCommand(IServiceProvider provider) : ICommand { + private readonly ICaseManager cases = + provider.GetRequiredService(); + + private readonly IRdmStore store = provider.GetRequiredService(); + + private readonly IMsgLocalizer locale = + provider.GetRequiredService(); + + private readonly DeathLogListener deathLog = + provider.GetRequiredService(); + + public void Dispose() { } + public void Start() { } + public string Id => "rdm"; + public string? Description => "File or dismiss an RDM report for your death"; + public string[] Usage => ["[number] [reason]"]; + + public async Task Execute(IOnlinePlayer? executor, + ICommandInfo info) { + if (executor == null) return CommandResult.PLAYER_ONLY; + + var round = deathLog.CurrentRound; + var deaths = await store.GetSuspectDeathsForVictim(executor.Id, round); + + if (info.ArgCount == 0) { + if (deaths.Count == 0) { + info.ReplySync(locale[RdmMsgs.RDM_LIST_EMPTY()]); + return CommandResult.SUCCESS; + } + + info.ReplySync(locale[RdmMsgs.RDM_LIST_HEADER()]); + for (var i = 0; i < deaths.Count; i++) + info.ReplySync( + locale[RdmMsgs.RDM_LIST_ENTRY(i + 1, deaths[i].AttackerName)]); + return CommandResult.SUCCESS; + } + + if (!int.TryParse(info.Args[0], out var index) || index < 1 + || index > deaths.Count) { + info.ReplySync(locale[RdmMsgs.RDM_REPORT_REJECTED()]); + return CommandResult.INVALID_ARGS; + } + + var reason = info.ArgCount > 1 + ? string.Join(' ', info.Args.Skip(1)) + : null; + var filed = await cases.FileReport(executor, deaths[index - 1].Id, reason); + + info.ReplySync(filed == null + ? locale[RdmMsgs.RDM_REPORT_REJECTED()] + : locale[RdmMsgs.RDM_REPORT_FILED(filed.Id)]); + return filed == null ? CommandResult.ERROR : CommandResult.SUCCESS; + } +} +``` + +- [ ] **Step 5: Register `DeathLogListener` as a resolvable service for tests** + +The command resolves `DeathLogListener` directly, so it must be registerable. In `TTT/Test/Startup.cs`, add near the other `AddScoped` lines: +```csharp + services.AddScoped(); +``` + +- [ ] **Step 6: Build and run tests** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo && ./TTT/Test/bin/Debug/net10.0/Test` +Expected: PASS — `RdmCommandTests` pass. No NEW failures. + +- [ ] **Step 7: Commit** + +```bash +git add TTT/RDM/Commands/RdmCommand.cs TTT/RDM/lang TTT/Test/Startup.cs TTT/Test/RDM/RdmCommandTests.cs +git commit -m "feat(rdm): !rdm victim report command" +``` + +--- + +### Task 9: Staff `!cases` and `!info` commands + +**Files:** +- Modify: `TTT/RDM/lang/RdmMsgs.cs` (add `RDM_CASES_COUNT`, `RDM_CASES_ENTRY`, `RDM_INFO`, `RDM_CASE_NOT_FOUND`) +- Modify: `TTT/RDM/lang/en.yml` +- Create: `TTT/RDM/Commands/CasesCommand.cs` +- Create: `TTT/RDM/Commands/InfoCommand.cs` +- Test: `TTT/Test/RDM/StaffQueryCommandsTests.cs` + +**Interfaces:** +- Consumes: `ICaseManager.GetOpen()`, `IRdmStore.GetCase`/`GetDeath`. +- Produces: `class CasesCommand : ICommand` (`Id => "cases"`, `RequiredFlags => [config.StaffFlag]`), `class InfoCommand : ICommand` (`Id => "info"`, staff-gated). + +- [ ] **Step 1: Add localization keys** + +In `TTT/RDM/lang/RdmMsgs.cs` add: +```csharp + public static IMsg RDM_CASES_COUNT(int count) { + return MsgFactory.Create(nameof(RDM_CASES_COUNT), count); + } + + public static IMsg RDM_CASES_ENTRY(int caseId, string victim, + string attacker) { + return MsgFactory.Create(nameof(RDM_CASES_ENTRY), caseId, victim, attacker); + } + + public static IMsg RDM_INFO(int caseId, string victim, string victimRole, + string attacker, string attackerRole, string weapon, string reason) { + return MsgFactory.Create(nameof(RDM_INFO), caseId, victim, victimRole, + attacker, attackerRole, weapon, reason); + } + + public static IMsg RDM_CASE_NOT_FOUND() { + return MsgFactory.Create(nameof(RDM_CASE_NOT_FOUND)); + } +``` + +In `TTT/RDM/lang/en.yml` add: +```yaml +RDM_CASES_COUNT: "%PREFIX%There {yellow}{0}{grey} open RDM case%s%." +RDM_CASES_ENTRY: "{yellow}#{0}{grey}: {yellow}{1}{grey} vs {yellow}{2}" +RDM_INFO: "%PREFIX%Case {yellow}#{0}{grey}: {yellow}{1}{grey} ({3}) killed by {yellow}{2}{grey} ({4}) with {yellow}{5}{grey}. Reason: {6}" +RDM_CASE_NOT_FOUND: "%PREFIX%{red}No such case.{grey}" +``` + +Note: `RDM_INFO`'s `{1}` is the victim and `{2}` the attacker; placeholders `{3}`/`{4}` are their roles to keep the sentence readable. + +- [ ] **Step 2: Write the failing test** `TTT/Test/RDM/StaffQueryCommandsTests.cs` + +```csharp +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Command; +using TTT.API.Player; +using TTT.Game.Damage; +using TTT.Locale; +using TTT.RDM; +using TTT.RDM.Commands; +using TTT.RDM.lang; +using TTT.RDM.Models; +using TTT.Test.Fakes; +using TTT.Test.Game.Command; +using Xunit; + +namespace TTT.Test.RDM; + +public class StaffQueryCommandsTests { + private readonly IServiceProvider provider; + private readonly ICommandManager commands; + private readonly ICaseManager manager; + private readonly IRdmStore store; + private readonly IMsgLocalizer locale; + private readonly IPlayerFinder players; + private readonly FakePermissionManager perms; + + public StaffQueryCommandsTests(IServiceProvider provider) { + this.provider = provider; + commands = provider.GetRequiredService(); + manager = provider.GetRequiredService(); + store = provider.GetRequiredService(); + locale = provider.GetRequiredService(); + players = provider.GetRequiredService(); + perms = (FakePermissionManager)provider + .GetRequiredService(); + commands.RegisterCommand(new CasesCommand(provider)); + commands.RegisterCommand(new InfoCommand(provider)); + } + + private async Task SeedOpenCase(IOnlinePlayer victim) { + var deathId = await store.AddDeath(new DeathRecord { + Round = 0, VictimId = victim.Id, VictimName = victim.Name, + VictimRole = "Innocent", AttackerId = "k", AttackerName = "Killer", + AttackerRole = "Innocent", Weapon = "ak47", Timestamp = DateTime.UtcNow, + IsSuspect = true, Fault = KillFault.KillerGuilty + }); + return (await manager.FileReport(victim, deathId, "spawn kill"))!; + } + + [Fact] + public async Task Cases_AsStaff_ShowsCount() { + var victim = TestPlayer.Random(); + var admin = TestPlayer.Random(); + players.AddPlayers(victim, admin); + perms.SetFlags(admin, "@ttt/admin"); + await SeedOpenCase(victim); + + var result = await commands.ProcessCommand( + new TestCommandInfo(provider, admin, "cases")); + Assert.Equal(CommandResult.SUCCESS, result); + Assert.Contains(locale[RdmMsgs.RDM_CASES_COUNT(1)], admin.Messages); + } + + [Fact] + public async Task Cases_WithoutFlag_NoPermission() { + var player = TestPlayer.Random(); + players.AddPlayer(player); + var result = await commands.ProcessCommand( + new TestCommandInfo(provider, player, "cases")); + Assert.Equal(CommandResult.NO_PERMISSION, result); + } + + [Fact] + public async Task Info_ShowsCaseDetails() { + var victim = TestPlayer.Random(); + var admin = TestPlayer.Random(); + players.AddPlayers(victim, admin); + perms.SetFlags(admin, "@ttt/admin"); + var c = await SeedOpenCase(victim); + + var result = await commands.ProcessCommand( + new TestCommandInfo(provider, admin, "info", c.Id.ToString())); + Assert.Equal(CommandResult.SUCCESS, result); + Assert.Contains(admin.Messages, + m => m.Contains("Killer") && m.Contains("spawn kill")); + } +} +``` + +- [ ] **Step 3: Confirm `FakePermissionManager` supports `SetFlags`** + +Open `TTT/Test/Fakes/FakePermissionManager.cs`. If it lacks a way to grant flags to a player, add: +```csharp + private readonly Dictionary> flags = new(); + + public void SetFlags(IPlayer player, params string[] grant) { + flags[player.Id] = [..grant]; + } +``` +and make `HasFlags` return `flags.TryGetValue(player.Id, out var f) && requested.All(f.Contains)` (preserve any existing behavior such as `@css/root` always-true if present). Keep the change minimal and consistent with the existing fake. + +- [ ] **Step 4: Run test to verify it fails** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo` +Expected: FAIL to compile — `CasesCommand`/`InfoCommand` do not exist. + +- [ ] **Step 5: Write `TTT/RDM/Commands/CasesCommand.cs`** + +```csharp +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Command; +using TTT.API.Player; +using TTT.API.Storage; +using TTT.Locale; +using TTT.RDM.lang; + +namespace TTT.RDM.Commands; + +public class CasesCommand(IServiceProvider provider) : ICommand { + private readonly ICaseManager cases = + provider.GetRequiredService(); + + private readonly IMsgLocalizer locale = + provider.GetRequiredService(); + + private RdmConfig config + => provider.GetService>()?.Load().GetAwaiter() + .GetResult() ?? new RdmConfig(); + + public void Dispose() { } + public void Start() { } + public string Id => "cases"; + public string? Description => "List open RDM cases"; + public string[] RequiredFlags => [config.StaffFlag]; + + public async Task Execute(IOnlinePlayer? executor, + ICommandInfo info) { + var open = await cases.GetOpen(); + info.ReplySync(locale[RdmMsgs.RDM_CASES_COUNT(open.Count)]); + foreach (var c in open) { + var death = await provider.GetRequiredService() + .GetDeath(c.DeathId); + if (death != null) + info.ReplySync(locale[RdmMsgs.RDM_CASES_ENTRY(c.Id, death.VictimName, + death.AttackerName)]); + } + + return CommandResult.SUCCESS; + } +} +``` + +- [ ] **Step 6: Write `TTT/RDM/Commands/InfoCommand.cs`** + +```csharp +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Command; +using TTT.API.Player; +using TTT.API.Storage; +using TTT.Locale; +using TTT.RDM.lang; + +namespace TTT.RDM.Commands; + +public class InfoCommand(IServiceProvider provider) : ICommand { + private readonly IRdmStore store = provider.GetRequiredService(); + + private readonly IMsgLocalizer locale = + provider.GetRequiredService(); + + private RdmConfig config + => provider.GetService>()?.Load().GetAwaiter() + .GetResult() ?? new RdmConfig(); + + public void Dispose() { } + public void Start() { } + public string Id => "info"; + public string? Description => "Show details for an RDM case"; + public string[] Usage => [""]; + public string[] RequiredFlags => [config.StaffFlag]; + + public async Task Execute(IOnlinePlayer? executor, + ICommandInfo info) { + if (info.ArgCount < 1 || !int.TryParse(info.Args[0], out var caseId)) + return CommandResult.PRINT_USAGE; + + var c = await store.GetCase(caseId); + var death = c == null ? null : await store.GetDeath(c.DeathId); + if (c == null || death == null) { + info.ReplySync(locale[RdmMsgs.RDM_CASE_NOT_FOUND()]); + return CommandResult.ERROR; + } + + info.ReplySync(locale[RdmMsgs.RDM_INFO(c.Id, death.VictimName, + death.VictimRole, death.AttackerName, death.AttackerRole, + death.Weapon ?? "unknown", c.Reason ?? "(none)")]); + return CommandResult.SUCCESS; + } +} +``` + +- [ ] **Step 7: Build and run tests** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo && ./TTT/Test/bin/Debug/net10.0/Test` +Expected: PASS — `StaffQueryCommandsTests` pass. No NEW failures. + +- [ ] **Step 8: Commit** + +```bash +git add TTT/RDM/Commands/CasesCommand.cs TTT/RDM/Commands/InfoCommand.cs TTT/RDM/lang TTT/Test/Fakes/FakePermissionManager.cs TTT/Test/RDM/StaffQueryCommandsTests.cs +git commit -m "feat(rdm): staff !cases and !info commands" +``` + +--- + +### Task 10: Slay service + slay-queue listener + +**Files:** +- Create: `TTT/RDM/ISlayService.cs` +- Create: `TTT/RDM/SlayService.cs` +- Create: `TTT/RDM/SlayQueueListener.cs` +- Test: `TTT/Test/RDM/SlayServiceTests.cs` + +**Interfaces:** +- Produces: `interface ISlayService { Task ApplyGuilty(IPlayer offender, string victimRole, int caseId); Task PayRoundStart(); }` + - `ApplyGuilty`: owed = `RdmConfig.SlaysForRole(victimRole)`; if offender is online + alive, slay immediately (`Health = 0`) and persist `owed - 1`, else persist `owed`. + - `PayRoundStart`: for each slay debt, if the player is online + alive, slay once and decrement; returns the number of slays applied. +- Produces: `class SlayService : ISlayService`, `class SlayQueueListener : BaseListener` (calls `PayRoundStart` on `State.IN_PROGRESS`). + +- [ ] **Step 1: Write the failing test** `TTT/Test/RDM/SlayServiceTests.cs` + +```csharp +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Player; +using TTT.RDM; +using Xunit; + +namespace TTT.Test.RDM; + +public class SlayServiceTests { + private readonly ISlayService slay; + private readonly IRdmStore store; + private readonly IPlayerFinder players; + + public SlayServiceTests(IServiceProvider provider) { + slay = provider.GetRequiredService(); + store = provider.GetRequiredService(); + players = provider.GetRequiredService(); + } + + [Fact] + public async Task ApplyGuilty_AliveOffender_SlaysNowAndQueuesRest() { + var offender = TestPlayer.Random(); + offender.IsAlive = true; + offender.Health = 100; + players.AddPlayer(offender); + + await slay.ApplyGuilty(offender, "Innocent", caseId: 1); // 3 slays + + Assert.Equal(0, offender.Health); // immediate slay applied + Assert.Equal(2, await store.GetSlayDebt(offender.Id)); // 3 - 1 + } + + [Fact] + public async Task ApplyGuilty_DeadOffender_QueuesAll() { + var offender = TestPlayer.Random(); + offender.IsAlive = false; + players.AddPlayer(offender); + + await slay.ApplyGuilty(offender, "Traitor", caseId: 1); // 5 slays + Assert.Equal(5, await store.GetSlayDebt(offender.Id)); + } + + [Fact] + public async Task PayRoundStart_SlaysAliveDebtorsOncePerRound() { + var offender = TestPlayer.Random(); + offender.IsAlive = false; + players.AddPlayer(offender); + await slay.ApplyGuilty(offender, "Innocent", caseId: 1); // queues 3 + + // Round 1: offender respawns alive, pays one slay. + offender.IsAlive = true; + offender.Health = 100; + var applied = await slay.PayRoundStart(); + Assert.Equal(1, applied); + Assert.Equal(0, offender.Health); + Assert.Equal(2, await store.GetSlayDebt(offender.Id)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo` +Expected: FAIL to compile — `ISlayService` does not exist. + +- [ ] **Step 3: Write `TTT/RDM/ISlayService.cs`** + +```csharp +using TTT.API.Player; + +namespace TTT.RDM; + +public interface ISlayService { + /// Apply a guilty verdict: slay now if alive, queue the remainder. + Task ApplyGuilty(IPlayer offender, string victimRole, int caseId); + + /// Pay one slay for each alive debtor at round start. Returns slays applied. + Task PayRoundStart(); +} +``` + +- [ ] **Step 4: Write `TTT/RDM/SlayService.cs`** + +```csharp +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Player; +using TTT.API.Storage; + +namespace TTT.RDM; + +public class SlayService(IServiceProvider provider) : ISlayService { + private readonly IRdmStore store = provider.GetRequiredService(); + + private readonly IPlayerFinder finder = + provider.GetRequiredService(); + + private RdmConfig config + => provider.GetService>()?.Load().GetAwaiter() + .GetResult() ?? new RdmConfig(); + + public async Task ApplyGuilty(IPlayer offender, string victimRole, + int caseId) { + var owed = config.SlaysForRole(victimRole); + var existing = await store.GetSlayDebt(offender.Id); + var total = existing + owed; + + var online = finder.GetPlayerById(offender.Id); + if (online is { IsAlive: true }) { + online.Health = 0; // immediate slay + total--; + } + + await store.SetSlayDebt(offender.Id, total, caseId); + } + + public async Task PayRoundStart() { + var applied = 0; + foreach (var debt in await store.GetAllSlayDebts()) { + var online = finder.GetPlayerById(debt.PlayerId); + if (online is not { IsAlive: true }) continue; + online.Health = 0; + await store.SetSlayDebt(debt.PlayerId, debt.RemainingSlays - 1, + debt.SourceCaseId); + applied++; + } + + return applied; + } +} +``` + +- [ ] **Step 5: Write `TTT/RDM/SlayQueueListener.cs`** + +```csharp +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Events; +using TTT.API.Game; +using TTT.Game.Events.Game; +using TTT.Game.Listeners; + +namespace TTT.RDM; + +public class SlayQueueListener(IServiceProvider provider) + : BaseListener(provider) { + private readonly ISlayService slay = + provider.GetRequiredService(); + + [EventHandler] + [UsedImplicitly] + public void OnRoundStart(GameStateUpdateEvent ev) { + if (ev.NewState != State.IN_PROGRESS) return; + Task.Run(async () => await slay.PayRoundStart()); + } +} +``` + +- [ ] **Step 6: Register `ISlayService` for tests** in `TTT/Test/Startup.cs` + +Add near the other `AddScoped` lines: +```csharp + services.AddScoped(); +``` + +- [ ] **Step 7: Build and run tests** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo && ./TTT/Test/bin/Debug/net10.0/Test` +Expected: PASS — `SlayServiceTests` pass. No NEW failures. + +- [ ] **Step 8: Commit** + +```bash +git add TTT/RDM/ISlayService.cs TTT/RDM/SlayService.cs TTT/RDM/SlayQueueListener.cs TTT/Test/Startup.cs TTT/Test/RDM/SlayServiceTests.cs +git commit -m "feat(rdm): slay service + round-start slay queue" +``` + +--- + +### Task 11: Staff `!handle` and `!verdict` commands + +`!handle [id]` claims the next open case (or a specific id) for the calling admin. `!verdict ` resolves the admin's currently-claimed case; guilty triggers slays via `ISlayService`. + +**Files:** +- Modify: `TTT/RDM/lang/RdmMsgs.cs` (add `RDM_HANDLED`, `RDM_NO_OPEN_CASES`, `RDM_NO_CLAIMED_CASE`, `RDM_VERDICT_USAGE`, `RDM_VERDICT_GUILTY`, `RDM_VERDICT_FORGIVEN`) +- Modify: `TTT/RDM/lang/en.yml` +- Create: `TTT/RDM/Commands/HandleCommand.cs` +- Create: `TTT/RDM/Commands/VerdictCommand.cs` +- Test: `TTT/Test/RDM/VerdictFlowTests.cs` + +**Interfaces:** +- Consumes: `ICaseManager.ClaimNext`/`Claim`/`Resolve`/`GetOpen`, `IRdmStore.GetDeath`/`GetCase`, `ISlayService.ApplyGuilty`, `IPlayerFinder.GetPlayerById`. +- Produces: `class HandleCommand : ICommand` (`Id => "handle"`, staff-gated), `class VerdictCommand : ICommand` (`Id => "verdict"`, staff-gated). Both track the admin's claimed case via a shared in-memory map kept on `CaseManager`. To avoid extra shared state, `VerdictCommand` resolves the admin's claimed case by querying open cases where `HandlerAdminId == admin.Id && State == Claimed`. + +- [ ] **Step 1: Add localization keys** + +In `TTT/RDM/lang/RdmMsgs.cs` add: +```csharp + public static IMsg RDM_HANDLED(int caseId, string victim, string attacker) { + return MsgFactory.Create(nameof(RDM_HANDLED), caseId, victim, attacker); + } + + public static IMsg RDM_NO_OPEN_CASES() { + return MsgFactory.Create(nameof(RDM_NO_OPEN_CASES)); + } + + public static IMsg RDM_NO_CLAIMED_CASE() { + return MsgFactory.Create(nameof(RDM_NO_CLAIMED_CASE)); + } + + public static IMsg RDM_VERDICT_USAGE() { + return MsgFactory.Create(nameof(RDM_VERDICT_USAGE)); + } + + public static IMsg RDM_VERDICT_GUILTY(int caseId, int slays) { + return MsgFactory.Create(nameof(RDM_VERDICT_GUILTY), caseId, slays); + } + + public static IMsg RDM_VERDICT_FORGIVEN(int caseId) { + return MsgFactory.Create(nameof(RDM_VERDICT_FORGIVEN), caseId); + } +``` + +In `TTT/RDM/lang/en.yml` add: +```yaml +RDM_HANDLED: "%PREFIX%Now handling case {yellow}#{0}{grey}: {yellow}{1}{grey} vs {yellow}{2}{grey}. Use {yellow}!verdict guilty|forgive{grey}." +RDM_NO_OPEN_CASES: "%PREFIX%There are no open RDM cases." +RDM_NO_CLAIMED_CASE: "%PREFIX%{red}You are not handling a case.{grey} Use {yellow}!handle{grey} first." +RDM_VERDICT_USAGE: "%PREFIX%Usage: {yellow}!verdict guilty{grey} or {yellow}!verdict forgive{grey}." +RDM_VERDICT_GUILTY: "%PREFIX%Case {yellow}#{0}{grey}: {red}GUILTY{grey} — {yellow}{1}{grey} slay%s% queued." +RDM_VERDICT_FORGIVEN: "%PREFIX%Case {yellow}#{0}{grey}: {green}forgiven{grey}." +``` + +- [ ] **Step 2: Add `ICaseManager.GetClaimedBy`** + +In `TTT/RDM/ICaseManager.cs` add: +```csharp + Task GetClaimedBy(string adminId); +``` +In `TTT/RDM/CaseManager.cs` implement: +```csharp + public async Task GetClaimedBy(string adminId) { + var open = await store.GetOpenCases(); + return open.FirstOrDefault(c + => c.State == CaseState.Claimed && c.HandlerAdminId == adminId); + } +``` + +- [ ] **Step 3: Write the failing test** `TTT/Test/RDM/VerdictFlowTests.cs` + +```csharp +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Command; +using TTT.API.Player; +using TTT.Game.Damage; +using TTT.Locale; +using TTT.RDM; +using TTT.RDM.Commands; +using TTT.RDM.lang; +using TTT.RDM.Models; +using TTT.Test.Fakes; +using TTT.Test.Game.Command; +using Xunit; + +namespace TTT.Test.RDM; + +public class VerdictFlowTests { + private readonly IServiceProvider provider; + private readonly ICommandManager commands; + private readonly ICaseManager manager; + private readonly IRdmStore store; + private readonly IMsgLocalizer locale; + private readonly IPlayerFinder players; + private readonly FakePermissionManager perms; + + public VerdictFlowTests(IServiceProvider provider) { + this.provider = provider; + commands = provider.GetRequiredService(); + manager = provider.GetRequiredService(); + store = provider.GetRequiredService(); + locale = provider.GetRequiredService(); + players = provider.GetRequiredService(); + perms = (FakePermissionManager)provider + .GetRequiredService(); + commands.RegisterCommand(new HandleCommand(provider)); + commands.RegisterCommand(new VerdictCommand(provider)); + } + + private async Task<(TestPlayer offender, TestPlayer admin, RdmCase c)> + SeedReportedCase() { + var victim = TestPlayer.Random(); + var offender = TestPlayer.Random(); + var admin = TestPlayer.Random(); + offender.IsAlive = false; + players.AddPlayers(victim, offender, admin); + perms.SetFlags(admin, "@ttt/admin"); + + var deathId = await store.AddDeath(new DeathRecord { + Round = 0, VictimId = victim.Id, VictimName = victim.Name, + VictimRole = "Innocent", AttackerId = offender.Id, + AttackerName = offender.Name, AttackerRole = "Innocent", + Weapon = "ak47", Timestamp = DateTime.UtcNow, IsSuspect = true, + Fault = KillFault.KillerGuilty + }); + var c = (await manager.FileReport(victim, deathId, "rdm"))!; + return (offender, admin, c); + } + + [Fact] + public async Task Handle_ThenGuilty_QueuesSlays() { + var (offender, admin, c) = await SeedReportedCase(); + + Assert.Equal(CommandResult.SUCCESS, await commands.ProcessCommand( + new TestCommandInfo(provider, admin, "handle"))); + Assert.Equal(CommandResult.SUCCESS, await commands.ProcessCommand( + new TestCommandInfo(provider, admin, "verdict", "guilty"))); + + Assert.Equal(3, await store.GetSlayDebt(offender.Id)); // innocent victim + Assert.Empty(await manager.GetOpen()); // case resolved + } + + [Fact] + public async Task Handle_ThenForgive_NoSlays() { + var (offender, admin, _) = await SeedReportedCase(); + await commands.ProcessCommand( + new TestCommandInfo(provider, admin, "handle")); + Assert.Equal(CommandResult.SUCCESS, await commands.ProcessCommand( + new TestCommandInfo(provider, admin, "verdict", "forgive"))); + + Assert.Equal(0, await store.GetSlayDebt(offender.Id)); + Assert.Empty(await manager.GetOpen()); + } + + [Fact] + public async Task Verdict_WithoutClaimedCase_Errors() { + var admin = TestPlayer.Random(); + players.AddPlayer(admin); + perms.SetFlags(admin, "@ttt/admin"); + var result = await commands.ProcessCommand( + new TestCommandInfo(provider, admin, "verdict", "guilty")); + Assert.Equal(CommandResult.ERROR, result); + Assert.Contains(locale[RdmMsgs.RDM_NO_CLAIMED_CASE()], admin.Messages); + } +} +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo` +Expected: FAIL to compile — `HandleCommand`/`VerdictCommand` do not exist. + +- [ ] **Step 5: Write `TTT/RDM/Commands/HandleCommand.cs`** + +```csharp +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Command; +using TTT.API.Player; +using TTT.API.Storage; +using TTT.Locale; +using TTT.RDM.lang; + +namespace TTT.RDM.Commands; + +public class HandleCommand(IServiceProvider provider) : ICommand { + private readonly ICaseManager cases = + provider.GetRequiredService(); + + private readonly IRdmStore store = provider.GetRequiredService(); + + private readonly IMsgLocalizer locale = + provider.GetRequiredService(); + + private RdmConfig config + => provider.GetService>()?.Load().GetAwaiter() + .GetResult() ?? new RdmConfig(); + + public void Dispose() { } + public void Start() { } + public string Id => "handle"; + public string? Description => "Claim the next (or a specific) RDM case"; + public string[] Usage => ["[caseId]"]; + public string[] RequiredFlags => [config.StaffFlag]; + + public async Task Execute(IOnlinePlayer? executor, + ICommandInfo info) { + if (executor == null) return CommandResult.PLAYER_ONLY; + + var claimed = info.ArgCount >= 1 && int.TryParse(info.Args[0], out var id) + ? await cases.Claim(executor, id) + : await cases.ClaimNext(executor); + + if (claimed == null) { + info.ReplySync(locale[RdmMsgs.RDM_NO_OPEN_CASES()]); + return CommandResult.ERROR; + } + + var death = await store.GetDeath(claimed.DeathId); + info.ReplySync(locale[RdmMsgs.RDM_HANDLED(claimed.Id, + death?.VictimName ?? "?", death?.AttackerName ?? "?")]); + return CommandResult.SUCCESS; + } +} +``` + +- [ ] **Step 6: Write `TTT/RDM/Commands/VerdictCommand.cs`** + +```csharp +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Command; +using TTT.API.Player; +using TTT.API.Storage; +using TTT.Locale; +using TTT.RDM.lang; +using TTT.RDM.Models; + +namespace TTT.RDM.Commands; + +public class VerdictCommand(IServiceProvider provider) : ICommand { + private readonly ICaseManager cases = + provider.GetRequiredService(); + + private readonly IRdmStore store = provider.GetRequiredService(); + + private readonly ISlayService slay = + provider.GetRequiredService(); + + private readonly IPlayerFinder finder = + provider.GetRequiredService(); + + private readonly IMsgLocalizer locale = + provider.GetRequiredService(); + + private RdmConfig config + => provider.GetService>()?.Load().GetAwaiter() + .GetResult() ?? new RdmConfig(); + + public void Dispose() { } + public void Start() { } + public string Id => "verdict"; + public string? Description => "Decide your claimed RDM case"; + public string[] Usage => [""]; + public string[] RequiredFlags => [config.StaffFlag]; + + public async Task Execute(IOnlinePlayer? executor, + ICommandInfo info) { + if (executor == null) return CommandResult.PLAYER_ONLY; + + var claimed = await cases.GetClaimedBy(executor.Id); + if (claimed == null) { + info.ReplySync(locale[RdmMsgs.RDM_NO_CLAIMED_CASE()]); + return CommandResult.ERROR; + } + + if (info.ArgCount < 1) { + info.ReplySync(locale[RdmMsgs.RDM_VERDICT_USAGE()]); + return CommandResult.PRINT_USAGE; + } + + var choice = info.Args[0].ToLowerInvariant(); + var death = await store.GetDeath(claimed.DeathId); + + switch (choice) { + case "forgive": + case "forgiven": + await cases.Resolve(claimed.Id, Verdict.Forgiven, executor); + info.ReplySync(locale[RdmMsgs.RDM_VERDICT_FORGIVEN(claimed.Id)]); + return CommandResult.SUCCESS; + case "guilty": + await cases.Resolve(claimed.Id, Verdict.Guilty, executor); + if (death != null) { + var offender = finder.GetPlayerById(death.AttackerId); + var slays = config.SlaysForRole(death.VictimRole); + await slay.ApplyGuilty( + offender ?? new OfflineRef(death.AttackerId), death.VictimRole, + claimed.Id); + info.ReplySync( + locale[RdmMsgs.RDM_VERDICT_GUILTY(claimed.Id, slays)]); + } + + return CommandResult.SUCCESS; + default: + info.ReplySync(locale[RdmMsgs.RDM_VERDICT_USAGE()]); + return CommandResult.INVALID_ARGS; + } + } + + // Minimal IPlayer for an offline offender so slay debt can be persisted by id. + private sealed record OfflineRef(string Id) : IPlayer { + public string Name { get; set; } = Id; + } +} +``` + +- [ ] **Step 7: Build and run tests** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo && ./TTT/Test/bin/Debug/net10.0/Test` +Expected: PASS — `VerdictFlowTests` pass. No NEW failures. + +- [ ] **Step 8: Commit** + +```bash +git add TTT/RDM/Commands/HandleCommand.cs TTT/RDM/Commands/VerdictCommand.cs TTT/RDM/ICaseManager.cs TTT/RDM/CaseManager.cs TTT/RDM/lang TTT/Test/RDM/VerdictFlowTests.cs +git commit -m "feat(rdm): staff !handle and !verdict commands" +``` + +--- + +### Task 12: Production wiring + CS2 config + end-to-end test + +Wire every RDM service into `RdmServiceCollection` (production), provide a CS2 cvar-backed `RdmConfig`, and add one end-to-end test proving the full path. + +**Files:** +- Modify: `TTT/RDM/RdmServiceCollection.cs` (register everything via `AddModBehavior`/`AddSingleton`) +- Create: `TTT/CS2/Configs/CS2RdmConfig.cs` +- Modify: `TTT/CS2/CS2ServiceCollection.cs` (register `IStorage`) +- Test: `TTT/Test/RDM/RdmEndToEndTests.cs` + +**Interfaces:** +- Consumes: all RDM services from prior tasks. +- Produces: fully registered RDM module + `CS2RdmConfig : IStorage, IPluginModule`. + +- [ ] **Step 1: Wire production registration** `TTT/RDM/RdmServiceCollection.cs` + +```csharp +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Extensions; +using TTT.RDM.Commands; + +namespace TTT.RDM; + +public static class RdmServiceCollection { + public static void AddRdmService(this IServiceCollection collection) { + // Persistence (production: SQLite). Config is resolved lazily so the + // connection string comes from IStorage when present. + collection.AddSingleton(provider => { + var config = provider.GetService>() + ?.Load().GetAwaiter().GetResult() ?? new RdmConfig(); + return new SqliteRdmStore(config.DbString); + }); + + collection.AddSingleton(); + collection.AddSingleton(); + + // Listeners + collection.AddModBehavior(); + collection.AddModBehavior(); + + // Commands + collection.AddModBehavior(); + collection.AddModBehavior(); + collection.AddModBehavior(); + collection.AddModBehavior(); + collection.AddModBehavior(); + } +} +``` + +- [ ] **Step 2: Create `TTT/CS2/Configs/CS2RdmConfig.cs`** (mirrors `CS2KarmaConfig`) + +```csharp +using CounterStrikeSharp.API.Modules.Cvars; +using CounterStrikeSharp.API.Modules.Cvars.Validators; +using TTT.API; +using TTT.API.Storage; +using TTT.RDM; + +namespace TTT.CS2.Configs; + +public class CS2RdmConfig : IStorage, IPluginModule { + public static readonly FakeConVar CV_DB_STRING = new( + "css_ttt_rdm_db_string", "Database connection string for RDM storage", + "Data Source=rdm.db"); + + public static readonly FakeConVar CV_TRAITOR_SLAYS = new( + "css_ttt_rdm_traitor_slays", "Slays when the RDM victim was a Traitor", 5, + ConVarFlags.FCVAR_NONE, new RangeValidator(0, 100)); + + public static readonly FakeConVar CV_DETECTIVE_SLAYS = new( + "css_ttt_rdm_detective_slays", "Slays when the RDM victim was a Detective", + 5, ConVarFlags.FCVAR_NONE, new RangeValidator(0, 100)); + + public static readonly FakeConVar CV_INNOCENT_SLAYS = new( + "css_ttt_rdm_innocent_slays", "Slays when the RDM victim was an Innocent", + 3, ConVarFlags.FCVAR_NONE, new RangeValidator(0, 100)); + + public static readonly FakeConVar CV_NOTIFY_ADMINS = new( + "css_ttt_rdm_notify_admins", "Message online staff when a report is filed", + true); + + public static readonly FakeConVar CV_AUTO_PROMPT = new( + "css_ttt_rdm_auto_prompt", "Prompt victims to report after a suspect kill", + true); + + public static readonly FakeConVar CV_REPORT_WINDOW = new( + "css_ttt_rdm_report_window_seconds", + "Seconds after a death during which a victim may report it", 60, + ConVarFlags.FCVAR_NONE, new RangeValidator(1, 3600)); + + public static readonly FakeConVar CV_MAX_REPORTS = new( + "css_ttt_rdm_max_reports_per_round", + "Max reports a single victim may file per round", 3, + ConVarFlags.FCVAR_NONE, new RangeValidator(1, 100)); + + public static readonly FakeConVar CV_STAFF_FLAG = new( + "css_ttt_rdm_staff_flag", "Admin flag required for RDM staff commands", + "@ttt/admin"); + + public void Dispose() { } + public void Start() { } + + public Task Load() { + return Task.FromResult(new RdmConfig { + DbString = CV_DB_STRING.Value, + TraitorSlays = CV_TRAITOR_SLAYS.Value, + DetectiveSlays = CV_DETECTIVE_SLAYS.Value, + InnocentSlays = CV_INNOCENT_SLAYS.Value, + NotifyAdmins = CV_NOTIFY_ADMINS.Value, + AutoPromptOnSuspectKill = CV_AUTO_PROMPT.Value, + ReportWindowSeconds = CV_REPORT_WINDOW.Value, + MaxReportsPerVictimPerRound = CV_MAX_REPORTS.Value, + StaffFlag = CV_STAFF_FLAG.Value + }); + } +} +``` + +- [ ] **Step 3: Register the CS2 config** in `TTT/CS2/CS2ServiceCollection.cs` + +Add `using TTT.RDM;` if needed and, next to the `CS2KarmaConfig` registration line, add: +```csharp + collection.AddModBehavior, CS2RdmConfig>(); +``` + +- [ ] **Step 4: Write the end-to-end test** `TTT/Test/RDM/RdmEndToEndTests.cs` + +```csharp +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Command; +using TTT.API.Events; +using TTT.API.Game; +using TTT.API.Player; +using TTT.API.Role; +using TTT.Game.Damage; +using TTT.Game.Events.Player; +using TTT.Game.Roles; +using TTT.RDM; +using TTT.RDM.Commands; +using TTT.Test.Fakes; +using TTT.Test.Game.Command; +using Xunit; + +namespace TTT.Test.RDM; + +public class RdmEndToEndTests { + private readonly IServiceProvider provider; + private readonly IEventBus bus; + private readonly IGameManager games; + private readonly IPlayerFinder players; + private readonly IRoleAssigner roles; + private readonly ICommandManager commands; + private readonly IRdmStore store; + private readonly FakePermissionManager perms; + private readonly IList roleSet; + + public RdmEndToEndTests(IServiceProvider provider) { + this.provider = provider; + bus = provider.GetRequiredService(); + games = provider.GetRequiredService(); + players = provider.GetRequiredService(); + roles = provider.GetRequiredService(); + commands = provider.GetRequiredService(); + store = provider.GetRequiredService(); + perms = (FakePermissionManager)provider + .GetRequiredService(); + roleSet = new List { + new InnocentRole(provider), new TraitorRole(provider), + new DetectiveRole(provider) + }; + bus.RegisterListener(provider.GetRequiredService()); + bus.RegisterListener(provider.GetRequiredService()); + commands.RegisterCommand(new RdmCommand(provider)); + commands.RegisterCommand(new HandleCommand(provider)); + commands.RegisterCommand(new VerdictCommand(provider)); + } + + [Fact] + public async Task SuspectKill_Report_Handle_Guilty_QueuesSlays() { + var victim = TestPlayer.Random(); + var offender = TestPlayer.Random(); + var admin = TestPlayer.Random(); + players.AddPlayers(victim, offender, admin); + perms.SetFlags(admin, "@ttt/admin"); + + var game = games.CreateGame(); + bus.Dispatch(new TTT.Game.Events.Game.GameStateUpdateEvent(game!, + State.IN_PROGRESS)); // round 1 + game!.Start(); + roles.SetRole(victim, roleSet[0]); // innocent + roles.SetRole(offender, roleSet[0]); // innocent -> suspect + + bus.Dispatch(new PlayerDamagedEvent(victim, offender, 100)); + bus.Dispatch(new PlayerDeathEvent(victim).WithKiller(offender) + .WithWeapon("ak47")); + + // Give the fire-and-forget AddDeath a beat to land. + await Task.Delay(50, TestContext.Current.CancellationToken); + + // Victim reports the only listed suspect death. + Assert.Equal(CommandResult.SUCCESS, await commands.ProcessCommand( + new TestCommandInfo(provider, victim, "rdm", "1"))); + + // Staff handles + rules guilty. + Assert.Equal(CommandResult.SUCCESS, await commands.ProcessCommand( + new TestCommandInfo(provider, admin, "handle"))); + Assert.Equal(CommandResult.SUCCESS, await commands.ProcessCommand( + new TestCommandInfo(provider, admin, "verdict", "guilty"))); + + Assert.Equal(3, await store.GetSlayDebt(offender.Id)); + } +} +``` + +- [ ] **Step 5: Build and run the full suite** + +Run: `dotnet build TTT/Test/Test.csproj -v q --nologo && ./TTT/Test/bin/Debug/net10.0/Test` +Expected: PASS — `RdmEndToEndTests` passes; summary shows only the single pre-existing `ModuleInitializationTest` flake (if present) and no other failures. + +- [ ] **Step 6: Verify the Plugin project still builds (production registration path)** + +Run: `dotnet build TTT/Plugin/Plugin.csproj -v q --nologo` +Expected: `Build succeeded` (0 errors). + +- [ ] **Step 7: Commit** + +```bash +git add TTT/RDM/RdmServiceCollection.cs TTT/CS2/Configs/CS2RdmConfig.cs TTT/CS2/CS2ServiceCollection.cs TTT/Test/RDM/RdmEndToEndTests.cs +git commit -m "feat(rdm): production wiring + CS2 cvar config + e2e test" +``` + +--- + +## Post-Implementation + +- [ ] **Update the README feature list** (optional): add `- [X] RDM Manager` under Features in `README.md`. +- [ ] Run the full suite once more and confirm the regression gate: **no NEW failing tests** vs the `185 passed / 1 known flake` baseline. +- [ ] Use `superpowers:requesting-code-review` before merging `feat/rdm-manager` back to `dev`. + +## Self-Review Notes (spec coverage) + +- Victim-driven, opt-in reporting with non-response = not RDM → Tasks 6 (prompt) + 8 (`!rdm`) + 7 (no auto-case). +- Auto-prompt on suspect kills + `!rdm` fallback → Tasks 6 + 8. +- Staff chat commands `!cases`/`!handle`/`!info`/`!verdict` → Tasks 9 + 11. +- Slay queue scaled by victim role + immediate slay + forgive → Task 10 + 11. +- SQLite persistence (deaths, cases, slays) surviving restarts/map changes → Task 5 (+ persistence test) + 12 (production store). +- Shared `IDamageTracker` extracted from `KarmaListener` (option A) → Task 2. +- Staff notification toggle → Task 7 (`NotifyAdmins`). +- Config (`RdmConfig` + CS2 cvars) → Tasks 1 + 12. +- Localization → keys added across Tasks 6–11; `lang/en.json` regenerated on build. From 0d5d712edf86d9923424d8c0feb89a24e148a906 Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 11:45:07 -0700 Subject: [PATCH 03/19] feat(rdm): scaffold RDM module + RdmConfig --- TTT.sln | 139 ++++++++++++++++++++++++++++- TTT/Plugin/Plugin.csproj | 1 + TTT/Plugin/TTTServiceCollection.cs | 2 + TTT/RDM/RDM.csproj | 18 ++++ TTT/RDM/RdmConfig.cs | 27 ++++++ TTT/RDM/RdmServiceCollection.cs | 9 ++ TTT/Test/RDM/RdmConfigTests.cs | 23 +++++ TTT/Test/Test.csproj | 1 + 8 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 TTT/RDM/RDM.csproj create mode 100644 TTT/RDM/RdmConfig.cs create mode 100644 TTT/RDM/RdmServiceCollection.cs create mode 100644 TTT/Test/RDM/RdmConfigTests.cs diff --git a/TTT.sln b/TTT.sln index 141ba60..59f2513 100644 --- a/TTT.sln +++ b/TTT.sln @@ -31,72 +31,205 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SpecialRound", "TTT\Special EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SpecialRoundAPI", "TTT\SpecialRoundAPI\SpecialRoundAPI.csproj", "{360FEF16-54DA-42EE-995A-3D31C699287D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TTT", "TTT", "{25037950-9138-9D8C-8669-C9001530CD3B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RDM", "TTT\RDM\RDM.csproj", "{2D21D87A-E594-4A7B-AC18-D307CC9A6596}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A7B58A1F-7C15-423B-B008-30E38D0E2135}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A7B58A1F-7C15-423B-B008-30E38D0E2135}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7B58A1F-7C15-423B-B008-30E38D0E2135}.Debug|x64.ActiveCfg = Debug|Any CPU + {A7B58A1F-7C15-423B-B008-30E38D0E2135}.Debug|x64.Build.0 = Debug|Any CPU + {A7B58A1F-7C15-423B-B008-30E38D0E2135}.Debug|x86.ActiveCfg = Debug|Any CPU + {A7B58A1F-7C15-423B-B008-30E38D0E2135}.Debug|x86.Build.0 = Debug|Any CPU {A7B58A1F-7C15-423B-B008-30E38D0E2135}.Release|Any CPU.ActiveCfg = Release|Any CPU {A7B58A1F-7C15-423B-B008-30E38D0E2135}.Release|Any CPU.Build.0 = Release|Any CPU + {A7B58A1F-7C15-423B-B008-30E38D0E2135}.Release|x64.ActiveCfg = Release|Any CPU + {A7B58A1F-7C15-423B-B008-30E38D0E2135}.Release|x64.Build.0 = Release|Any CPU + {A7B58A1F-7C15-423B-B008-30E38D0E2135}.Release|x86.ActiveCfg = Release|Any CPU + {A7B58A1F-7C15-423B-B008-30E38D0E2135}.Release|x86.Build.0 = Release|Any CPU {581DA648-28A9-4E6F-9E1C-7DD19B90C10F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {581DA648-28A9-4E6F-9E1C-7DD19B90C10F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {581DA648-28A9-4E6F-9E1C-7DD19B90C10F}.Debug|x64.ActiveCfg = Debug|Any CPU + {581DA648-28A9-4E6F-9E1C-7DD19B90C10F}.Debug|x64.Build.0 = Debug|Any CPU + {581DA648-28A9-4E6F-9E1C-7DD19B90C10F}.Debug|x86.ActiveCfg = Debug|Any CPU + {581DA648-28A9-4E6F-9E1C-7DD19B90C10F}.Debug|x86.Build.0 = Debug|Any CPU {581DA648-28A9-4E6F-9E1C-7DD19B90C10F}.Release|Any CPU.ActiveCfg = Release|Any CPU {581DA648-28A9-4E6F-9E1C-7DD19B90C10F}.Release|Any CPU.Build.0 = Release|Any CPU + {581DA648-28A9-4E6F-9E1C-7DD19B90C10F}.Release|x64.ActiveCfg = Release|Any CPU + {581DA648-28A9-4E6F-9E1C-7DD19B90C10F}.Release|x64.Build.0 = Release|Any CPU + {581DA648-28A9-4E6F-9E1C-7DD19B90C10F}.Release|x86.ActiveCfg = Release|Any CPU + {581DA648-28A9-4E6F-9E1C-7DD19B90C10F}.Release|x86.Build.0 = Release|Any CPU {D0424929-808F-49F8-9894-DDD770F55B7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D0424929-808F-49F8-9894-DDD770F55B7C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0424929-808F-49F8-9894-DDD770F55B7C}.Debug|x64.ActiveCfg = Debug|Any CPU + {D0424929-808F-49F8-9894-DDD770F55B7C}.Debug|x64.Build.0 = Debug|Any CPU + {D0424929-808F-49F8-9894-DDD770F55B7C}.Debug|x86.ActiveCfg = Debug|Any CPU + {D0424929-808F-49F8-9894-DDD770F55B7C}.Debug|x86.Build.0 = Debug|Any CPU {D0424929-808F-49F8-9894-DDD770F55B7C}.Release|Any CPU.ActiveCfg = Release|Any CPU {D0424929-808F-49F8-9894-DDD770F55B7C}.Release|Any CPU.Build.0 = Release|Any CPU + {D0424929-808F-49F8-9894-DDD770F55B7C}.Release|x64.ActiveCfg = Release|Any CPU + {D0424929-808F-49F8-9894-DDD770F55B7C}.Release|x64.Build.0 = Release|Any CPU + {D0424929-808F-49F8-9894-DDD770F55B7C}.Release|x86.ActiveCfg = Release|Any CPU + {D0424929-808F-49F8-9894-DDD770F55B7C}.Release|x86.Build.0 = Release|Any CPU {A595E6E3-A881-4524-8E5D-DE65106E1DD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A595E6E3-A881-4524-8E5D-DE65106E1DD5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A595E6E3-A881-4524-8E5D-DE65106E1DD5}.Debug|x64.ActiveCfg = Debug|Any CPU + {A595E6E3-A881-4524-8E5D-DE65106E1DD5}.Debug|x64.Build.0 = Debug|Any CPU + {A595E6E3-A881-4524-8E5D-DE65106E1DD5}.Debug|x86.ActiveCfg = Debug|Any CPU + {A595E6E3-A881-4524-8E5D-DE65106E1DD5}.Debug|x86.Build.0 = Debug|Any CPU {A595E6E3-A881-4524-8E5D-DE65106E1DD5}.Release|Any CPU.ActiveCfg = Release|Any CPU {A595E6E3-A881-4524-8E5D-DE65106E1DD5}.Release|Any CPU.Build.0 = Release|Any CPU + {A595E6E3-A881-4524-8E5D-DE65106E1DD5}.Release|x64.ActiveCfg = Release|Any CPU + {A595E6E3-A881-4524-8E5D-DE65106E1DD5}.Release|x64.Build.0 = Release|Any CPU + {A595E6E3-A881-4524-8E5D-DE65106E1DD5}.Release|x86.ActiveCfg = Release|Any CPU + {A595E6E3-A881-4524-8E5D-DE65106E1DD5}.Release|x86.Build.0 = Release|Any CPU {C41F5E15-2E28-4D14-A9F8-06FEADD24F2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C41F5E15-2E28-4D14-A9F8-06FEADD24F2D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C41F5E15-2E28-4D14-A9F8-06FEADD24F2D}.Debug|x64.ActiveCfg = Debug|Any CPU + {C41F5E15-2E28-4D14-A9F8-06FEADD24F2D}.Debug|x64.Build.0 = Debug|Any CPU + {C41F5E15-2E28-4D14-A9F8-06FEADD24F2D}.Debug|x86.ActiveCfg = Debug|Any CPU + {C41F5E15-2E28-4D14-A9F8-06FEADD24F2D}.Debug|x86.Build.0 = Debug|Any CPU {C41F5E15-2E28-4D14-A9F8-06FEADD24F2D}.Release|Any CPU.ActiveCfg = Release|Any CPU {C41F5E15-2E28-4D14-A9F8-06FEADD24F2D}.Release|Any CPU.Build.0 = Release|Any CPU + {C41F5E15-2E28-4D14-A9F8-06FEADD24F2D}.Release|x64.ActiveCfg = Release|Any CPU + {C41F5E15-2E28-4D14-A9F8-06FEADD24F2D}.Release|x64.Build.0 = Release|Any CPU + {C41F5E15-2E28-4D14-A9F8-06FEADD24F2D}.Release|x86.ActiveCfg = Release|Any CPU + {C41F5E15-2E28-4D14-A9F8-06FEADD24F2D}.Release|x86.Build.0 = Release|Any CPU {7AABCDC7-14BE-437C-BD41-C765CAB82F0E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7AABCDC7-14BE-437C-BD41-C765CAB82F0E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7AABCDC7-14BE-437C-BD41-C765CAB82F0E}.Debug|x64.ActiveCfg = Debug|Any CPU + {7AABCDC7-14BE-437C-BD41-C765CAB82F0E}.Debug|x64.Build.0 = Debug|Any CPU + {7AABCDC7-14BE-437C-BD41-C765CAB82F0E}.Debug|x86.ActiveCfg = Debug|Any CPU + {7AABCDC7-14BE-437C-BD41-C765CAB82F0E}.Debug|x86.Build.0 = Debug|Any CPU {7AABCDC7-14BE-437C-BD41-C765CAB82F0E}.Release|Any CPU.ActiveCfg = Release|Any CPU {7AABCDC7-14BE-437C-BD41-C765CAB82F0E}.Release|Any CPU.Build.0 = Release|Any CPU + {7AABCDC7-14BE-437C-BD41-C765CAB82F0E}.Release|x64.ActiveCfg = Release|Any CPU + {7AABCDC7-14BE-437C-BD41-C765CAB82F0E}.Release|x64.Build.0 = Release|Any CPU + {7AABCDC7-14BE-437C-BD41-C765CAB82F0E}.Release|x86.ActiveCfg = Release|Any CPU + {7AABCDC7-14BE-437C-BD41-C765CAB82F0E}.Release|x86.Build.0 = Release|Any CPU {B5F91489-CD1B-42F2-9CF0-889604BD7C7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B5F91489-CD1B-42F2-9CF0-889604BD7C7E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5F91489-CD1B-42F2-9CF0-889604BD7C7E}.Debug|x64.ActiveCfg = Debug|Any CPU + {B5F91489-CD1B-42F2-9CF0-889604BD7C7E}.Debug|x64.Build.0 = Debug|Any CPU + {B5F91489-CD1B-42F2-9CF0-889604BD7C7E}.Debug|x86.ActiveCfg = Debug|Any CPU + {B5F91489-CD1B-42F2-9CF0-889604BD7C7E}.Debug|x86.Build.0 = Debug|Any CPU {B5F91489-CD1B-42F2-9CF0-889604BD7C7E}.Release|Any CPU.ActiveCfg = Release|Any CPU {B5F91489-CD1B-42F2-9CF0-889604BD7C7E}.Release|Any CPU.Build.0 = Release|Any CPU + {B5F91489-CD1B-42F2-9CF0-889604BD7C7E}.Release|x64.ActiveCfg = Release|Any CPU + {B5F91489-CD1B-42F2-9CF0-889604BD7C7E}.Release|x64.Build.0 = Release|Any CPU + {B5F91489-CD1B-42F2-9CF0-889604BD7C7E}.Release|x86.ActiveCfg = Release|Any CPU + {B5F91489-CD1B-42F2-9CF0-889604BD7C7E}.Release|x86.Build.0 = Release|Any CPU {478416D7-4996-41CC-BDDF-5BF50B505D0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {478416D7-4996-41CC-BDDF-5BF50B505D0F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {478416D7-4996-41CC-BDDF-5BF50B505D0F}.Debug|x64.ActiveCfg = Debug|Any CPU + {478416D7-4996-41CC-BDDF-5BF50B505D0F}.Debug|x64.Build.0 = Debug|Any CPU + {478416D7-4996-41CC-BDDF-5BF50B505D0F}.Debug|x86.ActiveCfg = Debug|Any CPU + {478416D7-4996-41CC-BDDF-5BF50B505D0F}.Debug|x86.Build.0 = Debug|Any CPU {478416D7-4996-41CC-BDDF-5BF50B505D0F}.Release|Any CPU.ActiveCfg = Release|Any CPU {478416D7-4996-41CC-BDDF-5BF50B505D0F}.Release|Any CPU.Build.0 = Release|Any CPU + {478416D7-4996-41CC-BDDF-5BF50B505D0F}.Release|x64.ActiveCfg = Release|Any CPU + {478416D7-4996-41CC-BDDF-5BF50B505D0F}.Release|x64.Build.0 = Release|Any CPU + {478416D7-4996-41CC-BDDF-5BF50B505D0F}.Release|x86.ActiveCfg = Release|Any CPU + {478416D7-4996-41CC-BDDF-5BF50B505D0F}.Release|x86.Build.0 = Release|Any CPU {AFC791EC-750C-423F-9F35-87636657E990}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AFC791EC-750C-423F-9F35-87636657E990}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AFC791EC-750C-423F-9F35-87636657E990}.Debug|x64.ActiveCfg = Debug|Any CPU + {AFC791EC-750C-423F-9F35-87636657E990}.Debug|x64.Build.0 = Debug|Any CPU + {AFC791EC-750C-423F-9F35-87636657E990}.Debug|x86.ActiveCfg = Debug|Any CPU + {AFC791EC-750C-423F-9F35-87636657E990}.Debug|x86.Build.0 = Debug|Any CPU {AFC791EC-750C-423F-9F35-87636657E990}.Release|Any CPU.ActiveCfg = Release|Any CPU {AFC791EC-750C-423F-9F35-87636657E990}.Release|Any CPU.Build.0 = Release|Any CPU + {AFC791EC-750C-423F-9F35-87636657E990}.Release|x64.ActiveCfg = Release|Any CPU + {AFC791EC-750C-423F-9F35-87636657E990}.Release|x64.Build.0 = Release|Any CPU + {AFC791EC-750C-423F-9F35-87636657E990}.Release|x86.ActiveCfg = Release|Any CPU + {AFC791EC-750C-423F-9F35-87636657E990}.Release|x86.Build.0 = Release|Any CPU {16F720B5-9D45-47BF-8C80-4F91005E36D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {16F720B5-9D45-47BF-8C80-4F91005E36D1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {16F720B5-9D45-47BF-8C80-4F91005E36D1}.Debug|x64.ActiveCfg = Debug|Any CPU + {16F720B5-9D45-47BF-8C80-4F91005E36D1}.Debug|x64.Build.0 = Debug|Any CPU + {16F720B5-9D45-47BF-8C80-4F91005E36D1}.Debug|x86.ActiveCfg = Debug|Any CPU + {16F720B5-9D45-47BF-8C80-4F91005E36D1}.Debug|x86.Build.0 = Debug|Any CPU {16F720B5-9D45-47BF-8C80-4F91005E36D1}.Release|Any CPU.ActiveCfg = Release|Any CPU {16F720B5-9D45-47BF-8C80-4F91005E36D1}.Release|Any CPU.Build.0 = Release|Any CPU + {16F720B5-9D45-47BF-8C80-4F91005E36D1}.Release|x64.ActiveCfg = Release|Any CPU + {16F720B5-9D45-47BF-8C80-4F91005E36D1}.Release|x64.Build.0 = Release|Any CPU + {16F720B5-9D45-47BF-8C80-4F91005E36D1}.Release|x86.ActiveCfg = Release|Any CPU + {16F720B5-9D45-47BF-8C80-4F91005E36D1}.Release|x86.Build.0 = Release|Any CPU {8A426E84-45DA-4558-A218-E042F1AC60B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8A426E84-45DA-4558-A218-E042F1AC60B2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A426E84-45DA-4558-A218-E042F1AC60B2}.Debug|x64.ActiveCfg = Debug|Any CPU + {8A426E84-45DA-4558-A218-E042F1AC60B2}.Debug|x64.Build.0 = Debug|Any CPU + {8A426E84-45DA-4558-A218-E042F1AC60B2}.Debug|x86.ActiveCfg = Debug|Any CPU + {8A426E84-45DA-4558-A218-E042F1AC60B2}.Debug|x86.Build.0 = Debug|Any CPU {8A426E84-45DA-4558-A218-E042F1AC60B2}.Release|Any CPU.ActiveCfg = Release|Any CPU {8A426E84-45DA-4558-A218-E042F1AC60B2}.Release|Any CPU.Build.0 = Release|Any CPU + {8A426E84-45DA-4558-A218-E042F1AC60B2}.Release|x64.ActiveCfg = Release|Any CPU + {8A426E84-45DA-4558-A218-E042F1AC60B2}.Release|x64.Build.0 = Release|Any CPU + {8A426E84-45DA-4558-A218-E042F1AC60B2}.Release|x86.ActiveCfg = Release|Any CPU + {8A426E84-45DA-4558-A218-E042F1AC60B2}.Release|x86.Build.0 = Release|Any CPU {256473A2-6ACD-440C-83FA-6056147656C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {256473A2-6ACD-440C-83FA-6056147656C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {256473A2-6ACD-440C-83FA-6056147656C7}.Debug|x64.ActiveCfg = Debug|Any CPU + {256473A2-6ACD-440C-83FA-6056147656C7}.Debug|x64.Build.0 = Debug|Any CPU + {256473A2-6ACD-440C-83FA-6056147656C7}.Debug|x86.ActiveCfg = Debug|Any CPU + {256473A2-6ACD-440C-83FA-6056147656C7}.Debug|x86.Build.0 = Debug|Any CPU {256473A2-6ACD-440C-83FA-6056147656C7}.Release|Any CPU.ActiveCfg = Release|Any CPU {256473A2-6ACD-440C-83FA-6056147656C7}.Release|Any CPU.Build.0 = Release|Any CPU + {256473A2-6ACD-440C-83FA-6056147656C7}.Release|x64.ActiveCfg = Release|Any CPU + {256473A2-6ACD-440C-83FA-6056147656C7}.Release|x64.Build.0 = Release|Any CPU + {256473A2-6ACD-440C-83FA-6056147656C7}.Release|x86.ActiveCfg = Release|Any CPU + {256473A2-6ACD-440C-83FA-6056147656C7}.Release|x86.Build.0 = Release|Any CPU {5092069A-3CFA-41C8-B685-341040AB435C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5092069A-3CFA-41C8-B685-341040AB435C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5092069A-3CFA-41C8-B685-341040AB435C}.Debug|x64.ActiveCfg = Debug|Any CPU + {5092069A-3CFA-41C8-B685-341040AB435C}.Debug|x64.Build.0 = Debug|Any CPU + {5092069A-3CFA-41C8-B685-341040AB435C}.Debug|x86.ActiveCfg = Debug|Any CPU + {5092069A-3CFA-41C8-B685-341040AB435C}.Debug|x86.Build.0 = Debug|Any CPU {5092069A-3CFA-41C8-B685-341040AB435C}.Release|Any CPU.ActiveCfg = Release|Any CPU {5092069A-3CFA-41C8-B685-341040AB435C}.Release|Any CPU.Build.0 = Release|Any CPU + {5092069A-3CFA-41C8-B685-341040AB435C}.Release|x64.ActiveCfg = Release|Any CPU + {5092069A-3CFA-41C8-B685-341040AB435C}.Release|x64.Build.0 = Release|Any CPU + {5092069A-3CFA-41C8-B685-341040AB435C}.Release|x86.ActiveCfg = Release|Any CPU + {5092069A-3CFA-41C8-B685-341040AB435C}.Release|x86.Build.0 = Release|Any CPU {360FEF16-54DA-42EE-995A-3D31C699287D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {360FEF16-54DA-42EE-995A-3D31C699287D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {360FEF16-54DA-42EE-995A-3D31C699287D}.Debug|x64.ActiveCfg = Debug|Any CPU + {360FEF16-54DA-42EE-995A-3D31C699287D}.Debug|x64.Build.0 = Debug|Any CPU + {360FEF16-54DA-42EE-995A-3D31C699287D}.Debug|x86.ActiveCfg = Debug|Any CPU + {360FEF16-54DA-42EE-995A-3D31C699287D}.Debug|x86.Build.0 = Debug|Any CPU {360FEF16-54DA-42EE-995A-3D31C699287D}.Release|Any CPU.ActiveCfg = Release|Any CPU {360FEF16-54DA-42EE-995A-3D31C699287D}.Release|Any CPU.Build.0 = Release|Any CPU + {360FEF16-54DA-42EE-995A-3D31C699287D}.Release|x64.ActiveCfg = Release|Any CPU + {360FEF16-54DA-42EE-995A-3D31C699287D}.Release|x64.Build.0 = Release|Any CPU + {360FEF16-54DA-42EE-995A-3D31C699287D}.Release|x86.ActiveCfg = Release|Any CPU + {360FEF16-54DA-42EE-995A-3D31C699287D}.Release|x86.Build.0 = Release|Any CPU + {2D21D87A-E594-4A7B-AC18-D307CC9A6596}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2D21D87A-E594-4A7B-AC18-D307CC9A6596}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2D21D87A-E594-4A7B-AC18-D307CC9A6596}.Debug|x64.ActiveCfg = Debug|Any CPU + {2D21D87A-E594-4A7B-AC18-D307CC9A6596}.Debug|x64.Build.0 = Debug|Any CPU + {2D21D87A-E594-4A7B-AC18-D307CC9A6596}.Debug|x86.ActiveCfg = Debug|Any CPU + {2D21D87A-E594-4A7B-AC18-D307CC9A6596}.Debug|x86.Build.0 = Debug|Any CPU + {2D21D87A-E594-4A7B-AC18-D307CC9A6596}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2D21D87A-E594-4A7B-AC18-D307CC9A6596}.Release|Any CPU.Build.0 = Release|Any CPU + {2D21D87A-E594-4A7B-AC18-D307CC9A6596}.Release|x64.ActiveCfg = Release|Any CPU + {2D21D87A-E594-4A7B-AC18-D307CC9A6596}.Release|x64.Build.0 = Release|Any CPU + {2D21D87A-E594-4A7B-AC18-D307CC9A6596}.Release|x86.ActiveCfg = Release|Any CPU + {2D21D87A-E594-4A7B-AC18-D307CC9A6596}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {2D21D87A-E594-4A7B-AC18-D307CC9A6596} = {25037950-9138-9D8C-8669-C9001530CD3B} EndGlobalSection EndGlobal diff --git a/TTT/Plugin/Plugin.csproj b/TTT/Plugin/Plugin.csproj index 7dc85ff..08a23e7 100644 --- a/TTT/Plugin/Plugin.csproj +++ b/TTT/Plugin/Plugin.csproj @@ -11,6 +11,7 @@ + diff --git a/TTT/Plugin/TTTServiceCollection.cs b/TTT/Plugin/TTTServiceCollection.cs index 58d7fa1..671f9d9 100644 --- a/TTT/Plugin/TTTServiceCollection.cs +++ b/TTT/Plugin/TTTServiceCollection.cs @@ -6,6 +6,7 @@ using TTT.CS2; using TTT.Game; using TTT.Karma; +using TTT.RDM; using TTT.RTD; using TTT.Shop; @@ -16,6 +17,7 @@ public void ConfigureServices(IServiceCollection serviceCollection) { serviceCollection.AddScoped(_ => Scheduler.Default); serviceCollection.AddKarmaService(); + serviceCollection.AddRdmService(); serviceCollection.AddGameServices(); serviceCollection.AddCS2Services(); serviceCollection.AddShopServices(); diff --git a/TTT/RDM/RDM.csproj b/TTT/RDM/RDM.csproj new file mode 100644 index 0000000..dc8e4fc --- /dev/null +++ b/TTT/RDM/RDM.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + TTT.RDM + + + + + + + + + + + diff --git a/TTT/RDM/RdmConfig.cs b/TTT/RDM/RdmConfig.cs new file mode 100644 index 0000000..aca4413 --- /dev/null +++ b/TTT/RDM/RdmConfig.cs @@ -0,0 +1,27 @@ +namespace TTT.RDM; + +public record RdmConfig { + public string DbString { get; init; } = "Data Source=rdm.db"; + + public int TraitorSlays { get; init; } = 5; + public int DetectiveSlays { get; init; } = 5; + public int InnocentSlays { get; init; } = 3; + + public bool NotifyAdmins { get; init; } = true; + public bool AutoPromptOnSuspectKill { get; init; } = true; + public int ReportWindowSeconds { get; init; } = 60; + public int MaxReportsPerVictimPerRound { get; init; } = 3; + public string StaffFlag { get; init; } = "@ttt/admin"; + + /// + /// Number of slays owed when a guilty verdict's victim held the given role. + /// roleName is compared against IRole.Name (case-insensitive). + /// + public int SlaysForRole(string roleName) { + if (roleName.Contains("Traitor", StringComparison.OrdinalIgnoreCase)) + return TraitorSlays; + if (roleName.Contains("Detective", StringComparison.OrdinalIgnoreCase)) + return DetectiveSlays; + return InnocentSlays; + } +} diff --git a/TTT/RDM/RdmServiceCollection.cs b/TTT/RDM/RdmServiceCollection.cs new file mode 100644 index 0000000..f64d6ec --- /dev/null +++ b/TTT/RDM/RdmServiceCollection.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace TTT.RDM; + +public static class RdmServiceCollection { + public static void AddRdmService(this IServiceCollection collection) { + // Services are registered by later tasks. + } +} diff --git a/TTT/Test/RDM/RdmConfigTests.cs b/TTT/Test/RDM/RdmConfigTests.cs new file mode 100644 index 0000000..bea254d --- /dev/null +++ b/TTT/Test/RDM/RdmConfigTests.cs @@ -0,0 +1,23 @@ +using TTT.RDM; +using Xunit; + +namespace TTT.Test.RDM; + +public class RdmConfigTests { + [Fact] + public void Defaults_AreSourceModParity() { + var config = new RdmConfig(); + Assert.Equal(5, config.TraitorSlays); + Assert.Equal(5, config.DetectiveSlays); + Assert.Equal(3, config.InnocentSlays); + Assert.Equal("@ttt/admin", config.StaffFlag); + } + + [Theory] + [InlineData(" Traitor", 5)] + [InlineData(" Detective", 5)] + [InlineData(" Innocent", 3)] + public void SlaysForRole_MapsByRoleName(string roleName, int expected) { + Assert.Equal(expected, new RdmConfig().SlaysForRole(roleName)); + } +} diff --git a/TTT/Test/Test.csproj b/TTT/Test/Test.csproj index ab0687b..fb8da73 100644 --- a/TTT/Test/Test.csproj +++ b/TTT/Test/Test.csproj @@ -12,6 +12,7 @@ + From 44c25637b1df70021d2a0baf542b319ec2501ffc Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 11:52:09 -0700 Subject: [PATCH 04/19] refactor(karma): extract shared IDamageTracker (RDM prep) --- TTT/Game/Damage/DamageTracker.cs | 45 ++++++++++++++++ TTT/Game/Damage/IDamageTracker.cs | 14 +++++ TTT/Game/Damage/KillFault.cs | 7 +++ TTT/Game/GameServiceCollection.cs | 2 + TTT/Karma/KarmaListener.cs | 32 +++--------- TTT/Test/Game/Damage/DamageTrackerTests.cs | 61 ++++++++++++++++++++++ TTT/Test/Karma/KarmaListenerTests.cs | 2 + TTT/Test/Startup.cs | 2 + 8 files changed, 141 insertions(+), 24 deletions(-) create mode 100644 TTT/Game/Damage/DamageTracker.cs create mode 100644 TTT/Game/Damage/IDamageTracker.cs create mode 100644 TTT/Game/Damage/KillFault.cs create mode 100644 TTT/Test/Game/Damage/DamageTrackerTests.cs diff --git a/TTT/Game/Damage/DamageTracker.cs b/TTT/Game/Damage/DamageTracker.cs new file mode 100644 index 0000000..95723c0 --- /dev/null +++ b/TTT/Game/Damage/DamageTracker.cs @@ -0,0 +1,45 @@ +using JetBrains.Annotations; +using TTT.API.Events; +using TTT.API.Game; +using TTT.Game.Events.Game; +using TTT.Game.Events.Player; +using TTT.Game.Listeners; + +namespace TTT.Game.Damage; + +public class DamageTracker(IServiceProvider provider) + : BaseListener(provider), IDamageTracker { + // Ordered pairs (attackerId, victimId) of who damaged whom first this round. + private readonly HashSet<(string, string)> firstDamage = []; + + public void RecordFirstDamage(string attackerId, string victimId) { + // If the victim already hit the attacker first, this is not first damage. + if (firstDamage.Contains((victimId, attackerId))) return; + firstDamage.Add((attackerId, victimId)); + } + + public KillFault GetFault(string killerId, string victimId) { + if (firstDamage.Contains((killerId, victimId))) + return KillFault.KillerGuilty; + if (firstDamage.Contains((victimId, killerId))) + return KillFault.VictimGuilty; + return KillFault.Unknown; + } + + public void Clear() { firstDamage.Clear(); } + + [EventHandler] + [UsedImplicitly] + public void OnHurt(PlayerDamagedEvent ev) { + if (Games.ActiveGame is not { State: State.IN_PROGRESS }) return; + var attacker = ev.Attacker; + if (attacker == null) return; + RecordFirstDamage(attacker.Id, ev.Player.Id); + } + + [EventHandler] + [UsedImplicitly] + public void OnRoundStart(GameStateUpdateEvent ev) { + if (ev.NewState == State.IN_PROGRESS) Clear(); + } +} diff --git a/TTT/Game/Damage/IDamageTracker.cs b/TTT/Game/Damage/IDamageTracker.cs new file mode 100644 index 0000000..9577406 --- /dev/null +++ b/TTT/Game/Damage/IDamageTracker.cs @@ -0,0 +1,14 @@ +using TTT.API.Events; + +namespace TTT.Game.Damage; + +public interface IDamageTracker : IListener { + /// Record that attacker dealt the first damage to victim this round. + void RecordFirstDamage(string attackerId, string victimId); + + /// Who threw the first punch between these two this round. + KillFault GetFault(string killerId, string victimId); + + /// Clear all first-damage state (called at round start). + void Clear(); +} diff --git a/TTT/Game/Damage/KillFault.cs b/TTT/Game/Damage/KillFault.cs new file mode 100644 index 0000000..3d9f3c9 --- /dev/null +++ b/TTT/Game/Damage/KillFault.cs @@ -0,0 +1,7 @@ +namespace TTT.Game.Damage; + +public enum KillFault { + Unknown, + KillerGuilty, + VictimGuilty +} diff --git a/TTT/Game/GameServiceCollection.cs b/TTT/Game/GameServiceCollection.cs index bb3779c..c21b267 100644 --- a/TTT/Game/GameServiceCollection.cs +++ b/TTT/Game/GameServiceCollection.cs @@ -3,6 +3,7 @@ using TTT.API.Extensions; using TTT.API.Role; using TTT.Game.Commands; +using TTT.Game.Damage; using TTT.Game.Listeners; using TTT.Game.Listeners.Loggers; using TTT.Game.Roles; @@ -15,6 +16,7 @@ public static void AddGameServices(this IServiceCollection collection) { collection.AddScoped(); // Listeners + collection.AddModBehavior(); collection.AddModBehavior(); collection.AddModBehavior(); collection.AddModBehavior(); diff --git a/TTT/Karma/KarmaListener.cs b/TTT/Karma/KarmaListener.cs index 17322d2..b884cb5 100644 --- a/TTT/Karma/KarmaListener.cs +++ b/TTT/Karma/KarmaListener.cs @@ -5,6 +5,7 @@ using TTT.API.Messages; using TTT.API.Role; using TTT.API.Storage; +using TTT.Game.Damage; using TTT.Game.Events.Game; using TTT.Game.Events.Player; using TTT.Game.Listeners; @@ -14,16 +15,17 @@ namespace TTT.Karma; public class KarmaListener(IServiceProvider provider) : BaseListener(provider) { private readonly Dictionary badKills = new(); - private readonly List<(string, string)> firstDamage = new(); - private readonly IMessenger messenger = + private readonly IMessenger messenger = provider.GetRequiredService(); private readonly IGameManager games = provider.GetRequiredService(); private readonly IRoleAssigner roles = provider.GetRequiredService(); - private readonly IKarmaUpdateManager karmaUpdateManager = + private readonly IKarmaUpdateManager karmaUpdateManager = provider.GetRequiredService(); + private readonly IDamageTracker damageTracker = + provider.GetRequiredService(); public bool GiveKarmaOnRoundEnd = true; @@ -37,25 +39,6 @@ private KarmaConfig config [UsedImplicitly] public void OnRoundStart(GameStateUpdateEvent ev) { badKills.Clear(); - firstDamage.Clear(); - } - - [EventHandler] - [UsedImplicitly] - public void OnHurt(PlayerDamagedEvent ev) { - if (games.ActiveGame is not { State: State.IN_PROGRESS }) return; - - var victim = ev.Player; - var attacker = ev.Attacker; - if (attacker == null) return; - - // If the victim already damaged the attacker, don't mark this as first damage. - if (firstDamage.Contains((victim.Id, attacker.Id))) return; - - // Otherwise, mark down that the attacker damaged the victim first. - var pairing = (attacker.Id, victim.Id); - if (!firstDamage.Contains(pairing)) - firstDamage.Add(pairing); } [EventHandler] @@ -69,8 +52,9 @@ public void OnKill(PlayerDeathEvent ev) { if (killer == null) return; if (victim.Id == killer.Id) return; - var killerIsGuilty = firstDamage.Contains((killer.Id, victim.Id)); - var victimIsGuilty = firstDamage.Contains((victim.Id, killer.Id)); + var fault = damageTracker.GetFault(killer.Id, victim.Id); + var killerIsGuilty = fault == KillFault.KillerGuilty; + var victimIsGuilty = fault == KillFault.VictimGuilty; // Assume killerIsGuilty if there is no damage info. if (!killerIsGuilty && !victimIsGuilty) { killerIsGuilty = true; diff --git a/TTT/Test/Game/Damage/DamageTrackerTests.cs b/TTT/Test/Game/Damage/DamageTrackerTests.cs new file mode 100644 index 0000000..537ee8d --- /dev/null +++ b/TTT/Test/Game/Damage/DamageTrackerTests.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Events; +using TTT.API.Game; +using TTT.API.Player; +using TTT.Game.Damage; +using TTT.Game.Events.Player; +using Xunit; + +namespace TTT.Test.Game.Damage; + +public class DamageTrackerTests { + private readonly IEventBus bus; + private readonly IGameManager games; + private readonly IPlayerFinder players; + private readonly IDamageTracker tracker; + + public DamageTrackerTests(IServiceProvider provider) { + bus = provider.GetRequiredService(); + games = provider.GetRequiredService(); + players = provider.GetRequiredService(); + tracker = provider.GetRequiredService(); + bus.RegisterListener(tracker); + } + + [Fact] + public void GetFault_NoDamage_ReturnsUnknown() { + Assert.Equal(KillFault.Unknown, tracker.GetFault("a", "b")); + } + + [Fact] + public void OnHurt_DuringGame_RecordsKillerGuilty() { + var victim = TestPlayer.Random(); + var attacker = TestPlayer.Random(); + players.AddPlayers(victim, attacker); + games.CreateGame()?.Start(); + + bus.Dispatch(new PlayerDamagedEvent(victim, attacker, 100)); + + Assert.Equal(KillFault.KillerGuilty, + tracker.GetFault(attacker.Id, victim.Id)); + Assert.Equal(KillFault.VictimGuilty, + tracker.GetFault(victim.Id, attacker.Id)); + } + + [Fact] + public void RoundStart_ClearsPriorDamage() { + var victim = TestPlayer.Random(); + var attacker = TestPlayer.Random(); + players.AddPlayers(victim, attacker); + var game = games.CreateGame(); + game?.Start(); + bus.Dispatch(new PlayerDamagedEvent(victim, attacker, 100)); + + // New round start clears first-damage state. + bus.Dispatch(new TTT.Game.Events.Game.GameStateUpdateEvent(game!, + State.IN_PROGRESS)); + + Assert.Equal(KillFault.Unknown, + tracker.GetFault(attacker.Id, victim.Id)); + } +} diff --git a/TTT/Test/Karma/KarmaListenerTests.cs b/TTT/Test/Karma/KarmaListenerTests.cs index b8ac634..39f2f52 100644 --- a/TTT/Test/Karma/KarmaListenerTests.cs +++ b/TTT/Test/Karma/KarmaListenerTests.cs @@ -3,6 +3,7 @@ using TTT.API.Game; using TTT.API.Player; using TTT.API.Role; +using TTT.Game.Damage; using TTT.Game.Events.Player; using TTT.Game.Roles; using TTT.Karma; @@ -37,6 +38,7 @@ public KarmaListenerTests(IServiceProvider provider) { new DetectiveRole(provider) }; + bus.RegisterListener(provider.GetRequiredService()); var listener = new KarmaListener(provider); listener.GiveKarmaOnRoundEnd = false; diff --git a/TTT/Test/Startup.cs b/TTT/Test/Startup.cs index 133706e..c732dec 100644 --- a/TTT/Test/Startup.cs +++ b/TTT/Test/Startup.cs @@ -13,6 +13,7 @@ using TTT.API.Storage; using TTT.Game; using TTT.Game.Commands; +using TTT.Game.Damage; using TTT.Game.Roles; using TTT.Karma; using TTT.Locale; @@ -41,6 +42,7 @@ public void ConfigureServices(IServiceCollection services) { services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddModBehavior(); services.AddModBehavior(); From 56762bbf77d87fdfe3d5b9e92cf4a2a2cde54a3c Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 11:58:14 -0700 Subject: [PATCH 05/19] feat(rdm): suspect-kill classifier --- TTT/RDM/RdmClassifier.cs | 19 ++++++++++++++ TTT/Test/RDM/RdmClassifierTests.cs | 40 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 TTT/RDM/RdmClassifier.cs create mode 100644 TTT/Test/RDM/RdmClassifierTests.cs diff --git a/TTT/RDM/RdmClassifier.cs b/TTT/RDM/RdmClassifier.cs new file mode 100644 index 0000000..6b98fc8 --- /dev/null +++ b/TTT/RDM/RdmClassifier.cs @@ -0,0 +1,19 @@ +using TTT.API.Role; +using TTT.Game.Roles; + +namespace TTT.RDM; + +public static class RdmClassifier { + /// + /// A kill is "suspect" (worth an RDM report) when the parties share a role + /// (teamkill) or neither is a Traitor. A Traitor killing a non-Traitor (or + /// vice versa) is legitimate and not suspect. + /// + public static bool IsSuspectKill(IRole killer, IRole victim) { + var killerIsTraitor = killer is TraitorRole; + var victimIsTraitor = victim is TraitorRole; + + if (killer.GetType() == victim.GetType()) return true; // same role + return !killerIsTraitor && !victimIsTraitor; // neither is traitor + } +} diff --git a/TTT/Test/RDM/RdmClassifierTests.cs b/TTT/Test/RDM/RdmClassifierTests.cs new file mode 100644 index 0000000..2ed8314 --- /dev/null +++ b/TTT/Test/RDM/RdmClassifierTests.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Role; +using TTT.Game.Roles; +using TTT.RDM; +using Xunit; + +namespace TTT.Test.RDM; + +public class RdmClassifierTests { + public enum R { Innocent, Traitor, Detective } + + private readonly IList roles; + + public RdmClassifierTests(IServiceProvider provider) { + roles = new List { + new InnocentRole(provider), + new TraitorRole(provider), + new DetectiveRole(provider) + }; + } + + [Theory] + // same role -> suspect + [InlineData(R.Innocent, R.Innocent, true)] + [InlineData(R.Traitor, R.Traitor, true)] + [InlineData(R.Detective, R.Detective, true)] + // different roles, neither traitor -> suspect + [InlineData(R.Innocent, R.Detective, true)] + [InlineData(R.Detective, R.Innocent, true)] + // different roles, one is traitor -> NOT suspect + [InlineData(R.Traitor, R.Innocent, false)] + [InlineData(R.Traitor, R.Detective, false)] + [InlineData(R.Innocent, R.Traitor, false)] + [InlineData(R.Detective, R.Traitor, false)] + public void IsSuspectKill_MatchesBadKillTable(R killer, R victim, + bool expected) { + Assert.Equal(expected, + RdmClassifier.IsSuspectKill(roles[(int)killer], roles[(int)victim])); + } +} From 47babf74e680aab4bb02bf68c265c8a1662c1c24 Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 12:01:39 -0700 Subject: [PATCH 06/19] feat(rdm): domain models + IRdmStore + in-memory store --- TTT/RDM/IRdmStore.cs | 21 +++++++ TTT/RDM/InMemoryRdmStore.cs | 88 +++++++++++++++++++++++++++ TTT/RDM/Models/CaseState.cs | 3 + TTT/RDM/Models/DeathRecord.cs | 18 ++++++ TTT/RDM/Models/RdmCase.cs | 12 ++++ TTT/RDM/Models/SlayDebt.cs | 7 +++ TTT/RDM/Models/Verdict.cs | 3 + TTT/Test/RDM/InMemoryRdmStoreTests.cs | 84 +++++++++++++++++++++++++ 8 files changed, 236 insertions(+) create mode 100644 TTT/RDM/IRdmStore.cs create mode 100644 TTT/RDM/InMemoryRdmStore.cs create mode 100644 TTT/RDM/Models/CaseState.cs create mode 100644 TTT/RDM/Models/DeathRecord.cs create mode 100644 TTT/RDM/Models/RdmCase.cs create mode 100644 TTT/RDM/Models/SlayDebt.cs create mode 100644 TTT/RDM/Models/Verdict.cs create mode 100644 TTT/Test/RDM/InMemoryRdmStoreTests.cs diff --git a/TTT/RDM/IRdmStore.cs b/TTT/RDM/IRdmStore.cs new file mode 100644 index 0000000..95f0ebe --- /dev/null +++ b/TTT/RDM/IRdmStore.cs @@ -0,0 +1,21 @@ +using TTT.RDM.Models; + +namespace TTT.RDM; + +public interface IRdmStore { + Task AddDeath(DeathRecord death); + Task GetDeath(int id); + Task> GetSuspectDeathsForVictim(string victimId, + int round); + + Task AddCase(RdmCase rdmCase); + Task GetCase(int id); + Task> GetOpenCases(); + Task UpdateCase(RdmCase rdmCase); + Task HasReport(string reporterId, int deathId); + Task CountReportsByVictim(string reporterId, int round); + + Task SetSlayDebt(string playerId, int remaining, int sourceCaseId); + Task GetSlayDebt(string playerId); + Task> GetAllSlayDebts(); +} diff --git a/TTT/RDM/InMemoryRdmStore.cs b/TTT/RDM/InMemoryRdmStore.cs new file mode 100644 index 0000000..a734e2a --- /dev/null +++ b/TTT/RDM/InMemoryRdmStore.cs @@ -0,0 +1,88 @@ +using System.Collections.Concurrent; +using TTT.RDM.Models; + +namespace TTT.RDM; + +public sealed class InMemoryRdmStore : IRdmStore { + private readonly ConcurrentDictionary deaths = new(); + private readonly ConcurrentDictionary cases = new(); + private readonly ConcurrentDictionary slays = new(); + private int nextDeathId; + private int nextCaseId; + + public Task AddDeath(DeathRecord death) { + var id = Interlocked.Increment(ref nextDeathId); + deaths[id] = death with { Id = id }; + return Task.FromResult(id); + } + + public Task GetDeath(int id) { + return Task.FromResult(deaths.GetValueOrDefault(id)); + } + + public Task> GetSuspectDeathsForVictim( + string victimId, int round) { + IReadOnlyList result = deaths.Values + .Where(d => d.IsSuspect && d.VictimId == victimId && d.Round == round) + .OrderBy(d => d.Id) + .ToList(); + return Task.FromResult(result); + } + + public Task AddCase(RdmCase rdmCase) { + var id = Interlocked.Increment(ref nextCaseId); + cases[id] = rdmCase with { Id = id }; + return Task.FromResult(id); + } + + public Task GetCase(int id) { + return Task.FromResult(cases.GetValueOrDefault(id)); + } + + public Task> GetOpenCases() { + IReadOnlyList result = cases.Values + .Where(c => c.State != CaseState.Resolved) + .OrderBy(c => c.Id) + .ToList(); + return Task.FromResult(result); + } + + public Task UpdateCase(RdmCase rdmCase) { + cases[rdmCase.Id] = rdmCase; + return Task.CompletedTask; + } + + public Task HasReport(string reporterId, int deathId) { + return Task.FromResult(cases.Values.Any(c + => c.ReporterId == reporterId && c.DeathId == deathId)); + } + + public Task CountReportsByVictim(string reporterId, int round) { + var count = cases.Values.Count(c + => c.ReporterId == reporterId + && deaths.TryGetValue(c.DeathId, out var d) && d.Round == round); + return Task.FromResult(count); + } + + public Task SetSlayDebt(string playerId, int remaining, int sourceCaseId) { + if (remaining <= 0) + slays.TryRemove(playerId, out _); + else + slays[playerId] = new SlayDebt { + PlayerId = playerId, RemainingSlays = remaining, + SourceCaseId = sourceCaseId + }; + return Task.CompletedTask; + } + + public Task GetSlayDebt(string playerId) { + return Task.FromResult( + slays.TryGetValue(playerId, out var d) ? d.RemainingSlays : 0); + } + + public Task> GetAllSlayDebts() { + IReadOnlyList result = + slays.Values.Where(s => s.RemainingSlays > 0).ToList(); + return Task.FromResult(result); + } +} diff --git a/TTT/RDM/Models/CaseState.cs b/TTT/RDM/Models/CaseState.cs new file mode 100644 index 0000000..a5f1d79 --- /dev/null +++ b/TTT/RDM/Models/CaseState.cs @@ -0,0 +1,3 @@ +namespace TTT.RDM.Models; + +public enum CaseState { Open, Claimed, Resolved } diff --git a/TTT/RDM/Models/DeathRecord.cs b/TTT/RDM/Models/DeathRecord.cs new file mode 100644 index 0000000..7a365a1 --- /dev/null +++ b/TTT/RDM/Models/DeathRecord.cs @@ -0,0 +1,18 @@ +using TTT.Game.Damage; + +namespace TTT.RDM.Models; + +public record DeathRecord { + public int Id { get; init; } + public required int Round { get; init; } + public required string VictimId { get; init; } + public required string VictimName { get; init; } + public required string VictimRole { get; init; } + public required string AttackerId { get; init; } + public required string AttackerName { get; init; } + public required string AttackerRole { get; init; } + public string? Weapon { get; init; } + public required DateTime Timestamp { get; init; } + public required bool IsSuspect { get; init; } + public required KillFault Fault { get; init; } +} diff --git a/TTT/RDM/Models/RdmCase.cs b/TTT/RDM/Models/RdmCase.cs new file mode 100644 index 0000000..8dc62c5 --- /dev/null +++ b/TTT/RDM/Models/RdmCase.cs @@ -0,0 +1,12 @@ +namespace TTT.RDM.Models; + +public record RdmCase { + public int Id { get; init; } + public required int DeathId { get; init; } + public required string ReporterId { get; init; } + public string? Reason { get; init; } + public CaseState State { get; init; } = CaseState.Open; + public string? HandlerAdminId { get; init; } + public Verdict Verdict { get; init; } = Verdict.None; + public required DateTime CreatedAt { get; init; } +} diff --git a/TTT/RDM/Models/SlayDebt.cs b/TTT/RDM/Models/SlayDebt.cs new file mode 100644 index 0000000..fa1ed51 --- /dev/null +++ b/TTT/RDM/Models/SlayDebt.cs @@ -0,0 +1,7 @@ +namespace TTT.RDM.Models; + +public record SlayDebt { + public required string PlayerId { get; init; } + public required int RemainingSlays { get; init; } + public int SourceCaseId { get; init; } +} diff --git a/TTT/RDM/Models/Verdict.cs b/TTT/RDM/Models/Verdict.cs new file mode 100644 index 0000000..d043f55 --- /dev/null +++ b/TTT/RDM/Models/Verdict.cs @@ -0,0 +1,3 @@ +namespace TTT.RDM.Models; + +public enum Verdict { None, Forgiven, Guilty } diff --git a/TTT/Test/RDM/InMemoryRdmStoreTests.cs b/TTT/Test/RDM/InMemoryRdmStoreTests.cs new file mode 100644 index 0000000..c5358c5 --- /dev/null +++ b/TTT/Test/RDM/InMemoryRdmStoreTests.cs @@ -0,0 +1,84 @@ +using TTT.Game.Damage; +using TTT.RDM; +using TTT.RDM.Models; +using Xunit; + +namespace TTT.Test.RDM; + +public class InMemoryRdmStoreTests { + private static DeathRecord SampleDeath(string victimId = "v", + bool suspect = true, int round = 1) { + return new DeathRecord { + Round = round, VictimId = victimId, VictimName = "Victim", + VictimRole = "Innocent", AttackerId = "k", AttackerName = "Killer", + AttackerRole = "Innocent", Weapon = "ak47", + Timestamp = new DateTime(2026, 1, 1), IsSuspect = suspect, + Fault = KillFault.KillerGuilty + }; + } + + private static IRdmStore NewStore() { return new InMemoryRdmStore(); } + + [Fact] + public async Task AddDeath_AssignsIncrementingIds() { + var store = NewStore(); + var id1 = await store.AddDeath(SampleDeath()); + var id2 = await store.AddDeath(SampleDeath()); + Assert.True(id2 > id1); + Assert.Equal(id1, (await store.GetDeath(id1))!.Id); + } + + [Fact] + public async Task GetSuspectDeathsForVictim_FiltersBySuspectAndRound() { + var store = NewStore(); + await store.AddDeath(SampleDeath("v", true, 1)); + await store.AddDeath(SampleDeath("v", false, 1)); // not suspect + await store.AddDeath(SampleDeath("v", true, 2)); // wrong round + await store.AddDeath(SampleDeath("w", true, 1)); // wrong victim + + var result = await store.GetSuspectDeathsForVictim("v", 1); + Assert.Single(result); + } + + [Fact] + public async Task Cases_RoundTripAndOpenFilter() { + var store = NewStore(); + var deathId = await store.AddDeath(SampleDeath()); + var id = await store.AddCase(new RdmCase { + DeathId = deathId, ReporterId = "v", CreatedAt = new DateTime(2026, 1, 1) + }); + + var open = await store.GetOpenCases(); + Assert.Single(open); + + await store.UpdateCase((await store.GetCase(id))! with { + State = CaseState.Resolved, Verdict = Verdict.Forgiven + }); + Assert.Empty(await store.GetOpenCases()); + } + + [Fact] + public async Task HasReport_And_CountReportsByVictim() { + var store = NewStore(); + var deathId = await store.AddDeath(SampleDeath()); + Assert.False(await store.HasReport("v", deathId)); + + await store.AddCase(new RdmCase { + DeathId = deathId, ReporterId = "v", CreatedAt = new DateTime(2026, 1, 1) + }); + Assert.True(await store.HasReport("v", deathId)); + Assert.Equal(1, await store.CountReportsByVictim("v", 1)); + } + + [Fact] + public async Task SlayDebt_SetGetAndList() { + var store = NewStore(); + Assert.Equal(0, await store.GetSlayDebt("p")); + await store.SetSlayDebt("p", 3, 7); + Assert.Equal(3, await store.GetSlayDebt("p")); + Assert.Single(await store.GetAllSlayDebts()); + + await store.SetSlayDebt("p", 0, 7); + Assert.Empty(await store.GetAllSlayDebts()); + } +} From a4f728d2af76c0cdeaeb209f6a34f8cc7bf6a3a3 Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 12:06:20 -0700 Subject: [PATCH 07/19] feat(rdm): SQLite-backed IRdmStore --- TTT/RDM/SqliteRdmStore.cs | 234 ++++++++++++++++++++++++++++ TTT/Test/RDM/SqliteRdmStoreTests.cs | 88 +++++++++++ 2 files changed, 322 insertions(+) create mode 100644 TTT/RDM/SqliteRdmStore.cs create mode 100644 TTT/Test/RDM/SqliteRdmStoreTests.cs diff --git a/TTT/RDM/SqliteRdmStore.cs b/TTT/RDM/SqliteRdmStore.cs new file mode 100644 index 0000000..3741708 --- /dev/null +++ b/TTT/RDM/SqliteRdmStore.cs @@ -0,0 +1,234 @@ +using Microsoft.Data.Sqlite; +using TTT.Game.Damage; +using TTT.RDM.Models; + +namespace TTT.RDM; + +public sealed class SqliteRdmStore : IRdmStore, IDisposable { + private readonly SqliteConnection connection; + + public SqliteRdmStore(string connectionString) { + connection = new SqliteConnection(connectionString); + connection.Open(); + CreateTables(); + } + + private void CreateTables() { + using var cmd = connection.CreateCommand(); + cmd.CommandText = + """ + CREATE TABLE IF NOT EXISTS deaths ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + round INTEGER NOT NULL, + victim_id TEXT NOT NULL, victim_name TEXT NOT NULL, victim_role TEXT NOT NULL, + attacker_id TEXT NOT NULL, attacker_name TEXT NOT NULL, attacker_role TEXT NOT NULL, + weapon TEXT, timestamp TEXT NOT NULL, is_suspect INTEGER NOT NULL, fault INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS cases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + death_id INTEGER NOT NULL, reporter_id TEXT NOT NULL, reason TEXT, + state INTEGER NOT NULL, handler_admin_id TEXT, verdict INTEGER NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS slays ( + player_id TEXT PRIMARY KEY, remaining INTEGER NOT NULL, source_case_id INTEGER NOT NULL + ); + """; + cmd.ExecuteNonQuery(); + } + + private static DeathRecord ReadDeath(SqliteDataReader r) { + return new DeathRecord { + Id = r.GetInt32(0), Round = r.GetInt32(1), + VictimId = r.GetString(2), VictimName = r.GetString(3), + VictimRole = r.GetString(4), AttackerId = r.GetString(5), + AttackerName = r.GetString(6), AttackerRole = r.GetString(7), + Weapon = r.IsDBNull(8) ? null : r.GetString(8), + Timestamp = DateTime.Parse(r.GetString(9)), + IsSuspect = r.GetInt32(10) != 0, Fault = (KillFault)r.GetInt32(11) + }; + } + + private const string DeathCols = + "id, round, victim_id, victim_name, victim_role, attacker_id, attacker_name, attacker_role, weapon, timestamp, is_suspect, fault"; + + public async Task AddDeath(DeathRecord d) { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + """ + INSERT INTO deaths (round, victim_id, victim_name, victim_role, attacker_id, attacker_name, attacker_role, weapon, timestamp, is_suspect, fault) + VALUES ($round, $vid, $vname, $vrole, $aid, $aname, $arole, $weapon, $ts, $suspect, $fault); + SELECT last_insert_rowid(); + """; + cmd.Parameters.AddWithValue("$round", d.Round); + cmd.Parameters.AddWithValue("$vid", d.VictimId); + cmd.Parameters.AddWithValue("$vname", d.VictimName); + cmd.Parameters.AddWithValue("$vrole", d.VictimRole); + cmd.Parameters.AddWithValue("$aid", d.AttackerId); + cmd.Parameters.AddWithValue("$aname", d.AttackerName); + cmd.Parameters.AddWithValue("$arole", d.AttackerRole); + cmd.Parameters.AddWithValue("$weapon", (object?)d.Weapon ?? DBNull.Value); + cmd.Parameters.AddWithValue("$ts", d.Timestamp.ToString("o")); + cmd.Parameters.AddWithValue("$suspect", d.IsSuspect ? 1 : 0); + cmd.Parameters.AddWithValue("$fault", (int)d.Fault); + return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + } + + public async Task GetDeath(int id) { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = $"SELECT {DeathCols} FROM deaths WHERE id = $id"; + cmd.Parameters.AddWithValue("$id", id); + await using var r = await cmd.ExecuteReaderAsync(); + return await r.ReadAsync() ? ReadDeath(r) : null; + } + + public async Task> GetSuspectDeathsForVictim( + string victimId, int round) { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + $"SELECT {DeathCols} FROM deaths WHERE victim_id = $v AND round = $r AND is_suspect = 1 ORDER BY id"; + cmd.Parameters.AddWithValue("$v", victimId); + cmd.Parameters.AddWithValue("$r", round); + var list = new List(); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) list.Add(ReadDeath(r)); + return list; + } + + public async Task AddCase(RdmCase c) { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + """ + INSERT INTO cases (death_id, reporter_id, reason, state, handler_admin_id, verdict, created_at) + VALUES ($did, $rid, $reason, $state, $handler, $verdict, $created); + SELECT last_insert_rowid(); + """; + cmd.Parameters.AddWithValue("$did", c.DeathId); + cmd.Parameters.AddWithValue("$rid", c.ReporterId); + cmd.Parameters.AddWithValue("$reason", (object?)c.Reason ?? DBNull.Value); + cmd.Parameters.AddWithValue("$state", (int)c.State); + cmd.Parameters.AddWithValue("$handler", + (object?)c.HandlerAdminId ?? DBNull.Value); + cmd.Parameters.AddWithValue("$verdict", (int)c.Verdict); + cmd.Parameters.AddWithValue("$created", c.CreatedAt.ToString("o")); + return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + } + + private static RdmCase ReadCase(SqliteDataReader r) { + return new RdmCase { + Id = r.GetInt32(0), DeathId = r.GetInt32(1), ReporterId = r.GetString(2), + Reason = r.IsDBNull(3) ? null : r.GetString(3), + State = (CaseState)r.GetInt32(4), + HandlerAdminId = r.IsDBNull(5) ? null : r.GetString(5), + Verdict = (Verdict)r.GetInt32(6), + CreatedAt = DateTime.Parse(r.GetString(7)) + }; + } + + private const string CaseCols = + "id, death_id, reporter_id, reason, state, handler_admin_id, verdict, created_at"; + + public async Task GetCase(int id) { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = $"SELECT {CaseCols} FROM cases WHERE id = $id"; + cmd.Parameters.AddWithValue("$id", id); + await using var r = await cmd.ExecuteReaderAsync(); + return await r.ReadAsync() ? ReadCase(r) : null; + } + + public async Task> GetOpenCases() { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + $"SELECT {CaseCols} FROM cases WHERE state != $resolved ORDER BY id"; + cmd.Parameters.AddWithValue("$resolved", (int)CaseState.Resolved); + var list = new List(); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) list.Add(ReadCase(r)); + return list; + } + + public async Task UpdateCase(RdmCase c) { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + """ + UPDATE cases SET death_id=$did, reporter_id=$rid, reason=$reason, + state=$state, handler_admin_id=$handler, verdict=$verdict, created_at=$created + WHERE id=$id + """; + cmd.Parameters.AddWithValue("$id", c.Id); + cmd.Parameters.AddWithValue("$did", c.DeathId); + cmd.Parameters.AddWithValue("$rid", c.ReporterId); + cmd.Parameters.AddWithValue("$reason", (object?)c.Reason ?? DBNull.Value); + cmd.Parameters.AddWithValue("$state", (int)c.State); + cmd.Parameters.AddWithValue("$handler", + (object?)c.HandlerAdminId ?? DBNull.Value); + cmd.Parameters.AddWithValue("$verdict", (int)c.Verdict); + cmd.Parameters.AddWithValue("$created", c.CreatedAt.ToString("o")); + await cmd.ExecuteNonQueryAsync(); + } + + public async Task HasReport(string reporterId, int deathId) { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + "SELECT COUNT(*) FROM cases WHERE reporter_id = $r AND death_id = $d"; + cmd.Parameters.AddWithValue("$r", reporterId); + cmd.Parameters.AddWithValue("$d", deathId); + return Convert.ToInt32(await cmd.ExecuteScalarAsync()) > 0; + } + + public async Task CountReportsByVictim(string reporterId, int round) { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + """ + SELECT COUNT(*) FROM cases c JOIN deaths d ON c.death_id = d.id + WHERE c.reporter_id = $r AND d.round = $round + """; + cmd.Parameters.AddWithValue("$r", reporterId); + cmd.Parameters.AddWithValue("$round", round); + return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + } + + public async Task SetSlayDebt(string playerId, int remaining, + int sourceCaseId) { + await using var cmd = connection.CreateCommand(); + if (remaining <= 0) { + cmd.CommandText = "DELETE FROM slays WHERE player_id = $p"; + cmd.Parameters.AddWithValue("$p", playerId); + } else { + cmd.CommandText = + """ + INSERT INTO slays (player_id, remaining, source_case_id) + VALUES ($p, $rem, $src) + ON CONFLICT(player_id) DO UPDATE SET remaining=$rem, source_case_id=$src + """; + cmd.Parameters.AddWithValue("$p", playerId); + cmd.Parameters.AddWithValue("$rem", remaining); + cmd.Parameters.AddWithValue("$src", sourceCaseId); + } + await cmd.ExecuteNonQueryAsync(); + } + + public async Task GetSlayDebt(string playerId) { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT remaining FROM slays WHERE player_id = $p"; + cmd.Parameters.AddWithValue("$p", playerId); + var result = await cmd.ExecuteScalarAsync(); + return result == null ? 0 : Convert.ToInt32(result); + } + + public async Task> GetAllSlayDebts() { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + "SELECT player_id, remaining, source_case_id FROM slays WHERE remaining > 0"; + var list = new List(); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + list.Add(new SlayDebt { + PlayerId = r.GetString(0), RemainingSlays = r.GetInt32(1), + SourceCaseId = r.GetInt32(2) + }); + return list; + } + + public void Dispose() { connection.Dispose(); } +} diff --git a/TTT/Test/RDM/SqliteRdmStoreTests.cs b/TTT/Test/RDM/SqliteRdmStoreTests.cs new file mode 100644 index 0000000..9d4a142 --- /dev/null +++ b/TTT/Test/RDM/SqliteRdmStoreTests.cs @@ -0,0 +1,88 @@ +using TTT.Game.Damage; +using TTT.RDM; +using TTT.RDM.Models; +using Xunit; + +namespace TTT.Test.RDM; + +public class SqliteRdmStoreTests { + private static DeathRecord SampleDeath(string victimId = "v", + bool suspect = true, int round = 1) { + return new DeathRecord { + Round = round, VictimId = victimId, VictimName = "Victim", + VictimRole = "Innocent", AttackerId = "k", AttackerName = "Killer", + AttackerRole = "Innocent", Weapon = "ak47", + Timestamp = new DateTime(2026, 1, 1), IsSuspect = suspect, + Fault = KillFault.KillerGuilty + }; + } + + // Unique in-memory DB per test (shared cache so the schema persists across + // the store's internal connection lifetime). + private static SqliteRdmStore NewStore() { + return new SqliteRdmStore("Data Source=:memory:"); + } + + [Fact] + public async Task Death_RoundTrip_PreservesFields() { + using var store = NewStore(); + var id = await store.AddDeath(SampleDeath()); + var got = await store.GetDeath(id); + Assert.NotNull(got); + Assert.Equal("Killer", got!.AttackerName); + Assert.Equal(KillFault.KillerGuilty, got.Fault); + Assert.True(got.IsSuspect); + } + + [Fact] + public async Task SuspectDeaths_FilterByVictimRoundSuspect() { + using var store = NewStore(); + await store.AddDeath(SampleDeath("v", true, 1)); + await store.AddDeath(SampleDeath("v", false, 1)); + await store.AddDeath(SampleDeath("v", true, 2)); + Assert.Single(await store.GetSuspectDeathsForVictim("v", 1)); + } + + [Fact] + public async Task Cases_OpenFilter_And_Update() { + using var store = NewStore(); + var deathId = await store.AddDeath(SampleDeath()); + var id = await store.AddCase(new RdmCase { + DeathId = deathId, ReporterId = "v", CreatedAt = new DateTime(2026, 1, 1) + }); + Assert.Single(await store.GetOpenCases()); + await store.UpdateCase((await store.GetCase(id))! with { + State = CaseState.Resolved, Verdict = Verdict.Guilty, + HandlerAdminId = "admin" + }); + Assert.Empty(await store.GetOpenCases()); + Assert.Equal(Verdict.Guilty, (await store.GetCase(id))!.Verdict); + } + + [Fact] + public async Task SlayDebt_Persisted_AcrossNewStoreSameFile() { + var file = $"Data Source=rdm-test-{Guid.NewGuid():N}.db"; + try { + using (var store = new SqliteRdmStore(file)) + await store.SetSlayDebt("p", 4, 1); + using (var store2 = new SqliteRdmStore(file)) + Assert.Equal(4, await store2.GetSlayDebt("p")); + } finally { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + var path = file.Replace("Data Source=", ""); + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public async Task HasReport_And_CountReportsByVictim() { + using var store = NewStore(); + var deathId = await store.AddDeath(SampleDeath()); + Assert.False(await store.HasReport("v", deathId)); + await store.AddCase(new RdmCase { + DeathId = deathId, ReporterId = "v", CreatedAt = new DateTime(2026, 1, 1) + }); + Assert.True(await store.HasReport("v", deathId)); + Assert.Equal(1, await store.CountReportsByVictim("v", 1)); + } +} From 90448ec2b7ffa48dbe391e650f41f8b0c8b7835a Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 12:09:46 -0700 Subject: [PATCH 08/19] test(rdm): cover SqliteRdmStore GetAllSlayDebts + delete-on-zero --- TTT/Test/RDM/SqliteRdmStoreTests.cs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/TTT/Test/RDM/SqliteRdmStoreTests.cs b/TTT/Test/RDM/SqliteRdmStoreTests.cs index 9d4a142..0c8e2b0 100644 --- a/TTT/Test/RDM/SqliteRdmStoreTests.cs +++ b/TTT/Test/RDM/SqliteRdmStoreTests.cs @@ -85,4 +85,26 @@ await store.AddCase(new RdmCase { Assert.True(await store.HasReport("v", deathId)); Assert.Equal(1, await store.CountReportsByVictim("v", 1)); } + + [Fact] + public async Task GetAllSlayDebts_ReturnsOnlyPositiveRemaining() { + using var store = NewStore(); + await store.SetSlayDebt("p1", 3, 10); + await store.SetSlayDebt("p2", 1, 11); + var all = await store.GetAllSlayDebts(); + Assert.Equal(2, all.Count); + Assert.Contains(all, d => d.PlayerId == "p1" && d.RemainingSlays == 3 + && d.SourceCaseId == 10); + } + + [Fact] + public async Task SetSlayDebt_ZeroOrNegative_RemovesRow() { + using var store = NewStore(); + await store.SetSlayDebt("p", 4, 1); + Assert.Equal(4, await store.GetSlayDebt("p")); + + await store.SetSlayDebt("p", 0, 1); + Assert.Equal(0, await store.GetSlayDebt("p")); + Assert.Empty(await store.GetAllSlayDebts()); + } } From ee2680387dbe52adf1c949666e71ca521733dc09 Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 12:19:55 -0700 Subject: [PATCH 09/19] feat(rdm): death log listener with suspect detection + victim prompt --- TTT/RDM/DeathLogListener.cs | 60 ++++++++++++++++++++ TTT/RDM/lang/RdmMsgs.cs | 9 +++ TTT/RDM/lang/en.yml | 1 + TTT/Test/RDM/DeathLogListenerTests.cs | 79 +++++++++++++++++++++++++++ TTT/Test/Startup.cs | 2 + 5 files changed, 151 insertions(+) create mode 100644 TTT/RDM/DeathLogListener.cs create mode 100644 TTT/RDM/lang/RdmMsgs.cs create mode 100644 TTT/RDM/lang/en.yml create mode 100644 TTT/Test/RDM/DeathLogListenerTests.cs diff --git a/TTT/RDM/DeathLogListener.cs b/TTT/RDM/DeathLogListener.cs new file mode 100644 index 0000000..0a71419 --- /dev/null +++ b/TTT/RDM/DeathLogListener.cs @@ -0,0 +1,60 @@ +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Events; +using TTT.API.Game; +using TTT.API.Storage; +using TTT.Game.Damage; +using TTT.Game.Events.Game; +using TTT.Game.Events.Player; +using TTT.Game.Listeners; +using TTT.RDM.lang; + +namespace TTT.RDM; + +public class DeathLogListener(IServiceProvider provider) + : BaseListener(provider) { + private readonly IRdmStore store = provider.GetRequiredService(); + + private readonly IDamageTracker damage = + provider.GetRequiredService(); + + public int CurrentRound { get; private set; } + + private RdmConfig config + => Provider.GetService>()?.Load().GetAwaiter() + .GetResult() ?? new RdmConfig(); + + [EventHandler] + [UsedImplicitly] + public void OnRoundStart(GameStateUpdateEvent ev) { + if (ev.NewState == State.IN_PROGRESS) CurrentRound++; + } + + [EventHandler] + [UsedImplicitly] + public void OnKill(PlayerDeathEvent ev) { + if (Games.ActiveGame is not { State: State.IN_PROGRESS }) return; + var victim = ev.Victim; + var killer = ev.Killer; + if (killer == null || victim.Id == killer.Id) return; + + var victimRole = Roles.GetRoles(victim).First(); + var killerRole = Roles.GetRoles(killer).First(); + var suspect = RdmClassifier.IsSuspectKill(killerRole, victimRole); + var fault = damage.GetFault(killer.Id, victim.Id); + + var record = new Models.DeathRecord { + Round = CurrentRound, + VictimId = victim.Id, VictimName = victim.Name, + VictimRole = victimRole.Name, + AttackerId = killer.Id, AttackerName = killer.Name, + AttackerRole = killerRole.Name, Weapon = ev.Weapon, + Timestamp = DateTime.UtcNow, IsSuspect = suspect, Fault = fault + }; + + _ = store.AddDeath(record); + + if (suspect && config.AutoPromptOnSuspectKill) + Messenger.Message(victim, Locale[RdmMsgs.RDM_PROMPT(killer.Name)]); + } +} diff --git a/TTT/RDM/lang/RdmMsgs.cs b/TTT/RDM/lang/RdmMsgs.cs new file mode 100644 index 0000000..74bbc94 --- /dev/null +++ b/TTT/RDM/lang/RdmMsgs.cs @@ -0,0 +1,9 @@ +using TTT.Locale; + +namespace TTT.RDM.lang; + +public static class RdmMsgs { + public static IMsg RDM_PROMPT(string attacker) { + return MsgFactory.Create(nameof(RDM_PROMPT), attacker); + } +} diff --git a/TTT/RDM/lang/en.yml b/TTT/RDM/lang/en.yml new file mode 100644 index 0000000..595694d --- /dev/null +++ b/TTT/RDM/lang/en.yml @@ -0,0 +1 @@ +RDM_PROMPT: "%PREFIX%You were killed by {yellow}{0}{grey}. Type {yellow}!rdm{grey} to report it, or ignore (defaults to not RDM)." diff --git a/TTT/Test/RDM/DeathLogListenerTests.cs b/TTT/Test/RDM/DeathLogListenerTests.cs new file mode 100644 index 0000000..769d14d --- /dev/null +++ b/TTT/Test/RDM/DeathLogListenerTests.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Events; +using TTT.API.Game; +using TTT.API.Player; +using TTT.API.Role; +using TTT.Game.Damage; +using TTT.Game.Events.Player; +using TTT.Game.Roles; +using TTT.Locale; +using TTT.RDM; +using TTT.RDM.lang; +using Xunit; + +namespace TTT.Test.RDM; + +public class DeathLogListenerTests { + private readonly IEventBus bus; + private readonly IGameManager games; + private readonly IPlayerFinder players; + private readonly IRoleAssigner roles; + private readonly IRdmStore store; + private readonly IMsgLocalizer locale; + private readonly IList roleSet; + + public DeathLogListenerTests(IServiceProvider provider) { + bus = provider.GetRequiredService(); + games = provider.GetRequiredService(); + players = provider.GetRequiredService(); + roles = provider.GetRequiredService(); + store = provider.GetRequiredService(); + locale = provider.GetRequiredService(); + roleSet = new List { + new InnocentRole(provider), new TraitorRole(provider), + new DetectiveRole(provider) + }; + bus.RegisterListener(provider.GetRequiredService()); + bus.RegisterListener(new DeathLogListener(provider)); + } + + private (TestPlayer victim, TestPlayer killer) StartRoundWith( + IRole victimRole, IRole killerRole) { + var victim = TestPlayer.Random(); + var killer = TestPlayer.Random(); + var game = games.CreateGame(); + game?.Start(); // 0 players online → MinimumPlayers check fails → no StartRound + players.AddPlayers(victim, killer); + // Set game state: dispatches GameStateUpdateEvent (CurrentRound → 1) AND + // updates game.State so DeathLogListener.OnKill state-guard passes. + game!.State = State.IN_PROGRESS; + roles.SetRole(victim, victimRole); + roles.SetRole(killer, killerRole); + return (victim, killer); + } + + [Fact] + public async Task SuspectKill_RecordsDeath_AndPromptsVictim() { + var (victim, killer) = + StartRoundWith(roleSet[0], roleSet[0]); // inno on inno + bus.Dispatch(new PlayerDamagedEvent(victim, killer, 100)); + var death = new PlayerDeathEvent(victim).WithKiller(killer) + .WithWeapon("ak47"); + bus.Dispatch(death); + + var recorded = await store.GetSuspectDeathsForVictim(victim.Id, 1); + Assert.Single(recorded); + Assert.Contains(locale[RdmMsgs.RDM_PROMPT(killer.Name)], victim.Messages); + } + + [Fact] + public async Task LegitKill_RecordsNonSuspect_NoPrompt() { + var (victim, killer) = + StartRoundWith(roleSet[0], roleSet[1]); // traitor kills inno + bus.Dispatch(new PlayerDamagedEvent(victim, killer, 100)); + bus.Dispatch(new PlayerDeathEvent(victim).WithKiller(killer)); + + Assert.Empty(await store.GetSuspectDeathsForVictim(victim.Id, 1)); + Assert.Empty(victim.Messages); + } +} diff --git a/TTT/Test/Startup.cs b/TTT/Test/Startup.cs index c732dec..b55de86 100644 --- a/TTT/Test/Startup.cs +++ b/TTT/Test/Startup.cs @@ -17,6 +17,7 @@ using TTT.Game.Roles; using TTT.Karma; using TTT.Locale; +using TTT.RDM; using TTT.Test.Abstract; using TTT.Test.Fakes; @@ -43,6 +44,7 @@ public void ConfigureServices(IServiceCollection services) { services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddModBehavior(); services.AddModBehavior(); From 9c51a3cc0e15b47208e23affea6973a25de6fb94 Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 12:25:05 -0700 Subject: [PATCH 10/19] fix(rdm): observe/log death-write failures (no silent swallow) --- TTT/RDM/DeathLogListener.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/TTT/RDM/DeathLogListener.cs b/TTT/RDM/DeathLogListener.cs index 0a71419..466c1ac 100644 --- a/TTT/RDM/DeathLogListener.cs +++ b/TTT/RDM/DeathLogListener.cs @@ -52,7 +52,10 @@ public void OnKill(PlayerDeathEvent ev) { Timestamp = DateTime.UtcNow, IsSuspect = suspect, Fault = fault }; - _ = store.AddDeath(record); + _ = store.AddDeath(record).ContinueWith( + t => Messenger.Debug("RDM: failed to log death: {0}", + t.Exception?.Message ?? "unknown"), + TaskContinuationOptions.OnlyOnFaulted); if (suspect && config.AutoPromptOnSuspectKill) Messenger.Message(victim, Locale[RdmMsgs.RDM_PROMPT(killer.Name)]); From fd27f39688636d73a2c7428cd6e133e2d9e8585e Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 12:28:44 -0700 Subject: [PATCH 11/19] feat(rdm): case manager (report lifecycle + staff notify) --- TTT/RDM/CaseManager.cs | 91 ++++++++++++++++++++++++++++++++ TTT/RDM/ICaseManager.cs | 13 +++++ TTT/RDM/lang/RdmMsgs.cs | 6 +++ TTT/RDM/lang/en.yml | 1 + TTT/Test/RDM/CaseManagerTests.cs | 86 ++++++++++++++++++++++++++++++ TTT/Test/Startup.cs | 1 + 6 files changed, 198 insertions(+) create mode 100644 TTT/RDM/CaseManager.cs create mode 100644 TTT/RDM/ICaseManager.cs create mode 100644 TTT/Test/RDM/CaseManagerTests.cs diff --git a/TTT/RDM/CaseManager.cs b/TTT/RDM/CaseManager.cs new file mode 100644 index 0000000..266df4e --- /dev/null +++ b/TTT/RDM/CaseManager.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Messages; +using TTT.API.Player; +using TTT.API.Storage; +using TTT.Locale; +using TTT.RDM.lang; +using TTT.RDM.Models; + +namespace TTT.RDM; + +public class CaseManager(IServiceProvider provider) : ICaseManager { + private readonly IRdmStore store = provider.GetRequiredService(); + + private readonly IPlayerFinder finder = + provider.GetRequiredService(); + + private readonly IPermissionManager perms = + provider.GetRequiredService(); + + private readonly IMessenger messenger = + provider.GetRequiredService(); + + private readonly IMsgLocalizer locale = + provider.GetRequiredService(); + + private RdmConfig config + => provider.GetService>()?.Load().GetAwaiter() + .GetResult() ?? new RdmConfig(); + + public async Task FileReport(IOnlinePlayer reporter, int deathId, + string? reason) { + var death = await store.GetDeath(deathId); + if (death == null) return null; + if (death.VictimId != reporter.Id) return null; // only the victim + if (await store.HasReport(reporter.Id, deathId)) return null; // duplicate + + var cfg = config; + if (await store.CountReportsByVictim(reporter.Id, death.Round) + >= cfg.MaxReportsPerVictimPerRound) return null; // per-round cap + + var age = DateTime.UtcNow - death.Timestamp; + if (age.TotalSeconds > cfg.ReportWindowSeconds) return null; // window + + var id = await store.AddCase(new RdmCase { + DeathId = deathId, ReporterId = reporter.Id, Reason = reason, + State = CaseState.Open, CreatedAt = DateTime.UtcNow + }); + var created = await store.GetCase(id); + + if (cfg.NotifyAdmins && created != null) + NotifyStaff(death, created); + return created; + } + + private void NotifyStaff(DeathRecord death, RdmCase rdmCase) { + var staffFlag = config.StaffFlag; + foreach (var online in finder.GetOnline()) + if (perms.HasFlags(online, staffFlag)) + messenger.Message(online, + locale[RdmMsgs.RDM_STAFF_NEW_REPORT(death.VictimName, + death.AttackerName, rdmCase.Id)]); + } + + public async Task ClaimNext(IPlayer admin) { + var open = await store.GetOpenCases(); + var next = open.FirstOrDefault(c => c.State == CaseState.Open); + if (next == null) return null; + return await Claim(admin, next.Id); + } + + public async Task Claim(IPlayer admin, int caseId) { + var c = await store.GetCase(caseId); + if (c == null || c.State == CaseState.Resolved) return null; + var claimed = c with { + State = CaseState.Claimed, HandlerAdminId = admin.Id + }; + await store.UpdateCase(claimed); + return claimed; + } + + public async Task Resolve(int caseId, Verdict verdict, IPlayer admin) { + var c = await store.GetCase(caseId); + if (c == null) return; + await store.UpdateCase(c with { + State = CaseState.Resolved, Verdict = verdict, + HandlerAdminId = admin.Id + }); + } + + public Task> GetOpen() { return store.GetOpenCases(); } +} diff --git a/TTT/RDM/ICaseManager.cs b/TTT/RDM/ICaseManager.cs new file mode 100644 index 0000000..ab82da7 --- /dev/null +++ b/TTT/RDM/ICaseManager.cs @@ -0,0 +1,13 @@ +using TTT.API.Player; +using TTT.RDM.Models; + +namespace TTT.RDM; + +public interface ICaseManager { + Task FileReport(IOnlinePlayer reporter, int deathId, + string? reason); + Task ClaimNext(IPlayer admin); + Task Claim(IPlayer admin, int caseId); + Task Resolve(int caseId, Verdict verdict, IPlayer admin); + Task> GetOpen(); +} diff --git a/TTT/RDM/lang/RdmMsgs.cs b/TTT/RDM/lang/RdmMsgs.cs index 74bbc94..debf242 100644 --- a/TTT/RDM/lang/RdmMsgs.cs +++ b/TTT/RDM/lang/RdmMsgs.cs @@ -6,4 +6,10 @@ public static class RdmMsgs { public static IMsg RDM_PROMPT(string attacker) { return MsgFactory.Create(nameof(RDM_PROMPT), attacker); } + + public static IMsg RDM_STAFF_NEW_REPORT(string victim, string attacker, + int caseId) { + return MsgFactory.Create(nameof(RDM_STAFF_NEW_REPORT), victim, attacker, + caseId); + } } diff --git a/TTT/RDM/lang/en.yml b/TTT/RDM/lang/en.yml index 595694d..986344c 100644 --- a/TTT/RDM/lang/en.yml +++ b/TTT/RDM/lang/en.yml @@ -1 +1,2 @@ RDM_PROMPT: "%PREFIX%You were killed by {yellow}{0}{grey}. Type {yellow}!rdm{grey} to report it, or ignore (defaults to not RDM)." +RDM_STAFF_NEW_REPORT: "%PREFIX%{red}[RDM]{grey} New report #{2}: {yellow}{0}{grey} reported {yellow}{1}{grey}." diff --git a/TTT/Test/RDM/CaseManagerTests.cs b/TTT/Test/RDM/CaseManagerTests.cs new file mode 100644 index 0000000..cbdb0f6 --- /dev/null +++ b/TTT/Test/RDM/CaseManagerTests.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Player; +using TTT.Game.Damage; +using TTT.RDM; +using TTT.RDM.Models; +using Xunit; + +namespace TTT.Test.RDM; + +public class CaseManagerTests { + private readonly ICaseManager manager; + private readonly IRdmStore store; + private readonly IPlayerFinder players; + + public CaseManagerTests(IServiceProvider provider) { + manager = provider.GetRequiredService(); + store = provider.GetRequiredService(); + players = provider.GetRequiredService(); + } + + private async Task SeedSuspectDeath(IPlayer victim) { + return await store.AddDeath(new DeathRecord { + Round = 1, VictimId = victim.Id, VictimName = victim.Name, + VictimRole = "Innocent", AttackerId = "k", AttackerName = "Killer", + AttackerRole = "Innocent", Timestamp = DateTime.UtcNow, + IsSuspect = true, Fault = KillFault.KillerGuilty + }); + } + + [Fact] + public async Task FileReport_CreatesOpenCase() { + var victim = TestPlayer.Random(); + players.AddPlayer(victim); + var deathId = await SeedSuspectDeath(victim); + + var c = await manager.FileReport(victim, deathId, "shot me in spawn"); + Assert.NotNull(c); + Assert.Equal(CaseState.Open, c!.State); + Assert.Single(await manager.GetOpen()); + } + + [Fact] + public async Task FileReport_ByNonVictim_Rejected() { + var victim = TestPlayer.Random(); + var other = TestPlayer.Random(); + players.AddPlayers(victim, other); + var deathId = await SeedSuspectDeath(victim); + Assert.Null(await manager.FileReport(other, deathId, null)); + } + + [Fact] + public async Task FileReport_Duplicate_Rejected() { + var victim = TestPlayer.Random(); + players.AddPlayer(victim); + var deathId = await SeedSuspectDeath(victim); + Assert.NotNull(await manager.FileReport(victim, deathId, null)); + Assert.Null(await manager.FileReport(victim, deathId, null)); + } + + [Fact] + public async Task ClaimNext_MovesOldestToClaimed() { + var victim = TestPlayer.Random(); + players.AddPlayer(victim); + var deathId = await SeedSuspectDeath(victim); + await manager.FileReport(victim, deathId, null); + + var admin = TestPlayer.Random(); + var c = await manager.ClaimNext(admin); + Assert.NotNull(c); + Assert.Equal(CaseState.Claimed, c!.State); + Assert.Equal(admin.Id, c.HandlerAdminId); + } + + [Fact] + public async Task Resolve_ClosesCase() { + var victim = TestPlayer.Random(); + players.AddPlayer(victim); + var deathId = await SeedSuspectDeath(victim); + var c = await manager.FileReport(victim, deathId, null); + var admin = TestPlayer.Random(); + + await manager.Resolve(c!.Id, Verdict.Forgiven, admin); + Assert.Empty(await manager.GetOpen()); + Assert.Equal(Verdict.Forgiven, (await store.GetCase(c.Id))!.Verdict); + } +} diff --git a/TTT/Test/Startup.cs b/TTT/Test/Startup.cs index b55de86..88bab6f 100644 --- a/TTT/Test/Startup.cs +++ b/TTT/Test/Startup.cs @@ -45,6 +45,7 @@ public void ConfigureServices(IServiceCollection services) { services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddModBehavior(); services.AddModBehavior(); From 4fa7bef82e8a15ca6995e7904cfc39b8abdae562 Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 12:32:34 -0700 Subject: [PATCH 12/19] fix(rdm): ClaimNext oldest-first by construction + ordering test --- TTT/RDM/CaseManager.cs | 4 +++- TTT/Test/RDM/CaseManagerTests.cs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/TTT/RDM/CaseManager.cs b/TTT/RDM/CaseManager.cs index 266df4e..4ecbc1f 100644 --- a/TTT/RDM/CaseManager.cs +++ b/TTT/RDM/CaseManager.cs @@ -63,7 +63,9 @@ private void NotifyStaff(DeathRecord death, RdmCase rdmCase) { public async Task ClaimNext(IPlayer admin) { var open = await store.GetOpenCases(); - var next = open.FirstOrDefault(c => c.State == CaseState.Open); + var next = open.Where(c => c.State == CaseState.Open) + .OrderBy(c => c.Id) + .FirstOrDefault(); if (next == null) return null; return await Claim(admin, next.Id); } diff --git a/TTT/Test/RDM/CaseManagerTests.cs b/TTT/Test/RDM/CaseManagerTests.cs index cbdb0f6..d6cda3b 100644 --- a/TTT/Test/RDM/CaseManagerTests.cs +++ b/TTT/Test/RDM/CaseManagerTests.cs @@ -71,6 +71,22 @@ public async Task ClaimNext_MovesOldestToClaimed() { Assert.Equal(admin.Id, c.HandlerAdminId); } + [Fact] + public async Task ClaimNext_PicksOldestOpenCase() { + var v1 = TestPlayer.Random(); + var v2 = TestPlayer.Random(); + players.AddPlayers(v1, v2); + var death1 = await SeedSuspectDeath(v1); + var death2 = await SeedSuspectDeath(v2); + var first = await manager.FileReport(v1, death1, null); + await manager.FileReport(v2, death2, null); + + var admin = TestPlayer.Random(); + var claimed = await manager.ClaimNext(admin); + Assert.NotNull(claimed); + Assert.Equal(first!.Id, claimed!.Id); // oldest filed is claimed first + } + [Fact] public async Task Resolve_ClosesCase() { var victim = TestPlayer.Random(); From 3be98fca706df7cef60d9edf21a9a8ceb8c4c629 Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 12:36:32 -0700 Subject: [PATCH 13/19] feat(rdm): !rdm victim report command --- TTT/RDM/Commands/RdmCommand.cs | 65 ++++++++++++++++++++++++++++++++ TTT/RDM/lang/RdmMsgs.cs | 20 ++++++++++ TTT/RDM/lang/en.yml | 5 +++ TTT/Test/RDM/RdmCommandTests.cs | 67 +++++++++++++++++++++++++++++++++ TTT/Test/Startup.cs | 1 + 5 files changed, 158 insertions(+) create mode 100644 TTT/RDM/Commands/RdmCommand.cs create mode 100644 TTT/Test/RDM/RdmCommandTests.cs diff --git a/TTT/RDM/Commands/RdmCommand.cs b/TTT/RDM/Commands/RdmCommand.cs new file mode 100644 index 0000000..fb677da --- /dev/null +++ b/TTT/RDM/Commands/RdmCommand.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Command; +using TTT.API.Player; +using TTT.Locale; +using TTT.RDM.lang; + +namespace TTT.RDM.Commands; + +public class RdmCommand(IServiceProvider provider) : ICommand { + private readonly ICaseManager cases = + provider.GetRequiredService(); + + private readonly IRdmStore store = provider.GetRequiredService(); + + private readonly IMsgLocalizer locale = + provider.GetRequiredService(); + + private readonly DeathLogListener deathLog = + provider.GetRequiredService(); + + public void Dispose() { } + public void Start() { } + public string Id => "rdm"; + public string? Description => "File or dismiss an RDM report for your death"; + public string[] Usage => ["[number] [reason]"]; + + public async Task Execute(IOnlinePlayer? executor, + ICommandInfo info) { + if (executor == null) return CommandResult.PLAYER_ONLY; + + var round = deathLog.CurrentRound; + var deaths = await store.GetSuspectDeathsForVictim(executor.Id, round); + + // ArgCount == 1 means just the command name, no extra arguments + if (info.ArgCount == 1) { + if (deaths.Count == 0) { + info.ReplySync(locale[RdmMsgs.RDM_LIST_EMPTY()]); + return CommandResult.SUCCESS; + } + + info.ReplySync(locale[RdmMsgs.RDM_LIST_HEADER()]); + for (var i = 0; i < deaths.Count; i++) + info.ReplySync( + locale[RdmMsgs.RDM_LIST_ENTRY(i + 1, deaths[i].AttackerName)]); + return CommandResult.SUCCESS; + } + + // Args[1] is the first real argument (the death index number) + if (!int.TryParse(info.Args[1], out var index) || index < 1 + || index > deaths.Count) { + info.ReplySync(locale[RdmMsgs.RDM_REPORT_REJECTED()]); + return CommandResult.INVALID_ARGS; + } + + var reason = info.ArgCount > 2 + ? string.Join(' ', info.Args.Skip(2)) + : null; + var filed = await cases.FileReport(executor, deaths[index - 1].Id, reason); + + info.ReplySync(filed == null + ? locale[RdmMsgs.RDM_REPORT_REJECTED()] + : locale[RdmMsgs.RDM_REPORT_FILED(filed.Id)]); + return filed == null ? CommandResult.ERROR : CommandResult.SUCCESS; + } +} diff --git a/TTT/RDM/lang/RdmMsgs.cs b/TTT/RDM/lang/RdmMsgs.cs index debf242..1c6ea16 100644 --- a/TTT/RDM/lang/RdmMsgs.cs +++ b/TTT/RDM/lang/RdmMsgs.cs @@ -12,4 +12,24 @@ public static IMsg RDM_STAFF_NEW_REPORT(string victim, string attacker, return MsgFactory.Create(nameof(RDM_STAFF_NEW_REPORT), victim, attacker, caseId); } + + public static IMsg RDM_LIST_HEADER() { + return MsgFactory.Create(nameof(RDM_LIST_HEADER)); + } + + public static IMsg RDM_LIST_ENTRY(int index, string attacker) { + return MsgFactory.Create(nameof(RDM_LIST_ENTRY), index, attacker); + } + + public static IMsg RDM_LIST_EMPTY() { + return MsgFactory.Create(nameof(RDM_LIST_EMPTY)); + } + + public static IMsg RDM_REPORT_FILED(int caseId) { + return MsgFactory.Create(nameof(RDM_REPORT_FILED), caseId); + } + + public static IMsg RDM_REPORT_REJECTED() { + return MsgFactory.Create(nameof(RDM_REPORT_REJECTED)); + } } diff --git a/TTT/RDM/lang/en.yml b/TTT/RDM/lang/en.yml index 986344c..c811334 100644 --- a/TTT/RDM/lang/en.yml +++ b/TTT/RDM/lang/en.yml @@ -1,2 +1,7 @@ RDM_PROMPT: "%PREFIX%You were killed by {yellow}{0}{grey}. Type {yellow}!rdm{grey} to report it, or ignore (defaults to not RDM)." RDM_STAFF_NEW_REPORT: "%PREFIX%{red}[RDM]{grey} New report #{2}: {yellow}{0}{grey} reported {yellow}{1}{grey}." +RDM_LIST_HEADER: "%PREFIX%Recent suspect deaths — type {yellow}!rdm {grey} to report:" +RDM_LIST_ENTRY: "{yellow}{0}{grey}. Killed by {yellow}{1}" +RDM_LIST_EMPTY: "%PREFIX%You have no recent suspect deaths to report." +RDM_REPORT_FILED: "%PREFIX%Report {yellow}#{0}{grey} filed. Staff have been notified." +RDM_REPORT_REJECTED: "%PREFIX%{red}Could not file that report{grey} (too late, already reported, or invalid)." diff --git a/TTT/Test/RDM/RdmCommandTests.cs b/TTT/Test/RDM/RdmCommandTests.cs new file mode 100644 index 0000000..0404546 --- /dev/null +++ b/TTT/Test/RDM/RdmCommandTests.cs @@ -0,0 +1,67 @@ +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Command; +using TTT.API.Player; +using TTT.Game.Damage; +using TTT.Locale; +using TTT.RDM; +using TTT.RDM.Commands; +using TTT.RDM.lang; +using TTT.RDM.Models; +using TTT.Test.Game.Command; +using Xunit; + +namespace TTT.Test.RDM; + +public class RdmCommandTests : CommandTest { + private readonly IRdmStore store; + private readonly IMsgLocalizer locale; + private readonly IPlayerFinder players; + + public RdmCommandTests(IServiceProvider provider) : base(provider, + new RdmCommand(provider)) { + store = provider.GetRequiredService(); + locale = provider.GetRequiredService(); + players = provider.GetRequiredService(); + } + + private async Task SeedSuspectDeath(IPlayer victim) { + return await store.AddDeath(new DeathRecord { + Round = 0, VictimId = victim.Id, VictimName = victim.Name, + VictimRole = "Innocent", AttackerId = "k", AttackerName = "Killer", + AttackerRole = "Innocent", Timestamp = DateTime.UtcNow, + IsSuspect = true, Fault = KillFault.KillerGuilty + }); + } + + [Fact] + public async Task NoArgs_NoDeaths_ShowsEmpty() { + var victim = TestPlayer.Random(); + players.AddPlayer(victim); + var info = new TestCommandInfo(Provider, victim, "rdm"); + var result = await Commands.ProcessCommand(info); + Assert.Equal(CommandResult.SUCCESS, result); + Assert.Contains(locale[RdmMsgs.RDM_LIST_EMPTY()], victim.Messages); + } + + [Fact] + public async Task NoArgs_WithDeaths_ListsThem() { + var victim = TestPlayer.Random(); + players.AddPlayer(victim); + await SeedSuspectDeath(victim); + var info = new TestCommandInfo(Provider, victim, "rdm"); + Assert.Equal(CommandResult.SUCCESS, await Commands.ProcessCommand(info)); + Assert.Contains(locale[RdmMsgs.RDM_LIST_ENTRY(1, "Killer")], + victim.Messages); + } + + [Fact] + public async Task WithIndex_FilesReport() { + var victim = TestPlayer.Random(); + players.AddPlayer(victim); + await SeedSuspectDeath(victim); + var info = new TestCommandInfo(Provider, victim, "rdm", "1"); + Assert.Equal(CommandResult.SUCCESS, await Commands.ProcessCommand(info)); + Assert.Single(await store.GetOpenCases()); + Assert.Contains(locale[RdmMsgs.RDM_REPORT_FILED(1)], victim.Messages); + } +} diff --git a/TTT/Test/Startup.cs b/TTT/Test/Startup.cs index 88bab6f..036f489 100644 --- a/TTT/Test/Startup.cs +++ b/TTT/Test/Startup.cs @@ -46,6 +46,7 @@ public void ConfigureServices(IServiceCollection services) { services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddModBehavior(); services.AddModBehavior(); From f92917a12bdb68a7489fe534ff7f4e692769342b Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 12:47:25 -0700 Subject: [PATCH 14/19] feat(rdm): staff !cases and !info commands - CasesCommand: staff-gated !cases lists open RDM case count + brief entries - InfoCommand: staff-gated !info shows full case details (Args[1] corrected from brief's Args[0]) - New locale keys: RDM_CASES_COUNT, RDM_CASES_ENTRY, RDM_INFO, RDM_CASE_NOT_FOUND - FakePermissionManager: add SetFlags for per-player flag grants; HasFlags now enforces when flags required - LogsTest: grant @ttt/admin explicitly (LogsCommand requires it; old blanket-true fake masked this) --- TTT/RDM/Commands/CasesCommand.cs | 42 ++++++++++++ TTT/RDM/Commands/InfoCommand.cs | 45 +++++++++++++ TTT/RDM/lang/RdmMsgs.cs | 19 ++++++ TTT/RDM/lang/en.yml | 4 ++ TTT/Test/Fakes/FakePermissionManager.cs | 11 +++- TTT/Test/Game/Command/LogsTest.cs | 6 ++ TTT/Test/RDM/StaffQueryCommandsTests.cs | 85 +++++++++++++++++++++++++ 7 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 TTT/RDM/Commands/CasesCommand.cs create mode 100644 TTT/RDM/Commands/InfoCommand.cs create mode 100644 TTT/Test/RDM/StaffQueryCommandsTests.cs diff --git a/TTT/RDM/Commands/CasesCommand.cs b/TTT/RDM/Commands/CasesCommand.cs new file mode 100644 index 0000000..f72aa3c --- /dev/null +++ b/TTT/RDM/Commands/CasesCommand.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Command; +using TTT.API.Player; +using TTT.API.Storage; +using TTT.Locale; +using TTT.RDM.lang; + +namespace TTT.RDM.Commands; + +public class CasesCommand(IServiceProvider provider) : ICommand { + private readonly ICaseManager cases = + provider.GetRequiredService(); + + private readonly IRdmStore store = provider.GetRequiredService(); + + private readonly IMsgLocalizer locale = + provider.GetRequiredService(); + + private RdmConfig config + => provider.GetService>()?.Load().GetAwaiter() + .GetResult() ?? new RdmConfig(); + + public void Dispose() { } + public void Start() { } + public string Id => "cases"; + public string? Description => "List open RDM cases"; + public string[] RequiredFlags => [config.StaffFlag]; + + public async Task Execute(IOnlinePlayer? executor, + ICommandInfo info) { + var open = await cases.GetOpen(); + info.ReplySync(locale[RdmMsgs.RDM_CASES_COUNT(open.Count)]); + foreach (var c in open) { + var death = await store.GetDeath(c.DeathId); + if (death != null) + info.ReplySync(locale[RdmMsgs.RDM_CASES_ENTRY(c.Id, death.VictimName, + death.AttackerName)]); + } + + return CommandResult.SUCCESS; + } +} diff --git a/TTT/RDM/Commands/InfoCommand.cs b/TTT/RDM/Commands/InfoCommand.cs new file mode 100644 index 0000000..a8ebcf4 --- /dev/null +++ b/TTT/RDM/Commands/InfoCommand.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Command; +using TTT.API.Player; +using TTT.API.Storage; +using TTT.Locale; +using TTT.RDM.lang; + +namespace TTT.RDM.Commands; + +public class InfoCommand(IServiceProvider provider) : ICommand { + private readonly IRdmStore store = provider.GetRequiredService(); + + private readonly IMsgLocalizer locale = + provider.GetRequiredService(); + + private RdmConfig config + => provider.GetService>()?.Load().GetAwaiter() + .GetResult() ?? new RdmConfig(); + + public void Dispose() { } + public void Start() { } + public string Id => "info"; + public string? Description => "Show details for an RDM case"; + public string[] Usage => [""]; + public string[] RequiredFlags => [config.StaffFlag]; + + public async Task Execute(IOnlinePlayer? executor, + ICommandInfo info) { + // Args[0] is the command name; Args[1] is the first real user argument + if (info.ArgCount < 2 || !int.TryParse(info.Args[1], out var caseId)) + return CommandResult.PRINT_USAGE; + + var c = await store.GetCase(caseId); + var death = c == null ? null : await store.GetDeath(c.DeathId); + if (c == null || death == null) { + info.ReplySync(locale[RdmMsgs.RDM_CASE_NOT_FOUND()]); + return CommandResult.ERROR; + } + + info.ReplySync(locale[RdmMsgs.RDM_INFO(c.Id, death.VictimName, + death.VictimRole, death.AttackerName, death.AttackerRole, + death.Weapon ?? "unknown", c.Reason ?? "(none)")]); + return CommandResult.SUCCESS; + } +} diff --git a/TTT/RDM/lang/RdmMsgs.cs b/TTT/RDM/lang/RdmMsgs.cs index 1c6ea16..b7ff462 100644 --- a/TTT/RDM/lang/RdmMsgs.cs +++ b/TTT/RDM/lang/RdmMsgs.cs @@ -32,4 +32,23 @@ public static IMsg RDM_REPORT_FILED(int caseId) { public static IMsg RDM_REPORT_REJECTED() { return MsgFactory.Create(nameof(RDM_REPORT_REJECTED)); } + + public static IMsg RDM_CASES_COUNT(int count) { + return MsgFactory.Create(nameof(RDM_CASES_COUNT), count); + } + + public static IMsg RDM_CASES_ENTRY(int caseId, string victim, + string attacker) { + return MsgFactory.Create(nameof(RDM_CASES_ENTRY), caseId, victim, attacker); + } + + public static IMsg RDM_INFO(int caseId, string victim, string victimRole, + string attacker, string attackerRole, string weapon, string reason) { + return MsgFactory.Create(nameof(RDM_INFO), caseId, victim, victimRole, + attacker, attackerRole, weapon, reason); + } + + public static IMsg RDM_CASE_NOT_FOUND() { + return MsgFactory.Create(nameof(RDM_CASE_NOT_FOUND)); + } } diff --git a/TTT/RDM/lang/en.yml b/TTT/RDM/lang/en.yml index c811334..e641d4c 100644 --- a/TTT/RDM/lang/en.yml +++ b/TTT/RDM/lang/en.yml @@ -5,3 +5,7 @@ RDM_LIST_ENTRY: "{yellow}{0}{grey}. Killed by {yellow}{1}" RDM_LIST_EMPTY: "%PREFIX%You have no recent suspect deaths to report." RDM_REPORT_FILED: "%PREFIX%Report {yellow}#{0}{grey} filed. Staff have been notified." RDM_REPORT_REJECTED: "%PREFIX%{red}Could not file that report{grey} (too late, already reported, or invalid)." +RDM_CASES_COUNT: "%PREFIX%There {yellow}{0}{grey} open RDM case%s%." +RDM_CASES_ENTRY: "{yellow}#{0}{grey}: {yellow}{1}{grey} vs {yellow}{2}" +RDM_INFO: "%PREFIX%Case {yellow}#{0}{grey}: {yellow}{1}{grey} ({3}) killed by {yellow}{2}{grey} ({4}) with {yellow}{5}{grey}. Reason: {6}" +RDM_CASE_NOT_FOUND: "%PREFIX%{red}No such case.{grey}" diff --git a/TTT/Test/Fakes/FakePermissionManager.cs b/TTT/Test/Fakes/FakePermissionManager.cs index 4313cf3..2f6b8aa 100644 --- a/TTT/Test/Fakes/FakePermissionManager.cs +++ b/TTT/Test/Fakes/FakePermissionManager.cs @@ -3,7 +3,16 @@ namespace TTT.Test.Fakes; public class FakePermissionManager : IPermissionManager { - public bool HasFlags(IPlayer player, params string[] flags) { return true; } + private readonly Dictionary> playerFlags = new(); + + public void SetFlags(IPlayer player, params string[] grant) { + playerFlags[player.Id] = [..grant]; + } + + public bool HasFlags(IPlayer player, params string[] flags) { + if (flags.Length == 0) return true; + return playerFlags.TryGetValue(player.Id, out var f) && flags.All(f.Contains); + } public bool InGroups(IPlayer player, params string[] groups) { return true; } } \ No newline at end of file diff --git a/TTT/Test/Game/Command/LogsTest.cs b/TTT/Test/Game/Command/LogsTest.cs index 7593e19..4b3ee74 100644 --- a/TTT/Test/Game/Command/LogsTest.cs +++ b/TTT/Test/Game/Command/LogsTest.cs @@ -5,6 +5,7 @@ using TTT.Game.Commands; using TTT.Game.lang; using TTT.Locale; +using TTT.Test.Fakes; using Xunit; namespace TTT.Test.Game.Command; @@ -14,9 +15,13 @@ public class LogsTest(IServiceProvider provider) : CommandTest(provider, private readonly IMsgLocalizer locale = provider.GetRequiredService(); + private readonly FakePermissionManager perms = + (FakePermissionManager)provider.GetRequiredService(); + [Fact] public async Task LogsCommand_WithoutGame_PrintsNoActiveGame() { var player = TestPlayer.Random(); + perms.SetFlags(player, "@ttt/admin"); var info = new TestCommandInfo(Provider, player, Command.Id); var result = await Commands.ProcessCommand(info); Assert.Equal(CommandResult.ERROR, result); @@ -27,6 +32,7 @@ public async Task LogsCommand_WithoutGame_PrintsNoActiveGame() { [Fact] public async Task LogsCommand_WithGame_PrintsLogs() { var player = TestPlayer.Random(); + perms.SetFlags(player, "@ttt/admin"); Provider.GetRequiredService() .AddPlayers(player, TestPlayer.Random()); diff --git a/TTT/Test/RDM/StaffQueryCommandsTests.cs b/TTT/Test/RDM/StaffQueryCommandsTests.cs new file mode 100644 index 0000000..2908ef8 --- /dev/null +++ b/TTT/Test/RDM/StaffQueryCommandsTests.cs @@ -0,0 +1,85 @@ +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Command; +using TTT.API.Player; +using TTT.Game.Damage; +using TTT.Locale; +using TTT.RDM; +using TTT.RDM.Commands; +using TTT.RDM.lang; +using TTT.RDM.Models; +using TTT.Test.Fakes; +using TTT.Test.Game.Command; +using Xunit; + +namespace TTT.Test.RDM; + +public class StaffQueryCommandsTests { + private readonly IServiceProvider provider; + private readonly ICommandManager commands; + private readonly ICaseManager manager; + private readonly IRdmStore store; + private readonly IMsgLocalizer locale; + private readonly IPlayerFinder players; + private readonly FakePermissionManager perms; + + public StaffQueryCommandsTests(IServiceProvider provider) { + this.provider = provider; + commands = provider.GetRequiredService(); + manager = provider.GetRequiredService(); + store = provider.GetRequiredService(); + locale = provider.GetRequiredService(); + players = provider.GetRequiredService(); + perms = (FakePermissionManager)provider + .GetRequiredService(); + commands.RegisterCommand(new CasesCommand(provider)); + commands.RegisterCommand(new InfoCommand(provider)); + } + + private async Task SeedOpenCase(IOnlinePlayer victim) { + var deathId = await store.AddDeath(new DeathRecord { + Round = 0, VictimId = victim.Id, VictimName = victim.Name, + VictimRole = "Innocent", AttackerId = "k", AttackerName = "Killer", + AttackerRole = "Innocent", Weapon = "ak47", Timestamp = DateTime.UtcNow, + IsSuspect = true, Fault = KillFault.KillerGuilty + }); + return (await manager.FileReport(victim, deathId, "spawn kill"))!; + } + + [Fact] + public async Task Cases_AsStaff_ShowsCount() { + var victim = TestPlayer.Random(); + var admin = TestPlayer.Random(); + players.AddPlayers(victim, admin); + perms.SetFlags(admin, "@ttt/admin"); + await SeedOpenCase(victim); + + var result = await commands.ProcessCommand( + new TestCommandInfo(provider, admin, "cases")); + Assert.Equal(CommandResult.SUCCESS, result); + Assert.Contains(locale[RdmMsgs.RDM_CASES_COUNT(1)], admin.Messages); + } + + [Fact] + public async Task Cases_WithoutFlag_NoPermission() { + var player = TestPlayer.Random(); + players.AddPlayer(player); + var result = await commands.ProcessCommand( + new TestCommandInfo(provider, player, "cases")); + Assert.Equal(CommandResult.NO_PERMISSION, result); + } + + [Fact] + public async Task Info_ShowsCaseDetails() { + var victim = TestPlayer.Random(); + var admin = TestPlayer.Random(); + players.AddPlayers(victim, admin); + perms.SetFlags(admin, "@ttt/admin"); + var c = await SeedOpenCase(victim); + + var result = await commands.ProcessCommand( + new TestCommandInfo(provider, admin, "info", c.Id.ToString())); + Assert.Equal(CommandResult.SUCCESS, result); + Assert.Contains(admin.Messages, + m => m.Contains("Killer") && m.Contains("spawn kill")); + } +} From b68a1c9f8c2640290b46dc65cd90b9e4f3726c3e Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 12:54:28 -0700 Subject: [PATCH 15/19] fix(rdm): correct RDM_INFO placeholder order + guard with positional assert --- TTT/RDM/lang/en.yml | 2 +- TTT/Test/RDM/StaffQueryCommandsTests.cs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/TTT/RDM/lang/en.yml b/TTT/RDM/lang/en.yml index e641d4c..beaab06 100644 --- a/TTT/RDM/lang/en.yml +++ b/TTT/RDM/lang/en.yml @@ -7,5 +7,5 @@ RDM_REPORT_FILED: "%PREFIX%Report {yellow}#{0}{grey} filed. Staff have been noti RDM_REPORT_REJECTED: "%PREFIX%{red}Could not file that report{grey} (too late, already reported, or invalid)." RDM_CASES_COUNT: "%PREFIX%There {yellow}{0}{grey} open RDM case%s%." RDM_CASES_ENTRY: "{yellow}#{0}{grey}: {yellow}{1}{grey} vs {yellow}{2}" -RDM_INFO: "%PREFIX%Case {yellow}#{0}{grey}: {yellow}{1}{grey} ({3}) killed by {yellow}{2}{grey} ({4}) with {yellow}{5}{grey}. Reason: {6}" +RDM_INFO: "%PREFIX%Case {yellow}#{0}{grey}: {yellow}{1}{grey} ({2}) killed by {yellow}{3}{grey} ({4}) with {yellow}{5}{grey}. Reason: {6}" RDM_CASE_NOT_FOUND: "%PREFIX%{red}No such case.{grey}" diff --git a/TTT/Test/RDM/StaffQueryCommandsTests.cs b/TTT/Test/RDM/StaffQueryCommandsTests.cs index 2908ef8..9702e15 100644 --- a/TTT/Test/RDM/StaffQueryCommandsTests.cs +++ b/TTT/Test/RDM/StaffQueryCommandsTests.cs @@ -79,7 +79,10 @@ public async Task Info_ShowsCaseDetails() { var result = await commands.ProcessCommand( new TestCommandInfo(provider, admin, "info", c.Id.ToString())); Assert.Equal(CommandResult.SUCCESS, result); - Assert.Contains(admin.Messages, - m => m.Contains("Killer") && m.Contains("spawn kill")); + Assert.Contains(admin.Messages, m => + m.Contains("killed by") + && m.IndexOf("Killer", StringComparison.Ordinal) + > m.IndexOf("killed by", StringComparison.Ordinal) + && m.Contains("spawn kill")); } } From 3743d1c6cbde6765ce1dc91980a9b7005b204054 Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 12:57:31 -0700 Subject: [PATCH 16/19] feat(rdm): slay service + round-start slay queue --- TTT/RDM/ISlayService.cs | 11 ++++++ TTT/RDM/SlayQueueListener.cs | 21 ++++++++++++ TTT/RDM/SlayService.cs | 45 +++++++++++++++++++++++++ TTT/Test/RDM/SlayServiceTests.cs | 57 ++++++++++++++++++++++++++++++++ TTT/Test/Startup.cs | 1 + 5 files changed, 135 insertions(+) create mode 100644 TTT/RDM/ISlayService.cs create mode 100644 TTT/RDM/SlayQueueListener.cs create mode 100644 TTT/RDM/SlayService.cs create mode 100644 TTT/Test/RDM/SlayServiceTests.cs diff --git a/TTT/RDM/ISlayService.cs b/TTT/RDM/ISlayService.cs new file mode 100644 index 0000000..b021596 --- /dev/null +++ b/TTT/RDM/ISlayService.cs @@ -0,0 +1,11 @@ +using TTT.API.Player; + +namespace TTT.RDM; + +public interface ISlayService { + /// Apply a guilty verdict: slay now if alive, queue the remainder. + Task ApplyGuilty(IPlayer offender, string victimRole, int caseId); + + /// Pay one slay for each alive debtor at round start. Returns slays applied. + Task PayRoundStart(); +} diff --git a/TTT/RDM/SlayQueueListener.cs b/TTT/RDM/SlayQueueListener.cs new file mode 100644 index 0000000..ff9d686 --- /dev/null +++ b/TTT/RDM/SlayQueueListener.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Events; +using TTT.API.Game; +using TTT.Game.Events.Game; +using TTT.Game.Listeners; + +namespace TTT.RDM; + +public class SlayQueueListener(IServiceProvider provider) + : BaseListener(provider) { + private readonly ISlayService slay = + provider.GetRequiredService(); + + [EventHandler] + [UsedImplicitly] + public void OnRoundStart(GameStateUpdateEvent ev) { + if (ev.NewState != State.IN_PROGRESS) return; + Task.Run(async () => await slay.PayRoundStart()); + } +} diff --git a/TTT/RDM/SlayService.cs b/TTT/RDM/SlayService.cs new file mode 100644 index 0000000..9465bf4 --- /dev/null +++ b/TTT/RDM/SlayService.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Player; +using TTT.API.Storage; + +namespace TTT.RDM; + +public class SlayService(IServiceProvider provider) : ISlayService { + private readonly IRdmStore store = provider.GetRequiredService(); + + private readonly IPlayerFinder finder = + provider.GetRequiredService(); + + private RdmConfig config + => provider.GetService>()?.Load().GetAwaiter() + .GetResult() ?? new RdmConfig(); + + public async Task ApplyGuilty(IPlayer offender, string victimRole, + int caseId) { + var owed = config.SlaysForRole(victimRole); + var existing = await store.GetSlayDebt(offender.Id); + var total = existing + owed; + + var online = finder.GetPlayerById(offender.Id); + if (online is { IsAlive: true }) { + online.Health = 0; // immediate slay + total--; + } + + await store.SetSlayDebt(offender.Id, total, caseId); + } + + public async Task PayRoundStart() { + var applied = 0; + foreach (var debt in await store.GetAllSlayDebts()) { + var online = finder.GetPlayerById(debt.PlayerId); + if (online is not { IsAlive: true }) continue; + online.Health = 0; + await store.SetSlayDebt(debt.PlayerId, debt.RemainingSlays - 1, + debt.SourceCaseId); + applied++; + } + + return applied; + } +} diff --git a/TTT/Test/RDM/SlayServiceTests.cs b/TTT/Test/RDM/SlayServiceTests.cs new file mode 100644 index 0000000..8c440c8 --- /dev/null +++ b/TTT/Test/RDM/SlayServiceTests.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Player; +using TTT.RDM; +using Xunit; + +namespace TTT.Test.RDM; + +public class SlayServiceTests { + private readonly ISlayService slay; + private readonly IRdmStore store; + private readonly IPlayerFinder players; + + public SlayServiceTests(IServiceProvider provider) { + slay = provider.GetRequiredService(); + store = provider.GetRequiredService(); + players = provider.GetRequiredService(); + } + + [Fact] + public async Task ApplyGuilty_AliveOffender_SlaysNowAndQueuesRest() { + var offender = TestPlayer.Random(); + offender.IsAlive = true; + offender.Health = 100; + players.AddPlayer(offender); + + await slay.ApplyGuilty(offender, "Innocent", caseId: 1); // 3 slays + + Assert.Equal(0, offender.Health); // immediate slay applied + Assert.Equal(2, await store.GetSlayDebt(offender.Id)); // 3 - 1 + } + + [Fact] + public async Task ApplyGuilty_DeadOffender_QueuesAll() { + var offender = TestPlayer.Random(); + offender.IsAlive = false; + players.AddPlayer(offender); + + await slay.ApplyGuilty(offender, "Traitor", caseId: 1); // 5 slays + Assert.Equal(5, await store.GetSlayDebt(offender.Id)); + } + + [Fact] + public async Task PayRoundStart_SlaysAliveDebtorsOncePerRound() { + var offender = TestPlayer.Random(); + offender.IsAlive = false; + players.AddPlayer(offender); + await slay.ApplyGuilty(offender, "Innocent", caseId: 1); // queues 3 + + // Round 1: offender respawns alive, pays one slay. + offender.IsAlive = true; + offender.Health = 100; + var applied = await slay.PayRoundStart(); + Assert.Equal(1, applied); + Assert.Equal(0, offender.Health); + Assert.Equal(2, await store.GetSlayDebt(offender.Id)); + } +} diff --git a/TTT/Test/Startup.cs b/TTT/Test/Startup.cs index 036f489..fe3fbb4 100644 --- a/TTT/Test/Startup.cs +++ b/TTT/Test/Startup.cs @@ -46,6 +46,7 @@ public void ConfigureServices(IServiceCollection services) { services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddModBehavior(); From a2a3c758310f499823bab53aebb74464d8cacb5f Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 13:04:29 -0700 Subject: [PATCH 17/19] feat(rdm): staff !handle and !verdict commands --- TTT/RDM/CaseManager.cs | 6 ++ TTT/RDM/Commands/HandleCommand.cs | 49 ++++++++++++++++ TTT/RDM/Commands/VerdictCommand.cs | 86 +++++++++++++++++++++++++++ TTT/RDM/ICaseManager.cs | 1 + TTT/RDM/lang/RdmMsgs.cs | 24 ++++++++ TTT/RDM/lang/en.yml | 6 ++ TTT/Test/RDM/VerdictFlowTests.cs | 93 ++++++++++++++++++++++++++++++ 7 files changed, 265 insertions(+) create mode 100644 TTT/RDM/Commands/HandleCommand.cs create mode 100644 TTT/RDM/Commands/VerdictCommand.cs create mode 100644 TTT/Test/RDM/VerdictFlowTests.cs diff --git a/TTT/RDM/CaseManager.cs b/TTT/RDM/CaseManager.cs index 4ecbc1f..49dd57e 100644 --- a/TTT/RDM/CaseManager.cs +++ b/TTT/RDM/CaseManager.cs @@ -90,4 +90,10 @@ await store.UpdateCase(c with { } public Task> GetOpen() { return store.GetOpenCases(); } + + public async Task GetClaimedBy(string adminId) { + var open = await store.GetOpenCases(); + return open.FirstOrDefault(c + => c.State == CaseState.Claimed && c.HandlerAdminId == adminId); + } } diff --git a/TTT/RDM/Commands/HandleCommand.cs b/TTT/RDM/Commands/HandleCommand.cs new file mode 100644 index 0000000..08d1bc8 --- /dev/null +++ b/TTT/RDM/Commands/HandleCommand.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Command; +using TTT.API.Player; +using TTT.API.Storage; +using TTT.Locale; +using TTT.RDM.lang; + +namespace TTT.RDM.Commands; + +public class HandleCommand(IServiceProvider provider) : ICommand { + private readonly ICaseManager cases = + provider.GetRequiredService(); + + private readonly IRdmStore store = provider.GetRequiredService(); + + private readonly IMsgLocalizer locale = + provider.GetRequiredService(); + + private RdmConfig config + => provider.GetService>()?.Load().GetAwaiter() + .GetResult() ?? new RdmConfig(); + + public void Dispose() { } + public void Start() { } + public string Id => "handle"; + public string? Description => "Claim the next (or a specific) RDM case"; + public string[] Usage => ["[caseId]"]; + public string[] RequiredFlags => [config.StaffFlag]; + + public async Task Execute(IOnlinePlayer? executor, + ICommandInfo info) { + if (executor == null) return CommandResult.PLAYER_ONLY; + + // Args[0] is the command name; Args[1] is the optional case id + var claimed = info.ArgCount >= 2 && int.TryParse(info.Args[1], out var id) + ? await cases.Claim(executor, id) + : await cases.ClaimNext(executor); + + if (claimed == null) { + info.ReplySync(locale[RdmMsgs.RDM_NO_OPEN_CASES()]); + return CommandResult.ERROR; + } + + var death = await store.GetDeath(claimed.DeathId); + info.ReplySync(locale[RdmMsgs.RDM_HANDLED(claimed.Id, + death?.VictimName ?? "?", death?.AttackerName ?? "?")]); + return CommandResult.SUCCESS; + } +} diff --git a/TTT/RDM/Commands/VerdictCommand.cs b/TTT/RDM/Commands/VerdictCommand.cs new file mode 100644 index 0000000..b2e0dd9 --- /dev/null +++ b/TTT/RDM/Commands/VerdictCommand.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Command; +using TTT.API.Player; +using TTT.API.Storage; +using TTT.Locale; +using TTT.RDM.lang; +using TTT.RDM.Models; + +namespace TTT.RDM.Commands; + +public class VerdictCommand(IServiceProvider provider) : ICommand { + private readonly ICaseManager cases = + provider.GetRequiredService(); + + private readonly IRdmStore store = provider.GetRequiredService(); + + private readonly ISlayService slay = + provider.GetRequiredService(); + + private readonly IPlayerFinder finder = + provider.GetRequiredService(); + + private readonly IMsgLocalizer locale = + provider.GetRequiredService(); + + private RdmConfig config + => provider.GetService>()?.Load().GetAwaiter() + .GetResult() ?? new RdmConfig(); + + public void Dispose() { } + public void Start() { } + public string Id => "verdict"; + public string? Description => "Decide your claimed RDM case"; + public string[] Usage => [""]; + public string[] RequiredFlags => [config.StaffFlag]; + + public async Task Execute(IOnlinePlayer? executor, + ICommandInfo info) { + if (executor == null) return CommandResult.PLAYER_ONLY; + + var claimed = await cases.GetClaimedBy(executor.Id); + if (claimed == null) { + info.ReplySync(locale[RdmMsgs.RDM_NO_CLAIMED_CASE()]); + return CommandResult.ERROR; + } + + // Args[0] is the command name; Args[1] is the verdict choice + if (info.ArgCount < 2) { + info.ReplySync(locale[RdmMsgs.RDM_VERDICT_USAGE()]); + return CommandResult.PRINT_USAGE; + } + + var choice = info.Args[1].ToLowerInvariant(); + var death = await store.GetDeath(claimed.DeathId); + + switch (choice) { + case "forgive": + case "forgiven": + await cases.Resolve(claimed.Id, Verdict.Forgiven, executor); + info.ReplySync(locale[RdmMsgs.RDM_VERDICT_FORGIVEN(claimed.Id)]); + return CommandResult.SUCCESS; + case "guilty": + await cases.Resolve(claimed.Id, Verdict.Guilty, executor); + if (death != null) { + var offender = (IPlayer?)finder.GetPlayerById(death.AttackerId) + ?? new OfflineRef(death.AttackerId); + var slays = config.SlaysForRole(death.VictimRole); + await slay.ApplyGuilty( + offender, death.VictimRole, + claimed.Id); + info.ReplySync( + locale[RdmMsgs.RDM_VERDICT_GUILTY(claimed.Id, slays)]); + } + + return CommandResult.SUCCESS; + default: + info.ReplySync(locale[RdmMsgs.RDM_VERDICT_USAGE()]); + return CommandResult.INVALID_ARGS; + } + } + + // Minimal IPlayer for an offline offender so slay debt can be persisted by id. + private sealed record OfflineRef(string Id) : IPlayer { + public string Name { get; set; } = Id; + } +} diff --git a/TTT/RDM/ICaseManager.cs b/TTT/RDM/ICaseManager.cs index ab82da7..3bd7720 100644 --- a/TTT/RDM/ICaseManager.cs +++ b/TTT/RDM/ICaseManager.cs @@ -10,4 +10,5 @@ public interface ICaseManager { Task Claim(IPlayer admin, int caseId); Task Resolve(int caseId, Verdict verdict, IPlayer admin); Task> GetOpen(); + Task GetClaimedBy(string adminId); } diff --git a/TTT/RDM/lang/RdmMsgs.cs b/TTT/RDM/lang/RdmMsgs.cs index b7ff462..67a86c5 100644 --- a/TTT/RDM/lang/RdmMsgs.cs +++ b/TTT/RDM/lang/RdmMsgs.cs @@ -51,4 +51,28 @@ public static IMsg RDM_INFO(int caseId, string victim, string victimRole, public static IMsg RDM_CASE_NOT_FOUND() { return MsgFactory.Create(nameof(RDM_CASE_NOT_FOUND)); } + + public static IMsg RDM_HANDLED(int caseId, string victim, string attacker) { + return MsgFactory.Create(nameof(RDM_HANDLED), caseId, victim, attacker); + } + + public static IMsg RDM_NO_OPEN_CASES() { + return MsgFactory.Create(nameof(RDM_NO_OPEN_CASES)); + } + + public static IMsg RDM_NO_CLAIMED_CASE() { + return MsgFactory.Create(nameof(RDM_NO_CLAIMED_CASE)); + } + + public static IMsg RDM_VERDICT_USAGE() { + return MsgFactory.Create(nameof(RDM_VERDICT_USAGE)); + } + + public static IMsg RDM_VERDICT_GUILTY(int caseId, int slays) { + return MsgFactory.Create(nameof(RDM_VERDICT_GUILTY), caseId, slays); + } + + public static IMsg RDM_VERDICT_FORGIVEN(int caseId) { + return MsgFactory.Create(nameof(RDM_VERDICT_FORGIVEN), caseId); + } } diff --git a/TTT/RDM/lang/en.yml b/TTT/RDM/lang/en.yml index beaab06..8c9234d 100644 --- a/TTT/RDM/lang/en.yml +++ b/TTT/RDM/lang/en.yml @@ -9,3 +9,9 @@ RDM_CASES_COUNT: "%PREFIX%There {yellow}{0}{grey} open RDM case%s%." RDM_CASES_ENTRY: "{yellow}#{0}{grey}: {yellow}{1}{grey} vs {yellow}{2}" RDM_INFO: "%PREFIX%Case {yellow}#{0}{grey}: {yellow}{1}{grey} ({2}) killed by {yellow}{3}{grey} ({4}) with {yellow}{5}{grey}. Reason: {6}" RDM_CASE_NOT_FOUND: "%PREFIX%{red}No such case.{grey}" +RDM_HANDLED: "%PREFIX%Now handling case {yellow}#{0}{grey}: {yellow}{1}{grey} vs {yellow}{2}{grey}. Use {yellow}!verdict guilty|forgive{grey}." +RDM_NO_OPEN_CASES: "%PREFIX%There are no open RDM cases." +RDM_NO_CLAIMED_CASE: "%PREFIX%{red}You are not handling a case.{grey} Use {yellow}!handle{grey} first." +RDM_VERDICT_USAGE: "%PREFIX%Usage: {yellow}!verdict guilty{grey} or {yellow}!verdict forgive{grey}." +RDM_VERDICT_GUILTY: "%PREFIX%Case {yellow}#{0}{grey}: {red}GUILTY{grey} — {yellow}{1}{grey} slay%s% queued." +RDM_VERDICT_FORGIVEN: "%PREFIX%Case {yellow}#{0}{grey}: {green}forgiven{grey}." diff --git a/TTT/Test/RDM/VerdictFlowTests.cs b/TTT/Test/RDM/VerdictFlowTests.cs new file mode 100644 index 0000000..b18d5d1 --- /dev/null +++ b/TTT/Test/RDM/VerdictFlowTests.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Command; +using TTT.API.Player; +using TTT.Game.Damage; +using TTT.Locale; +using TTT.RDM; +using TTT.RDM.Commands; +using TTT.RDM.lang; +using TTT.RDM.Models; +using TTT.Test.Fakes; +using TTT.Test.Game.Command; +using Xunit; + +namespace TTT.Test.RDM; + +public class VerdictFlowTests { + private readonly IServiceProvider provider; + private readonly ICommandManager commands; + private readonly ICaseManager manager; + private readonly IRdmStore store; + private readonly IMsgLocalizer locale; + private readonly IPlayerFinder players; + private readonly FakePermissionManager perms; + + public VerdictFlowTests(IServiceProvider provider) { + this.provider = provider; + commands = provider.GetRequiredService(); + manager = provider.GetRequiredService(); + store = provider.GetRequiredService(); + locale = provider.GetRequiredService(); + players = provider.GetRequiredService(); + perms = (FakePermissionManager)provider + .GetRequiredService(); + commands.RegisterCommand(new HandleCommand(provider)); + commands.RegisterCommand(new VerdictCommand(provider)); + } + + private async Task<(TestPlayer offender, TestPlayer admin, RdmCase c)> + SeedReportedCase() { + var victim = TestPlayer.Random(); + var offender = TestPlayer.Random(); + var admin = TestPlayer.Random(); + offender.IsAlive = false; + players.AddPlayers(victim, offender, admin); + perms.SetFlags(admin, "@ttt/admin"); + + var deathId = await store.AddDeath(new DeathRecord { + Round = 0, VictimId = victim.Id, VictimName = victim.Name, + VictimRole = "Innocent", AttackerId = offender.Id, + AttackerName = offender.Name, AttackerRole = "Innocent", + Weapon = "ak47", Timestamp = DateTime.UtcNow, IsSuspect = true, + Fault = KillFault.KillerGuilty + }); + var c = (await manager.FileReport(victim, deathId, "rdm"))!; + return (offender, admin, c); + } + + [Fact] + public async Task Handle_ThenGuilty_QueuesSlays() { + var (offender, admin, c) = await SeedReportedCase(); + + Assert.Equal(CommandResult.SUCCESS, await commands.ProcessCommand( + new TestCommandInfo(provider, admin, "handle"))); + Assert.Equal(CommandResult.SUCCESS, await commands.ProcessCommand( + new TestCommandInfo(provider, admin, "verdict", "guilty"))); + + Assert.Equal(3, await store.GetSlayDebt(offender.Id)); // innocent victim + Assert.Empty(await manager.GetOpen()); // case resolved + } + + [Fact] + public async Task Handle_ThenForgive_NoSlays() { + var (offender, admin, _) = await SeedReportedCase(); + await commands.ProcessCommand( + new TestCommandInfo(provider, admin, "handle")); + Assert.Equal(CommandResult.SUCCESS, await commands.ProcessCommand( + new TestCommandInfo(provider, admin, "verdict", "forgive"))); + + Assert.Equal(0, await store.GetSlayDebt(offender.Id)); + Assert.Empty(await manager.GetOpen()); + } + + [Fact] + public async Task Verdict_WithoutClaimedCase_Errors() { + var admin = TestPlayer.Random(); + players.AddPlayer(admin); + perms.SetFlags(admin, "@ttt/admin"); + var result = await commands.ProcessCommand( + new TestCommandInfo(provider, admin, "verdict", "guilty")); + Assert.Equal(CommandResult.ERROR, result); + Assert.Contains(locale[RdmMsgs.RDM_NO_CLAIMED_CASE()], admin.Messages); + } +} From 503fb95b4953871b076b47134d4a6afc9470e7ac Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 13:15:52 -0700 Subject: [PATCH 18/19] feat(rdm): production wiring + CS2 cvar config + e2e test --- TTT/CS2/CS2.csproj | 1 + TTT/CS2/CS2ServiceCollection.cs | 2 + TTT/CS2/Configs/CS2RdmConfig.cs | 66 ++++++++++++++++++++++++ TTT/RDM/RdmServiceCollection.cs | 24 ++++++++- TTT/Test/RDM/RdmEndToEndTests.cs | 86 ++++++++++++++++++++++++++++++++ 5 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 TTT/CS2/Configs/CS2RdmConfig.cs create mode 100644 TTT/Test/RDM/RdmEndToEndTests.cs diff --git a/TTT/CS2/CS2.csproj b/TTT/CS2/CS2.csproj index 88f431c..d6c725a 100644 --- a/TTT/CS2/CS2.csproj +++ b/TTT/CS2/CS2.csproj @@ -12,6 +12,7 @@ + diff --git a/TTT/CS2/CS2ServiceCollection.cs b/TTT/CS2/CS2ServiceCollection.cs index 49671db..15ab2e9 100644 --- a/TTT/CS2/CS2ServiceCollection.cs +++ b/TTT/CS2/CS2ServiceCollection.cs @@ -25,6 +25,7 @@ using TTT.Game; using TTT.Karma; using TTT.Locale; +using TTT.RDM; namespace TTT.CS2; @@ -51,6 +52,7 @@ public static void AddCS2Services(this IServiceCollection collection) { collection .AddModBehavior, CS2PoisonSmokeConfig>(); collection.AddModBehavior, CS2KarmaConfig>(); + collection.AddModBehavior, CS2RdmConfig>(); collection.AddModBehavior, CS2CamoConfig>(); collection.AddModBehavior, CS2StickersConfig>(); collection.AddModBehavior, CS2BodyPaintConfig>(); diff --git a/TTT/CS2/Configs/CS2RdmConfig.cs b/TTT/CS2/Configs/CS2RdmConfig.cs new file mode 100644 index 0000000..c6d86e2 --- /dev/null +++ b/TTT/CS2/Configs/CS2RdmConfig.cs @@ -0,0 +1,66 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Cvars; +using CounterStrikeSharp.API.Modules.Cvars.Validators; +using TTT.API; +using TTT.API.Storage; +using TTT.RDM; + +namespace TTT.CS2.Configs; + +public class CS2RdmConfig : IStorage, IPluginModule { + public static readonly FakeConVar CV_DB_STRING = new( + "css_ttt_rdm_db_string", "Database connection string for RDM storage", + "Data Source=rdm.db"); + + public static readonly FakeConVar CV_TRAITOR_SLAYS = new( + "css_ttt_rdm_traitor_slays", "Slays when the RDM victim was a Traitor", 5, + ConVarFlags.FCVAR_NONE, new RangeValidator(0, 100)); + + public static readonly FakeConVar CV_DETECTIVE_SLAYS = new( + "css_ttt_rdm_detective_slays", "Slays when the RDM victim was a Detective", + 5, ConVarFlags.FCVAR_NONE, new RangeValidator(0, 100)); + + public static readonly FakeConVar CV_INNOCENT_SLAYS = new( + "css_ttt_rdm_innocent_slays", "Slays when the RDM victim was an Innocent", + 3, ConVarFlags.FCVAR_NONE, new RangeValidator(0, 100)); + + public static readonly FakeConVar CV_NOTIFY_ADMINS = new( + "css_ttt_rdm_notify_admins", "Message online staff when a report is filed", + true); + + public static readonly FakeConVar CV_AUTO_PROMPT = new( + "css_ttt_rdm_auto_prompt", "Prompt victims to report after a suspect kill", + true); + + public static readonly FakeConVar CV_REPORT_WINDOW = new( + "css_ttt_rdm_report_window_seconds", + "Seconds after a death during which a victim may report it", 60, + ConVarFlags.FCVAR_NONE, new RangeValidator(1, 3600)); + + public static readonly FakeConVar CV_MAX_REPORTS = new( + "css_ttt_rdm_max_reports_per_round", + "Max reports a single victim may file per round", 3, + ConVarFlags.FCVAR_NONE, new RangeValidator(1, 100)); + + public static readonly FakeConVar CV_STAFF_FLAG = new( + "css_ttt_rdm_staff_flag", "Admin flag required for RDM staff commands", + "@ttt/admin"); + + public void Dispose() { } + public void Start() { } + + public Task Load() { + return Task.FromResult(new RdmConfig { + DbString = CV_DB_STRING.Value, + TraitorSlays = CV_TRAITOR_SLAYS.Value, + DetectiveSlays = CV_DETECTIVE_SLAYS.Value, + InnocentSlays = CV_INNOCENT_SLAYS.Value, + NotifyAdmins = CV_NOTIFY_ADMINS.Value, + AutoPromptOnSuspectKill = CV_AUTO_PROMPT.Value, + ReportWindowSeconds = CV_REPORT_WINDOW.Value, + MaxReportsPerVictimPerRound = CV_MAX_REPORTS.Value, + StaffFlag = CV_STAFF_FLAG.Value + }); + } +} diff --git a/TTT/RDM/RdmServiceCollection.cs b/TTT/RDM/RdmServiceCollection.cs index f64d6ec..76e9013 100644 --- a/TTT/RDM/RdmServiceCollection.cs +++ b/TTT/RDM/RdmServiceCollection.cs @@ -1,9 +1,31 @@ using Microsoft.Extensions.DependencyInjection; +using TTT.API.Extensions; +using TTT.RDM.Commands; namespace TTT.RDM; public static class RdmServiceCollection { public static void AddRdmService(this IServiceCollection collection) { - // Services are registered by later tasks. + // Persistence (production: SQLite). Config is resolved lazily so the + // connection string comes from IStorage when present. + collection.AddSingleton(provider => { + var config = provider.GetService>() + ?.Load().GetAwaiter().GetResult() ?? new RdmConfig(); + return new SqliteRdmStore(config.DbString); + }); + + collection.AddSingleton(); + collection.AddSingleton(); + + // Listeners + collection.AddModBehavior(); + collection.AddModBehavior(); + + // Commands + collection.AddModBehavior(); + collection.AddModBehavior(); + collection.AddModBehavior(); + collection.AddModBehavior(); + collection.AddModBehavior(); } } diff --git a/TTT/Test/RDM/RdmEndToEndTests.cs b/TTT/Test/RDM/RdmEndToEndTests.cs new file mode 100644 index 0000000..833d5e1 --- /dev/null +++ b/TTT/Test/RDM/RdmEndToEndTests.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.DependencyInjection; +using TTT.API.Command; +using TTT.API.Events; +using TTT.API.Game; +using TTT.API.Player; +using TTT.API.Role; +using TTT.Game.Damage; +using TTT.Game.Events.Player; +using TTT.Game.Roles; +using TTT.RDM; +using TTT.RDM.Commands; +using TTT.Test.Fakes; +using TTT.Test.Game.Command; +using Xunit; + +namespace TTT.Test.RDM; + +public class RdmEndToEndTests { + private readonly IServiceProvider provider; + private readonly IEventBus bus; + private readonly IGameManager games; + private readonly IPlayerFinder players; + private readonly IRoleAssigner roles; + private readonly ICommandManager commands; + private readonly IRdmStore store; + private readonly FakePermissionManager perms; + private readonly IList roleSet; + + public RdmEndToEndTests(IServiceProvider provider) { + this.provider = provider; + bus = provider.GetRequiredService(); + games = provider.GetRequiredService(); + players = provider.GetRequiredService(); + roles = provider.GetRequiredService(); + commands = provider.GetRequiredService(); + store = provider.GetRequiredService(); + perms = (FakePermissionManager)provider + .GetRequiredService(); + roleSet = new List { + new InnocentRole(provider), new TraitorRole(provider), + new DetectiveRole(provider) + }; + bus.RegisterListener(provider.GetRequiredService()); + bus.RegisterListener(provider.GetRequiredService()); + commands.RegisterCommand(new RdmCommand(provider)); + commands.RegisterCommand(new HandleCommand(provider)); + commands.RegisterCommand(new VerdictCommand(provider)); + } + + [Fact] + public async Task SuspectKill_Report_Handle_Guilty_QueuesSlays() { + var victim = TestPlayer.Random(); + var offender = TestPlayer.Random(); + var admin = TestPlayer.Random(); + players.AddPlayers(victim, offender, admin); + perms.SetFlags(admin, "@ttt/admin"); + + var game = games.CreateGame(); + bus.Dispatch(new TTT.Game.Events.Game.GameStateUpdateEvent(game!, + State.IN_PROGRESS)); // round 1 + game!.Start(); + roles.SetRole(victim, roleSet[0]); // innocent + roles.SetRole(offender, roleSet[0]); // innocent -> suspect + + bus.Dispatch(new PlayerDamagedEvent(victim, offender, 100)); + bus.Dispatch(new PlayerDeathEvent(victim).WithKiller(offender) + .WithWeapon("ak47")); + victim.IsAlive = false; // victim died from the kill + offender.IsAlive = false; // offender died later that round + + // Give the fire-and-forget AddDeath a beat to land. + await Task.Delay(50, TestContext.Current.CancellationToken); + + // Victim reports the only listed suspect death. + Assert.Equal(CommandResult.SUCCESS, await commands.ProcessCommand( + new TestCommandInfo(provider, victim, "rdm", "1"))); + + // Staff handles + rules guilty. + Assert.Equal(CommandResult.SUCCESS, await commands.ProcessCommand( + new TestCommandInfo(provider, admin, "handle"))); + Assert.Equal(CommandResult.SUCCESS, await commands.ProcessCommand( + new TestCommandInfo(provider, admin, "verdict", "guilty"))); + + Assert.Equal(3, await store.GetSlayDebt(offender.Id)); + } +} From 3826d0ecba661b28b047bda1c4d5a3ef3ab578ed Mon Sep 17 00:00:00 2001 From: Gabriel Hirakawa Date: Sun, 28 Jun 2026 13:34:01 -0700 Subject: [PATCH 19/19] fix(rdm): register CS2 cvars + serialize SQLite store access (final review) - CS2RdmConfig.Start(BasePlugin?) now calls plugin.RegisterFakeConVars(this) so css_ttt_rdm_* cvars are registered in the engine on plugin load - SqliteRdmStore wraps all 11 IRdmStore methods with SemaphoreSlim(1,1) gate to prevent concurrent access on the single shared connection - SlayQueueListener faulted-task continuation logs payout errors via Messenger - RdmCommand.Description corrected (no dismiss path exists) --- TTT/CS2/Configs/CS2RdmConfig.cs | 5 + TTT/RDM/Commands/RdmCommand.cs | 2 +- TTT/RDM/SlayQueueListener.cs | 5 +- TTT/RDM/SqliteRdmStore.cs | 320 +++++++++++++++++++------------- 4 files changed, 202 insertions(+), 130 deletions(-) diff --git a/TTT/CS2/Configs/CS2RdmConfig.cs b/TTT/CS2/Configs/CS2RdmConfig.cs index c6d86e2..da38a25 100644 --- a/TTT/CS2/Configs/CS2RdmConfig.cs +++ b/TTT/CS2/Configs/CS2RdmConfig.cs @@ -50,6 +50,11 @@ public class CS2RdmConfig : IStorage, IPluginModule { public void Dispose() { } public void Start() { } + public void Start(BasePlugin? plugin) { + ArgumentNullException.ThrowIfNull(plugin, nameof(plugin)); + plugin.RegisterFakeConVars(this); + } + public Task Load() { return Task.FromResult(new RdmConfig { DbString = CV_DB_STRING.Value, diff --git a/TTT/RDM/Commands/RdmCommand.cs b/TTT/RDM/Commands/RdmCommand.cs index fb677da..343f2aa 100644 --- a/TTT/RDM/Commands/RdmCommand.cs +++ b/TTT/RDM/Commands/RdmCommand.cs @@ -21,7 +21,7 @@ public class RdmCommand(IServiceProvider provider) : ICommand { public void Dispose() { } public void Start() { } public string Id => "rdm"; - public string? Description => "File or dismiss an RDM report for your death"; + public string? Description => "List suspect deaths this round or file a report."; public string[] Usage => ["[number] [reason]"]; public async Task Execute(IOnlinePlayer? executor, diff --git a/TTT/RDM/SlayQueueListener.cs b/TTT/RDM/SlayQueueListener.cs index ff9d686..45f11d2 100644 --- a/TTT/RDM/SlayQueueListener.cs +++ b/TTT/RDM/SlayQueueListener.cs @@ -16,6 +16,9 @@ public class SlayQueueListener(IServiceProvider provider) [UsedImplicitly] public void OnRoundStart(GameStateUpdateEvent ev) { if (ev.NewState != State.IN_PROGRESS) return; - Task.Run(async () => await slay.PayRoundStart()); + _ = Task.Run(async () => await slay.PayRoundStart()) + .ContinueWith(t => Messenger.Debug("RDM: slay payout failed: {0}", + t.Exception?.Message ?? "unknown"), + TaskContinuationOptions.OnlyOnFaulted); } } diff --git a/TTT/RDM/SqliteRdmStore.cs b/TTT/RDM/SqliteRdmStore.cs index 3741708..8492830 100644 --- a/TTT/RDM/SqliteRdmStore.cs +++ b/TTT/RDM/SqliteRdmStore.cs @@ -6,6 +6,7 @@ namespace TTT.RDM; public sealed class SqliteRdmStore : IRdmStore, IDisposable { private readonly SqliteConnection connection; + private readonly SemaphoreSlim gate = new(1, 1); public SqliteRdmStore(string connectionString) { connection = new SqliteConnection(connectionString); @@ -53,65 +54,85 @@ private static DeathRecord ReadDeath(SqliteDataReader r) { "id, round, victim_id, victim_name, victim_role, attacker_id, attacker_name, attacker_role, weapon, timestamp, is_suspect, fault"; public async Task AddDeath(DeathRecord d) { - await using var cmd = connection.CreateCommand(); - cmd.CommandText = - """ - INSERT INTO deaths (round, victim_id, victim_name, victim_role, attacker_id, attacker_name, attacker_role, weapon, timestamp, is_suspect, fault) - VALUES ($round, $vid, $vname, $vrole, $aid, $aname, $arole, $weapon, $ts, $suspect, $fault); - SELECT last_insert_rowid(); - """; - cmd.Parameters.AddWithValue("$round", d.Round); - cmd.Parameters.AddWithValue("$vid", d.VictimId); - cmd.Parameters.AddWithValue("$vname", d.VictimName); - cmd.Parameters.AddWithValue("$vrole", d.VictimRole); - cmd.Parameters.AddWithValue("$aid", d.AttackerId); - cmd.Parameters.AddWithValue("$aname", d.AttackerName); - cmd.Parameters.AddWithValue("$arole", d.AttackerRole); - cmd.Parameters.AddWithValue("$weapon", (object?)d.Weapon ?? DBNull.Value); - cmd.Parameters.AddWithValue("$ts", d.Timestamp.ToString("o")); - cmd.Parameters.AddWithValue("$suspect", d.IsSuspect ? 1 : 0); - cmd.Parameters.AddWithValue("$fault", (int)d.Fault); - return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + await gate.WaitAsync(); + try { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + """ + INSERT INTO deaths (round, victim_id, victim_name, victim_role, attacker_id, attacker_name, attacker_role, weapon, timestamp, is_suspect, fault) + VALUES ($round, $vid, $vname, $vrole, $aid, $aname, $arole, $weapon, $ts, $suspect, $fault); + SELECT last_insert_rowid(); + """; + cmd.Parameters.AddWithValue("$round", d.Round); + cmd.Parameters.AddWithValue("$vid", d.VictimId); + cmd.Parameters.AddWithValue("$vname", d.VictimName); + cmd.Parameters.AddWithValue("$vrole", d.VictimRole); + cmd.Parameters.AddWithValue("$aid", d.AttackerId); + cmd.Parameters.AddWithValue("$aname", d.AttackerName); + cmd.Parameters.AddWithValue("$arole", d.AttackerRole); + cmd.Parameters.AddWithValue("$weapon", (object?)d.Weapon ?? DBNull.Value); + cmd.Parameters.AddWithValue("$ts", d.Timestamp.ToString("o")); + cmd.Parameters.AddWithValue("$suspect", d.IsSuspect ? 1 : 0); + cmd.Parameters.AddWithValue("$fault", (int)d.Fault); + return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + } finally { + gate.Release(); + } } public async Task GetDeath(int id) { - await using var cmd = connection.CreateCommand(); - cmd.CommandText = $"SELECT {DeathCols} FROM deaths WHERE id = $id"; - cmd.Parameters.AddWithValue("$id", id); - await using var r = await cmd.ExecuteReaderAsync(); - return await r.ReadAsync() ? ReadDeath(r) : null; + await gate.WaitAsync(); + try { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = $"SELECT {DeathCols} FROM deaths WHERE id = $id"; + cmd.Parameters.AddWithValue("$id", id); + await using var r = await cmd.ExecuteReaderAsync(); + return await r.ReadAsync() ? ReadDeath(r) : null; + } finally { + gate.Release(); + } } public async Task> GetSuspectDeathsForVictim( string victimId, int round) { - await using var cmd = connection.CreateCommand(); - cmd.CommandText = - $"SELECT {DeathCols} FROM deaths WHERE victim_id = $v AND round = $r AND is_suspect = 1 ORDER BY id"; - cmd.Parameters.AddWithValue("$v", victimId); - cmd.Parameters.AddWithValue("$r", round); - var list = new List(); - await using var r = await cmd.ExecuteReaderAsync(); - while (await r.ReadAsync()) list.Add(ReadDeath(r)); - return list; + await gate.WaitAsync(); + try { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + $"SELECT {DeathCols} FROM deaths WHERE victim_id = $v AND round = $r AND is_suspect = 1 ORDER BY id"; + cmd.Parameters.AddWithValue("$v", victimId); + cmd.Parameters.AddWithValue("$r", round); + var list = new List(); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) list.Add(ReadDeath(r)); + return list; + } finally { + gate.Release(); + } } public async Task AddCase(RdmCase c) { - await using var cmd = connection.CreateCommand(); - cmd.CommandText = - """ - INSERT INTO cases (death_id, reporter_id, reason, state, handler_admin_id, verdict, created_at) - VALUES ($did, $rid, $reason, $state, $handler, $verdict, $created); - SELECT last_insert_rowid(); - """; - cmd.Parameters.AddWithValue("$did", c.DeathId); - cmd.Parameters.AddWithValue("$rid", c.ReporterId); - cmd.Parameters.AddWithValue("$reason", (object?)c.Reason ?? DBNull.Value); - cmd.Parameters.AddWithValue("$state", (int)c.State); - cmd.Parameters.AddWithValue("$handler", - (object?)c.HandlerAdminId ?? DBNull.Value); - cmd.Parameters.AddWithValue("$verdict", (int)c.Verdict); - cmd.Parameters.AddWithValue("$created", c.CreatedAt.ToString("o")); - return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + await gate.WaitAsync(); + try { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + """ + INSERT INTO cases (death_id, reporter_id, reason, state, handler_admin_id, verdict, created_at) + VALUES ($did, $rid, $reason, $state, $handler, $verdict, $created); + SELECT last_insert_rowid(); + """; + cmd.Parameters.AddWithValue("$did", c.DeathId); + cmd.Parameters.AddWithValue("$rid", c.ReporterId); + cmd.Parameters.AddWithValue("$reason", (object?)c.Reason ?? DBNull.Value); + cmd.Parameters.AddWithValue("$state", (int)c.State); + cmd.Parameters.AddWithValue("$handler", + (object?)c.HandlerAdminId ?? DBNull.Value); + cmd.Parameters.AddWithValue("$verdict", (int)c.Verdict); + cmd.Parameters.AddWithValue("$created", c.CreatedAt.ToString("o")); + return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + } finally { + gate.Release(); + } } private static RdmCase ReadCase(SqliteDataReader r) { @@ -129,106 +150,149 @@ private static RdmCase ReadCase(SqliteDataReader r) { "id, death_id, reporter_id, reason, state, handler_admin_id, verdict, created_at"; public async Task GetCase(int id) { - await using var cmd = connection.CreateCommand(); - cmd.CommandText = $"SELECT {CaseCols} FROM cases WHERE id = $id"; - cmd.Parameters.AddWithValue("$id", id); - await using var r = await cmd.ExecuteReaderAsync(); - return await r.ReadAsync() ? ReadCase(r) : null; + await gate.WaitAsync(); + try { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = $"SELECT {CaseCols} FROM cases WHERE id = $id"; + cmd.Parameters.AddWithValue("$id", id); + await using var r = await cmd.ExecuteReaderAsync(); + return await r.ReadAsync() ? ReadCase(r) : null; + } finally { + gate.Release(); + } } public async Task> GetOpenCases() { - await using var cmd = connection.CreateCommand(); - cmd.CommandText = - $"SELECT {CaseCols} FROM cases WHERE state != $resolved ORDER BY id"; - cmd.Parameters.AddWithValue("$resolved", (int)CaseState.Resolved); - var list = new List(); - await using var r = await cmd.ExecuteReaderAsync(); - while (await r.ReadAsync()) list.Add(ReadCase(r)); - return list; + await gate.WaitAsync(); + try { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + $"SELECT {CaseCols} FROM cases WHERE state != $resolved ORDER BY id"; + cmd.Parameters.AddWithValue("$resolved", (int)CaseState.Resolved); + var list = new List(); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) list.Add(ReadCase(r)); + return list; + } finally { + gate.Release(); + } } public async Task UpdateCase(RdmCase c) { - await using var cmd = connection.CreateCommand(); - cmd.CommandText = - """ - UPDATE cases SET death_id=$did, reporter_id=$rid, reason=$reason, - state=$state, handler_admin_id=$handler, verdict=$verdict, created_at=$created - WHERE id=$id - """; - cmd.Parameters.AddWithValue("$id", c.Id); - cmd.Parameters.AddWithValue("$did", c.DeathId); - cmd.Parameters.AddWithValue("$rid", c.ReporterId); - cmd.Parameters.AddWithValue("$reason", (object?)c.Reason ?? DBNull.Value); - cmd.Parameters.AddWithValue("$state", (int)c.State); - cmd.Parameters.AddWithValue("$handler", - (object?)c.HandlerAdminId ?? DBNull.Value); - cmd.Parameters.AddWithValue("$verdict", (int)c.Verdict); - cmd.Parameters.AddWithValue("$created", c.CreatedAt.ToString("o")); - await cmd.ExecuteNonQueryAsync(); + await gate.WaitAsync(); + try { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + """ + UPDATE cases SET death_id=$did, reporter_id=$rid, reason=$reason, + state=$state, handler_admin_id=$handler, verdict=$verdict, created_at=$created + WHERE id=$id + """; + cmd.Parameters.AddWithValue("$id", c.Id); + cmd.Parameters.AddWithValue("$did", c.DeathId); + cmd.Parameters.AddWithValue("$rid", c.ReporterId); + cmd.Parameters.AddWithValue("$reason", (object?)c.Reason ?? DBNull.Value); + cmd.Parameters.AddWithValue("$state", (int)c.State); + cmd.Parameters.AddWithValue("$handler", + (object?)c.HandlerAdminId ?? DBNull.Value); + cmd.Parameters.AddWithValue("$verdict", (int)c.Verdict); + cmd.Parameters.AddWithValue("$created", c.CreatedAt.ToString("o")); + await cmd.ExecuteNonQueryAsync(); + } finally { + gate.Release(); + } } public async Task HasReport(string reporterId, int deathId) { - await using var cmd = connection.CreateCommand(); - cmd.CommandText = - "SELECT COUNT(*) FROM cases WHERE reporter_id = $r AND death_id = $d"; - cmd.Parameters.AddWithValue("$r", reporterId); - cmd.Parameters.AddWithValue("$d", deathId); - return Convert.ToInt32(await cmd.ExecuteScalarAsync()) > 0; + await gate.WaitAsync(); + try { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + "SELECT COUNT(*) FROM cases WHERE reporter_id = $r AND death_id = $d"; + cmd.Parameters.AddWithValue("$r", reporterId); + cmd.Parameters.AddWithValue("$d", deathId); + return Convert.ToInt32(await cmd.ExecuteScalarAsync()) > 0; + } finally { + gate.Release(); + } } public async Task CountReportsByVictim(string reporterId, int round) { - await using var cmd = connection.CreateCommand(); - cmd.CommandText = - """ - SELECT COUNT(*) FROM cases c JOIN deaths d ON c.death_id = d.id - WHERE c.reporter_id = $r AND d.round = $round - """; - cmd.Parameters.AddWithValue("$r", reporterId); - cmd.Parameters.AddWithValue("$round", round); - return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + await gate.WaitAsync(); + try { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + """ + SELECT COUNT(*) FROM cases c JOIN deaths d ON c.death_id = d.id + WHERE c.reporter_id = $r AND d.round = $round + """; + cmd.Parameters.AddWithValue("$r", reporterId); + cmd.Parameters.AddWithValue("$round", round); + return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + } finally { + gate.Release(); + } } public async Task SetSlayDebt(string playerId, int remaining, int sourceCaseId) { - await using var cmd = connection.CreateCommand(); - if (remaining <= 0) { - cmd.CommandText = "DELETE FROM slays WHERE player_id = $p"; - cmd.Parameters.AddWithValue("$p", playerId); - } else { - cmd.CommandText = - """ - INSERT INTO slays (player_id, remaining, source_case_id) - VALUES ($p, $rem, $src) - ON CONFLICT(player_id) DO UPDATE SET remaining=$rem, source_case_id=$src - """; - cmd.Parameters.AddWithValue("$p", playerId); - cmd.Parameters.AddWithValue("$rem", remaining); - cmd.Parameters.AddWithValue("$src", sourceCaseId); + await gate.WaitAsync(); + try { + await using var cmd = connection.CreateCommand(); + if (remaining <= 0) { + cmd.CommandText = "DELETE FROM slays WHERE player_id = $p"; + cmd.Parameters.AddWithValue("$p", playerId); + } else { + cmd.CommandText = + """ + INSERT INTO slays (player_id, remaining, source_case_id) + VALUES ($p, $rem, $src) + ON CONFLICT(player_id) DO UPDATE SET remaining=$rem, source_case_id=$src + """; + cmd.Parameters.AddWithValue("$p", playerId); + cmd.Parameters.AddWithValue("$rem", remaining); + cmd.Parameters.AddWithValue("$src", sourceCaseId); + } + await cmd.ExecuteNonQueryAsync(); + } finally { + gate.Release(); } - await cmd.ExecuteNonQueryAsync(); } public async Task GetSlayDebt(string playerId) { - await using var cmd = connection.CreateCommand(); - cmd.CommandText = "SELECT remaining FROM slays WHERE player_id = $p"; - cmd.Parameters.AddWithValue("$p", playerId); - var result = await cmd.ExecuteScalarAsync(); - return result == null ? 0 : Convert.ToInt32(result); + await gate.WaitAsync(); + try { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT remaining FROM slays WHERE player_id = $p"; + cmd.Parameters.AddWithValue("$p", playerId); + var result = await cmd.ExecuteScalarAsync(); + return result == null ? 0 : Convert.ToInt32(result); + } finally { + gate.Release(); + } } public async Task> GetAllSlayDebts() { - await using var cmd = connection.CreateCommand(); - cmd.CommandText = - "SELECT player_id, remaining, source_case_id FROM slays WHERE remaining > 0"; - var list = new List(); - await using var r = await cmd.ExecuteReaderAsync(); - while (await r.ReadAsync()) - list.Add(new SlayDebt { - PlayerId = r.GetString(0), RemainingSlays = r.GetInt32(1), - SourceCaseId = r.GetInt32(2) - }); - return list; - } - - public void Dispose() { connection.Dispose(); } + await gate.WaitAsync(); + try { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = + "SELECT player_id, remaining, source_case_id FROM slays WHERE remaining > 0"; + var list = new List(); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + list.Add(new SlayDebt { + PlayerId = r.GetString(0), RemainingSlays = r.GetInt32(1), + SourceCaseId = r.GetInt32(2) + }); + return list; + } finally { + gate.Release(); + } + } + + public void Dispose() { + connection.Dispose(); + gate.Dispose(); + } }