Skip to content

Sector Presence

Status: 🚧 Partial — The who-is-where core ships — atomic players_present update in the move transaction and full tunnel-event handling — but the encounter engine is a thin subset (no faction-patrol/Wanted pursuit … (impl audit 2026-06-16)

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 — eventually removes stale entries (target spec).

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)
for patrol in sector.defenses.get("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"   # immediate, not optional
        })

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. Patrol pursuit is non-optional — unlike drone or hazard encounters which are pings, patrols immediately initiate combat per the canonical resolver.

Encounter results other than patrol pursuit are returned to the player client as informational pings the UI surfaces; pursuit results call combat_resolver.attack_player(patrol, player) directly.

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.

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) Periodic sweep removes entries where arrived_at older than session TTL AND player is offline. (Target spec.)
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/sector_presence_sweeper.py (target — not yet split out)