Skip to content

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:

  1. Existence — the named job is registered.
  2. CooldownTeamMembership.CooldownUntil[jobName] > clock.UtcNow refuses. Set on leaving a job whose RejoinCooldownMinutes > 0 (the rejoin-cooldown half of legacy's {maxJobTime, rejoinCooldown} pair — the term-limit half is §6).
  3. CapMaxPlayers > 0 && count of other currently-loaded characters holding that job >= MaxPlayers. Counts only currently-loaded characters, inheriting the same no-disconnect-hook limitation every Store in 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.
  4. Mandatory transit — legacy's M flag / "Master Race": "you cannot go from Police Officer to Rogue directly; you must become a Citizen first." Refused when current.Group != destination.Group && !current.IsMandatoryTransit && !destination.IsMandatoryTransit — a cross-group move is only ever direct if one endpoint is the mandatory-transit job.
  5. Permissiondestination.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 thing CharacterNeedsComponent builds 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.Salary on 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.md already 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.