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):
- 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, 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:
- 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); plus a small chance ofspacetime_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. 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¶
- 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 |
| 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) |
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.