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):
- Publish
CharacterArresting— a cancelled event refuses with its reason. - Set
IsArrested = trueand a persisted absoluteArrestReleaseAtUtc = 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. - Consume any active warrant (§3: "UnWarrant() — the warrant is spent").
- Confiscate real inventory weapons — loads the target's
CharacterInventoryHolder(Inventory/CharacterInventoryHolder.cs), removes (destroys — never re-added anywhere) every item whoseItemDefinition.EffectiveCategoryisWeapons,PoliceWeapons, orIllegalWeapons, 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;
DoorComponenthas noIsLockedyet. 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.