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):
- Load
old_sectorbysector_id == old_sector_id. If found: - Read
players_presentlist (default empty). - Find entry where
player_id == str(player.id). - Remove if found.
- Write back;
flag_modified(old_sector, 'players_present'). - Load
new_sectorbysector_id == new_sector_id. If found: - Read
players_presentlist (default empty). - Build entry from current player + ship state.
- Defensive: if entry for this player_id already exists, remove it first (idempotent re-entry).
- Append new entry.
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:
- Load tunnel by
(origin_sector_id, destination_sector_id). - If
tunnel.stability < 0.7: roll for instability events: < 0.5:radiation_exposureevent (severity high if < 0.3, else medium).- tunnel.type ∈ {QUANTUM, UNSTABLE}: small chance of
spacetime_anomaly. - If
tunnel.max_usesset:tunnel.current_uses += 1. - If
max_uses - current_uses ≤ 3: emittunnel_degradationwarning. - If
current_uses >= max_uses: tunnel.status = COLLAPSED, emittunnel_collapse. - 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¶
- A player appears in exactly one sector's
players_presentat any time. Player.current_sector_idmatches the sector that contains the player's entry.- After
_update_player_presence, both old and new sectors have been written andflag_modifiedcalled (JSONB change detection). arrived_atis monotonically newer-or-equal than the previous arrival.- The presence update happens atomically with the player's sector_id change (same transaction).
- A sector's
players_presentlist contains at most one entry perplayer_id(idempotent on duplicate insert). - Tunnel
current_usesnever exceedsmax_uses; on equality the tunnel transitions to COLLAPSED. - Encounter roll results are derived from current sector state, not cached — readers see fresh state on every move.
- 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) |
Related¶
realtime-bus.md— where join/leave events go.combat-resolver.md— combat starts with two players in the same sector.turn-regeneration.md— moving costs turns.../FEATURES/galaxy/sectors.md— sector concept.