NPCs¶
Status: 🚧 Partial — NPCCharacter, NPCRoster, NPCDeathLog models exist with most fields. Lodging schema has landed:
NPCBarracks/OutlawBasetables andhome_barracks_id/home_outlaw_base_idFKs are committed (see./npc-lodging.md, commit5cccdbf8, 2026-08-10). Remaining gap: no seeding pipeline populates lodging rows or wires those FKs on live NPC rows yet — schema present, live data absent (companion gameserver seeding WO). (re-verified 2026-08-16)
Persistent named characters that populate the universe — Federation Marshals, Nexus Sentinels, pirate captains, faction leaders, station masters, contract issuers, NPC traders, Nova researchers, civilian background population. Each NPC is a real persistent individual with single-place presence (one sector at a time), a backstory, a faction allegiance, a daily schedule (sleep / commute / work / off-duty), a career arc (recruit → active → senior → decorated → retired/KIA → succession), and an identity that survives across encounters.
The runtime that moves NPCs along their schedules and processes lifecycle transitions lives in ../SYSTEMS/npc-scheduler.md (movement, KIA, engagement routing) and ../SYSTEMS/npc-lifecycle.md (the wider framework — schedules, careers, succession, archetypes). This doc covers the data model.
The design replaces the legacy "anonymous squad slots" treatment where Sector.defenses.patrol_ships carried only faction_code + squad_kind + ship_count. NPCs are now first-class persistent entities; patrol squad records reference specific NPC rows by ID. NPCs aren't 24/7 automata — they live full days like players do, with sleep / off-duty cycles that mean a Marshal isn't always at her patrol post.
Schema status¶
Per ADR-0066 D-V1, schema-level implementation status is consolidated here. Field descriptions describe the target schema.
Committed:
NPCCharacter,NPCRoster, andNPCDeathLog— schemas below describe the shipped shape.- Lodging tables
NPCBarracks/OutlawBaseand FKshome_barracks_id/home_outlaw_base_id— committed; see./npc-lodging.md. Seeding / live population of those rows is still outstanding.
Design-only / future-work:
NPCCharacter.npc_social_affinityis operator-tunable future-work.
NPCCharacter¶
Source: services/gameserver/src/models/npc_character.py.
Purpose: Single source of truth for a named NPC's identity, faction allegiance, current location, status, and assigned ship. One row per character; the NPC's identity persists across encounters, deaths, reassignments, and admin tooling.
Fields:
| name | type | constraints | notes |
|---|---|---|---|
| id | UUID | PK | |
| name | String(100) | not null | Player-visible name (e.g., "Marshal Cassandra Vance"). Non-unique; multiple NPCs can share a name. |
| title | String(50) | nullable | Display title / rank (e.g., "Marshal", "Sentinel-Captain", "Pirate-Lord"). Rendered before the name in player-facing UI. |
| faction_code | String(50) | not null, indexed | Matches Faction.code. Pirates and Cabal NPCs use a sentinel faction code (pirates / cabal) since those entities are hostile-only and not in the standing reputation list. |
| home_region_id | UUID FK regions.id | not null, CASCADE | The region this NPC belongs to. Federation Marshals are scoped to one player region's roster; Nexus Sentinels all home to the Central Nexus region. NPCs do not cross region boundaries on their own — re-assignment is admin-driven. |
| current_sector_id | Integer | nullable | The sector the NPC is in right now. NULL = sleeping / on personal time / between assignments / deceased. The value is Sector.sector_id — globally unique across the galaxy, not a per-region-local number — so a plain lookup on current_sector_id alone resolves the sector; home_region_id is a separate field, not a compound-key partner. Single-presence invariant: the sector referenced by current_sector_id belongs to home_region_id's region; never cross-region except during reassigned lifecycle transitions. |
| current_activity | Enum npc_activity |
default sleep |
What the NPC is doing right now — derived from daily_schedule + overrides; cached for fast reads. Vocabulary: sleep / commute / patrol / work_station / socialize / dine / train / personal / raid / survey / engaged / reassigned. See ../SYSTEMS/npc-lifecycle.md#activities. |
| archetype | Enum npc_archetype |
not null | One of ten archetypes: LAW_ENFORCEMENT, FACTION_PATROL, HOSTILE_RAIDER, FACTION_LEADER, STATION_OFFICIAL, MISSION_GIVER, TRADER, RESEARCHER, CIVILIAN, STATION_SECURITY. Drives default schedule template, default behavior tree, and engagement-routing eligibility. |
| lifecycle_stage | Enum npc_lifecycle_stage |
default recruit |
recruit (training, 7-day) / active (default) / senior (≥90-day tenure) / decorated (medal earned) / retired (voluntary) / kia (killed) / reassigned (region transfer transient). See ../SYSTEMS/npc-lifecycle.md#lifecycle-states. |
| status | Enum npc_status |
default on_duty |
Legacy short-form duty marker. on_duty / off_duty / engaged / kia / retired / reassigned. Largely supplanted by current_activity + lifecycle_stage; kept as a denormalized fast-read for engagement-routing queries. |
| ship_id | UUID FK ships.id | nullable | The ship this NPC pilots. 1:1 — an NPC pilots exactly one ship; that ship has the NPC as its sole pilot. NULL means "between ships" (typically during a respawn cooldown or while on personal/sleep activities). The ship must be an is_npc_only hull (e.g., NPC_MARSHAL_INTERDICTOR) for LAW_ENFORCEMENT archetype; TRADER archetype NPCs pilot standard merchant hulls; FACTION_LEADER archetype may pilot any ship including capital classes. |
| daily_schedule | JSONB | not null | The 24-hour schedule template + weekly overrides. Shape: {timezone: "UTC", shift_offset_hours: int, blocks: [{start_minute, end_minute, activity, location_type, location_ref}], weekly_overrides: [{weekday: int, blocks: [...]}]}. Generated from the archetype's default template at spawn time; mutated by promotions / reassignments. See ../SYSTEMS/npc-lifecycle.md#schedule-mechanism. |
| role_history | JSONB | default {} |
Career arc: {current_role, role_history: [{role, started_at, ended_at, reason}], promotion_eligible_at, decorations: [{medal_id, awarded_at, reason}]}. Mutated by the career processor. Player-visible in faction news feeds and NPC profile views. |
| mentor_id | UUID FK npc_characters.id | nullable | The NPC's predecessor or trainer. Set on succession (e.g., Marshal Reyna's mentor_id points at the KIA Marshal Vance she replaced). Player-visible succession lore. |
| patrol_route | JSONB | nullable | Legacy — per-character patrol pattern in the v1 design. Now superseded by daily_schedule.blocks where activity = "patrol" carries the route via location_ref. Retained nullable for migration; new NPCs leave it NULL. |
| home_barracks_id | UUID FK npc_barracks.id | nullable | The NPC's permanent lodging assignment for lawful-faction archetypes. Set at spawn from NPCRoster.default_lodging_id. NULL for HOSTILE_RAIDER (uses home_outlaw_base_id instead) and CIVILIAN (ghost presence). See ./npc-lodging.md. |
| home_outlaw_base_id | UUID FK outlaw_bases.id | nullable | Same as home_barracks_id but for HOSTILE_RAIDER archetype NPCs. Mutually exclusive with home_barracks_id. |
| shift_preempt_pending | Boolean | default false | Set when this NPC's shift end-time arrived while they were engaged in combat. The relieving NPC begins their shift but covers a "preempted" gap until this flag clears. See ../SYSTEMS/npc-lifecycle.md#engagement-preemption. |
| preempted_prior_shift_npc_id | UUID FK npc_characters.id | nullable | When a relieving NPC begins their shift while their predecessor is still engaged, this points at the predecessor. Cleared when the predecessor's combat resolves. |
| promotion_pending_at | DateTime | nullable | Set by the daily career processor when the NPC is eligible for promotion; the schedule executor applies the new role's template at the next block boundary. |
| npc_social_affinity | JSONB | default {} |
Optional allies/rivals graph: {"allies": [npc_id, …], "rivals": [npc_id, …]}. Used by the off-duty social-colocation enhancement (allies share station-sectors during socialize blocks; rivals are separated). Empty default; non-blocking for launch. |
| spawned_at | DateTime | not null | When this character first entered the universe. Persists across reassignments. |
| last_seen_at | DateTime | not null | Last sector-presence event (arrival, departure, engagement). Updated by the NPC scheduler and by combat / encounter handlers. |
| destroyed_at | DateTime | nullable | When this character was killed in combat. Set on status = kia. |
| respawn_eligible_at | DateTime | nullable | Same-identity respawn timer, set only for respawn-permitted archetypes (currently HOSTILE_RAIDER only) — 15 minutes after KIA, this same NPC row re-enters on_duty at full stats. NULL for all other archetypes; their role-replacement is a different mechanism — a brand-new NPCCharacter row spawned immediately by the roster-maintenance loop, entering at reduced-stat recruit lifecycle_stage and reaching active after a 7-canonical-day tenure (own spawned_at, not this field). See ../SYSTEMS/npc-scheduler.md#kia-and-replacement. |
| replaced_by_id | UUID FK npc_characters.id | nullable | When this character was KIA, the new NPC that took the assignment. Forms a succession chain — players can see "Marshal Vance (KIA 2026-04-12, succeeded by Marshal Reyna)". |
| backstory | JSONB | default {} |
{"origin": str, "personality_traits": [str], "skills": [str], "voice_pack": str, "portrait_id": str} — flavor metadata. Procedurally generated at NPC creation; can be admin-edited for hand-crafted faction leaders. |
Indexes:
- (home_region_id, status) — fast scan for "all on-duty Federation Marshals in region X".
- (current_sector_id) — fast scan for "who is currently in sector Y" — current_sector_id alone resolves the sector globally; no home_region_id pairing needed for the lookup.
- (faction_code, home_region_id) — fast roster lookup per region per faction.
Relationships:
- home_region → Region (FK home_region_id, CASCADE delete).
- current_sector → Sector (lookup by current_sector_id alone, a global Sector.sector_id; not a declared FK).
- ship → Ship (FK ship_id, SET NULL on ship destruction so the NPC stays alive even if the ship dies — ship destruction triggers an NPC-respawn flow that may give the NPC a fresh hull).
- replaced_by → NPCCharacter (self-FK).
- death_logs → NPCDeathLog (1:many — tracks every death this NPC has had; usually 0 or 1).
npc_status enum (legacy fast-read marker)¶
on_duty— actively performing assignment percurrent_activity. Visible incurrent_sector_id. Engagement-routing eligible.off_duty— taking scheduled rest, dining, or socializing.current_sector_idtypically points to a station or rest sector. Engagement-routing skips.engaged— currently in a combat encounter. Locked from being routed to another response.engaged_pending_arrival— per ADR-0042 (and confirmed in ADR-0065 R-I2). NPC has accepted an engagement assignment but is still en-route. Engagement-routing skips this NPC (already assigned); fleet engagement counts include it (committed to the fight). Distinct fromengagedso the routing handler can prevent a second offense from picking the same officer before they arrive at the first.kia— killed in action.respawning— same NPC identity in 15-minute respawn cooldown after KIA per ADR-0063 N-D2. Slot is vacant; engagement-routing skips. After cooldown the NPC re-enters its existing slot aton_dutywith full stats — career and reputation persist.retired— voluntarily removed from active duty.reassigned— moved to a different region (transient).
duty_role (per ADR-0063 N-F1) is mutable mid-shift. When a primary Marshal goes KIA, the on-duty backup NPC's duty_role flips from backup_marshal to primary_marshal immediately (zero-gap promotion). The previously-fixed-per-shift assumption is dropped; scheduler logic reads live duty_role, not a snapshot from shift start.
The richer state is captured by current_activity + lifecycle_stage; status is the denormalized fast-read for engagement-routing's hot path. The runtime maintains coherence between the three.
npc_archetype enum¶
The ten archetypes that drive default schedule templates and behavior trees. See ../SYSTEMS/npc-lifecycle.md#npc-archetypes for the full breakdown of patterns and example NPCs per archetype.
LAW_ENFORCEMENT— Federation Marshals, Nexus Sentinels.FACTION_PATROL— non-police faction patrols (Federation Navy, AM enforcers, Frontier militia).STATION_SECURITY— hired station guards (Standard / Premium tier protected stations); station-funded, station-jurisdictioned. See../FEATURES/economy/station-protection.md.HOSTILE_RAIDER— named pirate captains, Cabal lieutenants.FACTION_LEADER— admirals, council members, pirate lords; high-rank named NPCs at fixed locations.STATION_OFFICIAL— port masters, dock managers, contract clerks; stationary, run station business.MISSION_GIVER— quest issuers, rep representatives.TRADER— NPC merchant captains; full economic actors who buy and sell at market.RESEARCHER— Nova Scientific surveyors; expedition cycles.CIVILIAN— refugees, colonists-in-transit, background population; mostly passive.
npc_lifecycle_stage enum¶
recruit— newly spawned; in 7-day training period. Stat reductions; flavor cue ("Marshal Vance, Trainee").active— full duty. Dominant lifecycle stage.senior— tenure ≥ 90 days. Slight stat buffs. Eligible for promotion.decorated— medal earned. Stat buff scales with medal count.retired— voluntary exit. Persists in lore; cannot be reactivated.kia— killed in action. Permanent.reassigned— region transfer transient.
npc_activity enum¶
The vocabulary of activities an NPC can be performing right now (driven by daily_schedule + combat overrides). See ../SYSTEMS/npc-lifecycle.md#activities for engagement-eligibility per activity.
sleep— at home; not interactive; not in any sector room.commute— in transit between activities.patrol— on assigned route, fully active; engagement-eligible (police archetypes).work_station— at a station performing job duties.socialize— at a station bar / social area; off-duty but visible.dine— taking a meal.train— at a faction training facility.personal— off-grid; not visible.raid— hostile-archetype actively raiding; engagement-eligible.survey— researcher scanning a nebula / anomaly.engaged— combat in progress; suspends schedule.reassigned— region transfer in progress.shift_handoff— overlap window between outgoing and incoming NPCs on a shifted patrol; both NPCs visible on the route while threat-notes pass viashift_handoff_stateJSONB.shift_reroute— set on a relieving NPC when their predecessor is KIA mid-handoff; the relieving NPC takes coverage of the prior shift's route.error_stranded— set when the schedule executor cannot route a commute due to disconnected warp graph; ops-only state requiring manual intervention.
NPCDeathLog¶
Source: services/gameserver/src/models/npc_death_log.py.
Region-deletion handling (per ADR-0050 SK24): rows gain a region_id_snapshot UUID column populated at row creation, and the sector_id FK becomes ON DELETE SET NULL. Survives region regeneration and termination cleanup so KIA history is preserved. (folded from ADR-0050 SK24, re-verified 2026-08-07)
Purpose: Audit trail for named-NPC kills. Lets players see "I've defeated Marshal Vance" in their personal kill history; lets ops query "how many Federation Marshals has player X killed this month."
Fields:
| name | type | constraints | notes |
|---|---|---|---|
| id | UUID | PK | |
| npc_id | UUID FK npc_characters.id | not null, CASCADE | The NPC that died. |
| killed_by_player_id | UUID FK players.id | nullable | The player whose action caused the kill. NULL for environmental kills (sector hazard, anomaly mishap). |
| sector_id | Integer | not null | Where the kill happened. Compound (home_region_id, sector_id) — home_region_id snapshot from the NPC at kill time. |
| home_region_id | UUID FK regions.id | not null | NPC's region at kill time, snapshot for orphan-resilience after region termination. |
| combat_log_id | UUID FK combat_logs.id | nullable | Cross-link to the combat row that resolved the kill (if combat-driven). |
| destruction_cause | Enum destruction_cause |
not null | Same enum as Ship.destruction_cause. COMBAT is overwhelmingly common; HAZARD fires for environmental deaths. |
| killed_at | DateTime | not null | Timestamp. |
Indexes:
- (killed_by_player_id, killed_at DESC) — player-side kill history.
- (home_region_id, killed_at DESC) — per-region death audit.
NPCRoster¶
Source: services/gameserver/src/models/npc_character.py (defined alongside NPCCharacter and NPCDeathLog, not in a separate npc_roster.py file).
Purpose: Per-region per-faction roster targets. Tells the NPC scheduler "this region should have N active Federation Marshals; if fewer, spawn replacements." Decoupled from the actual NPC rows so admins can rebalance roster sizes without touching NPCs directly.
Fields:
| name | type | constraints | notes |
|---|---|---|---|
| id | UUID | PK | |
| region_id | UUID FK regions.id | not null, CASCADE | The region this roster targets. Central Nexus is one region; each player region is its own. |
| faction_code | String(50) | not null | Matches Faction.code. |
| role | String(50) | not null | Role within the faction. Each role binds to one of the nine npc_archetype values via the default_archetype field. Examples: marshal (LAW_ENFORCEMENT), sentinel (LAW_ENFORCEMENT), pirate_captain (HOSTILE_RAIDER), faction_leader (FACTION_LEADER), station_master (STATION_OFFICIAL), contract_issuer (MISSION_GIVER), enforcer (FACTION_PATROL), merchant_captain (TRADER), nebula_surveyor (RESEARCHER). |
| default_archetype | Enum npc_archetype |
not null | The archetype this role spawns NPCs as. Drives the default daily_schedule template and behavior_tree_id at spawn. |
| schedule_template | JSONB | not null | The daily_schedule template applied at NPC creation. Per-NPC offsets are added at spawn time so different NPCs in the same role run at staggered shifts. |
| default_lodging_id | UUID | nullable | UUID of the canonical lodging entity (NPCBarracks or OutlawBase) for this role. Loop B reads this at NPC spawn and sets the new NPC's home_barracks_id or home_outlaw_base_id. Generator Phase 12.5 sets this. |
| default_lodging_type | Enum npc_lodging_type |
not null | barracks or outlaw_base — disambiguates which table default_lodging_id references. |
| target_count | Integer | not null | How many active NPCs of this role the region should have. The scheduler maintains the roster against this target. |
| host_sector_id | Integer | not null | Global sectors.sector_id where this roster's NPCs spawn/respawn. Not a UUID — FINDINGS Phase-3 ruled code wins (Integer). Compound identity with region_id. |
| bang_roster_ref | String(80) | not null, UNIQUE | Adoption link to bang's roster id / gameserver-seeded synthetic ref. |
| name_pool | JSONB | default {} |
Procedural-generation seed and constraints for new NPC names: {"first_names": [str], "surnames": [str], "title_template": str}. Admins can hand-curate name pools per faction for flavor (e.g., Cabal lieutenants get sinister-sounding name pools). |
Indexes:
- (region_id, faction_code, role) UNIQUE — exactly one roster target per (region, faction, role) tuple. Documented divergence (added 2026-08-06, code self-flagged this in models/npc_character.py): this constraint does not hold in practice for pirate_captain rosters — BANG emits multiple pirate rosters per region, one per holding anchor. The shipped schema keeps a non-unique index on this triple instead and enforces real uniqueness on bang_roster_ref (the spawn-idempotency marker) — see NPCRoster.bang_roster_ref.
Roster targets at launch¶
Reference targets (operator-tunable):
| Region kind | Faction | Role | Target count |
|---|---|---|---|
| Central Nexus | Galactic Concord | sentinel | 24 |
| Central Nexus | Galactic Concord | sentinel_captain | 4 |
| Player-owned region | Terran Federation | marshal | 8 |
| Player-owned region | Terran Federation | marshal_captain | 1 |
| Player-owned region | Pirates | pirate_captain | 2 |
| Player-owned region | Pirates | enforcer | 4–6 |
| Terran Space | Terran Federation | marshal | 12 |
| Terran Space | Terran Federation | marshal_captain | 2 |
| Terran Space | The Cabal | cabal_lieutenant | 1 |
A player region with 8 Federation Marshals + 1 Captain means a player traversing the region's Federation Zone could meet any of 9 named officers depending on which sector they enter. Re-encounters with the same officer build a real relationship. Killing an officer leaves the roster short-handed for the cooldown period before a new named officer takes the seat.
Patrol-squad linkage¶
The existing Sector.defenses.patrol_ships JSONB is extended, not replaced. The shape gains an npc_character_ids array; the legacy ship_count field becomes derived (len(npc_character_ids)) but is retained for read-fast denormalization.
{
"patrol_id": "<uuid>",
"faction_code": "terran_federation",
"squad_kind": "federation_marshal",
"npc_character_ids": ["<npc-uuid-1>", "<npc-uuid-2>", "<npc-uuid-3>"],
"ship_count": 3,
"wanted_threshold": -500,
"deployed_at": "<iso8601>",
"scheduled_clear_at": null
}
When npc_character_ids is set, every NPC in the array has current_sector_id matching the row's parent sector — the JSONB row and the per-NPC location are always coherent. The scheduler enforces this invariant on every NPC-move event.
A patrol squad is not a separate persistent entity — when the NPCs leave (off-duty rotation, response routing elsewhere, KIA), the squad row is deleted and the per-NPC current_sector_id updates point elsewhere.
Schema-level invariants¶
- Single-presence:
NPCCharacter.current_sector_idreferences at most one sector at any time. The runtime scheduler enforces this on every move; a transactional update changes the row atomically. - Single-pilot:
NPCCharacter.ship_idis 1:1. No NPC pilots two ships; no ship has two NPC pilots. (Hangared player ships inside an NPC Carrier are not "piloted by" the NPC — the hangar payload is separate.) - Region-scoped identity: An NPC's
home_region_idplusnameis not unique across the universe — Marshal Vance can exist in Region A and Region B independently. They're different individuals. - Roster targets vs. live count: The scheduler tries to keep
count(NPCCharacter where region+faction+role = roster_target.* AND status in ['on_duty', 'off_duty', 'engaged']) ≥ roster_target.target_count. KIA / retired / reassigned NPCs don't count.
Cross-references¶
../SYSTEMS/npc-scheduler.md— runtime scheduler that moves NPCs, routes engagement responses, and respawns after KIA.../FEATURES/gameplay/police-forces.md— Federation Police and Nexus Sentinel design that consumes this schema.../FEATURES/gameplay/faction-lore.md— faction lore that informs name pools and personality traits../jsonb-schema.md—Sector.defenses.patrol_shipsextended withnpc_character_idsarray../galaxy.md— Region / Sector compound identity that NPCs inherit../ships.md—Ship.is_npc_onlyflag that gates NPC-only hull classes.