Design — COP UI v2 rethink + game balance¶
Follow-up to plan/design-cop-ui-v2.md after
live testing showed two issues:
- The inline
BeliefBaris 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. - 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.tsx—BeliefBarimport + JSX embed removed. The lozenge returns to its pre-v2 shape.apps/cop-ui/app/globals.css— the@keyframes beliefPulseanimation stays (still used byDoctrineStack).
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
LeftRailslot atleft-3(BeliefPanel goes here) BottomStrip(ScoreHud) is alreadyright-[440px]to clear the RightRail; with BeliefPanel takingleft-3 w-[300px], BottomStrip needsleft-[320px]too — adjust accordinglyMapCanvaskeeps its full-bleedabsolute inset-0— the rails float over the map (already the existing pattern forRightRail)
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_crossed→bump({ hostilesCrossedThreshold: 1 })uci-demo/world/friendly_lost→bump({ 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¶
- Store extensions (
lib/store.ts): state.assetsDestroyed: Set<string>— asset ids that have been hit. Driven byuci-demo/world/friendly_lostevents.-
state.thresholdEvents: ThresholdEvent[]— last N (cap 8) crossings for the intel stream. -
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. -
Map overlay (
MapCanvas.tsxorTacticalOverlay.tsx): - 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 -
When an asset is destroyed: that asset's
TrackSymbolequivalent on the map renders in--color-threat-hotwith a strikethrough or fractured-square overlay -
ScoreHud (
ScoreHud.tsx): add the two new micro-counters to the bottom row: XING <int>— hostilesCrossedThresholdLOST <int>— friendlyAssetsLost-
Both force HUD threat-hot regardless of
blueScoresign when> 0(same override asFRAT) -
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_CENTERconstants - Add the per-tick
checkThresholdwalk (above) - Add
publishThresholdCrossed,publishFriendlyLosthelpers - 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¶
pnpm -r typecheck+pnpm -r testremain clean. The existing payoff tests get updated to reflect PAYOFF_V=2 and the new counters; the three new worked examples pass.- Manual smoke (
pnpm run up, Tripwire): -
BeliefPanelis clearly visible in the left rail with non-zero rows within 10s of boot - Each
TrackSymbolon the map has a colored donut ring reflecting its belief distribution - The map shows a 1.5km dashed amber ring around the FOB centroid
- If the operator withholds all engagements for ~10s on a
hostile, the intel stream shows a
THRESHOLD CROSSEDalert and the ScoreHud'sXINGcounter increments - If the operator withholds for ~30s, the intel stream shows
an
ASSET LOSTalert and the ScoreHud'sLOSTcounter increments + the affected asset on the map turns red/destroyed - Validator audit stays at 100% valid — none of the new side-channels touch the UCI v2.5 schema-bound channels
BELIEF_V = 1,PAYOFF_V = 2,INFOSET_V = 1exported 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_WEIGHTSat 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=1and will be refused by the deserializer. That is the correct behavior; retrain.