Jobs Framework¶
The group→gang→team hierarchy and job definitions — "the system that makes Applejack a roleplay framework rather than a sandbox with money" (Legacy/05_TEAMS_JOBS_FACTIONS.md). This document covers the hierarchy, job definitions as content, and joining. Demotion, mutiny, job-blacklisting, term-limit expiry, and salary/payday are named as deliberate remainders in §6 — real features on their own, not folded in here.
1. The hierarchy¶
Three levels, per Standards/01_NAMING.md's own domain vocabulary (line 152: "Group / Gang /
Team — the three-level job hierarchy"):
GROUP Officials · Civilians · Underground
└── GANG an organisation within a group (gang 0 = the group's general population)
└── TEAM a job, with a Level (rank) inside its gang
A group is a fixed, closed set — TeamGroup { Officials, Civilians, Underground } — the
city's social structure is a deliberate setting choice, not open content. A gang is just
an integer id scoped to a group (0 = unaffiliated, per
Legacy/04_ENTITIES_DOORS_PROPERTY.md §1's
"group;0" door-access pattern). A team — your job — is a JobDefinition asset.
2. Job definitions are content¶
Legacy/05_TEAMS_JOBS_FACTIONS.md §2 calls its own 15-positional-parameter cider.team.add
"the single clearest argument for jobs as authored assets rather than code", citing
ADR-0004 directly — the same reasoning
that made ItemDefinition a GameResource. JobDefinition follows it exactly: a .job
asset (Standards/01_NAMING.md line 170), loaded the same way ItemsModule loads
ItemDefinition (ResourceLibrary.GetAll<T>(), flagged unverified the same way
Code/Items/ItemsModule.cs already is).
namespace Applejack.Jobs;
public enum TeamGroup { Officials, Civilians, Underground }
[GameResource("Job", "job", "An Applejack job/team definition.", Icon = "badge")]
public sealed class JobDefinition : GameResource
{
public string AssetId => ResourceName;
[Property] public string DisplayName { get; set; } = "";
[Property] public TeamGroup Group { get; set; }
[Property] public int GangId { get; set; } // 0 = the group's general population
[Property] public int Level { get; set; } // rank within the gang
[Property] public int MaxPlayers { get; set; } // 0 = unlimited
[Property] public string? RequiredCharacterPermission { get; set; }
[Property] public bool IsMandatoryTransit { get; set; } // legacy's "M" flag, §4
[Property] public int RejoinCooldownMinutes { get; set; }
[Property] public int SortOrder { get; set; } // explicit sort key, replacing file order
}
Deliberately not modelled yet: the b/d/D/g governance flags, Salary, and
MaxJobMinutes (term-limit) — see §6. Nothing reads them until the feature that needs them
is built; an unread config field is the same smell NeedsConfiguration already avoided by
dropping HungerDamagePerSecond
(14_NEEDS_FRAMEWORK.md §2).
SortOrder replaces the legacy's "presentation order is file order" (§2's own design note:
"the new asset format needs an explicit sort key") — sh_jobs.lua's own comment says order
was encoded as load order, which is exactly the load-order fragility
D-08 already names as a defect to fix.
One real asset, not the full legacy table: Assets/Jobs/citizen.job, mirroring
Assets/Items/Food/cake.item's JSON shape, for the one job the legacy source calls the
default (TEAM_DEFAULT = TEAM_CITIZEN). The other ~20 jobs are Milestone 8's content
migration, not this milestone's engineering
(ROADMAP.md).
3. Access reuses PermissionSet, not a second flag namespace¶
JobDefinition.RequiredCharacterPermission is checked via Character.HasPermission — the
same reuse decision Commands already made for CommandDefinition.RequiredPermission
(15_COMMANDS_FRAMEWORK.md §3). Legacy's own team-level
access flags (distinct from character access flags — 05_TEAMS_JOBS_FACTIONS.md §2) are a
second namespace this design collapses into the one already built.
4. Joining¶
IJobService.JoinJobAsync(Character character, string jobName) checks, in order:
- Existence — the named job is registered.
- Cooldown —
TeamMembership.CooldownUntil[jobName] > clock.UtcNowrefuses. Set on leaving a job whoseRejoinCooldownMinutes > 0(the rejoin-cooldown half of legacy's{maxJobTime, rejoinCooldown}pair — the term-limit half is §6). - Cap —
MaxPlayers > 0 &&count of other currently-loaded characters holding that job>= MaxPlayers. Counts only currently-loaded characters, inheriting the same no-disconnect-hook limitation everyStorein this codebase already has (Code/Core/Modules/ApplejackBootstrap.cs's own header comment) — a disconnected character still occupies a slot until the process restarts. Not a new gap; not solved here. - Mandatory transit — legacy's
Mflag / "Master Race": "you cannot go from Police Officer to Rogue directly; you must become a Citizen first." Refused whencurrent.Group != destination.Group && !current.IsMandatoryTransit && !destination.IsMandatoryTransit— a cross-group move is only ever direct if one endpoint is the mandatory-transit job. - Permission —
destination.RequiredCharacterPermission, if set.
On success, TeamMembership.CurrentJobName updates and persists immediately — this is a
discrete event, not a per-frame decay, so it follows EconomyStore.CreditAsync's
persist-on-write shape (08_DATA_MODEL.md §4), not
NeedsStore.Tick's in-memory-only shape.
5. /team — Jobs' first real command¶
Registered by JobsModule.Ready() against ICommandRegistry
(15_COMMANDS_FRAMEWORK.md), the same Ready()-registers-against-
a-framework-service pattern EconomyModule/InventoryModule/NeedsModule already use for
IHudRegistry, applied here to Commands instead of Ui. /team <jobName> calls
JoinJobAsync(context.Caller, context.Arguments[0]) directly. The same "no reply-to-caller
yet" limitation /help already carries applies here too — a failed join is only visible in
the server log until Chat exists.
6. What this framework deliberately does not build¶
- Demotion and mutiny (
Legacy/05_TEAMS_JOBS_FACTIONS.md§4) — real governance logic: level/flag checks for who may demote whom, a quorum (Minimum to mutiny: 4), and a supermajority (Mutiny Percentage: 0.75). On the scale of its own design pass. - Job-blacklisting — no blacklist data model exists anywhere in this codebase yet
(
AccountModeration,Code/Characters/AccountModeration.cs, is account-ban status, an unrelated concept). - Term-limit auto-expiry (
maxJobTime) — kicking a character out of a job after N minutes needs a ticking/polling mechanism, the same class of thingCharacterNeedsComponentbuilds for Needs; not built for Jobs this pass. The rejoin cooldown half of the same legacy setting is built (§4). - Salary and payday. Legacy sets a character's pay rate from
TEAM.Salaryon join, for a recurring payday tick that doesn't exist in this codebase. Crediting a lump sum at join instead would be inventing a signing bonus nobody asked for —Code/Economy/README.mdalready defers "salary, payday" to this milestone; still deferred. - Manufacturing-rights gating (
canMakeCategories/cantUseCategories) — §5 of the legacy doc calls this "the most important thing in this document," and it stays that important; it's simply not reachable yet, since no recipe/crafting/manufacture action exists anywhere in this codebase to gate.
7. Testing¶
JobDefinition/JobsConfiguration have no PostLoad test, matching the precedent
Code/Needs/README.md already established (no GameResource.PostLoad override in this
codebase has one — it's engine-invoked only). JobRegistry and JobStore are plain C#,
tested directly against the shared InMemoryPersistenceBackend fixture and a fake IClock,
following EconomyStoreTests.cs's setup.