Skip to content

Crime Framework

Warrants and arrest — the mechanism half of Applejack's justice system. See Legacy/06_CRIME_AND_POLICE.md for the full legacy trace. Non-lethal force (knockout, tying), bleeding, lockpicking, and contraband are named as deliberate remainders in §5 — all need physics, a Health system, or a lockable door this codebase doesn't have yet, not folded in here.


1. Mechanism, gate, policy — reproduced structurally

Legacy/06_CRIME_AND_POLICE.md §1 calls this split "the best architectural decision in the entire legacy codebase": metatables/sv_player.lua provides mechanisms (Warrant, Arrest), hooks/sv_player.lua declares gates (PlayerCanArrest, ...), and plugins/officials/ supplies policy. "Remove the officials plugin and the mechanisms still exist; there is simply nobody authorised to use them." The doc's own design note names the replacement directly: "service interfaces plus cancellable events, not hooks returning truthy values."

CancellableEvent (Core/Events/CancellableEvent.cs, D-07's fix) already exists and is already used exactly this way for Commands' veto hook (15_COMMANDS_FRAMEWORK.md §5). ICrimeService publishes WarrantIssuing/CharacterArresting before acting, and — critically — registers no default subscriber this pass. That reproduces "remove the plugin, the mechanism still exists, nobody's authorised" structurally: right now, calling IssueWarrantAsync/ArrestAsync directly always succeeds (subject to the mechanical checks below), because no policy has been plugged in yet. A future "who may police whom" module subscribes to these events and calls Cancel(reason) when the caller isn't authorised — exactly the gate/policy layer this document deliberately does not build.

2. Warrants

namespace Applejack.Crime;

public enum WarrantClass { Search, Arrest }

One active warrant per character — legacy's NWString "Warrant" is a single field, not a set — persisted with an absolute expiry timestamp, the explicit fix §2 asks for: "all warrants are cleared by a server restart. Almost certainly unintended. The new design persists warrants with an absolute expiry timestamp." GetActiveWarrant is a lazy computed check (WarrantExpiresAtUtc > clock.UtcNow ? WarrantClass : null) against the new IClock — the same shape 16_JOBS_FRAMEWORK.md §4's rejoin cooldown already uses, no new ticking mechanism needed.

3. Arrest

ArrestAsync(Character arrestor, Character target, TimeSpan duration):

  1. Publish CharacterArresting — a cancelled event refuses with its reason.
  2. Set IsArrested = true and a persisted absolute ArrestReleaseAtUtc = clock.UtcNow + duration — §3's own named bug, fixed the same way as warrants: "the remaining time is not saved, only the boolean... reconnecting therefore restarts the full 5 minutes. Preserve the inescapability; fix the arithmetic." A reconnect reads the same absolute timestamp, so the sentence is never accidentally shortened or extended.
  3. Consume any active warrant (§3: "UnWarrant() — the warrant is spent").
  4. Confiscate real inventory weapons — loads the target's CharacterInventoryHolder (Inventory/CharacterInventoryHolder.cs), removes (destroys — never re-added anywhere) every item whose ItemDefinition.EffectiveCategory is Weapons, PoliceWeapons, or IllegalWeapons, saves the inventory. Matches §3 precisely: "weapons that correspond to inventory items are not stored for return. Arrest therefore destroys your item-backed weapons rather than holding them: you lose the gun permanently." This is the one place Crime reaches into Inventory — a real, justified new module edge for a real, well-specified mechanic.

IsCurrentlyArrested is the same lazy computed check as GetActiveWarrant.

Not built, named precisely: Incapacitate()'s speed effect, StripAmmo, turning off the flashlight, UnTie (no tying system exists to un-tie from), the jail teleport, and the auto-release Spawn() on expiry. All need Movement, a held-weapon/ammo system, a flashlight system, a tying system, or a Transform/position system — none exist in this codebase yet.

4. No command entry point yet — a real, named gap

/warrant, /unwarrant, and /arrest all target another connected player, and there was no way anywhere in this codebase to resolve "the connected character named X": ICharacterService.GetActive(SteamId) (Character/ICharacterService.cs) needs the target's SteamId already; LoadAsync(CharacterId) loads from persistence and marks it the active character for its owning account — the wrong side effect to trigger just to look someone up. This was a genuine missing capability (target resolution by name or selection), not something to fake with a raw Guid argument nobody could actually type in chat. ICrimeService is built and tested directly; the command layer was the explicit remainder, the same way Code/Items/README.md once named the missing RPC entry point before Inventory built RequestUseItem.

Milestone 12 update: the resolution mechanism itself now exists — ICharacterService.FindActiveByName (Code/Characters/ICharacterService.cs), a default interface method built only on GetAllActive()'s roster, zero side effects, exact match falling back to case-insensitive, ambiguity refused rather than guessed. Chat's /pm is the first real consumer (19_CHAT_FRAMEWORK.md §3.1). /warrant//unwarrant//arrest still have no command entry point wired to them — closing the resolution gap doesn't retroactively register the commands themselves; that remains this section's own future work, now unblocked rather than done.

5. What this framework deliberately does not build

  • Non-lethal force (knockout, tying) — ragdoll/pawn physics, none of which exists.
  • Bleeding — needs a Health system and world decal tracing.
  • Lockpicking — needs a lockable door; DoorComponent has no IsLocked yet. That's "the rest of World," a separate Milestone 7 item, not this one.
  • Contraband — needs its own world-entity type, not built.
  • A command entry point — see §4.
  • The policy layer ("who may police whom") — see §1; deliberately left for whichever module builds it.

6. Testing

CrimeStore is plain C#, tested against the shared InMemoryPersistenceBackend fixture, a fake IClock (deterministic expiry, no Thread.Sleep), and IInventoryService/ IItemRegistry for the confiscation path — following JobStoreTests.cs's setup.