Skip to content

Sector Presence

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, WORMHOLE}:
    encounters.append({
        type: "sector_hazard",
        hazard: sector.type,
        threat_level: "medium" if sector.hazard_level < 7 else "high"
    })

# 3. Sector defense drones
drones = sector.defenses.get("defense_drones", 0)
if drones > 0:
    encounters.append({
        type: "drones",
        count: drones,
        threat_level: "low" if drones < 10 else "medium"
    })

Encounter results are returned to the player client (not stored — they're informational pings the UI surfaces and the player chooses to engage).

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).
  4. tunnel.type ∈ {QUANTUM, UNSTABLE}: small chance of spacetime_anomaly.
  5. If tunnel.max_uses set: tunnel.current_uses += 1.
  6. If max_uses - current_uses ≤ 3: emit tunnel_degradation warning.
  7. If current_uses >= max_uses: tunnel.status = COLLAPSED, emit tunnel_collapse.
  8. 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. Updated in tandem with players_present (target — ships_present mutation is currently lighter than players_present and should be aligned).

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