Skip to content

Design — COP UI v2 rethink + game balance

Follow-up to plan/design-cop-ui-v2.md after live testing showed two issues:

  1. The inline BeliefBar is functionally invisible. Even bumped from 6px to 10px in a 156px-wide lozenge next to a small map marker, operators don't notice it. Information density is wrong: the most-important new signal lives in the least-prominent pixels.
  2. Blue cannot lose. The payoff function only penalizes blue actions (fratricide, ROE violation, failed effect, fuel, MTTD). There is no mechanism for adversary success — hostiles float passively until engaged, and the demo always ends with U_B > 0. The game has no stakes.

This memo locks the contracts for two strands that ship together on the existing feat/cop-ui-v2 PR. After this lands, the COP shows the Bayesian picture prominently AND a poorly-played Tripwire run ends with U_B < 0.


Strand 1 — Belief visualization rethink

What's removed

  • apps/cop-ui/components/BeliefBar.tsx — deleted entirely.
  • apps/cop-ui/components/TrackLozenge.tsxBeliefBar import + JSX embed removed. The lozenge returns to its pre-v2 shape.
  • apps/cop-ui/app/globals.css — the @keyframes beliefPulse animation stays (still used by DoctrineStack).

The BeliefSnapshot / BeliefDistribution types in lib/types.ts, the beliefs / beliefHistory fields in the store, the bus subscriber's uci-demo/copilot/belief/<trackId> branch, and the beliefMirror publisher in the copilot service all stay — they remain the data substrate for the new surfaces.

What replaces it

1. BeliefPanel.tsx — primary readout

A new dedicated panel pinned to the left rail, symmetric to the existing RightRail. One row per known track, sorted by P(HOSTILE) descending so the most-threatening contact is at top.

┌─ BELIEF MATRIX ──────────────────────────────────────┐
│ ◉ active   <track-count> TRACKED                      │
├──────────────────────────────────────────────────────┤
│ TRACK-001   HOSTILE                                   │
│ [██████████████░░░░░░░░░░░░░░░░░░░░░░] HOS 0.71  ◬  │
│ HOS 71 · SUS 18 · UNK 06 · NEU 02 · ASF 02 · FRI 01  │
├──────────────────────────────────────────────────────┤
│ A1B835      ASSUMED_FRIEND                            │
│ [░░░░░░░░░░░░░██████████████░░░░░░░░] ASF 0.34       │
│ ASF 34 · FRI 26 · NEU 21 · UNK 14 · SUS 04 · HOS 01  │
├──────────────────────────────────────────────────────┤
│ ...                                                   │
└──────────────────────────────────────────────────────┘

Visual spec: - Container: left-3 top-[68px] bottom-[200px] w-[300px] z-20, glass + corner brackets, mirroring RightRail discipline - Header: BELIEF MATRIX label + track count - Each row: ~64px tall, 3 lines 1. Track id (mono caps, identity color via the same palette RightRail/TrackLozenge already use) + most-likely identity in mono caps + fratricide-warn glyph on the right when P(FRIEND) > 0.25 2. 24px-tall stacked horizontal bar — same 6-color palette as the deleted BeliefBar, but big enough to actually read. Right end shows <MOSTLIKELY> <maxP.toFixed(2)> 3. All six bucket percentages, mono caps short labels (HOS / SUS / UNK / NEU / ASF / FRI), separated by · - Click a row → open TrackTimeline for that track (reuse the existing drawer + selectedTrackId setter) - Empty state (no tracks yet): AWAITING DETECTION… in --color-fg-faint - Sort: stable; P(HOSTILE) descending, then trackId asc. On every belief update, re-sort.

2. TrackSymbolBeliefRing.tsx — map-native annotation

A thin (~3px wide) ring drawn around each TrackSymbol, with arcs proportional to the belief distribution. Implemented as an SVG <g> of 6 arcs computed from cumulative percentages.

        ╱─ FRIEND-grant
       │
      ◯ ← track symbol
       │
        ╲─ HOSTILE-threat

Visual spec: - Outer radius 18px, inner radius 15px (3px stroke equivalent) - 6 arcs, colors from the same palette - Arc start angle: 12 o'clock (-90°), proceed clockwise - Skip arcs where P(id) < 0.02 (below visual-perceptibility) - When belief is undefined for the track: outline only, no fill - When P(FRIEND) > 0.4: ring pulses (1s opacity cycle) — same beliefPulse keyframe already in globals.css

Implementation: - Render the ring as a child SVG inside the existing TrackSymbol component, OR as a sibling component placed at the same map pixel coordinates by TacticalOverlay. The sibling approach keeps TrackSymbol from having to subscribe to the store directly — cleaner. Go with sibling.

Layout impact

  • New LeftRail slot at left-3 (BeliefPanel goes here)
  • BottomStrip (ScoreHud) is already right-[440px] to clear the RightRail; with BeliefPanel taking left-3 w-[300px], BottomStrip needs left-[320px] too — adjust accordingly
  • MapCanvas keeps its full-bleed absolute inset-0 — the rails float over the map (already the existing pattern for RightRail)

Strand 2 — Game balance / losability

Why the current model is unlosable

Existing PayoffCounters (PAYOFF_V=1):

Counter Weight Triggered by
neutralizedHostiles +1.0 EntityLostMT for HOSTILE/SUSPECT
fratricideEvents -5.0 EntityLostMT for FRIEND inside coverage
roeViolations -0.2 EffectPlanCommand kinetic under ROE GREEN
fuelBurnedTotal -0.05 SubsystemStatus band transitions
failedEffects -0.3 EffectStatus FAILED
commsDegradeSeconds -0.001 uci-demo/world/degrade window integral
meanTimeToDecisionMs -0.002/s copilot evaluate() wall-clock

Every negative term requires a blue action. In Tripwire, with a non-broken operator, none of them fire often. Result: blue scoreboard runs +X per neutralization with no offsetting force.

The fix — two new counters

Bump PAYOFF_V from 1 to 2. Extend PayoffCounters:

Counter Weight Triggered by
hostilesCrossedThreshold -2.0 A HOSTILE/SUSPECT first crosses inside the FOB defense radius (THRESHOLD_KM = 1.5) without being killed
friendlyAssetsLost -10.0 A friendly AssetCapability is destroyed by an unkilled hostile that has been inside the threshold for > DESTRUCTION_DWELL_SEC = 20

The weights are chosen so: - One threshold cross ≈ two neutralizations of profit erased - One asset destroyed = ten neutralizations of profit erased — a catastrophic, game-defining event - Operator with zero engagement clicks → after first hostile crosses + 20s dwell, score is 1 × -2.0 + 1 × -10.0 = -12 — clearly losing

These weights are deliberately harsh — the SBIR proposal wants demonstrably-stakes-bearing scenarios. Tune down later if Tripwire becomes too punishing.

World-sim implementation

services/world-sim/src/sim.ts gets a per-tick range check:

const THRESHOLD_KM = 1.5;
const DESTRUCTION_DWELL_SEC = 20;
const fobCenter = { lat: 34.9054, lng: -117.8839 };  // matches adsb-bridge AO

interface ThresholdState {
  readonly trackId: string;
  readonly enteredAt: number;
  /** Friendly asset already destroyed by this track (one per track). */
  destructionEmitted: boolean;
}
const inside = new Map<string, ThresholdState>();

function checkThreshold(t: SimulatedTrack, nowSec: number) {
  const isHostileLike = t.identity === "HOSTILE" || t.identity === "SUSPECT";
  if (!isHostileLike) return;

  const distKm = haversineKm(fobCenter, t.position);
  const prev = inside.get(t.id);

  if (distKm < THRESHOLD_KM && !prev) {
    // First crossing.
    inside.set(t.id, { trackId: t.id, enteredAt: nowSec, destructionEmitted: false });
    publishThresholdCrossed(t.id, distKm, nowSec);
  } else if (distKm >= THRESHOLD_KM && prev) {
    // Left the zone (either retired or got killed).
    inside.delete(t.id);
  } else if (distKm < THRESHOLD_KM && prev) {
    // Dwelling — check destruction trigger.
    const dwellSec = nowSec - prev.enteredAt;
    if (dwellSec >= DESTRUCTION_DWELL_SEC && !prev.destructionEmitted) {
      const victim = pickRandomFriendlyAsset();  // SENTINEL-1 | HAWK-2 | GUARDIAN-3
      publishFriendlyLost(victim, t.id, nowSec);
      prev.destructionEmitted = true;
    }
  }
}

On onEntityLost(trackId) the world-sim also clears inside.delete(trackId).

New MQTT side-channels

Both under uci-demo/world/... (free-form JSON, same convention as the existing uci-demo/world/roe and uci-demo/world/degrade).

uci-demo/world/threshold_crossed (NOT retained — one event per crossing)

{
  "schemaV": 1,
  "trackId": "TRACK-001",
  "distanceKm": 1.42,
  "fobCenter": { "lat": 34.9054, "lng": -117.8839 },
  "thresholdKm": 1.5,
  "ts": 1779316000000
}

uci-demo/world/friendly_lost (NOT retained)

{
  "schemaV": 1,
  "assetId": "SENTINEL-1",
  "byTrackId": "TRACK-001",
  "dwellSec": 21,
  "ts": 1779316020000
}

Copilot integration

services/copilot/src/scoreMirror.ts gains two subscriptions:

  • uci-demo/world/threshold_crossedbump({ hostilesCrossedThreshold: 1 })
  • uci-demo/world/friendly_lostbump({ friendlyAssetsLost: 1 })

The score JSON published on uci-demo/copilot/score carries the two new counter fields automatically (it spreads PayoffCounters). payoffV field becomes 2.

Cop-UI integration

  1. Store extensions (lib/store.ts):
  2. state.assetsDestroyed: Set<string> — asset ids that have been hit. Driven by uci-demo/world/friendly_lost events.
  3. state.thresholdEvents: ThresholdEvent[] — last N (cap 8) crossings for the intel stream.

  4. Bus subscriber (lib/busSubscriber.ts): two new dispatch branches for the world side-channels. Each also pushes a high-priority entry into the intel stream so the operator sees the event in real time.

  5. Map overlay (MapCanvas.tsx or TacticalOverlay.tsx):

  6. Draw a 1.5 km radius circle around the FOB center as a subtle dashed line in --color-amber — the visible "line" that hostiles must not cross
  7. When an asset is destroyed: that asset's TrackSymbol equivalent on the map renders in --color-threat-hot with a strikethrough or fractured-square overlay

  8. ScoreHud (ScoreHud.tsx): add the two new micro-counters to the bottom row:

  9. XING <int> — hostilesCrossedThreshold
  10. LOST <int> — friendlyAssetsLost
  11. Both force HUD threat-hot regardless of blueScore sign when > 0 (same override as FRAT)

  12. Tooltips on the new counters show the correct contribution math (XING 2 → -4.00, LOST 1 → -10.00).


TypeScript / module changes

@uci-demo/game/payoff.ts

// Bump:
export const PAYOFF_V = 2;

// Extend:
export interface PayoffCounters {
  // ...existing 7 fields
  readonly hostilesCrossedThreshold: number;
  readonly friendlyAssetsLost: number;
}

export const PAYOFF_WEIGHTS = {
  // ...existing
  hostilesCrossedThreshold: -2.0,
  friendlyAssetsLost: -10.0,
} as const;

export function bluePayoff(c: PayoffCounters): number {
  return (
    /* ...existing 7 terms... */
    + PAYOFF_WEIGHTS.hostilesCrossedThreshold * c.hostilesCrossedThreshold
    + PAYOFF_WEIGHTS.friendlyAssetsLost * c.friendlyAssetsLost
  );
}

export const ZERO_PAYOFF_COUNTERS: PayoffCounters = Object.freeze({
  /* ...existing 7 zeros... */
  hostilesCrossedThreshold: 0,
  friendlyAssetsLost: 0,
});

Existing tests for bluePayoff and ZERO_PAYOFF_COUNTERS get updated to include the new fields. Add three new worked examples: - One threshold crossing alone: U_B = -2.0 - Two neutralizations + one threshold crossing: U_B = +0.0 - One neutralization + one friendly lost: U_B = -9.0

apps/cop-ui/lib/types.ts

export interface ScoreCounters {
  // ...existing 7
  readonly hostilesCrossedThreshold: number;
  readonly friendlyAssetsLost: number;
}

export interface ThresholdEvent {
  readonly trackId: string;
  readonly distanceKm: number;
  readonly ts: number;
}

apps/cop-ui/lib/store.ts

interface CopState {
  // ...existing
  assetsDestroyed: Set<string>;
  thresholdEvents: ThresholdEvent[];

  pushThresholdEvent: (e: ThresholdEvent) => void;
  markAssetDestroyed: (assetId: string) => void;
}

services/world-sim/src/sim.ts + service.ts

  • Add THRESHOLD_KM, DESTRUCTION_DWELL_SEC, FOB_CENTER constants
  • Add the per-tick checkThreshold walk (above)
  • Add publishThresholdCrossed, publishFriendlyLost helpers
  • Add haversineKm(a, b) if not already in the world-sim's geo utils
  • (Optional) Stop emitting PositionReport for destroyed friendlies, OR flip their identity to NEUTRAL → either signals "out of action"

Module layout (delta vs v2)

apps/cop-ui/components/
├── BeliefBar.tsx                 # DELETED
├── BeliefPanel.tsx               # NEW
├── TrackSymbolBeliefRing.tsx     # NEW
├── TrackLozenge.tsx              # MODIFIED — remove BeliefBar import + embed
├── TacticalOverlay.tsx           # MODIFIED — render TrackSymbolBeliefRing alongside TrackSymbol
├── MapCanvas.tsx                 # MODIFIED — add FOB-threshold ring overlay
├── ScoreHud.tsx                  # MODIFIED — XING / LOST counters
├── BottomStrip.tsx               # MODIFIED — `left-[320px]` to clear BeliefPanel

apps/cop-ui/lib/
├── types.ts                      # MODIFIED — extend ScoreCounters; add ThresholdEvent
├── store.ts                      # MODIFIED — assetsDestroyed, thresholdEvents, setters
└── busSubscriber.ts              # MODIFIED — two new world dispatch branches

apps/cop-ui/app/
└── page.tsx                      # MODIFIED — mount BeliefPanel in LeftRail slot

packages/uci-game/src/
├── payoff.ts                     # MODIFIED — bump PAYOFF_V=2, add 2 counters + weights

packages/uci-game/test/
└── payoff.test.ts                # MODIFIED — update fixtures, add 3 worked examples

services/world-sim/src/
├── sim.ts                        # MODIFIED — per-tick threshold check + destruction dwell
└── service.ts                    # MODIFIED — clear dwell state on shutdown

services/copilot/src/
└── scoreMirror.ts                # MODIFIED — subscribe new world channels

Acceptance criteria

  1. pnpm -r typecheck + pnpm -r test remain clean. The existing payoff tests get updated to reflect PAYOFF_V=2 and the new counters; the three new worked examples pass.
  2. Manual smoke (pnpm run up, Tripwire):
  3. BeliefPanel is clearly visible in the left rail with non-zero rows within 10s of boot
  4. Each TrackSymbol on the map has a colored donut ring reflecting its belief distribution
  5. The map shows a 1.5km dashed amber ring around the FOB centroid
  6. If the operator withholds all engagements for ~10s on a hostile, the intel stream shows a THRESHOLD CROSSED alert and the ScoreHud's XING counter increments
  7. If the operator withholds for ~30s, the intel stream shows an ASSET LOST alert and the ScoreHud's LOST counter increments + the affected asset on the map turns red/destroyed
  8. Validator audit stays at 100% valid — none of the new side-channels touch the UCI v2.5 schema-bound channels
  9. BELIEF_V = 1, PAYOFF_V = 2, INFOSET_V = 1 exported from @uci-demo/game. Blueprint version-tag enforcement honors the bump (a v1-tagged blueprint refuses to load).

Agent dispatch plan

Same shape that worked for game/solver/v1: Wave 0 = me locks contracts; Wave 1 = parallel agents on disjoint files.

Wave 0 (me): - Bump PAYOFF_V + extend PayoffCounters in @uci-demo/game; update payoff.test.ts fixtures + add 3 worked examples - Extend apps/cop-ui/lib/types.ts (ScoreCounters + ThresholdEvent) - Extend apps/cop-ui/lib/store.ts (assetsDestroyed, thresholdEvents) - Add bus subscriber stubs for the two world side-channels - Delete BeliefBar.tsx + remove import/usage from TrackLozenge - Create empty stub files for BeliefPanel.tsx + TrackSymbolBeliefRing.tsx - Add LeftRail slot in app/page.tsx - Adjust BottomStrip to left-[320px] (clear BeliefPanel)

Wave 1 (5 parallel agents on disjoint files):

Agent Files Goal
A world-sim services/world-sim/src/{sim,service}.ts Per-tick threshold + destruction dwell; publish uci-demo/world/threshold_crossed + uci-demo/world/friendly_lost
B copilot scoreMirror services/copilot/src/scoreMirror.ts Subscribe new world channels; bump new counters
C BeliefPanel components/BeliefPanel.tsx (replace stub) New left-rail panel; opens TrackTimeline on row click
D TrackSymbolBeliefRing components/TrackSymbolBeliefRing.tsx (replace stub) + TacticalOverlay.tsx (modify, render alongside TrackSymbol) SVG arc-ring around each marker
E ScoreHud + FOB ring overlay components/ScoreHud.tsx (XING/LOST counters), MapCanvas.tsx (FOB-threshold dashed ring), asset-destroyed visual state The losability mechanics surface on the UI

Each agent gets a precise prompt: which files to touch, which to avoid, what contract to consume from Wave 0. None overlap.


What this memo deliberately does not specify

  • Active red-agent attacking blue assets via UCI MTs — Phase 0 weeks 5-7 work. The threshold/destruction mechanics here are the scenario-level approximation that makes the demo losable WITHOUT needing the solver-driven Red.
  • Tunable weights from the COP UI — operators can't currently adjust PAYOFF_WEIGHTS at runtime; the weights live in code. Add a debug pane later if useful.
  • Scenario victory conditions — "hold the line for N minutes" / "zero hostiles past Y" require extending the YAML scenario schema. Out of scope; the new counters give enough mechanism for a meaningful score without needing explicit win conditions.
  • Blueprint regret-table compatibility under PAYOFF_V bump — any solver-trained blueprints from before this PR would have payoffV=1 and will be refused by the deserializer. That is the correct behavior; retrain.