Skip to content

NPCs

Status: 🚧 Partial — NPCCharacter, NPCRoster, NPCDeathLog models exist with most fields. Lodging schema has landed: NPCBarracks / OutlawBase tables and home_barracks_id / home_outlaw_base_id FKs are committed (see ./npc-lodging.md, commit 5cccdbf8, 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, and NPCDeathLog — schemas below describe the shipped shape.
  • Lodging tables NPCBarracks / OutlawBase and FKs home_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_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 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_regionRegion (FK home_region_id, CASCADE delete). - current_sectorSector (lookup by current_sector_id alone, a global Sector.sector_id; not a declared FK). - shipShip (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_byNPCCharacter (self-FK). - death_logsNPCDeathLog (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. (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_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