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 unresolvedPendingEngagementrows) — worse thanhostiles_present:PendingEngagement.arrival_sector_idis only populated once the row transitions toARRIVED, 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.getThreat → GET /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 stub —
navAPI.getThreatinservices/player-client/src/services/api.ts(GET /api/v1/nav/threat). Gameserver endpoint is live. - 🚧 UI consumer — tip-absent.
TacticalMonitor.tsxdocuments the STATIC known-graph threat-band rollup was retired (WO-UI2-DECK-RECONCILE); the THREAT tab renders law/mines/hazard viaTacticalThreatPage, not GET/nav/threatband scores.getThreathas zero production callers underservices/player-client/srcon origin/feat46bce720(test mocks only).
(re-verified 2026-08-21 vs Sectorwars2102 HEAD 46bce720)
Related¶
course-plotting.md— the known-graph assembly this rollup reuses.../SYSTEMS/pirate-ecosystem.md—compute_population_scoreand the pirate-holding tier weights the pressure input reads.../FEATURES/gameplay/police-forces.md— thePendingEngagementmechanic whose leak risk is whyinbound_squadswas removed.