Skip to content

Tactical Threat Rollup

Status: 🚧 Partial — STATIC rollup endpoint (GET /api/v1/nav/threat, threat_service.compute_threat_rollup) and player-client consumer (useNavThreatRollup → TacticalThreatPage NAV THREAT ROLLUP section) tip-shipped; live hostiles_present / inbound_squads inputs remain intentionally removed. (re-verified 2026-08-31 vs Sectorwars2102 origin/feat 7012c53e5.)

Purpose

The tactical threat rollup answers a single question for the cockpit's TACTICAL deck-monitor: "of the sectors I know about, which ones are dangerous?" It computes a 0–100 threat score and a named band (CLEAR / CAUTION / HOSTILE / LETHAL) for every sector in the player's own known navigation graph — the same known-set assembly POST /nav/plot and GET /nav/chart use (NavService.get_known_sector_ids, see course-plotting.md). It is read-only: no writes, no migration, no mutation of any kind.

✅ Shipped, STATIC-only — a deliberate security-motivated design revision

GET /api/v1/nav/threat and threat_service.compute_threat_rollup are live (WO-UI2-TACTICAL-THREAT-ENDPOINT). The first-pass design scored each sector from four inputs; two were removed before ship, after Cipher (security review) and the implementation lead confirmed a HIGH cross-player intelligence-leak risk in both:

  • hostiles_present (live hostile NPC presence in a sector) — real-time intel about a sector the requesting player is NOT currently standing in, at odds with this codebase's own fog-of-war precedent: GET /player/current-sector (api/routes/player.py:472) only enriches live NPC presence for the player's current sector, never the wider known graph. Exposing it graph-wide breaks that precedent.
  • inbound_squads (live unresolved PendingEngagement rows) — worse than hostiles_present: PendingEngagement.arrival_sector_id is only populated once the row transitions to ARRIVED, meaning a "squad inbound" signal is actually "a police squad is ALREADY fighting a SPECIFIC named player, right now, in that player's current sector." Surfacing that keyed only by sector leaks one player's live combat encounter to every other player who merely knows that sector exists — a confirmed cross-player leak, not a hypothetical one. No clean genuinely-pending-arrival signal exists on the model to substitute.

The rollup ships STATIC-only on the four remaining inputs, none of which reveal what any other player or NPC squad is doing right now:

Input Source Weight (all NO-CANON, pending Max ratification)
Low security Sector.security_level (1–10, default 5) (10 − security_level) × SEC_W where SEC_W = 4
Hazard Sector.hazard_level (0–10) hazard_level × HAZ_W where HAZ_W = 2
Recent combat Sector.last_combat within 24h (RECENT_COMBAT_WINDOW_H) flat RECENT_COMBAT_W = 15 if within window, else 0
Pirate pressure pirate_ecosystem_service.compute_population_score(region_id) — LIVE region-level aggregate (CAMP=1/OUTPOST=3/STRONGHOLD=10 tier-weighted sum), clamped 0..PIRATE_MAX where PIRATE_MAX = 15

score = clamp(sum of the above, SCORE_MIN=0, SCORE_MAX=100), banded at CLEAR ≤24, CAUTION ≤49, HOSTILE ≤74, LETHAL above. Pirate pressure is deliberately a region-level aggregate, not per-player/per-sector live position data — qualitatively different from the two removed inputs, so it does not carry the same leak. (Explicitly NOT suppression_modifier, an inverse decay factor that would need an unstated inversion; NOT the cached current_population_score snapshot, which defaults to 0 until a region's first weekly tick ever runs and would under-report a region with live holdings but no tick yet — compute_population_score queries PirateHolding directly and is always live.)

Every weight, band edge, and cap above is ratified as-shipped (see DECISIONS.md#tactical-threat-rollup-weights, Option a).

Response contract

One ThreatEntry per known sector: {sector_id, score, band, contributors: [{input, points}]}, where input is one of the named contributor labels (low_security, hazard, recent_combat, pirate_pressure) and points is that input's contribution to the total score — so the client can show why a sector scored the way it did, not just the final number. Auth-gated behind get_current_player; a player only ever receives threat data for sectors already in their own known graph (fog-of-war consistent with every other nav endpoint).

Source map

Concern Path
Threat endpoint services/gameserver/src/api/routes/nav.py (GET /nav/threat)
Threat rollup service services/gameserver/src/services/threat_service.py (compute_threat_rollup)
Formula constants (ratified) threat_service.py module-top (SEC_W, HAZ_W, RECENT_COMBAT_W, RECENT_COMBAT_WINDOW_H, PIRATE_MAX, SCORE_MIN/MAX, BAND_*_MAX)
Known-set assembly (shared with plotting) NavService.get_known_sector_ids
Pirate-pressure source pirate_ecosystem_service.compute_population_score
Player-client API client services/player-client/src/services/api.ts (navAPI.getThreat → GET /api/v1/nav/threat)
Player-client rollup hook services/player-client/src/components/tactical/useNavThreatRollup.ts (useNavThreatRollup)
Player-client UI consumer services/player-client/src/components/tactical/pages/TacticalThreatPage.tsx (NAV THREAT ROLLUP section; mounted from TacticalMonitor.tsx THREAT tab)
Deck-level band overlay services/player-client/src/components/pages/GameDashboard.tsx (useNavThreatRollup → navThreatBandBySector)

Player-client wiring

  • ✅ API client — navAPI.getThreat in services/player-client/src/services/api.ts (GET /api/v1/nav/threat). Gameserver endpoint is live.
  • ✅ UI consumer — tip-shipped. useNavThreatRollup fetches GET /api/v1/nav/threat on mount; TacticalThreatPage renders the NAV THREAT ROLLUP section (sector band, score, expandable contributors) on the TACTICAL monitor's THREAT tab (TacticalMonitor.tsx). GameDashboard.tsx also consumes the same hook for deck-level current-sector band overlay.

(re-verified 2026-08-31 vs Sectorwars2102 origin/feat 7012c53e5.)