Skip to content

NPCs

Status: 🚧 Partial β€” NPCCharacter, NPCRoster, NPCDeathLog models exist with most fields; lodging FKs (home_barracks_id/home_outlaw_base_id) are explicitly deferred and the NPCBarracks/OutlawBase tables do not exist … Β· ⚠︎ contains code↔spec divergence (impl audit 2026-06-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.

Design-only:

  • All four entities on this page (NPCCharacter, NPCDeathLog, NPCRoster, plus the roster_targets reference table) β€” model classes target paths that do not yet exist; the schemas below describe the launch target.
  • NPCCharacter.npc_social_affinity is 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 compound canonical identifier is (home_region_id, current_sector_id) β€” the integer matches Sector.sector_number within home_region_id. Single-presence invariant: current_sector_id may only reference a sector belonging to home_region_id; 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 nine archetypes: LAW_ENFORCEMENT, FACTION_PATROL, HOSTILE_RAIDER, FACTION_LEADER, STATION_OFFICIAL, MISSION_GIVER, TRADER, RESEARCHER, CIVILIAN. 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 Cooldown for the NPC's role (not the same character) to be replaced by a new named NPC after KIA. The new replacement gets a different id and a different procedurally-generated name β€” Marshal Vance is gone forever; Marshal Reyna takes the seat. 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" (paired with home_region_id for compound match). - (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 (compound home_region_id + current_sector_id lookup; not a FK because Sector uses compound PK). - 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 per current_activity. Visible in current_sector_id. Engagement-routing eligible.
  • off_duty β€” taking scheduled rest, dining, or socializing. current_sector_id typically 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 from engaged so 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 at on_duty with 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 via shift_handoff_state JSONB.
  • 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.

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_roster.py.

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.
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.

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_id references 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_id is 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_id plus name is 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