Skip to content

Sector Presence

Status: ๐Ÿšง Partial โ€” The who-is-where core ships โ€” atomic players_present update in the move transaction, full tunnel-event handling, faction-patrol/Wanted pursuit dispatch (WO-DRIFT-combat-patrol-entry-dispatch, 2026-07-10), and the disconnect/inactivity sweep (WO-PRESWEEP, _run_presence_sweep_sync) โ€” the remaining gap is the write-amplification migration to a normalized sector_player_presence join table, still conditional on the P99 latency trigger. (Re-verified 2026-08-21 vs Sectorwars2102 HEAD 46bce720.)

Purpose

The sector-presence subsystem is the authoritative ledger of who is where. Every sector tracks the players and ships currently in it via two JSONB columns (Sector.players_present, Sector.ships_present); on every move the previous sector and the destination sector both update atomically, encounter rolls fire, tunnel-event triggers fire, and join/leave broadcasts go out on the realtime bus. This is the foundation for PvP detection, encounter generation, sector-based combat, and the live "who's nearby" UI.

Inputs

What triggers this: - MovementService.move_player_to_sector(player_id, destination) โ€” direct warp or tunnel travel. - MovementService._update_player_presence(player, old_sector_id, new_sector_id) โ€” the actual presence mutation. - Initial spawn after first-login โ€” player is placed in their starting sector and their entry is added. - Player disconnect / inactivity sweep โ€” _run_presence_sweep_sync (scheduler Loop, PRESENCE_STALE_MINUTES = 30) removes stale entries whose player hasn't been active per greatest(Player.last_activity_at, Player.last_game_login); NPC entries are exempt.

State read: - Player.id, Player.username, Player.team_id. - Player.current_ship_id, Player.current_ship.name, .type. - Sector.players_present (current list of player entries). - Sector.ships_present (current list of ship entries). - Sector.type (determines hazard encounters). - Sector.defenses["defense_drones"] (for drone encounters). - WarpTunnel.stability, .type, .max_uses, .current_uses (for tunnel events).

Process

Player entry shape

Each entry in Sector.players_present is a dict:

{
  "player_id": "uuid",
  "username": "Captain Foo",
  "ship_id": "uuid",
  "ship_name": "Quickstrike",
  "ship_type": "FAST_COURIER",
  "team_id": "uuid-or-null",
  "arrived_at": "2026-04-30T14:33:21.123Z"
}

Update presence on move

In _update_player_presence(player, old_sector_id, new_sector_id):

  1. Load old_sector by sector_id == old_sector_id. If found:
  2. Read players_present list (default empty).
  3. Find entry where player_id == str(player.id).
  4. Remove if found.
  5. Write back; flag_modified(old_sector, 'players_present').
  6. Load new_sector by sector_id == new_sector_id. If found:
  7. Read players_present list (default empty).
  8. Build entry from current player + ship state.
  9. Defensive: if entry for this player_id already exists, remove it first (idempotent re-entry).
  10. Append new entry.
  11. flag_modified(new_sector, 'players_present').

Both updates happen in the same DB transaction as the player's current_sector_id change. The whole _execute_movement is row-locked on the player.

Encounter rolls on entry

After _execute_movement completes, _check_for_encounters(player, sector_id) runs:

encounters = []

# 1. Other players
other = [p for p in sector.players_present if p["player_id"] != str(player.id)]
if other:
    encounters.append({type: "players", players: other, threat_level: "varies"})

# 2. Sector hazards
if sector.type in {BLACK_HOLE, NEBULA, ASTEROID_FIELD, RADIATION_ZONE, WARP_STORM}:
    encounters.append({
        type: "sector_hazard",
        hazard: sector.type,
        threat_level: "medium" if sector.hazard_level < 7 else "high"
    })

# 3. Sector defense drones
for block in sector.defenses.get("drone_blocks", []):
    if block["count"] > 0 and (
        block.get("is_hostile_to_passers") or block["owner_player_id"] in player.hostile_set
    ):
        encounters.append({
            type: "drones",
            count: block["count"],
            owner: block.get("dominant_owner_name") or block.get("faction_code"),
            threat_level: "low" if block["count"] < 10 else "medium"
        })

# 4. NPC faction patrols (Wanted-status pursuit). Shipped under a
# dedicated "police_patrol_ships" key, not "patrol_ships" โ€” see
# DATA_MODELS/jsonb-schema.md "Sector.defenses" for why.
for patrol in sector.defenses.get("police_patrol_ships", []):
    if player.is_wanted_at(patrol["faction_code"], threshold=patrol["wanted_threshold"]):
        encounters.append({
            type: "faction_patrol",
            faction: patrol["faction_code"],
            squad: patrol["squad_kind"],
            ship_count: patrol["ship_count"],
            threat_level: "high",
            engagement: "pursuit"   # dispatches the ADR-0042 response, not an instant fight
        })

is_wanted_at(faction_code, threshold) consults Player.personal_reputation, the player's active stolen-ship reports, and standing with the named faction; returns true if any signal exceeds wanted_threshold. A matched patrol is non-optional โ€” sector entry always dispatches the police response โ€” but combat isn't instant: dispatch creates a durable PendingEngagement watcher (route_engagement, see npc-scheduler.md ยง Engagement routing) and the squad arrives after a 2-turn delay measured in the offender's own turn consumption (ARRIVAL_TURN_DELAY, per ADR-0042). Combat begins only once the squad has arrived.

Encounter results other than patrol pursuit are returned to the player client as informational pings the UI surfaces; pursuit results dispatch the police-engagement watcher โ€” the squad's actual combat initiation happens on arrival, not at sector entry.

Tunnel event triggers

If movement was via warp tunnel, _check_for_tunnel_events(player, from_sector_id, to_sector_id) runs:

  1. Load tunnel by (origin_sector_id, destination_sector_id).
  2. If tunnel.stability < 0.7: roll for instability events:
  3. < 0.5: radiation_exposure event (severity high if < 0.3, else medium); plus a small chance of spacetime_anomaly.
  4. If tunnel.max_uses set: tunnel.current_uses += 1.
  5. If max_uses - current_uses โ‰ค 3: emit tunnel_degradation warning.
  6. If current_uses >= max_uses: tunnel.status = COLLAPSED, emit tunnel_collapse.
  7. Tunnel row mutation persisted on the same transaction.

Realtime broadcast on join/leave

Both add and remove fire WebSocket events on the realtime bus (see realtime-bus.md):

  • sector.player_left โ€” broadcast to old_sector subscribers.
  • sector.player_joined โ€” broadcast to new_sector subscribers.

Event payload (target):

{
  "type": "sector.player_joined",
  "sector_id": 442,
  "player": {
    "player_id": "...",
    "username": "...",
    "ship_id": "...",
    "ship_name": "...",
    "ship_type": "...",
    "team_id": "...",
    "arrived_at": "..."
  }
}

Subscribers include any player currently in that sector (their UI can show new arrivals immediately) plus admin / spectator views.

Ships present

Sector.ships_present is the parallel ship-only listing โ€” used by drone deployment, planet siege detection, and AI pathfinding that doesn't care about pilot identity. It mutates in tandem with players_present on every join/leave/destroy event so the two listings stay aligned.

Reputation/hostility classification (WO-API-PHASE2 Lane B6)

Every presence entry a player's client sees โ€” whether from the REST snapshot or the WebSocket broadcast โ€” carries the same two derived flags: a 3-bucket reputation color (rep_bucket: red/gray/blue, from the 8-tier REPUTATION_TIERS scale) and, for NPCs, a hostile boolean (fair-game-to-attack). Both are computed by a single shared kernel, presence_classification.py (player_rep_bucket(), npc_hostile()), and consumed by both emitting paths:

  • REST: intrasystem_movement_service.enrich_presence_with_live_pose, shared by sectors.py/player.py.
  • WS: websocket_service.get_sector_players.

npc_hostile() is archetype-first, notoriety-fallback: HOSTILE_RAIDER archetypes are always fair game, LAW_ENFORCEMENT never is, and everything else falls back to the trader-scruples axis (notoriety >= LAWFUL_TARGET_THRESHOLD) โ€” necessary because pirates and police always spawn with notoriety=None, so a notoriety-only read would silently miss every raider.

Manual-mirror hazard. The player-client keeps its own independent implementation of the identical classification logic in contactClassification.ts (playerRepBucket/isHostileNpc), used as a graceful-degrade fallback for contacts that predate these server-computed fields (legacy JSONB rows, older client builds). The two implementations are hand-mirrored, not built on a shared source โ€” there is no code-sharing mechanism between the Python kernel and the TypeScript fallback. Any future change to the bucket vocabulary or hostile threshold on one side must be manually ported to the other, or the two paths will silently diverge (server says red, stale client fallback says blue, or vice versa). This is a known, accepted drift risk, not an oversight โ€” flagged here so a future maintainer touching either file checks the other.

Pathfinding / route engine

RouteOptimizer (route_optimizer.py) is the canonical pathfinding engine. It owns the warp/tunnel graph, shortest-path and objective-weighted routing (profit / time / risk / balanced), and arbitrage discovery, and runs on async DB sessions (AsyncSession). All route computation resolves through it.

NavService (nav_service.py) provides the sync, player-known-sector-aware plot used by the movement path; it is a sync bridge that delegates routing semantics to RouteOptimizer rather than a second authority. Callers that need a route obtain it through RouteOptimizer, or through NavService where a synchronous, known-sector-filtered plot is required. New routing callers integrate via these two surfaces โ€” they do not introduce additional graph-walk or shortest-path implementations.

Write-amplification mitigation (๐Ÿ“ โ€” conditional migration)

Per ADR-0051 SK27, Postgres rewrites the full players_present JSONB on each update. At 10k concurrent players with frequent sector transitions, the write rate hits ~666/sec โ€” large blobs being rewritten constantly. The migration trigger is P99 write latency > 100ms sustained over 5 minutes. Until the trigger fires, the JSONB-only path is acceptable.

When the trigger fires, the write path migrates to a normalized sector_player_presence join table:

Column Type Notes
sector_id UUID FK Sector.id indexed
player_id UUID FK Player.id indexed
entered_at DateTime for "how long has X been here" queries

Composite UNIQUE on (sector_id, player_id). The JSONB column on Sector stays as a denormalized read cache updated alongside the join-table inserts/deletes via a service-layer write helper (one transaction, both surfaces written together). UI queries continue to hit the JSONB; live presence/scanning queries hit the join table for O(log n) writes.

Migration is forward-only Alembic when the trigger fires. The two surfaces stay in sync via the service-layer write helper indefinitely.

Outputs / state changes

Per move: - Player.current_sector_id updated. - Player.is_docked = false, Player.is_landed = false, current_port_id = null, current_planet_id = null (movement clears all "at-rest" state). - Player.current_ship.sector_id updated. - Player.turns -= turn_cost. - Player.aria_total_interactions += 1, possible consciousness level-up. - Sector(old).players_present shrinks by 1. - Sector(new).players_present grows by 1. - WarpTunnel.current_uses incremented (if tunnel travel). - WarpTunnel.status possibly transitions to COLLAPSED.

Events emitted: - sector.player_left (to old_sector subscribers). - sector.player_joined (to new_sector subscribers). - tunnel.degradation_warning (if applicable). - tunnel.collapsed (if applicable).

Invariants

  1. A player appears in exactly one sector's players_present at any time.
  2. Player.current_sector_id matches the sector that contains the player's entry.
  3. After _update_player_presence, both old and new sectors have been written and flag_modified called (JSONB change detection).
  4. arrived_at is monotonically newer-or-equal than the previous arrival.
  5. The presence update happens atomically with the player's sector_id change (same transaction).
  6. A sector's players_present list contains at most one entry per player_id (idempotent on duplicate insert).
  7. Tunnel current_uses never exceeds max_uses; on equality the tunnel transitions to COLLAPSED.
  8. Encounter roll results are derived from current sector state, not cached โ€” readers see fresh state on every move.
  9. Sector presence is consistent with Player.is_active โ€” inactive players are eligible for sweep removal.

Failure modes

Mode Target handling
Player row locked elsewhere during move with_for_update waits; concurrent moves serialize.
Old sector row missing Skip removal step; log warning; proceed with new sector add.
New sector row missing Movement rejected upstream โ€” destination must exist.
Player entry missing from old sector (out-of-band drift) Defensive: removal is no-op if entry not found.
Player entry duplicated in new sector (re-entry race) Defensive: existing entry removed before append.
Crash mid-update (after old removed, before new added) Transaction rolls back; player row unchanged; no presence drift.
WebSocket bus unavailable Best-effort emit; presence DB state is source of truth, broadcasts are recoverable on reconnect.
Tunnel mutation racing with collapse sweep Row lock on tunnel; first to commit wins; second sees COLLAPSED status and rejects.
Stale presence (player offline but listed) _run_presence_sweep_sync removes entries older than PRESENCE_STALE_MINUTES per the last_activity_at/last_game_login freshness signal (shipped, WO-PRESWEEP).
players_present JSONB write race between two players entering same sector Both moves get row lock on different player rows but read+write the same sector JSONB; SQLAlchemy flag_modified plus transaction isolation serialize correctly.
Player teleported by admin Admin op should call _update_player_presence directly with explicit from/to.

Source map

Concern Path (target)
Movement service services/gameserver/src/services/movement_service.py
Route engine (canonical) services/gameserver/src/services/route_optimizer.py (RouteOptimizer)
Sync route bridge services/gameserver/src/services/nav_service.py (NavService)
Presence update MovementService._update_player_presence
Encounter rolls MovementService._check_for_encounters
Tunnel events MovementService._check_for_tunnel_events
Sector model services/gameserver/src/models/sector.py (players_present, ships_present)
Warp tunnel model services/gameserver/src/models/warp_tunnel.py
Realtime broadcast services/gameserver/src/services/websocket_service.py
Stale entry sweeper services/gameserver/src/services/scheduler/presence_helpers.py (_run_presence_sweep_sync, wired via core_loop.py)
Rep/hostility classification kernel services/gameserver/src/services/presence_classification.py (player_rep_bucket, npc_hostile)
Client-side classification fallback (hand-mirrored, not shared) services/player-client/src/components/tactical/contactClassification.ts (playerRepBucket, isHostileNpc)