Skip to content

Tactical Threat Rollup

Status: 📐 Design-only — tactical threat aggregation algorithm documented; rollup service not on tip. (re-verified 2026-08-21 vs Sectorwars2102 HEAD 46bce720.)

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 stub services/player-client/src/services/api.ts (navAPI.getThreatGET /api/v1/nav/threat)
Player-client UI consumer None on tip — prior TACTICAL deck-monitor threat-band consumer retired; see TacticalMonitor.tsx module doc (WO-UI2-DECK-RECONCILE)

Player-client wiring

  • API client stubnavAPI.getThreat in services/player-client/src/services/api.ts (GET /api/v1/nav/threat). Gameserver endpoint is live.
  • 🚧 UI consumer — tip-absent. TacticalMonitor.tsx documents the STATIC known-graph threat-band rollup was retired (WO-UI2-DECK-RECONCILE); the THREAT tab renders law/mines/hazard via TacticalThreatPage, not GET /nav/threat band scores. getThreat has zero production callers under services/player-client/src on origin/feat 46bce720 (test mocks only).

(re-verified 2026-08-21 vs Sectorwars2102 HEAD 46bce720)