Skip to content

NPC Scheduler

Status: 🚧 Partial — The scheduler is substantially live and wired into main.py (gated by NPC_SCHEDULER_ENABLED): Loop A schedule execution, Loop B roster/respawn, hop-capped engagement routing, KIA+death-log … (impl audit 2026-06-16)

The runtime service that executes the NPC lifecycle. Moves named NPCs between sectors per their daily schedules, routes engagement responses to the nearest available NPC, manages off-duty rotations and sleep cycles, replaces KIA NPCs after cooldown, and maintains roster-target invariants. The data model lives in ../DATA_MODELS/npcs.md; the wider lifecycle framework (schedules, archetypes, career arcs, succession rules) lives in ./npc-lifecycle.md; this doc covers the scheduler's runtime behavior — the engine that runs the lifecycle.

Purpose

For every named NPC in the universe, the scheduler is responsible for:

  1. Movement — advancing NPCCharacter.current_sector_id along the NPC's patrol_route on the configured cycle.
  2. Routing — when an offense fires, picking the nearest available NPC of the right role to respond, not spawning a fresh anonymous squad.
  3. Lifecycle — transitioning between on_duty / off_duty / engaged / kia per real-time triggers.
  4. Replacement — after a cooldown, generating a new named NPC to fill an empty roster slot left by a KIA / retired / reassigned predecessor.
  5. Roster maintenance — running a periodic check that ensures each NPCRoster target is met; spawning replacements when short.
  6. Realtime broadcast — emitting npc_arrived / npc_departed / npc_engaged / npc_kia events on the realtime bus so player clients can render NPC presence.

Inputs

The scheduler reads: - NPCCharacter rows for movement, status updates, and roster checks. - NPCRoster rows for target-count maintenance. - Sector.defenses.patrol_ships JSONB for squad-row coherence. - Combat / encounter events from the realtime bus and combat-resolver hooks.

It writes: - NPCCharacter.current_sector_id, .status, .last_seen_at on every transition. - NPCDeathLog rows on KIA. - Sector.defenses.patrol_ships JSONB to keep squad-row npc_character_ids coherent with per-NPC location. - Realtime bus events on every NPC-state transition.

Trigger surface

The scheduler runs as three loops on different cadences, plus event-driven hooks.

Loop A — Schedule executor (cadence: every 60 seconds)

Scans all NPCCharacter rows where lifecycle_stage NOT IN ('retired', 'kia'). For each:

  1. Compute current_minute_of_day_utc = (now − date_truncated_to_day) / 60 adjusted by the NPC's daily_schedule.shift_offset_hours.
  2. Find the matching schedule block in daily_schedule.blocks (or the appropriate weekly_overrides entry if today's weekday is in the override list).
  3. Compare the block's activity to current_activity; if they differ, transition.
  4. Determine the new current_sector_id from the block's location_type + location_ref:
  5. home_sector / barracks — set to the NPC's home sector (lookup from roster).
  6. patrol_route — pick the appropriate route sector from the patrol-route definition referenced by location_ref, advancing per the cycle.
  7. station — set to the station's host sector.
  8. transit / dynamic — set NULL; NPC is not in a sector room.
  9. Update Sector.defenses.patrol_ships JSONB on both the prior and new sectors to keep npc_character_ids coherent.
  10. Emit appropriate realtime events: npc_off_duty, npc_began_patrol, npc_arrived_home, npc_began_socialize, npc_off_grid (for sleep / personal).

This loop is the canonical schedule-execution engine for the lifecycle layer.

Sleep / off-duty hooks (per the physical-realism layer in ./npc-lifecycle.md#physical-realism--ship-parking-lodging-and-shift-transitions):

When transitioning to sleep / socialize / dine / personal activities at a barracks-anchored block: - Set Ship.status = DOCKED, Ship.sector_id = <barracks host sector>. - Append to Sector.defenses.docked_npc_ships array with status = "docked_off_duty". - If the NPC's ship maintenance.current_integrity < 75% × max, set status = "docked_maintenance" and queue auto-repair (sleep block duration). - Increment NPCBarracks.current_occupants_count; append the NPC's id to assigned_npc_ids.

When transitioning back to patrol / active duty: - Remove the entry from docked_npc_ships. - Ship.status = IN_SPACE. - Decrement current_occupants_count; remove from assigned_npc_ids.

Shift overlap & handoff (when transitioning out of a patrol block with overlap_start_minute set): - Within the overlap window, set BOTH outgoing and incoming NPC's current_activity = shift_handoff. - Update the squad row's shift_handoff_state JSONB with both NPC IDs and any threat_notes from the outgoing NPC. - At the outgoing NPC's true block end, transition them out (commute home, then off-duty / sleep). - Clear shift_handoff_state once the outgoing NPC is gone.

Late or absent relief — cascade-hold logic: 1. If at the outgoing NPC's end_minute the relieving NPC is unavailable (status = engaged, off-duty, or unspawned), extend the outgoing NPC's end_minute by +15 min. 2. If still no relief at +15, recall the next-most-senior off-duty NPC of the same role: preempt their off-duty block, set their commute target to the patrol post. 3. If no secondary available, set shift_handoff_state.coverage_gap_started_at = now. Bus emits npc_coverage_gap. Engagement-routing's response-grace fallback applies for any offenses during the gap.

Zero-gap promotion on Marshal KIA (per ADR-0063 N-F1). When the primary Marshal in a sector goes KIA, the on-duty backup NPC immediately promotes to primary role — there is no 15-minute coverage gap waiting for recruit-pool fill:

def handle_npc_kia(npc, sector):
    if npc.duty_role == 'primary_marshal':
        backup = sector.npc_roster.find_by_role('backup_marshal', status='on_duty')
        if backup:
            backup.duty_role = 'primary_marshal'
            backup.promotion_at = utcnow()
            outbox.queue('npc.role_promoted', { ... })
            sector.npc_roster.queue_recruit('backup_marshal')   # fresh recruit fills slot immediately; 7d recruit stage at reduced stats per npc-lifecycle.md
        else:
            # Dual-KIA fallback: both primary and backup down. Rare; falls
            # through to emergency recruit. Coverage gap accepted in this
            # specific case.
            sector.npc_roster.queue_emergency_recruit('primary_marshal')

The recruit-pool fill happens for the now-vacant backup slot, on the standard 6-hour training cooldown. During that fill window the (formerly-backup, now-promoted) primary covers normally. The "kill the Marshal, time the offense in the gap" exploit closes — there is always a designated authority on duty unless both primary and backup go down simultaneously.

KIA reroute supersedes scheduled handoff (per ADR-0063 N-D3). Any KIA event invalidates pending shift-handoff orders for the affected sector. The shift scheduler reads the post-KIA roster on its next tick — preventing teleport-redundancy artifacts where a scheduled handoff and a KIA-driven reroute would both fire.

Engagement preemption — when shift end-time arrives while NPC is engaged: - Set NPCCharacter.shift_preempt_pending = true. - The relieving NPC's squad row sets preempted_prior_shift_npc_id = <outgoing>.id. - On engagement resolution: if the outgoing NPC survives, transition to off-duty + clear flags; if KIA, the KIA flow (below) handles cleanup.

KIA mid-handoff_handle_npc_kia calls _reroute_incoming_relief(kia_npc_id): - If the relieving NPC is in shift_handoff activity, set their current_activity = shift_reroute. - Teleport them to the patrol route's start sector. - Extend their shift end_minute by +30 min to cover the lost overlap window. - Bus emits npc_emergency_coverage.

Commute feasibility check — before transitioning to a commute block: 1. Query warp graph for path source → destination. 2. Sum WarpTunnel.turn_cost; convert to wall-clock at L1 ARIA pacing (~86 sec/turn). 3. If the actual duration exceeds the block's planned duration, dynamically extend end_minute; cascade downstream blocks. 4. If route is impossible (disconnected graph), set current_activity = error_stranded; emit npc_commute_impossible to ops bus.

Promotion application — checked at every block boundary: - If promotion_pending_at <= now, regenerate the NPC's daily_schedule from the new role's template. - Update home_barracks_id if the new role uses a different lodging. - Clear promotion_pending_at. Bus emits npc_promoted.

Per-archetype default schedules:

  • Marshals / Sentinels: 8h patrol shift / 8h off-duty + dining + socializing / 8h sleep at barracks. Three Marshals on staggered shifts give continuous regional coverage.
  • Pirate captains: variable raiding hours (typically peak-traffic windows) with off-duty at home base; weekly overrides for "lying-low" days.
  • Station officials: 12h shift on station / 12h off-station personal time; never far from their home station.
  • Traders: multi-day route blocks; one full day for transit, one for trading at each end station, repeat.
  • Faction leaders: mostly stationary (HQ sector) with rare ceremonial moves.

The legacy v1 patrol_route JSONB is auto-wrapped into a single patrol block in the new daily_schedule; existing rows migrate cleanly.

Loop B — Roster maintenance (cadence: every 10 minutes)

Scans all NPCRoster rows. For each:

  1. Count the live NPCs matching (region_id, faction_code, role) with status IN ('on_duty', 'off_duty', 'engaged').
  2. If count < target_count, spawn replacements one at a time — the procedurally-generated name from name_pool, an assigned ship from the appropriate is_npc_only hull class, an assigned starting current_sector_id, and a generated patrol_route.
  3. New NPC's respawn_eligible_at is checked: if a predecessor's cooldown is still active, the spawn waits; otherwise it fires immediately.
  4. replaced_by_id on the predecessor (if any) is set on the predecessor's row.
  5. Emit npc_spawned event on the bus.

Loop C — Off-duty rotation (cadence: every 30 minutes)

Scans on_duty NPCs and rotates a fraction (target: ~20% off-duty at any given time per faction) into off_duty for a 4–8 hour rest period. While off_duty, the NPC sits at a station or rest sector and is excluded from engagement routing. After the rest period, status flips back to on_duty.

Off-duty rotation gives the universe a heartbeat — players see Marshal Vance "in the morning" patrolling sector 47, then "in the evening" she's not there because she's resting at the Capital station. The next morning she's back at sector 47.

Event-driven hooks (synchronous on combat / scan / offense events)

In addition to the loops, the scheduler exposes synchronous handlers for time-critical events:

Engagement routing

When an offense fires (Wanted Status detected, contraband scan hit, protected-sector breach, hostile combat in protected sector), the offense handler calls route_engagement(offense). The scheduler:

  1. Determines the responding role (marshal for Federation offenses, sentinel for Nexus offenses).
  2. Finds eligible NPCs: status = on_duty, faction matching the offense, region matching the offense's sector.
  3. Picks the nearest NPC by warp-graph hop distance from the offense sector — capped per ADR-0063 N-I1 by NPC_CLASS_ROUTING_DISTANCE. Defaults: Sector Marshals 5 hops, Faction Patrol Captains 8 hops, Pirate Lords 3 hops (they don't roam far from their stronghold). Beyond the cap, the NPC won't pursue. QJ pursuit is excluded — standard warp-tunnel reachability only, consistent with ADR-0060 G-V3.
  4. If multiple NPCs are needed (squad response per the police-forces composition table), picks the next-nearest, etc., until the squad size is met.
  5. For each picked NPC: set status = engaged, atomically update current_sector_id to the offense sector, update squad rows accordingly, emit npc_engaged event with the NPC's name in the payload so player UI can render "Marshal Vance is engaging you."
  6. Hands the engaged NPCs to the combat-resolver as the squad's pilots.

If no eligible NPC is in-region (all KIA / off-duty / engaged elsewhere), the response falls back to a regional response delay: the offense logs a "no marshal available" event, and the next available NPC is dispatched when one becomes free. The offending player gets a 5–15 minute grace before any response arrives. This is the canonical answer to "what if every Federation Marshal in the region is dead or busy?" — the player has a small window of effective lawlessness, which the design treats as an emergent feature, not a bug.

KIA processing

When the combat resolver destroys an NPC's ship (via the canonical destruction handler, per ./combat-resolver.md):

  1. The destruction handler calls _handle_npc_kia(npc_id, killer_player_id, sector_id, combat_log_id).
  2. The NPC's status flips to kia, current_sector_id becomes NULL, destroyed_at = now, respawn_eligible_at = now + 7 days (faction-tunable cooldown).
  3. An NPCDeathLog row is inserted.
  4. The NPC's squad row is updated to remove the dead NPC; if the squad is empty, the row is deleted.
  5. The NPC's ship_id is detached (the ship row is destroyed normally per the canonical handler).
  6. An npc_kia event fires on the realtime bus carrying the NPC's name and the killer's player_id.
  7. Realtime broadcast lets player clients show "Marshal Vance has been killed by [Player]" in news feeds, faction-lore updates, etc.
  8. The killer player's reputation hooks fire per faction-rep rules (e.g., −250 Terran Federation rep for killing a Marshal per police-forces.md).
  9. The named NPC is permanently gone. Loop B will fill the role with a new NPC at cooldown expiry — a different name, different procedurally-generated personality. Marshal Vance never returns; Marshal Reyna takes the seat.

Player-NPC encounter recording

When a player and an on-duty NPC end up in the same sector (player-arrival event in the sector-presence pipeline), the encounter is recorded for the player's relationship-with-NPC tracking (📐 Design-only — surfaces emergent storytelling like "you've met Marshal Vance 12 times"). No combat is initiated unless an offense trigger fires; mere co-presence is just lore.

Process flow — typical Federation Marshal day

A worked example. Federation Marshal Cassandra Vance (NPCCharacter, role marshal, region <player-region-id>, patrol_route [12, 34, 47, 89, 102, 156], cycle 4h):

  • 00:00 UTC — Vance is at sector 12, on_duty.
  • 04:00 UTC — Loop A advances her to sector 34; squad row at sector 12 deletes; squad row at sector 34 created with her id.
  • 04:01 UTC — A player traverses sector 34 with active stolen-ship Wanted status. Engagement-routing fires; Vance is nearest, eligible. Her status flips to engaged. Combat resolver pulls her in. Combat resolves, player flees.
  • 04:08 UTC — Combat ended; Vance's status returns to on_duty; she stays at sector 34 (the offense sector) for the rest of the cycle.
  • 08:00 UTC — Loop A advances her to sector 47.
  • 12:00 UTC — Loop A advances her to sector 89.
  • 14:00 UTC — Loop C rolls Vance into off_duty. She moves to her assigned rest sector (the Capital). Squad row at sector 89 updates accordingly.
  • 22:00 UTC — Off-duty period ends; Vance returns to on_duty at sector 89, resumes the cycle.

Throughout, every transition emits a realtime event. Players in sector 89 see Vance arrive; players who later enter sector 89 after she's gone see "no Marshal here" (or her departure timestamp in the sector log).

Crash recovery

The scheduler tracks the last-completed cycle per loop in a scheduler_state row (📐 Design-only). On restart, each loop catches up from the recorded watermark — if the scheduler was down for 6 hours, Loop A executes 6 ticks worth of patrol movement on first wake (atomically per NPC, not all-at-once-fan-out). Realtime events for the catch-up movements are suppressed (the events would be stale; player clients resync from REST on next sector entry).

KIA and engagement events are durable in their own tables (NPCDeathLog, combat logs) — they don't depend on the scheduler being up. If the scheduler is down when an offense fires, the offense handler still records the event; engagement routing happens when the scheduler comes back online (with the in-band 5–15 min grace already expired, the response is delayed but eventual).

Configuration

Tunable per faction / role (📐 Design-only environment variables and NPCRoster.config JSONB):

  • cycle_hours_default — patrol movement period.
  • off_duty_rotation_target_pct — fraction of NPCs off-duty at any given time.
  • kia_cooldown_seconds — time between an NPC dying and the role being eligible to refill.
  • engagement_no_response_grace_seconds — how long the offending player has before the next available NPC arrives if the region is short-handed.
  • route_engagement_max_distance_hops — cap on how far an NPC can be from the offense sector before being considered "out of range" (forces a roster-short fallback rather than dispatching a Marshal across the entire region).

Outputs / state changes

Per loop tick: - NPCCharacter.current_sector_id, .status, .last_seen_at updates. - NPCCharacter.patrol_route.last_rotated_at updates. - Sector.defenses.patrol_ships add/remove npc_character_ids entries. - NPCRoster reads (no writes from loops; admin tooling writes here).

Per event: - NPCDeathLog insert on KIA. - NPCCharacter.status = kia and related fields on KIA. - New NPCCharacter row on roster-maintenance spawn. - Realtime bus events (npc_arrived, npc_departed, npc_engaged, npc_kia, npc_spawned).

Failure modes

Mode Detection Recovery
Loop A misses a tick (scheduler down) Watermark drift Catch up on restart with event suppression
Engagement routing finds no eligible NPC All NPCs KIA / off-duty / engaged elsewhere Fall back to in-band grace; offense queues for next available
NPCCharacter row references a deleted Region CASCADE FK Row is deleted with the region (NPC permanently lost)
Squad-row drift (NPC moved but squad row stale) Periodic invariant scan Auto-correct on next Loop A pass
Two scheduler instances racing Advisory lock keyed on region_id Single-instance-per-region serialization
Roster-target unreachable (e.g., 0 valid sectors for patrol_route) Spawn rejection Log and alert ops; admin manually adjusts the roster

Source map (target)

Topic Path
Scheduler service services/gameserver/src/services/npc_scheduler_service.py
NPC model services/gameserver/src/models/npc_character.py
Roster model services/gameserver/src/models/npc_roster.py
Death-log model services/gameserver/src/models/npc_death_log.py
Engagement routing npc_scheduler_service.route_engagement()
KIA handler npc_scheduler_service._handle_npc_kia()
Realtime events services/gameserver/src/services/realtime_event_emitter.py
Admin tooling services/gameserver/src/api/routes/admin_npc.py (respawn, reassign, edit)

Status

📐 Design-only. No NPC scheduler service exists today. The patrol pre-seeding in galaxy-generator-design.md Phase 12.5 currently emits anonymous squad rows; this scheduler is the canonical replacement that makes those rows reference named persistent NPCs. The existing Sector.defenses.patrol_ships JSONB is the integration point — extending the shape with npc_character_ids is non-breaking (squad rows without the array still parse; the legacy ship_count field is retained as a denormalized read-fast path).

Cross-references